Skip to content
Merged
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
@@ -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'
)
})
})
2 changes: 1 addition & 1 deletion packages/plugins/preset-commonmark/src/mark/emphasis.ts
Original file line number Diff line number Diff line change
Expand Up @@ -91,7 +91,7 @@ withMeta(emphasisStarInputRule, {

/// Input rule for use `_` to create emphasis mark.
export const emphasisUnderscoreInputRule = $inputRule((ctx) => {
return markRule(/\b_(?![_\s])(.*?[^_\s])_\b/, emphasisSchema.type(ctx), {
return markRule(/\b_(?![_\s])(.*?[^_\s])_$/, emphasisSchema.type(ctx), {
Comment thread
Saul-Mirone marked this conversation as resolved.
getAttr: () => ({
marker: '_',
}),
Expand Down
121 changes: 121 additions & 0 deletions packages/plugins/preset-gfm/src/__test__/paste-special-char.spec.ts
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'
)
})
})
2 changes: 1 addition & 1 deletion packages/plugins/preset-gfm/src/mark/strike-through.ts
Original file line number Diff line number Diff line change
Expand Up @@ -71,7 +71,7 @@ withMeta(toggleStrikethroughCommand, {
/// Input rule to create the strikethrough mark.
export const strikethroughInputRule = $inputRule((ctx) => {
return markRule(
/(?<![\w:/])(~{1,2})(.+?)\1(?!\w|\/)/,
/(?<![\w:/])(~{1,2})(.+?)\1(?!\w|\/)$/,
Comment thread
Saul-Mirone marked this conversation as resolved.
strikethroughSchema.type(ctx)
)
})
Expand Down
Loading