Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import StarterKit from '@tiptap/starter-kit'
import FindAndReplace from '@tiptap/extension-find-and-replace'
import { useGettext } from 'vue3-gettext'
import { EditorActionGroup, useEditorActions } from '../useEditorActions'
import { createPlainTextClipboardExtension } from '../../extensions'
import { ContentTypeStrategy, ExtensionsOptions } from './types'
import { TextEditorState } from '../../types'

Expand Down Expand Up @@ -57,7 +58,8 @@ export const useStrategyPlainText = (editorState: TextEditorState): ContentTypeS
orderedList: false,
strike: false
}),
FindAndReplace
FindAndReplace,
createPlainTextClipboardExtension()
]
}

Expand Down
1 change: 1 addition & 0 deletions packages/web-pkg/src/editor/extensions/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,5 +5,6 @@ export * from './imageFileHandler'
export * from './link'
export * from './markdownClipboard'
export * from './mentions'
export * from './plainTextClipboard'
export * from './slashCommands'
export * from './tableOfContents'
5 changes: 5 additions & 0 deletions packages/web-pkg/src/editor/extensions/markdownClipboard.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,11 @@ export function createMarkdownClipboardExtension() {
props: {
handleDOMEvents: {
paste(view, event) {
// DOM event handlers run before ProseMirror's own editable check
if (!view.editable) {
return false
}

const text =
event.clipboardData?.getData('text/plain') ||
event.clipboardData?.getData('Text') ||
Expand Down
72 changes: 72 additions & 0 deletions packages/web-pkg/src/editor/extensions/plainTextClipboard.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
import { Extension, getTextBetween, getTextSerializersFromSchema } from '@tiptap/core'
import { Fragment, Slice } from '@tiptap/pm/model'
import { Plugin, PluginKey } from '@tiptap/pm/state'

/**
* Clipboard handling for the plain text editor.
*
* Plain text files are stored as one paragraph per line (see the plain text strategy),
* so copying and pasting has to follow the same rule instead of ProseMirror's defaults.
*/
export function createPlainTextClipboardExtension() {
return Extension.create({
name: 'plainTextClipboard',

addProseMirrorPlugins() {
return [
new Plugin({
key: new PluginKey('plainTextClipboard'),
props: {
handleDOMEvents: {
// ProseMirror prefers text/html over text/plain when both are present. Parsing
// the html into the reduced plain text schema strips everything it can't
// represent, e.g. markdown copied from the markdown editor loses its syntax.
// This runs as a DOM event handler because `handlePaste` only receives the
// already parsed html slice, and calling `pasteText` from there would re-enter it.
paste(view, event) {
// DOM event handlers run before ProseMirror's own editable check
if (!view.editable) {
return false
}

const text = event.clipboardData?.getData('text/plain')
if (!text || !view.pasteText(text, event)) {
// no text payload (e.g. html only): fall back to the default paste handling
return false
}

event.preventDefault()
return true
}
},

// Tiptap's core clipboard text serializer separates blocks with a blank line, which
// would double every line break when copying within the plain text editor. Use the
// same helpers with a single line break instead, matching how the file is saved.
// Tiptap orders plugins of custom extensions before its core ones, so this one wins.
clipboardTextSerializer(_slice, view) {
const { doc, selection, schema } = view.state
return getTextBetween(doc, selection, {
blockSeparator: '\n',
textSerializers: getTextSerializersFromSchema(schema)
})
},

// ProseMirror's default parser collapses consecutive line breaks, which would drop
// empty lines. Map every line to its own paragraph instead, keeping empty ones.
// The open slice (1, 1) lets the first and last line merge into the paragraph
// at the cursor, so pasting a single line inserts it inline.
clipboardTextParser(text, _context, _plain, view) {
const { schema } = view.state
const paragraphs = text
.split(/\r\n?|\n/)
.map((line) => schema.nodes.paragraph.create(null, line ? schema.text(line) : null))

return new Slice(Fragment.from(paragraphs), 1, 1)
}
}
})
]
}
})
}
Original file line number Diff line number Diff line change
Expand Up @@ -226,4 +226,21 @@ describe('markdown clipboard extension', () => {
editor.destroy()
}
})

it('does not paste into a read-only editor', () => {
const editor = createEditor()
const event = createClipboardEvent('# Heading', '')

try {
editor.commands.setContent('original', { contentType: 'markdown' })
editor.setEditable(false)

const handled = pasteClipboardEvent(editor, event)

expect(handled).toBe(false)
expect(editor.markdown?.serialize(editor.getJSON()).trim()).toBe('original')
} finally {
editor.destroy()
}
})
})
Original file line number Diff line number Diff line change
@@ -0,0 +1,182 @@
import { Editor } from '@tiptap/core'
import { AllSelection } from '@tiptap/pm/state'
import StarterKit from '@tiptap/starter-kit'
import { describe, expect, it } from 'vitest'
import { createPlainTextClipboardExtension } from '../../../../src/editor/extensions/plainTextClipboard'

function createEditor(): Editor {
return new Editor({
extensions: [
StarterKit.configure({
blockquote: false,
bold: false,
bulletList: false,
code: false,
codeBlock: false,
heading: false,
italic: false,
link: false,
listItem: false,
listKeymap: false,
orderedList: false,
strike: false
}),
createPlainTextClipboardExtension()
]
})
}

function createClipboardEvent(text: string, html: string): ClipboardEvent {
const event = new Event('paste', { bubbles: true, cancelable: true }) as ClipboardEvent

Object.defineProperty(event, 'clipboardData', {
value: {
getData: (type: string) => {
if (type === 'text/plain') {
return text
}

if (type === 'text/html') {
return html
}

return ''
}
}
})

return event
}

function copyAll(editor: Editor) {
editor.view.dispatch(editor.state.tr.setSelection(new AllSelection(editor.state.doc)))
return editor.view.serializeForClipboard(editor.state.selection.content()).text
}

function getLines(editor: Editor) {
const lines: string[] = []
editor.state.doc.forEach((node) => lines.push(node.textContent))
return lines
}

describe('plain text clipboard extension', () => {
it('pastes the text/plain payload instead of html when both clipboard formats exist', () => {
const editor = createEditor()
const event = createClipboardEvent(
'# Heading\n\n- item **bold**',
'<h1>Heading</h1><ul><li><p>item <strong>bold</strong></p></li></ul>'
)

try {
editor.view.dom.dispatchEvent(event)

expect(event.defaultPrevented).toBe(true)
expect(getLines(editor)).toEqual(['# Heading', '', '- item **bold**'])
} finally {
editor.destroy()
}
})

it('keeps empty lines as empty paragraphs', () => {
const editor = createEditor()

try {
editor.view.pasteText('first\n\n\nsecond')

expect(getLines(editor)).toEqual(['first', '', '', 'second'])
} finally {
editor.destroy()
}
})

it('pastes a single line inline into the current paragraph', () => {
const editor = createEditor()

try {
editor.commands.setContent({
type: 'doc',
content: [{ type: 'paragraph', content: [{ type: 'text', text: 'hello ' }] }]
})
editor.commands.focus('end')
editor.view.pasteText('world')

expect(getLines(editor)).toEqual(['hello world'])
} finally {
editor.destroy()
}
})

it('falls back to the default paste handling when no text/plain payload exists', () => {
const editor = createEditor()
const event = createClipboardEvent('', '<p>html only</p>')

try {
editor.view.dom.dispatchEvent(event)

expect(getLines(editor)).toEqual(['html only'])
} finally {
editor.destroy()
}
})

it('does not paste into a read-only editor', () => {
const editor = createEditor()
const event = createClipboardEvent('pasted', '')

try {
editor.commands.setContent({
type: 'doc',
content: [{ type: 'paragraph', content: [{ type: 'text', text: 'original' }] }]
})
editor.setEditable(false)
editor.view.dom.dispatchEvent(event)

expect(event.defaultPrevented).toBe(false)
expect(getLines(editor)).toEqual(['original'])
} finally {
editor.destroy()
}
})

it('copies one line per paragraph, including empty ones and hard breaks', () => {
const editor = createEditor()

try {
editor.commands.setContent({
type: 'doc',
content: [
{
type: 'paragraph',
content: [
{ type: 'text', text: 'first' },
{ type: 'hardBreak' },
{ type: 'text', text: 'second' }
]
},
{ type: 'paragraph' },
{ type: 'paragraph', content: [{ type: 'text', text: 'third' }] }
]
})

expect(copyAll(editor)).toBe('first\nsecond\n\nthird')
} finally {
editor.destroy()
}
})

it('round-trips copied content without changing the lines', () => {
const editor = createEditor()

try {
editor.view.pasteText('first\n\nsecond\nthird')
const clipboardText = copyAll(editor)

editor.view.pasteText(clipboardText)

expect(clipboardText).toBe('first\n\nsecond\nthird')
expect(getLines(editor)).toEqual(['first', '', 'second', 'third'])
} finally {
editor.destroy()
}
})
})
Original file line number Diff line number Diff line change
Expand Up @@ -25,10 +25,10 @@ describe('useStrategyPlainText', () => {
})

describe('extensions', () => {
it('returns a starter kit extension', () => {
it('returns the starter kit, find and replace and clipboard extensions', () => {
const strategy = createStrategy()
const names = strategy.extensions().map((e) => e.name)
expect(names).toEqual(['starterKit', 'findAndReplace'])
expect(names).toEqual(['starterKit', 'findAndReplace', 'plainTextClipboard'])
expect(names).not.toContain('link')
})

Expand Down
Loading