From 8bb01c16a1106d6e229bfddecbeff93333c7a9e9 Mon Sep 17 00:00:00 2001 From: ocavue Date: Tue, 30 Jun 2026 21:38:59 +1000 Subject: [PATCH] fix: keep the caret out of hidden marker --- .../src/extensions/caret-marker-snap.test.ts | 173 ++++++++++++++++++ .../core/src/extensions/caret-marker-snap.ts | 148 +++++++++++++++ packages/core/src/extensions/extension.ts | 2 + 3 files changed, 323 insertions(+) create mode 100644 packages/core/src/extensions/caret-marker-snap.test.ts create mode 100644 packages/core/src/extensions/caret-marker-snap.ts diff --git a/packages/core/src/extensions/caret-marker-snap.test.ts b/packages/core/src/extensions/caret-marker-snap.test.ts new file mode 100644 index 00000000..127ed18d --- /dev/null +++ b/packages/core/src/extensions/caret-marker-snap.test.ts @@ -0,0 +1,173 @@ +import { TextSelection } from '@prosekit/pm/state' +import { describe, expect, it } from 'vitest' +import { userEvent } from 'vitest/browser' + +import { docToMarkdown } from '../converters/pm-to-md.ts' +import { findText } from '../testing/find-text.ts' +import { setupFixture, type Fixture } from '../testing/index.ts' + +import { defineMarkMode, type MarkMode } from './mark-mode.ts' + +function setupMode(mode: MarkMode, doc: (fixture: Fixture) => void): Fixture { + const fixture = setupFixture() + fixture.editor.use(defineMarkMode(mode)) + doc(fixture) + fixture.view.focus() + return fixture +} + +function setCaret(fixture: Fixture, pos: number): void { + const { view } = fixture + view.dispatch(view.state.tr.setSelection(TextSelection.create(view.state.doc, pos))) +} + +/** Dispatch a real (synthetic) left click at viewport coords, then settle. */ +async function clickAtCoords(fixture: Fixture, clientX: number, clientY: number): Promise { + const target = document.elementFromPoint(clientX, clientY) ?? fixture.view.dom + const init = { clientX, clientY, bubbles: true, cancelable: true, view: window, button: 0 } + target.dispatchEvent(new MouseEvent('mousedown', init)) + target.dispatchEvent(new MouseEvent('mouseup', init)) + target.dispatchEvent(new MouseEvent('click', init)) + await new Promise((resolve) => setTimeout(resolve, 15)) +} + +/** Click at the left edge of the first occurrence of `query`. */ +async function clickLeftEdgeOf(fixture: Fixture, query: string): Promise { + const coords = fixture.view.coordsAtPos(findText(fixture.doc, query)) + await clickAtCoords(fixture, coords.left, (coords.top + coords.bottom) / 2) +} + +async function traverse( + fixture: Fixture, + start: number, + key: string, + times: number, +): Promise { + setCaret(fixture, start) + const steps = [fixture.selectionSnapshot] + for (let index = 0; index < times; index++) { + await userEvent.keyboard(`{${key}}`) + steps.push(fixture.selectionSnapshot) + } + return steps.join(' -> ') +} + +describe('caret-marker-snap (hide mode)', () => { + it('a click left of "foo" then Enter keeps the bold intact', async () => { + using fixture = setupMode('hide', (f) => f.set(f.n.doc(f.n.paragraph('**foo**')))) + await clickLeftEdgeOf(fixture, 'foo') + await userEvent.keyboard('{Enter}') + expect(docToMarkdown(fixture.doc)).toBe('\n**foo**\n') + }) + + // KNOWN FAILING (display:none DOM-caret ambiguity): the click snaps the model + // selection to pos 1 (verified), but Space/typing go through the browser's DOM + // insertion, which resolves the zero-width hidden `**` to pos 3 anyway. Enter + // works because it uses the model selection (the Enter keymap); text input does not. + it('a click left of "foo" then Space inserts outside the bold', async () => { + using fixture = setupMode('hide', (f) => f.set(f.n.doc(f.n.paragraph('**foo**')))) + await clickLeftEdgeOf(fixture, 'foo') + await userEvent.keyboard(' ') + expect(docToMarkdown(fixture.doc)).toBe(' **foo**\n') + }) + + // KNOWN FAILING (same display:none DOM-caret ambiguity as the Space case above). + it('a click left of "foo" then typing inserts outside the bold', async () => { + using fixture = setupMode('hide', (f) => f.set(f.n.doc(f.n.paragraph('**foo**')))) + await clickLeftEdgeOf(fixture, 'foo') + await userEvent.keyboard('x') + expect(docToMarkdown(fixture.doc)).toBe('x**foo**\n') + }) + + it('ArrowLeft into the opening run then Enter keeps the bold intact', async () => { + using fixture = setupMode('hide', (f) => f.set(f.n.doc(f.n.paragraph('**bold**')))) + setCaret(fixture, findText(fixture.doc, 'bold') + 1) // after "b" + await userEvent.keyboard('{ArrowLeft}') + await userEvent.keyboard('{Enter}') + expect(docToMarkdown(fixture.doc)).toBe('\n**bold**\n') + }) + + // DOCUMENTS CURRENT BEHAVIOR (a known limitation): in hide mode the browser + // already skips the display:none markers natively, but it gets STUCK at the + // content edge - ArrowRight stops at pos 6 (before the hidden closing `**`) and + // never reaches pos 8. No selection change fires there, so appendTransaction + // cannot nudge it across. Ideal would reach the unit's outer edge. + it('hide-mode arrow nav stops at the content edge (known limitation)', async () => { + using fixture = setupMode('hide', (f) => f.set(f.n.doc(f.n.paragraph('**foo**')))) + expect(await traverse(fixture, 1, 'ArrowRight', 6)).toMatchInlineSnapshot( + `"┃**foo** -> **f┃oo** -> **fo┃o** -> **foo┃** -> **foo┃** -> **foo┃** -> **foo┃**"`, + ) + expect(await traverse(fixture, 8, 'ArrowLeft', 6)).toMatchInlineSnapshot( + `"**foo**┃ -> **fo┃o** -> **f┃oo** -> **┃foo** -> **┃foo** -> **┃foo** -> **┃foo**"`, + ) + }) + + // DOCUMENTS CURRENT BEHAVIOR: the caret reaches the single character (after "a") + // and the unit's left edge, so `**a**` is no longer skipped as an atom. + it('a single-character bold **a** is reachable by the caret', async () => { + using fixture = setupMode('hide', (f) => f.set(f.n.doc(f.n.paragraph('**a**')))) + expect(await traverse(fixture, 1, 'ArrowRight', 4)).toMatchInlineSnapshot( + `"┃**a** -> **a┃** -> **a┃** -> **a┃** -> **a┃**"`, + ) + }) + + it('Enter before "a" in **a** keeps the bold and adds an empty paragraph', async () => { + using fixture = setupMode('hide', (f) => f.set(f.n.doc(f.n.paragraph('**a**')))) + setCaret(fixture, findText(fixture.doc, 'a')) // content edge before "a" + await userEvent.keyboard('{Enter}') + expect(docToMarkdown(fixture.doc)).toBe('\n**a**\n') + }) + + it('Enter after "a" in **a** keeps the bold and adds an empty paragraph', async () => { + using fixture = setupMode('hide', (f) => f.set(f.n.doc(f.n.paragraph('**a**')))) + setCaret(fixture, findText(fixture.doc, 'a') + 1) // content edge after "a" + await userEvent.keyboard('{Enter}') + expect(docToMarkdown(fixture.doc)).toBe('**a**\n') + }) + + it('a click left of "foo" inside a bullet then Enter keeps the bold intact', async () => { + using fixture = setupMode('hide', (f) => + f.set(f.n.doc(f.n.list({ kind: 'bullet' }, f.n.paragraph('**foo**')))), + ) + await clickLeftEdgeOf(fixture, 'foo') + await userEvent.keyboard('{Enter}') + expect(docToMarkdown(fixture.doc)).toBe('-\n- **foo**\n') + }) + + it('Enter before "foo" in nested ***foo*** keeps the whole unit intact', async () => { + using fixture = setupMode('hide', (f) => f.set(f.n.doc(f.n.paragraph('***foo***')))) + setCaret(fixture, findText(fixture.doc, 'foo')) + await userEvent.keyboard('{Enter}') + expect(docToMarkdown(fixture.doc)).toBe('\n***foo***\n') + }) + + // KNOWN FAILING (hide-mode click hit-testing on a link is unreliable): the + // synthetic click on the link text lands at the paragraph start, not the link + // edge, so the empty line is inserted before "x" instead of before the link. + // The zone/snap logic itself handles a link's `](url)` tail (see unitInfoAt's + // hidden-mark walk); it is the click landing that is off here. + it('a click left of a link text then Enter keeps the link intact', async () => { + using fixture = setupMode('hide', (f) => + f.set(f.n.doc(f.n.paragraph('x [docs](http://example.test) y'))), + ) + await clickLeftEdgeOf(fixture, 'docs') + await userEvent.keyboard('{Enter}') + expect(docToMarkdown(fixture.doc)).toBe('x \n[docs](http://example.test) y\n') + }) +}) + +describe('caret-marker-snap (inert outside hide mode)', () => { + it('focus mode is unchanged: Enter at a revealed marker splits as before', async () => { + using fixture = setupMode('focus', (f) => f.set(f.n.doc(f.n.paragraph('**bold**')))) + setCaret(fixture, findText(fixture.doc, 'bold')) // between the revealed ** and "bold" + await userEvent.keyboard('{Enter}') + expect(docToMarkdown(fixture.doc)).toBe('**\n\nbold**\n') + }) + + it('show mode is unchanged: Enter at the marker boundary splits as before', async () => { + using fixture = setupMode('show', (f) => f.set(f.n.doc(f.n.paragraph('**bold**')))) + setCaret(fixture, findText(fixture.doc, 'bold')) + await userEvent.keyboard('{Enter}') + expect(docToMarkdown(fixture.doc)).toBe('**\n\nbold**\n') + }) +}) diff --git a/packages/core/src/extensions/caret-marker-snap.ts b/packages/core/src/extensions/caret-marker-snap.ts new file mode 100644 index 00000000..f9c982bc --- /dev/null +++ b/packages/core/src/extensions/caret-marker-snap.ts @@ -0,0 +1,148 @@ +import { + defineKeymap, + definePlugin, + getMarkRange, + getMarkType, + Priority, + union, + withPriority, + type PlainExtension, +} from '@prosekit/core' +import { + Plugin, + PluginKey, + TextSelection, + type Command, + type EditorState, +} from '@prosekit/pm/state' + +import { getMarkMode } from './mark-mode.ts' +import type { MarkName } from './mark-names.ts' + +/** + * In `hide` mode the Markdown syntax of an inline unit (`**`, `[`, `](url)`, ...) + * is real text that is never rendered, so the caret can rest in an invisible + * "dead zone" between a hidden marker run and the visible content. Acting there + * (Enter, Space, typing) silently corrupts the unit. These two hooks keep the + * caret out of that dead zone while leaving the content - down to a single + * character - reachable. The feature is inert outside `hide` mode, where the + * markers are visible (`show`) or revealed near the caret (`focus`). + */ + +// Marks whose characters `mark-mode` hides in `hide` mode (mirrors its CSS +// `display:none` rules and its clipboard strip set). A contiguous run of these +// is invisible, so the caret must not act inside it. +const HIDDEN_MARKS: ReadonlySet = new Set([ + 'mdMark', + 'mdLinkUri', + 'mdLinkTitle', +]) + +function charIsHidden(state: EditorState, charStart: number): boolean { + let hidden = false + state.doc.nodesBetween(charStart, charStart + 1, (node) => { + if (node.isText) + hidden = node.marks.some((mark) => HIDDEN_MARKS.has(mark.type.name as MarkName)) + }) + return hidden +} + +interface UnitInfo { + from: number + to: number + /** End of the leading hidden run, i.e. the content's opening edge (after `**` / `[`). */ + openTo: number + /** Start of the trailing hidden run, i.e. the content's closing edge (before `**` / `](url)`). */ + closeFrom: number +} + +/** The `mdPack` unit around `pos`, with its hidden marker runs measured by walking. */ +function unitInfoAt(state: EditorState, pos: number): UnitInfo | undefined { + const $pos = state.doc.resolve(pos) + if (!$pos.parent.isTextblock || $pos.parent.type.spec.code) return undefined + const unit = getMarkRange($pos, getMarkType(state.schema, 'mdPack')) + if (!unit) return undefined + let openTo = unit.from + while (openTo < unit.to && charIsHidden(state, openTo)) openTo++ + let closeFrom = unit.to + while (closeFrom > unit.from && charIsHidden(state, closeFrom - 1)) closeFrom-- + return { from: unit.from, to: unit.to, openTo, closeFrom } +} + +/** `pos` is inside the leading hidden run or at its content edge. */ +function inOpening(info: UnitInfo, pos: number): boolean { + return pos > info.from && pos <= info.openTo && info.openTo < info.to +} +/** `pos` is inside the trailing hidden run or at its content edge. */ +function inClosing(info: UnitInfo, pos: number): boolean { + return pos >= info.closeFrom && pos < info.to && info.closeFrom > info.from +} + +/** The unit's outer edge a pointer click or Enter relocates to, or `undefined`. */ +function outerEdge(info: UnitInfo, pos: number): number | undefined { + if (inOpening(info, pos)) return info.from + if (inClosing(info, pos)) return info.to + return undefined +} + +const snapPluginKey = new PluginKey('meowdown-caret-marker-snap') + +function createSnapPlugin(): Plugin { + return new Plugin({ + key: snapPluginKey, + appendTransaction: (transactions, oldState, newState) => { + if (getMarkMode(newState) !== 'hide') return null + const { selection } = newState + if (!selection.empty) return null + const pos = selection.from + const info = unitInfoAt(newState, pos) + if (!info) return null + + // prosemirror-view tags pointer-originated selections with this meta in + // `updateSelection`: + // https://code.haverbeke.berlin/prosemirror/prosemirror-view/src/tag/1.41.9/src/input.ts#L191 + const isPointer = transactions.some((tr) => !!tr.getMeta('pointer')) + + let target: number | undefined + if (isPointer) { + // A click aims at the visible content edge, i.e. outside the unit. + target = outerEdge(info, pos) + } else { + // Keyboard: skip only the invisible marker interior, in the travel + // direction. Content edges stay reachable so the content is navigable. + const dir = pos >= oldState.selection.from ? 1 : -1 + if (pos > info.from && pos < info.openTo) { + target = dir > 0 ? info.openTo : info.from + } else if (pos > info.closeFrom && pos < info.to) { + target = dir > 0 ? info.to : info.closeFrom + } + } + if (target == null || target === pos) return null + return newState.tr.setSelection(TextSelection.create(newState.doc, target)) + }, + }) +} + +/** + * Enter at a content edge would split the unit's hidden syntax from its content + * (`**foo**` -> `**` / `foo**`). Relocate the caret to the unit's outer edge, + * then return `false` so the normal Enter chain (flat-list / base) splits there. + */ +const enterOutsideMarkers: Command = (state, dispatch) => { + if (getMarkMode(state) !== 'hide') return false + const { selection } = state + if (!selection.empty) return false + const info = unitInfoAt(state, selection.from) + if (!info) return false + const target = outerEdge(info, selection.from) + if (target == null || target === selection.from) return false + dispatch?.(state.tr.setSelection(TextSelection.create(state.doc, target))) + return false +} + +export function defineCaretMarkerSnap(): PlainExtension { + return union( + definePlugin(createSnapPlugin()), + withPriority(defineKeymap({ Enter: enterOutsideMarkers }), Priority.high), + ) +} diff --git a/packages/core/src/extensions/extension.ts b/packages/core/src/extensions/extension.ts index 5e80591c..dbc29999 100644 --- a/packages/core/src/extensions/extension.ts +++ b/packages/core/src/extensions/extension.ts @@ -14,6 +14,7 @@ import { defineText } from '@prosekit/extensions/text' import { defineVirtualSelection } from '@prosekit/extensions/virtual-selection' import { defineAtomMarkNavigation } from './atom-mark-navigation.ts' +import { defineCaretMarkerSnap } from './caret-marker-snap.ts' import { defineCodeBlockSyntaxHighlight } from './code-block-highlight.ts' import { defineEditorCommands } from './commands.ts' import { defineDocFrontmatterAttr } from './frontmatter.ts' @@ -59,6 +60,7 @@ function defineEditorExtensionImpl() { { name: 'mdWikilink', modes: ['hide', 'focus', 'show'] }, ], }), + defineCaretMarkerSnap(), // others defineBaseKeymap(),