From f843ac63d51b4bc1dfb929dd2566f1a932e9fc1b Mon Sep 17 00:00:00 2001 From: ocavue Date: Fri, 7 Aug 2026 17:16:25 +1000 Subject: [PATCH 1/3] fix: apply the `spellcheck` attribute at mount and stop pausing it around edits --- .../core/src/extensions/spell-check.test.ts | 37 ++++++ packages/core/src/extensions/spell-check.ts | 123 +----------------- packages/core/src/utils/is-mark-step.ts | 19 --- 3 files changed, 44 insertions(+), 135 deletions(-) create mode 100644 packages/core/src/extensions/spell-check.test.ts delete mode 100644 packages/core/src/utils/is-mark-step.ts diff --git a/packages/core/src/extensions/spell-check.test.ts b/packages/core/src/extensions/spell-check.test.ts new file mode 100644 index 00000000..b991bb9c --- /dev/null +++ b/packages/core/src/extensions/spell-check.test.ts @@ -0,0 +1,37 @@ +import { describe, expect, it } from 'vitest' +import { page } from 'vitest/browser' + +import { setupFixture } from '../testing/index.ts' + +import { defineSpellCheckPlugin } from './spell-check.ts' + +const pmRoot = page.locate('.ProseMirror') + +describe('defineSpellCheckPlugin', () => { + it('applies the value at mount, before any input', async () => { + using fixture = setupFixture() + fixture.editor.use(defineSpellCheckPlugin(false)) + await expect.element(pmRoot).toHaveAttribute('spellcheck', 'false') + }) + + it('keeps the attribute through edits', async () => { + using fixture = setupFixture() + fixture.editor.use(defineSpellCheckPlugin(true)) + const { n, view } = fixture + fixture.set(n.doc(n.paragraph('helo world'))) + await expect.element(pmRoot).toHaveAttribute('spellcheck', 'true') + view.dispatch(view.state.tr.insertText('x', 5)) + // Synchronous read on purpose: the attribute must hold right after the + // edit, not only once a poll settles. + expect(fixture.dom.spellcheck).toBe(true) + }) + + it('follows the value when the extension is replaced', async () => { + using fixture = setupFixture() + const removeExtension = fixture.editor.use(defineSpellCheckPlugin(false)) + await expect.element(pmRoot).toHaveAttribute('spellcheck', 'false') + removeExtension() + fixture.editor.use(defineSpellCheckPlugin(true)) + await expect.element(pmRoot).toHaveAttribute('spellcheck', 'true') + }) +}) diff --git a/packages/core/src/extensions/spell-check.ts b/packages/core/src/extensions/spell-check.ts index a5bad38b..6bbc2c53 100644 --- a/packages/core/src/extensions/spell-check.ts +++ b/packages/core/src/extensions/spell-check.ts @@ -1,122 +1,13 @@ -import { definePlugin, type PlainExtension } from '@prosekit/core' -import type { Transaction } from '@prosekit/pm/state' -import { Plugin, PluginKey } from '@prosekit/pm/state' -import type { EditorView } from '@prosekit/pm/view' +import type { PlainExtension } from '@prosekit/core' -import { isMarkStep } from '../utils/is-mark-step.ts' +import { defineViewAttributes } from './view-attributes.ts' -const SPELL_CHECK_PAUSE_TIMEOUT = 1200 - -function hasContentChanged(transactions: readonly Transaction[]): boolean { - for (const tr of transactions) { - for (const step of tr.steps) { - if (!isMarkStep(step)) { - return true - } - } - } - return false -} /** - * Stop macOS from rewriting straight punctuation into "smart" punctuation as - * the user types. - * - * On macOS, WebKit applies the system "smart quotes and dashes" substitution - * inside `contenteditable` when `spellcheck` is true. Typing right after the hidden - * `` sizing comment that backs an image lets - * it rewrite the `--` in `-->` into an em dash. which invalidates the comment so - * meowdown can no longer parse it and it leaks into the note as literal text. - * - * We disable the `spellcheck` attribute for a few seconds before any doc - * change transaction. This would prevent the smart punctuation substitution from happening. + * Set the `spellcheck` attribute on the editable root, turning the browser's + * native spell checking on or off. The value lands at mount time, before the + * element can receive focus (iOS reads the flag at focus time to derive the + * keyboard's smart-punctuation traits). */ -function createSpellCheckPluginState(spellCheck: boolean) { - let view: EditorView | undefined - let timeoutId: ReturnType | undefined - let paused = false - let currentValue: boolean | undefined - - const update = () => { - const dom = view && !view.isDestroyed && view.dom - if (!dom) return - - const newValue = spellCheck && !paused - - if (newValue !== currentValue) { - currentValue = newValue - dom.spellcheck = newValue - } - } - - const pause = () => { - if (timeoutId) { - clearTimeout(timeoutId) - } - paused = true - update() - timeoutId = setTimeout(() => { - paused = false - update() - }, SPELL_CHECK_PAUSE_TIMEOUT) - } - - return { - pause, - apply(transactions: readonly Transaction[]): void { - if (hasContentChanged(transactions)) { - pause() - } - }, - - view(editorView: EditorView) { - view = editorView - return { - destroy() { - view = undefined - }, - } - }, - } -} - -type SpellCheckPluginState = ReturnType - -function createSpellCheckPlugin(spellCheck: boolean) { - const spellCheckKey = new PluginKey('spell-check') - - return new Plugin({ - key: spellCheckKey, - - state: { - init: (): SpellCheckPluginState => { - return createSpellCheckPluginState(spellCheck) - }, - apply: (tr, pluginState) => { - return pluginState - }, - }, - - view(view) { - const plugnState = spellCheckKey.getState(view.state) - return plugnState?.view(view) || {} - }, - - props: { - handleDOMEvents: { - beforeinput: (view) => { - const plugnState = spellCheckKey.getState(view.state) - plugnState?.pause() - }, - }, - }, - - appendTransaction(transactions, state) { - const plugnState = spellCheckKey.getState(state) - plugnState?.apply(transactions) - }, - }) -} - export function defineSpellCheckPlugin(spellCheck: boolean): PlainExtension { - return definePlugin(createSpellCheckPlugin(spellCheck)) + return defineViewAttributes({ spellcheck: spellCheck ? 'true' : 'false' }) } diff --git a/packages/core/src/utils/is-mark-step.ts b/packages/core/src/utils/is-mark-step.ts deleted file mode 100644 index e16b488d..00000000 --- a/packages/core/src/utils/is-mark-step.ts +++ /dev/null @@ -1,19 +0,0 @@ -import { - AddMarkStep, - RemoveNodeMarkStep, - AddNodeMarkStep, - RemoveMarkStep, - type Step, -} from '@prosekit/pm/transform' - -import { BatchSetMarkStep } from '../extensions/batch-set-mark-step.ts' - -export function isMarkStep(step: Step): boolean { - return ( - step instanceof AddMarkStep || - step instanceof AddNodeMarkStep || - step instanceof RemoveMarkStep || - step instanceof RemoveNodeMarkStep || - step instanceof BatchSetMarkStep - ) -} From 3c6fe88040154e37395599380676e5f03019021d Mon Sep 17 00:00:00 2001 From: ocavue Date: Fri, 7 Aug 2026 17:45:23 +1000 Subject: [PATCH 2/3] feat: block system text substitutions that corrupt Markdown syntax --- packages/core/src/extensions/extension.ts | 2 + .../system-substitution-guard.test.ts | 95 +++++++++++++++++++ .../extensions/system-substitution-guard.ts | 84 ++++++++++++++++ 3 files changed, 181 insertions(+) create mode 100644 packages/core/src/extensions/system-substitution-guard.test.ts create mode 100644 packages/core/src/extensions/system-substitution-guard.ts diff --git a/packages/core/src/extensions/extension.ts b/packages/core/src/extensions/extension.ts index 019e9002..72ee4e4f 100644 --- a/packages/core/src/extensions/extension.ts +++ b/packages/core/src/extensions/extension.ts @@ -39,6 +39,7 @@ import { defineMeowdownParagraph } from './paragraph.ts' import { definePendingReplacement } from './pending-replacement.ts' import { defineScrollToSelection } from './scroll-to-selection.ts' import { defineSelectDocBoundary } from './select-doc-boundary.ts' +import { defineSystemSubstitutionGuard } from './system-substitution-guard.ts' import { defineTable } from './table.ts' import { defineViewAttributes } from './view-attributes.ts' import { defineWikilink } from './wikilink.ts' @@ -77,6 +78,7 @@ function defineEditorExtensionImpl(options: EditorExtensionOptions) { defineClipboard(), defineScrollToSelection(), defineHiddenRunCaret(), + defineSystemSubstitutionGuard(), defineAtomMarkNavigation({ marks: ATOM_SOURCE_MARK_NAMES.map((name) => ({ name, modes: ['hide', 'focus', 'show'] })), }), diff --git a/packages/core/src/extensions/system-substitution-guard.test.ts b/packages/core/src/extensions/system-substitution-guard.test.ts new file mode 100644 index 00000000..68d59e57 --- /dev/null +++ b/packages/core/src/extensions/system-substitution-guard.test.ts @@ -0,0 +1,95 @@ +import { isSafari } from '@meowdown/vitest/helpers' +import { describe, expect, it } from 'vitest' + +import { findText } from '../testing/find-text.ts' +import { setupFixture } from '../testing/index.ts' + +import { isProtectedRange } from './system-substitution-guard.ts' + +function dispatchReplacement(target: HTMLElement, init: InputEventInit): InputEvent { + const event = new InputEvent('beforeinput', { cancelable: true, bubbles: true, ...init }) + target.dispatchEvent(event) + return event +} + +describe('defineSystemSubstitutionGuard', () => { + it('blocks a replacement that inserts smart punctuation', () => { + using fixture = setupFixture() + const { n } = fixture + fixture.set(n.doc(n.paragraph('a--b'))) + const event = dispatchReplacement(fixture.dom, { + inputType: 'insertReplacementText', + data: '—', + }) + expect(event.defaultPrevented).toBe(true) + }) + + // WebKit's synthetic InputEvent constructor drops the `dataTransfer` init + // entry; only trusted events carry one there. + it.skipIf(isSafari())('blocks smart punctuation delivered through the data transfer', () => { + using fixture = setupFixture() + const { n } = fixture + fixture.set(n.doc(n.paragraph('say "hi"'))) + const transfer = new DataTransfer() + transfer.setData('text/plain', '“hi”') + const event = dispatchReplacement(fixture.dom, { + inputType: 'insertReplacementText', + dataTransfer: transfer, + }) + expect(event.defaultPrevented).toBe(true) + }) + + it('lets a word-level replacement through', () => { + using fixture = setupFixture() + const { n } = fixture + fixture.set(n.doc(n.paragraph('thier plan'))) + const event = dispatchReplacement(fixture.dom, { + inputType: 'insertReplacementText', + data: 'their', + }) + expect(event.defaultPrevented).toBe(false) + }) + + it('ignores other input types', () => { + using fixture = setupFixture() + const { n } = fixture + fixture.set(n.doc(n.paragraph('a'))) + const event = dispatchReplacement(fixture.dom, { + inputType: 'insertText', + data: '—', + }) + expect(event.defaultPrevented).toBe(false) + }) +}) + +describe('isProtectedRange', () => { + it('protects syntax characters but not visible prose', () => { + using fixture = setupFixture() + const { n } = fixture + fixture.set(n.doc(n.paragraph('**bold** text'))) + const syntaxStart = findText(fixture.doc, '**') + const contentStart = findText(fixture.doc, 'bold') + const proseStart = findText(fixture.doc, 'text') + expect(isProtectedRange(fixture.state, syntaxStart, syntaxStart + 2)).toBe(true) + expect(isProtectedRange(fixture.state, contentStart, contentStart + 4)).toBe(false) + expect(isProtectedRange(fixture.state, proseStart, proseStart + 4)).toBe(false) + }) + + it('protects atom sources', () => { + using fixture = setupFixture() + const { n } = fixture + fixture.set(n.doc(n.paragraph('see [[note]] here'))) + const targetStart = findText(fixture.doc, 'note') + expect(isProtectedRange(fixture.state, targetStart, targetStart + 4)).toBe(true) + }) + + it('protects inline code and code blocks', () => { + using fixture = setupFixture() + const { n } = fixture + fixture.set(n.doc(n.paragraph('run `teh` now'), n.codeBlock('teh value'))) + const inlineStart = findText(fixture.doc, 'teh') + expect(isProtectedRange(fixture.state, inlineStart, inlineStart + 3)).toBe(true) + const blockStart = findText(fixture.doc, 'teh value') + expect(isProtectedRange(fixture.state, blockStart, blockStart + 3)).toBe(true) + }) +}) diff --git a/packages/core/src/extensions/system-substitution-guard.ts b/packages/core/src/extensions/system-substitution-guard.ts new file mode 100644 index 00000000..daa14bfd --- /dev/null +++ b/packages/core/src/extensions/system-substitution-guard.ts @@ -0,0 +1,84 @@ +import { definePlugin, type PlainExtension } from '@prosekit/core' +import type { EditorState } from '@prosekit/pm/state' +import { Plugin } from '@prosekit/pm/state' +import type { EditorView } from '@prosekit/pm/view' + +import { ATOM_MARK_NAMES, isMarkOfTypes, SYNTAX_MARK_NAMES, type MarkName } from './mark-names.ts' + +// En/em dash and curly quotes: the characters macOS "smart quotes and dashes" +// rewrites already-typed straight punctuation into. +const SMART_PUNCTUATION = /[–—‘’“”]/ + +// Ranges where any OS rewrite breaks parsing: syntax characters, atom sources +// (wiki links, image/file/math sources, including an image's trailing sizing +// comment), and code. +const PROTECTED_MARK_NAMES: readonly MarkName[] = [ + ...SYNTAX_MARK_NAMES, + ...ATOM_MARK_NAMES, + 'mdCode', +] + +/** + * Whether `[from, to)` touches text the OS must not rewrite: characters + * carrying a syntax, atom-source, or code mark, or a code block's content. + */ +export function isProtectedRange(state: EditorState, from: number, to: number): boolean { + let found = false + state.doc.nodesBetween(from, to, (node) => { + if (found) return false + if (node.isTextblock && node.type.spec.code) found = true + if (node.isText && node.marks.some((mark) => isMarkOfTypes(mark, PROTECTED_MARK_NAMES))) { + found = true + } + return !found + }) + return found +} + +function shouldBlockReplacement(view: EditorView, event: InputEvent): boolean { + if (event.inputType !== 'insertReplacementText') return false + + const replacement = event.dataTransfer?.getData('text/plain') || event.data || '' + if (SMART_PUNCTUATION.test(replacement)) return true + + for (const staticRange of event.getTargetRanges()) { + let from: number + let to: number + try { + from = view.posAtDOM(staticRange.startContainer, staticRange.startOffset) + to = view.posAtDOM(staticRange.endContainer, staticRange.endOffset) + } catch { + continue + } + if (from >= 0 && to >= from && isProtectedRange(view.state, from, to)) return true + } + return false +} + +/** + * Block the OS text substitutions that would corrupt Markdown syntax. + * + * With the `spellcheck` attribute on, macOS WebKit rewrites already-typed text + * near the caret (smart quotes/dashes, autocorrect, user text replacements), + * delivering each rewrite as a cancelable `beforeinput` with + * `inputType: 'insertReplacementText'`. A rewrite is cancelled when its + * replacement contains smart punctuation (`defineSubstitution` covers that + * typography deliberately, with undo and code-span exemptions) or when its + * target range is protected per {@link isProtectedRange}. Word-level + * autocorrect in visible prose passes through. + */ +export function defineSystemSubstitutionGuard(): PlainExtension { + return definePlugin( + new Plugin({ + props: { + handleDOMEvents: { + beforeinput: (view, event) => { + if (!shouldBlockReplacement(view, event)) return false + event.preventDefault() + return true + }, + }, + }, + }), + ) +} From 172061c0e09935478dbc51e5bfa4da8df9370a1d Mon Sep 17 00:00:00 2001 From: ocavue Date: Fri, 7 Aug 2026 22:37:27 +1000 Subject: [PATCH 3/3] refactor: reduce the substitution guard to the em dash rewrite --- .../system-substitution-guard.test.ts | 43 +---------- .../extensions/system-substitution-guard.ts | 76 +++---------------- 2 files changed, 14 insertions(+), 105 deletions(-) diff --git a/packages/core/src/extensions/system-substitution-guard.test.ts b/packages/core/src/extensions/system-substitution-guard.test.ts index 68d59e57..31bbdfc8 100644 --- a/packages/core/src/extensions/system-substitution-guard.test.ts +++ b/packages/core/src/extensions/system-substitution-guard.test.ts @@ -1,11 +1,8 @@ import { isSafari } from '@meowdown/vitest/helpers' import { describe, expect, it } from 'vitest' -import { findText } from '../testing/find-text.ts' import { setupFixture } from '../testing/index.ts' -import { isProtectedRange } from './system-substitution-guard.ts' - function dispatchReplacement(target: HTMLElement, init: InputEventInit): InputEvent { const event = new InputEvent('beforeinput', { cancelable: true, bubbles: true, ...init }) target.dispatchEvent(event) @@ -13,7 +10,7 @@ function dispatchReplacement(target: HTMLElement, init: InputEventInit): InputEv } describe('defineSystemSubstitutionGuard', () => { - it('blocks a replacement that inserts smart punctuation', () => { + it('blocks a replacement that inserts an em dash', () => { using fixture = setupFixture() const { n } = fixture fixture.set(n.doc(n.paragraph('a--b'))) @@ -26,12 +23,12 @@ describe('defineSystemSubstitutionGuard', () => { // WebKit's synthetic InputEvent constructor drops the `dataTransfer` init // entry; only trusted events carry one there. - it.skipIf(isSafari())('blocks smart punctuation delivered through the data transfer', () => { + it.skipIf(isSafari())('blocks an em dash delivered through the data transfer', () => { using fixture = setupFixture() const { n } = fixture - fixture.set(n.doc(n.paragraph('say "hi"'))) + fixture.set(n.doc(n.paragraph('a--b'))) const transfer = new DataTransfer() - transfer.setData('text/plain', '“hi”') + transfer.setData('text/plain', '—') const event = dispatchReplacement(fixture.dom, { inputType: 'insertReplacementText', dataTransfer: transfer, @@ -61,35 +58,3 @@ describe('defineSystemSubstitutionGuard', () => { expect(event.defaultPrevented).toBe(false) }) }) - -describe('isProtectedRange', () => { - it('protects syntax characters but not visible prose', () => { - using fixture = setupFixture() - const { n } = fixture - fixture.set(n.doc(n.paragraph('**bold** text'))) - const syntaxStart = findText(fixture.doc, '**') - const contentStart = findText(fixture.doc, 'bold') - const proseStart = findText(fixture.doc, 'text') - expect(isProtectedRange(fixture.state, syntaxStart, syntaxStart + 2)).toBe(true) - expect(isProtectedRange(fixture.state, contentStart, contentStart + 4)).toBe(false) - expect(isProtectedRange(fixture.state, proseStart, proseStart + 4)).toBe(false) - }) - - it('protects atom sources', () => { - using fixture = setupFixture() - const { n } = fixture - fixture.set(n.doc(n.paragraph('see [[note]] here'))) - const targetStart = findText(fixture.doc, 'note') - expect(isProtectedRange(fixture.state, targetStart, targetStart + 4)).toBe(true) - }) - - it('protects inline code and code blocks', () => { - using fixture = setupFixture() - const { n } = fixture - fixture.set(n.doc(n.paragraph('run `teh` now'), n.codeBlock('teh value'))) - const inlineStart = findText(fixture.doc, 'teh') - expect(isProtectedRange(fixture.state, inlineStart, inlineStart + 3)).toBe(true) - const blockStart = findText(fixture.doc, 'teh value') - expect(isProtectedRange(fixture.state, blockStart, blockStart + 3)).toBe(true) - }) -}) diff --git a/packages/core/src/extensions/system-substitution-guard.ts b/packages/core/src/extensions/system-substitution-guard.ts index daa14bfd..f207aca8 100644 --- a/packages/core/src/extensions/system-substitution-guard.ts +++ b/packages/core/src/extensions/system-substitution-guard.ts @@ -1,79 +1,23 @@ import { definePlugin, type PlainExtension } from '@prosekit/core' -import type { EditorState } from '@prosekit/pm/state' import { Plugin } from '@prosekit/pm/state' -import type { EditorView } from '@prosekit/pm/view' - -import { ATOM_MARK_NAMES, isMarkOfTypes, SYNTAX_MARK_NAMES, type MarkName } from './mark-names.ts' - -// En/em dash and curly quotes: the characters macOS "smart quotes and dashes" -// rewrites already-typed straight punctuation into. -const SMART_PUNCTUATION = /[–—‘’“”]/ - -// Ranges where any OS rewrite breaks parsing: syntax characters, atom sources -// (wiki links, image/file/math sources, including an image's trailing sizing -// comment), and code. -const PROTECTED_MARK_NAMES: readonly MarkName[] = [ - ...SYNTAX_MARK_NAMES, - ...ATOM_MARK_NAMES, - 'mdCode', -] - -/** - * Whether `[from, to)` touches text the OS must not rewrite: characters - * carrying a syntax, atom-source, or code mark, or a code block's content. - */ -export function isProtectedRange(state: EditorState, from: number, to: number): boolean { - let found = false - state.doc.nodesBetween(from, to, (node) => { - if (found) return false - if (node.isTextblock && node.type.spec.code) found = true - if (node.isText && node.marks.some((mark) => isMarkOfTypes(mark, PROTECTED_MARK_NAMES))) { - found = true - } - return !found - }) - return found -} - -function shouldBlockReplacement(view: EditorView, event: InputEvent): boolean { - if (event.inputType !== 'insertReplacementText') return false - - const replacement = event.dataTransfer?.getData('text/plain') || event.data || '' - if (SMART_PUNCTUATION.test(replacement)) return true - - for (const staticRange of event.getTargetRanges()) { - let from: number - let to: number - try { - from = view.posAtDOM(staticRange.startContainer, staticRange.startOffset) - to = view.posAtDOM(staticRange.endContainer, staticRange.endOffset) - } catch { - continue - } - if (from >= 0 && to >= from && isProtectedRange(view.state, from, to)) return true - } - return false -} /** - * Block the OS text substitutions that would corrupt Markdown syntax. - * - * With the `spellcheck` attribute on, macOS WebKit rewrites already-typed text - * near the caret (smart quotes/dashes, autocorrect, user text replacements), - * delivering each rewrite as a cancelable `beforeinput` with - * `inputType: 'insertReplacementText'`. A rewrite is cancelled when its - * replacement contains smart punctuation (`defineSubstitution` covers that - * typography deliberately, with undo and code-span exemptions) or when its - * target range is protected per {@link isProtectedRange}. Word-level - * autocorrect in visible prose passes through. + * Block the macOS "smart dashes" rewrite. With the `spellcheck` attribute on, + * WebKit rewrites already-typed `--` near the caret into an em dash and + * delivers the rewrite as a cancelable `beforeinput` with + * `inputType: 'insertReplacementText'`. That corrupts Markdown such as the + * `-->` closing an image sizing comment, so cancel any replacement that + * inserts an em dash; word-level autocorrect passes through. */ export function defineSystemSubstitutionGuard(): PlainExtension { return definePlugin( new Plugin({ props: { handleDOMEvents: { - beforeinput: (view, event) => { - if (!shouldBlockReplacement(view, event)) return false + beforeinput: (_view, event) => { + if (event.inputType !== 'insertReplacementText') return false + const replacement = event.dataTransfer?.getData('text/plain') || event.data || '' + if (!replacement.includes('—')) return false event.preventDefault() return true },