Skip to content

Commit 3d43982

Browse files
committed
fix(preset): anchor mark input rules to the cursor to prevent paste corruption
The underscore-emphasis and strikethrough input-rule regexes were not anchored to the end of the input (`$`), unlike the star-emphasis, strong and inline-code rules. When text ending with `_` (or `~`) was pasted twice and the user then pressed Space or Enter, the regex matched a `_..._` / `~...~` span *across* the two pastes — a match that did not end at the cursor. prosemirror's input-rule runner assumes the match ends at the cursor when it computes the affected range, so the off-by-one range deleted characters after the first paste. Adding the `$` anchor makes the rules fire only when the closing marker is typed at the cursor, which is the intended behavior and matches the other mark rules. Typing `_emphasis_` / `~~strike~~` by hand is unaffected. Closes #2400
1 parent 934c36b commit 3d43982

4 files changed

Lines changed: 249 additions & 2 deletions

File tree

Lines changed: 126 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,126 @@
1+
import '@testing-library/jest-dom/vitest'
2+
import { Editor, editorViewCtx } from '@milkdown/core'
3+
import { getMarkdown } from '@milkdown/utils'
4+
import userEvent from '@testing-library/user-event'
5+
import { describe, expect, it } from 'vitest'
6+
7+
import { commonmark } from '..'
8+
9+
// https://github.com/Milkdown/milkdown/issues/2400
10+
// Pasting text that ends with `_` twice and then pressing Space/Enter used to
11+
// delete characters: the underscore-emphasis input rule was not anchored to the
12+
// end of the input (`$`), so its regex matched an `_..._` span *across* the two
13+
// pastes — a match that did not end at the cursor. prosemirror's input-rule
14+
// runner assumes the match ends at the cursor when it computes the affected
15+
// range, so the off-by-one range corrupted the document.
16+
17+
const PASTED = 'This is for qa34%^%^&&&(&(&(&()(*()_'
18+
19+
async function createEditor() {
20+
const editor = Editor.make()
21+
editor.use(commonmark)
22+
await editor.create()
23+
return editor
24+
}
25+
26+
function hasEmphasis(editor: Editor) {
27+
const { doc } = editor.ctx.get(editorViewCtx).state
28+
let found = false
29+
doc.descendants((node) => {
30+
if (node.marks.some((mark) => mark.type.name === 'emphasis')) found = true
31+
})
32+
return found
33+
}
34+
35+
// Simulate a paste: a single programmatic insertion does not run input rules
36+
// (those only fire from `handleTextInput` / `handleKeyDown`), exactly like a
37+
// real clipboard paste.
38+
function paste(editor: Editor, text: string) {
39+
const view = editor.ctx.get(editorViewCtx)
40+
view.dispatch(view.state.tr.insertText(text))
41+
}
42+
43+
describe('pasting special characters (#2400)', () => {
44+
it('does not delete characters when pressing Space after two pastes', async () => {
45+
const editor = await createEditor()
46+
const view = editor.ctx.get(editorViewCtx)
47+
48+
paste(editor, PASTED)
49+
paste(editor, PASTED)
50+
51+
const before = view.state.doc.textContent
52+
const end = view.state.selection.from
53+
54+
// This is the exact call prosemirror-view makes when a space is typed.
55+
// The trailing `deflt` callback is required by the prop type but unused
56+
// by the input-rule plugin.
57+
view.someProp('handleTextInput', (f) =>
58+
f(view, end, end, ' ', () => view.state.tr)
59+
)
60+
61+
expect(view.state.doc.textContent).toBe(before)
62+
expect(hasEmphasis(editor)).toBe(false)
63+
})
64+
65+
it('does not delete characters when pressing Enter after two pastes', async () => {
66+
const editor = await createEditor()
67+
const view = editor.ctx.get(editorViewCtx)
68+
69+
paste(editor, PASTED)
70+
paste(editor, PASTED)
71+
72+
const before = view.state.doc.textContent
73+
74+
// This is the exact call the custom input-rule plugin makes on Enter.
75+
const event = new KeyboardEvent('keydown', { key: 'Enter' })
76+
view.someProp('handleKeyDown', (f) => f(view, event))
77+
78+
expect(view.state.doc.textContent).toBe(before)
79+
expect(hasEmphasis(editor)).toBe(false)
80+
})
81+
82+
it('still turns _word_ into emphasis when the closing _ is typed', async () => {
83+
const editor = await createEditor()
84+
const view = editor.ctx.get(editorViewCtx)
85+
86+
paste(editor, '_word')
87+
88+
const end = view.state.selection.from
89+
view.someProp('handleTextInput', (f) =>
90+
f(view, end, end, '_', () => view.state.tr)
91+
)
92+
93+
expect(view.state.doc.textContent).toBe('word')
94+
expect(hasEmphasis(editor)).toBe(true)
95+
})
96+
})
97+
98+
// The `$` anchor added to the underscore rule must not regress typing the
99+
// markdown syntax by hand: these mirror the input-rule e2e tests.
100+
describe('typing underscore emphasis still works', () => {
101+
it('creates emphasis when typing _on the grass_', async () => {
102+
const user = userEvent.setup()
103+
const editor = await createEditor()
104+
const view = editor.ctx.get(editorViewCtx)
105+
106+
await user.type(view.dom, 'The lunatic is _on the grass_')
107+
108+
expect(hasEmphasis(editor)).toBe(true)
109+
expect(editor.action(getMarkdown())).toBe('The lunatic is _on the grass_\n')
110+
})
111+
112+
it('does not create emphasis for intra-word underscores', async () => {
113+
const user = userEvent.setup()
114+
const editor = await createEditor()
115+
116+
await user.type(
117+
editor.ctx.get(editorViewCtx).dom,
118+
'the_lunatic_is_on_the_grass'
119+
)
120+
121+
expect(hasEmphasis(editor)).toBe(false)
122+
expect(editor.action(getMarkdown())).toBe(
123+
'the\\_lunatic\\_is\\_on\\_the\\_grass\n'
124+
)
125+
})
126+
})

