Skip to content
Draft
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
2 changes: 2 additions & 0 deletions packages/core/src/extensions/extension.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -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'] })),
}),
Expand Down
37 changes: 37 additions & 0 deletions packages/core/src/extensions/spell-check.test.ts
Original file line number Diff line number Diff line change
@@ -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')
})
})
123 changes: 7 additions & 116 deletions packages/core/src/extensions/spell-check.ts
Original file line number Diff line number Diff line change
@@ -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
* `<!-- {"width":..,"height":..} -->` 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<typeof setTimeout> | 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<typeof createSpellCheckPluginState>

function createSpellCheckPlugin(spellCheck: boolean) {
const spellCheckKey = new PluginKey<SpellCheckPluginState>('spell-check')

return new Plugin<SpellCheckPluginState>({
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' })
}
60 changes: 60 additions & 0 deletions packages/core/src/extensions/system-substitution-guard.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
import { isSafari } from '@meowdown/vitest/helpers'
import { describe, expect, it } from 'vitest'

import { setupFixture } from '../testing/index.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 an em dash', () => {
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 an em dash delivered through the data transfer', () => {
using fixture = setupFixture()
const { n } = fixture
fixture.set(n.doc(n.paragraph('a--b')))
const transfer = new DataTransfer()
transfer.setData('text/plain', '—')
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)
})
})
28 changes: 28 additions & 0 deletions packages/core/src/extensions/system-substitution-guard.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
import { definePlugin, type PlainExtension } from '@prosekit/core'
import { Plugin } from '@prosekit/pm/state'

/**
* 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 (event.inputType !== 'insertReplacementText') return false
const replacement = event.dataTransfer?.getData('text/plain') || event.data || ''
if (!replacement.includes('—')) return false
event.preventDefault()
return true
},
},
},
}),
)
}
19 changes: 0 additions & 19 deletions packages/core/src/utils/is-mark-step.ts

This file was deleted.

Loading