Skip to content

Commit f98e701

Browse files
committed
fix(web-pkg): paste text/plain in plain text editor to keep markdown syntax
1 parent ea72e31 commit f98e701

5 files changed

Lines changed: 236 additions & 3 deletions

File tree

‎packages/web-pkg/src/editor/composables/strategies/plainText.ts‎

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import StarterKit from '@tiptap/starter-kit'
44
import FindAndReplace from '@tiptap/extension-find-and-replace'
55
import { useGettext } from 'vue3-gettext'
66
import { EditorActionGroup, useEditorActions } from '../useEditorActions'
7+
import { createPlainTextClipboardExtension } from '../../extensions'
78
import { ContentTypeStrategy, ExtensionsOptions } from './types'
89
import { TextEditorState } from '../../types'
910

@@ -57,7 +58,8 @@ export const useStrategyPlainText = (editorState: TextEditorState): ContentTypeS
5758
orderedList: false,
5859
strike: false
5960
}),
60-
FindAndReplace
61+
FindAndReplace,
62+
createPlainTextClipboardExtension()
6163
]
6264
}
6365

‎packages/web-pkg/src/editor/extensions/index.ts‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,5 +5,6 @@ export * from './imageFileHandler'
55
export * from './link'
66
export * from './markdownClipboard'
77
export * from './mentions'
8+
export * from './plainTextClipboard'
89
export * from './slashCommands'
910
export * from './tableOfContents'
Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
import { Extension, getTextBetween, getTextSerializersFromSchema } from '@tiptap/core'
2+
import { Fragment, Slice } from '@tiptap/pm/model'
3+
import { Plugin, PluginKey } from '@tiptap/pm/state'
4+
5+
/**
6+
* Clipboard handling for the plain text editor.
7+
*
8+
* Plain text files are stored as one paragraph per line (see the plain text strategy),
9+
* so copying and pasting has to follow the same rule instead of ProseMirror's defaults.
10+
*/
11+
export function createPlainTextClipboardExtension() {
12+
return Extension.create({
13+
name: 'plainTextClipboard',
14+
15+
addProseMirrorPlugins() {
16+
return [
17+
new Plugin({
18+
key: new PluginKey('plainTextClipboard'),
19+
props: {
20+
handleDOMEvents: {
21+
// ProseMirror prefers text/html over text/plain when both are present. Parsing
22+
// the html into the reduced plain text schema strips everything it can't
23+
// represent, e.g. markdown copied from the markdown editor loses its syntax.
24+
// This runs as a DOM event handler because `handlePaste` only receives the
25+
// already parsed html slice, and calling `pasteText` from there would re-enter it.
26+
paste(view, event) {
27+
const text = event.clipboardData?.getData('text/plain')
28+
if (!text || !view.pasteText(text, event)) {
29+
// no text payload (e.g. html only): fall back to the default paste handling
30+
return false
31+
}
32+
33+
event.preventDefault()
34+
return true
35+
}
36+
},
37+
38+
// Tiptap's core clipboard text serializer separates blocks with a blank line, which
39+
// would double every line break when copying within the plain text editor. Use the
40+
// same helpers with a single line break instead, matching how the file is saved.
41+
// Tiptap orders plugins of custom extensions before its core ones, so this one wins.
42+
clipboardTextSerializer(_slice, view) {
43+
const { doc, selection, schema } = view.state
44+
return getTextBetween(doc, selection, {
45+
blockSeparator: '\n',
46+
textSerializers: getTextSerializersFromSchema(schema)
47+
})
48+
},
49+
50+
// ProseMirror's default parser collapses consecutive line breaks, which would drop
51+
// empty lines. Map every line to its own paragraph instead, keeping empty ones.
52+
// The open slice (1, 1) lets the first and last line merge into the paragraph
53+
// at the cursor, so pasting a single line inserts it inline.
54+
clipboardTextParser(text, _context, _plain, view) {
55+
const { schema } = view.state
56+
const paragraphs = text
57+
.split(/\r\n?|\n/)
58+
.map((line) => schema.nodes.paragraph.create(null, line ? schema.text(line) : null))
59+
60+
return new Slice(Fragment.from(paragraphs), 1, 1)
61+
}
62+
}
63+
})
64+
]
65+
}
66+
})
67+
}
Lines changed: 163 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,163 @@
1+
import { Editor } from '@tiptap/core'
2+
import { AllSelection } from '@tiptap/pm/state'
3+
import StarterKit from '@tiptap/starter-kit'
4+
import { describe, expect, it } from 'vitest'
5+
import { createPlainTextClipboardExtension } from '../../../../src/editor/extensions/plainTextClipboard'
6+
7+
function createEditor(): Editor {
8+
return new Editor({
9+
extensions: [
10+
StarterKit.configure({
11+
blockquote: false,
12+
bold: false,
13+
bulletList: false,
14+
code: false,
15+
codeBlock: false,
16+
heading: false,
17+
italic: false,
18+
link: false,
19+
listItem: false,
20+
listKeymap: false,
21+
orderedList: false,
22+
strike: false
23+
}),
24+
createPlainTextClipboardExtension()
25+
]
26+
})
27+
}
28+
29+
function createClipboardEvent(text: string, html: string): ClipboardEvent {
30+
const event = new Event('paste', { bubbles: true, cancelable: true }) as ClipboardEvent
31+
32+
Object.defineProperty(event, 'clipboardData', {
33+
value: {
34+
getData: (type: string) => {
35+
if (type === 'text/plain') {
36+
return text
37+
}
38+
39+
if (type === 'text/html') {
40+
return html
41+
}
42+
43+
return ''
44+
}
45+
}
46+
})
47+
48+
return event
49+
}
50+
51+
function copyAll(editor: Editor) {
52+
editor.view.dispatch(editor.state.tr.setSelection(new AllSelection(editor.state.doc)))
53+
return editor.view.serializeForClipboard(editor.state.selection.content()).text
54+
}
55+
56+
function getLines(editor: Editor) {
57+
const lines: string[] = []
58+
editor.state.doc.forEach((node) => lines.push(node.textContent))
59+
return lines
60+
}
61+
62+
describe('plain text clipboard extension', () => {
63+
it('pastes the text/plain payload instead of html when both clipboard formats exist', () => {
64+
const editor = createEditor()
65+
const event = createClipboardEvent(
66+
'# Heading\n\n- item **bold**',
67+
'<h1>Heading</h1><ul><li><p>item <strong>bold</strong></p></li></ul>'
68+
)
69+
70+
try {
71+
editor.view.dom.dispatchEvent(event)
72+
73+
expect(event.defaultPrevented).toBe(true)
74+
expect(getLines(editor)).toEqual(['# Heading', '', '- item **bold**'])
75+
} finally {
76+
editor.destroy()
77+
}
78+
})
79+
80+
it('keeps empty lines as empty paragraphs', () => {
81+
const editor = createEditor()
82+
83+
try {
84+
editor.view.pasteText('first\n\n\nsecond')
85+
86+
expect(getLines(editor)).toEqual(['first', '', '', 'second'])
87+
} finally {
88+
editor.destroy()
89+
}
90+
})
91+
92+
it('pastes a single line inline into the current paragraph', () => {
93+
const editor = createEditor()
94+
95+
try {
96+
editor.commands.setContent({
97+
type: 'doc',
98+
content: [{ type: 'paragraph', content: [{ type: 'text', text: 'hello ' }] }]
99+
})
100+
editor.commands.focus('end')
101+
editor.view.pasteText('world')
102+
103+
expect(getLines(editor)).toEqual(['hello world'])
104+
} finally {
105+
editor.destroy()
106+
}
107+
})
108+
109+
it('falls back to the default paste handling when no text/plain payload exists', () => {
110+
const editor = createEditor()
111+
const event = createClipboardEvent('', '<p>html only</p>')
112+
113+
try {
114+
editor.view.dom.dispatchEvent(event)
115+
116+
expect(getLines(editor)).toEqual(['html only'])
117+
} finally {
118+
editor.destroy()
119+
}
120+
})
121+
122+
it('copies one line per paragraph, including empty ones and hard breaks', () => {
123+
const editor = createEditor()
124+
125+
try {
126+
editor.commands.setContent({
127+
type: 'doc',
128+
content: [
129+
{
130+
type: 'paragraph',
131+
content: [
132+
{ type: 'text', text: 'first' },
133+
{ type: 'hardBreak' },
134+
{ type: 'text', text: 'second' }
135+
]
136+
},
137+
{ type: 'paragraph' },
138+
{ type: 'paragraph', content: [{ type: 'text', text: 'third' }] }
139+
]
140+
})
141+
142+
expect(copyAll(editor)).toBe('first\nsecond\n\nthird')
143+
} finally {
144+
editor.destroy()
145+
}
146+
})
147+
148+
it('round-trips copied content without changing the lines', () => {
149+
const editor = createEditor()
150+
151+
try {
152+
editor.view.pasteText('first\n\nsecond\nthird')
153+
const clipboardText = copyAll(editor)
154+
155+
editor.view.pasteText(clipboardText)
156+
157+
expect(clipboardText).toBe('first\n\nsecond\nthird')
158+
expect(getLines(editor)).toEqual(['first', '', 'second', 'third'])
159+
} finally {
160+
editor.destroy()
161+
}
162+
})
163+
})

‎packages/web-pkg/tests/unit/editor/strategies/plainText.spec.ts‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -25,10 +25,10 @@ describe('useStrategyPlainText', () => {
2525
})
2626

2727
describe('extensions', () => {
28-
it('returns a starter kit extension', () => {
28+
it('returns the starter kit, find and replace and clipboard extensions', () => {
2929
const strategy = createStrategy()
3030
const names = strategy.extensions().map((e) => e.name)
31-
expect(names).toEqual(['starterKit', 'findAndReplace'])
31+
expect(names).toEqual(['starterKit', 'findAndReplace', 'plainTextClipboard'])
3232
expect(names).not.toContain('link')
3333
})
3434

0 commit comments

Comments
 (0)