-
-
Notifications
You must be signed in to change notification settings - Fork 552
fix(preset): anchor mark input rules to the cursor to prevent paste corruption #2433
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from all commits
Commits
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
126 changes: 126 additions & 0 deletions
126
packages/plugins/preset-commonmark/src/__test__/paste-special-char.spec.ts
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,126 @@ | ||
| import '@testing-library/jest-dom/vitest' | ||
| import { Editor, editorViewCtx } from '@milkdown/core' | ||
| import { getMarkdown } from '@milkdown/utils' | ||
| import userEvent from '@testing-library/user-event' | ||
| import { describe, expect, it } from 'vitest' | ||
|
|
||
| import { commonmark } from '..' | ||
|
|
||
| // https://github.com/Milkdown/milkdown/issues/2400 | ||
| // Pasting text that ends with `_` twice and then pressing Space/Enter used to | ||
| // delete characters: the underscore-emphasis input rule was not anchored to the | ||
| // end of the input (`$`), so its regex matched an `_..._` 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 corrupted the document. | ||
|
|
||
| const PASTED = 'This is for qa34%^%^&&&(&(&(&()(*()_' | ||
|
|
||
| async function createEditor() { | ||
| const editor = Editor.make() | ||
| editor.use(commonmark) | ||
| await editor.create() | ||
| return editor | ||
| } | ||
|
|
||
| function hasEmphasis(editor: Editor) { | ||
| const { doc } = editor.ctx.get(editorViewCtx).state | ||
| let found = false | ||
| doc.descendants((node) => { | ||
| if (node.marks.some((mark) => mark.type.name === 'emphasis')) found = true | ||
| }) | ||
| return found | ||
| } | ||
|
|
||
| // Simulate a paste: a single programmatic insertion does not run input rules | ||
| // (those only fire from `handleTextInput` / `handleKeyDown`), exactly like a | ||
| // real clipboard paste. | ||
| function paste(editor: Editor, text: string) { | ||
| const view = editor.ctx.get(editorViewCtx) | ||
| view.dispatch(view.state.tr.insertText(text)) | ||
| } | ||
|
|
||
| describe('pasting special characters (#2400)', () => { | ||
| it('does not delete characters when pressing Space after two pastes', async () => { | ||
| const editor = await createEditor() | ||
| const view = editor.ctx.get(editorViewCtx) | ||
|
|
||
| paste(editor, PASTED) | ||
| paste(editor, PASTED) | ||
|
|
||
| const before = view.state.doc.textContent | ||
| const end = view.state.selection.from | ||
|
|
||
| // This is the exact call prosemirror-view makes when a space is typed. | ||
| // The trailing `deflt` callback is required by the prop type but unused | ||
| // by the input-rule plugin. | ||
| view.someProp('handleTextInput', (f) => | ||
| f(view, end, end, ' ', () => view.state.tr) | ||
| ) | ||
|
|
||
| expect(view.state.doc.textContent).toBe(before) | ||
| expect(hasEmphasis(editor)).toBe(false) | ||
| }) | ||
|
|
||
| it('does not delete characters when pressing Enter after two pastes', async () => { | ||
| const editor = await createEditor() | ||
| const view = editor.ctx.get(editorViewCtx) | ||
|
|
||
| paste(editor, PASTED) | ||
| paste(editor, PASTED) | ||
|
|
||
| const before = view.state.doc.textContent | ||
|
|
||
| // This is the exact call the custom input-rule plugin makes on Enter. | ||
| const event = new KeyboardEvent('keydown', { key: 'Enter' }) | ||
| view.someProp('handleKeyDown', (f) => f(view, event)) | ||
|
|
||
| expect(view.state.doc.textContent).toBe(before) | ||
| expect(hasEmphasis(editor)).toBe(false) | ||
| }) | ||
|
|
||
| it('still turns _word_ into emphasis when the closing _ is typed', async () => { | ||
| const editor = await createEditor() | ||
| const view = editor.ctx.get(editorViewCtx) | ||
|
|
||
| paste(editor, '_word') | ||
|
|
||
| const end = view.state.selection.from | ||
| view.someProp('handleTextInput', (f) => | ||
| f(view, end, end, '_', () => view.state.tr) | ||
| ) | ||
|
|
||
| expect(view.state.doc.textContent).toBe('word') | ||
| expect(hasEmphasis(editor)).toBe(true) | ||
| }) | ||
| }) | ||
|
|
||
| // The `$` anchor added to the underscore rule must not regress typing the | ||
| // markdown syntax by hand: these mirror the input-rule e2e tests. | ||
| describe('typing underscore emphasis still works', () => { | ||
| it('creates emphasis when typing _on the grass_', async () => { | ||
| const user = userEvent.setup() | ||
| const editor = await createEditor() | ||
| const view = editor.ctx.get(editorViewCtx) | ||
|
|
||
| await user.type(view.dom, 'The lunatic is _on the grass_') | ||
|
|
||
| expect(hasEmphasis(editor)).toBe(true) | ||
| expect(editor.action(getMarkdown())).toBe('The lunatic is _on the grass_\n') | ||
| }) | ||
|
|
||
| it('does not create emphasis for intra-word underscores', async () => { | ||
| const user = userEvent.setup() | ||
| const editor = await createEditor() | ||
|
|
||
| await user.type( | ||
| editor.ctx.get(editorViewCtx).dom, | ||
| 'the_lunatic_is_on_the_grass' | ||
| ) | ||
|
|
||
| expect(hasEmphasis(editor)).toBe(false) | ||
| expect(editor.action(getMarkdown())).toBe( | ||
| 'the\\_lunatic\\_is\\_on\\_the\\_grass\n' | ||
| ) | ||
| }) | ||
| }) |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
121 changes: 121 additions & 0 deletions
121
packages/plugins/preset-gfm/src/__test__/paste-special-char.spec.ts
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,121 @@ | ||
| import '@testing-library/jest-dom/vitest' | ||
| import { Editor, editorViewCtx } from '@milkdown/core' | ||
| import { commonmark } from '@milkdown/preset-commonmark' | ||
| import { getMarkdown } from '@milkdown/utils' | ||
| import userEvent from '@testing-library/user-event' | ||
| import { describe, expect, it } from 'vitest' | ||
|
|
||
| import { gfm } from '..' | ||
|
|
||
| // https://github.com/Milkdown/milkdown/issues/2400 | ||
| // The strikethrough input rule shares the same flaw as the underscore-emphasis | ||
| // rule: its regex was not anchored to the end of the input (`$`), so pasting | ||
| // text that ends with `~` twice and then pressing Space/Enter matched a `~...~` | ||
| // span across the two pastes and corrupted the document. | ||
|
|
||
| const PASTED = 'qa34 %~' | ||
|
|
||
| async function createEditor() { | ||
| const editor = Editor.make() | ||
| editor.use(commonmark) | ||
| editor.use(gfm) | ||
| await editor.create() | ||
| return editor | ||
| } | ||
|
|
||
| function hasStrikethrough(editor: Editor) { | ||
| const { doc } = editor.ctx.get(editorViewCtx).state | ||
| let found = false | ||
| doc.descendants((node) => { | ||
| if (node.marks.some((mark) => mark.type.name === 'strike_through')) | ||
| found = true | ||
| }) | ||
| return found | ||
| } | ||
|
|
||
| function paste(editor: Editor, text: string) { | ||
| const view = editor.ctx.get(editorViewCtx) | ||
| view.dispatch(view.state.tr.insertText(text)) | ||
| } | ||
|
|
||
| describe('pasting special characters — strikethrough (#2400)', () => { | ||
| it('does not delete characters when pressing Space after two pastes', async () => { | ||
| const editor = await createEditor() | ||
| const view = editor.ctx.get(editorViewCtx) | ||
|
|
||
| paste(editor, PASTED) | ||
| paste(editor, PASTED) | ||
|
|
||
| const before = view.state.doc.textContent | ||
| const end = view.state.selection.from | ||
|
|
||
| // The trailing `deflt` callback is required by the prop type but unused | ||
| // by the input-rule plugin. | ||
| view.someProp('handleTextInput', (f) => | ||
| f(view, end, end, ' ', () => view.state.tr) | ||
| ) | ||
|
|
||
| expect(view.state.doc.textContent).toBe(before) | ||
| expect(hasStrikethrough(editor)).toBe(false) | ||
| }) | ||
|
|
||
| it('does not delete characters when pressing Enter after two pastes', async () => { | ||
| const editor = await createEditor() | ||
| const view = editor.ctx.get(editorViewCtx) | ||
|
|
||
| paste(editor, PASTED) | ||
| paste(editor, PASTED) | ||
|
|
||
| const before = view.state.doc.textContent | ||
|
|
||
| const event = new KeyboardEvent('keydown', { key: 'Enter' }) | ||
| view.someProp('handleKeyDown', (f) => f(view, event)) | ||
|
|
||
| expect(view.state.doc.textContent).toBe(before) | ||
| expect(hasStrikethrough(editor)).toBe(false) | ||
| }) | ||
|
|
||
| it('still turns ~~word~~ into strikethrough when the closing ~ is typed', async () => { | ||
| const editor = await createEditor() | ||
| const view = editor.ctx.get(editorViewCtx) | ||
|
|
||
| paste(editor, '~~word~') | ||
|
|
||
| const end = view.state.selection.from | ||
| view.someProp('handleTextInput', (f) => | ||
| f(view, end, end, '~', () => view.state.tr) | ||
| ) | ||
|
|
||
| expect(view.state.doc.textContent).toBe('word') | ||
| expect(hasStrikethrough(editor)).toBe(true) | ||
| }) | ||
| }) | ||
|
|
||
| // The `$` anchor added to the strikethrough rule must not regress typing the | ||
| // markdown syntax by hand: these mirror the input-rule e2e tests. | ||
| describe('typing strikethrough still works', () => { | ||
| it('creates strikethrough when typing ~~on the grass~~', async () => { | ||
| const user = userEvent.setup() | ||
| const editor = await createEditor() | ||
| const view = editor.ctx.get(editorViewCtx) | ||
|
|
||
| await user.type(view.dom, 'The lunatic is ~~on the grass~~') | ||
|
|
||
| expect(hasStrikethrough(editor)).toBe(true) | ||
| }) | ||
|
|
||
| it('does not create strikethrough for intra-word tildes', async () => { | ||
| const user = userEvent.setup() | ||
| const editor = await createEditor() | ||
|
|
||
| await user.type( | ||
| editor.ctx.get(editorViewCtx).dom, | ||
| 'C:/the/~lunatic~/is/on/the/grass' | ||
| ) | ||
|
|
||
| expect(hasStrikethrough(editor)).toBe(false) | ||
| expect(editor.action(getMarkdown())).toBe( | ||
| 'C:/the/\\~lunatic\\~/is/on/the/grass\n' | ||
| ) | ||
| }) | ||
| }) |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.