packages/plugins/preset-commonmark/src/mark/emphasis.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -91,7 +91,7 @@ withMeta(emphasisStarInputRule, {
9191

9292
/// Input rule for use `_` to create emphasis mark.
9393
export const emphasisUnderscoreInputRule = $inputRule((ctx) => {
94-
return markRule(/\b_(?![_\s])(.*?[^_\s])_\b/, emphasisSchema.type(ctx), {
94+
return markRule(/\b_(?![_\s])(.*?[^_\s])_$/, emphasisSchema.type(ctx), {
9595
getAttr: () => ({
9696
marker: '_',
9797
}),
Lines changed: 121 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,121 @@
1+
import '@testing-library/jest-dom/vitest'
2+
import { Editor, editorViewCtx } from '@milkdown/core'
3+
import { commonmark } from '@milkdown/preset-commonmark'
4+
import { getMarkdown } from '@milkdown/utils'
5+
import userEvent from '@testing-library/user-event'
6+
import { describe, expect, it } from 'vitest'
7+
8+
import { gfm } from '..'
9+
10+
// https://github.com/Milkdown/milkdown/issues/2400
11+
// The strikethrough input rule shares the same flaw as the underscore-emphasis
12+
// rule: its regex was not anchored to the end of the input (`$`), so pasting
13+
// text that ends with `~` twice and then pressing Space/Enter matched a `~...~`
14+
// span across the two pastes and corrupted the document.
15+
16+
const PASTED = 'qa34 %~'
17+
18+
async function createEditor() {
19+
const editor = Editor.make()
20+
editor.use(commonmark)
21+
editor.use(gfm)
22+
await editor.create()
23+
return editor
24+
}
25+
26+
function hasStrikethrough(editor: Editor) {
27+
const { doc } = editor.ctx.get(editorViewCtx).state
28+
let found = false
29+
doc.descendants((node) => {
30+
if (node.marks.some((mark) => mark.type.name === 'strike_through'))
31+
found = true
32+
})
33+
return found
34+
}
35+
36+
function paste(editor: Editor, text: string) {
37+
const view = editor.ctx.get(editorViewCtx)
38+
view.dispatch(view.state.tr.insertText(text))
39+
}
40+
41+
describe('pasting special characters — strikethrough (#2400)', () => {
42+
it('does not delete characters when pressing Space after two pastes', async () => {
43+
const editor = await createEditor()
44+
const view = editor.ctx.get(editorViewCtx)
45+
46+
paste(editor, PASTED)
47+
paste(editor, PASTED)
48+
49+
const before = view.state.doc.textContent
50+
const end = view.state.selection.from
51+
52+
// The trailing `deflt` callback is required by the prop type but unused
53+
// by the input-rule plugin.
54+
view.someProp('handleTextInput', (f) =>
55+
f(view, end, end, ' ', () => view.state.tr)
56+
)
57+
58+
expect(view.state.doc.textContent).toBe(before)
59+
expect(hasStrikethrough(editor)).toBe(false)
60+
})
61+
62+
it('does not delete characters when pressing Enter after two pastes', async () => {
63+
const editor = await createEditor()
64+
const view = editor.ctx.get(editorViewCtx)
65+
66+
paste(editor, PASTED)
67+
paste(editor, PASTED)
68+
69+
const before = view.state.doc.textContent
70+
71+
const event = new KeyboardEvent('keydown', { key: 'Enter' })
72+
view.someProp('handleKeyDown', (f) => f(view, event))
73+
74+
expect(view.state.doc.textContent).toBe(before)
75+
expect(hasStrikethrough(editor)).toBe(false)
76+
})
77+
78+
it('still turns ~~word~~ into strikethrough when the closing ~ is typed', async () => {
79+
const editor = await createEditor()
80+
const view = editor.ctx.get(editorViewCtx)
81+
82+
paste(editor, '~~word~')
83+
84+
const end = view.state.selection.from
85+
view.someProp('handleTextInput', (f) =>
86+
f(view, end, end, '~', () => view.state.tr)
87+
)
88+
89+
expect(view.state.doc.textContent).toBe('word')
90+
expect(hasStrikethrough(editor)).toBe(true)
91+
})
92+
})
93+
94+
// The `$` anchor added to the strikethrough rule must not regress typing the
95+
// markdown syntax by hand: these mirror the input-rule e2e tests.
96+
describe('typing strikethrough still works', () => {
97+
it('creates strikethrough when typing ~~on the grass~~', async () => {
98+
const user = userEvent.setup()
99+
const editor = await createEditor()
100+
const view = editor.ctx.get(editorViewCtx)
101+
102+
await user.type(view.dom, 'The lunatic is ~~on the grass~~')
103+
104+
expect(hasStrikethrough(editor)).toBe(true)
105+
})
106+
107+
it('does not create strikethrough for intra-word tildes', async () => {
108+
const user = userEvent.setup()
109+
const editor = await createEditor()
110+
111+
await user.type(
112+
editor.ctx.get(editorViewCtx).dom,
113+
'C:/the/~lunatic~/is/on/the/grass'
114+
)
115+
116+
expect(hasStrikethrough(editor)).toBe(false)
117+
expect(editor.action(getMarkdown())).toBe(
118+
'C:/the/\\~lunatic\\~/is/on/the/grass\n'
119+
)
120+
})
121+
})

packages/plugins/preset-gfm/src/mark/strike-through.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -71,7 +71,7 @@ withMeta(toggleStrikethroughCommand, {
7171
/// Input rule to create the strikethrough mark.
7272
export const strikethroughInputRule = $inputRule((ctx) => {
7373
return markRule(
74-
/(?<![\w:/])(~{1,2})(.+?)\1(?!\w|\/)/,
74+
/(?<![\w:/])(~{1,2})(.+?)\1(?!\w|\/)$/,
7575
strikethroughSchema.type(ctx)
7676
)
7777
})

0 commit comments

Comments
 (0)