diff --git a/packages/core/src/extensions/caret-perch.test.ts b/packages/core/src/extensions/caret-perch.test.ts new file mode 100644 index 00000000..8d4c68c4 --- /dev/null +++ b/packages/core/src/extensions/caret-perch.test.ts @@ -0,0 +1,91 @@ +import { TextSelection } from '@prosekit/pm/state' +import { describe, expect, it } from 'vitest' +import { userEvent } from 'vitest/browser' + +import { findText } from '../testing/find-text.ts' +import { setupFixture, type Fixture } from '../testing/index.ts' + +import type { MarkMode } from './mark-mode.ts' + +const SOURCE = '[[Cat care basics]]' + +// A bullet whose only content is one wikilink: the layout that oscillates on +// iOS when the DOM anchor sinks into the hidden atom source. +function setup(mode: MarkMode): Fixture { + const fixture = setupFixture({ extensionOptions: { markMode: mode } }) + const { n } = fixture + fixture.set( + n.doc(n.list({ kind: 'bullet' }, n.paragraph(SOURCE)), n.paragraph('plain trailing paragraph')), + ) + fixture.view.focus() + return fixture +} + +function dropCaret(fixture: Fixture, pos: number): void { + fixture.view.dispatch(fixture.state.tr.setSelection(TextSelection.create(fixture.doc, pos))) +} + +// The element the DOM selection anchor effectively rests in. +function anchorElement(): Element | null { + const anchor = document.getSelection()?.anchorNode + if (anchor == null) return null + return anchor.nodeType === Node.TEXT_NODE ? anchor.parentElement : (anchor as Element) +} + +// The iOS-stability guard: iOS relocates anchors that have no caret geometry +// (hidden atom source text), so a visible anchor is the condition that keeps +// it from moving. This is checkable on desktop; the loop itself is not. +function expectHealthyAnchor(): void { + const el = anchorElement() + expect(el).not.toBeNull() + expect(el!.closest('.md-atom-view-content')).toBeNull() + expect(getComputedStyle(el!).fontSize).not.toBe('0px') +} + +const ALL_MODES: MarkMode[] = ['hide', 'focus', 'show'] + +describe.each(ALL_MODES)('caret perch in %s mode', (mode) => { + it('anchors the caret before the atom on visible geometry', () => { + using fixture = setup(mode) + dropCaret(fixture, findText(fixture.doc, SOURCE)) + const perch = fixture.dom.querySelector('.md-caret-perch') + expect(perch).not.toBeNull() + expect(perch!.parentElement?.nodeName).toBe('P') + expectHealthyAnchor() + }) + + it('anchors the caret after the atom on visible geometry', () => { + using fixture = setup(mode) + dropCaret(fixture, findText(fixture.doc, SOURCE) + SOURCE.length) + const perch = fixture.dom.querySelector('.md-caret-perch') + expect(perch).not.toBeNull() + expect(perch!.parentElement?.nodeName).toBe('P') + expectHealthyAnchor() + }) + + it('mounts no perch beside plain text', () => { + using fixture = setup(mode) + dropCaret(fixture, findText(fixture.doc, 'plain trailing') + 2) + expect(fixture.dom.querySelector('.md-caret-perch')).toBeNull() + }) +}) + +describe('caret perch editing', () => { + it('typing at the atom boundaries lands beside the source, without ZWSP', async () => { + using fixture = setup('hide') + const start = findText(fixture.doc, SOURCE) + dropCaret(fixture, start + SOURCE.length) + await userEvent.keyboard('B') + dropCaret(fixture, findText(fixture.doc, SOURCE)) + await userEvent.keyboard('A') + expect(fixture.doc.textContent).toContain(`A${SOURCE}B`) + expect(fixture.doc.textContent).not.toContain('\u{200B}') + }) + + it('Backspace after the atom still deletes it as a unit', async () => { + using fixture = setup('hide') + dropCaret(fixture, findText(fixture.doc, SOURCE) + SOURCE.length) + await userEvent.keyboard('{Backspace}') + expect(fixture.doc.textContent).not.toContain('Cat care') + }) +}) diff --git a/packages/core/src/extensions/caret-perch.ts b/packages/core/src/extensions/caret-perch.ts new file mode 100644 index 00000000..fb657799 --- /dev/null +++ b/packages/core/src/extensions/caret-perch.ts @@ -0,0 +1,218 @@ +import { definePlugin, isTextSelection, type PlainExtension } from '@prosekit/core' +import type { EditorState, PluginView } from '@prosekit/pm/state' +import { Plugin, PluginKey } from '@prosekit/pm/state' +import { Decoration, DecorationSet } from '@prosekit/pm/view' +import type { EditorView } from '@prosekit/pm/view' + +import { getMarkMode } from './mark-mode.ts' +import { ATOM_SOURCE_MARK_NAMES } from './mark-names.ts' +import { getMarkRangeAfter, getMarkRangeBefore } from './mark-range.ts' + +const key = new PluginKey('meowdown-caret-perch') + +// Experimental instrumentation for the real-device test sessions. Strip before +// this leaves the experiment branch. +function log(...args: unknown[]): void { + console.log('[caret-perch]', ...args) +} + +interface PerchSpot { + pos: number + side: 0 | -1 +} + +// The caret position whose DOM anchor would land in a hidden atom source, and +// the widget side that intercepts it. prosemirror-view's domFromPos resolves a +// caret at the textblock start through the NEXT child (side +1) and every +// other caret through the end of the PREVIOUS child (side -1); a mark view is +// never a stopping point (border 0, domAtom false), so an atom source on the +// entered side swallows the anchor into its zero-size text. A widget IS a +// stopping point (domAtom true), but only when the child scan can reach it: +// the back-off loop before the scan skips zero-size widgets with side >= 0, +// which is exactly what parks the scan on the widget for the side +1 walk and +// why the side -1 walk needs a side < 0 widget instead. +function findPerchSpot(state: EditorState): PerchSpot | undefined { + if (getMarkMode(state) == null) return + const selection = state.selection + if (!isTextSelection(selection) || !selection.empty) return + const $head = selection.$head + if (!$head.parent.isTextblock || $head.parent.type.spec.code) return + const pos = selection.head + if ($head.parentOffset === 0) { + if (getMarkRangeAfter(state, pos, ATOM_SOURCE_MARK_NAMES)) return { pos, side: 0 } + return + } + if (getMarkRangeBefore(state, pos, ATOM_SOURCE_MARK_NAMES)) return { pos, side: -1 } + return +} + +// A zero-width inline box (styled in style.css) with line height: real +// geometry for the caret to anchor beside, unlike the font-size: 0 atom +// source. Deliberately empty: the first device round showed iOS diving into a +// zero-width-space text node inside the widget, a position prosemirror-view's +// selection-equivalence scan can never reach (it skips the whole widget), so +// the two sides fought at ~60Hz. With no text node inside, every landing iOS +// can pick is an element offset beside the perch. +function createPerchDOM(): HTMLElement { + const span = document.createElement('span') + span.className = 'md-caret-perch' + span.contentEditable = 'false' + log('perch DOM created') + return span +} + +function buildDecorations(state: EditorState): DecorationSet | null { + const spot = findPerchSpot(state) + if (spot == null) return null + const widget = Decoration.widget(spot.pos, createPerchDOM, { + key: `md-caret-perch:${spot.side}`, + side: spot.side, + // Stay a direct child of the textblock: nested inside the atom's mark + // spans the anchor would sit in hidden territory again. + marks: [], + // Both flags stop prosemirror-view from fighting the browser over which + // exact side of the perch the DOM selection rests on. + relaxedSide: true, + ignoreSelection: true, + destroy: () => log('perch DOM destroyed'), + }) + return DecorationSet.create(state.doc, [widget]) +} + +function describeElement(el: Element | null): string { + if (el == null) return 'null' + const name = el.nodeName.toLowerCase() + const className = typeof el.className === 'string' && el.className ? el.className : '' + return className ? `${name}.${className.replaceAll(' ', '.')}` : name +} + +function describeDOMPosition(node: Node | null, offset: number): string { + if (node == null) return 'null' + if (node.nodeType === Node.TEXT_NODE) { + const text = JSON.stringify((node.nodeValue ?? '').slice(0, 24)) + return `text(${text})@${describeElement(node.parentElement)}+${offset}` + } + return `${describeElement(node as Element)}+${offset}` +} + +// Console reporter for the oscillation experiment: every selectionchange with +// its DOM anchor, plus perch mount/move/unmount transitions with the mounted +// element's actual DOM parent (the fix only works when that parent is the +// textblock itself, never a mark span). +class PerchLogView implements PluginView { + readonly #view: EditorView + #lastSpot = 'none' + #eventCount = 0 + readonly #start = performance.now() + + constructor(view: EditorView) { + this.#view = view + document.addEventListener('selectionchange', this.#handleSelectionChange) + log('attached; initial spot:', this.#describeSpot()) + } + + update() { + const spot = this.#describeSpot() + if (spot === this.#lastSpot) return + this.#lastSpot = spot + if (spot === 'none') { + log(this.#stamp(), 'perch unmounted') + return + } + const el = this.#view.dom.querySelector('.md-caret-perch') + const parent = el?.parentElement ?? null + log( + this.#stamp(), + `perch ${spot}`, + `domParent=${describeElement(parent)}`, + `insideMarkSpan=${String(el?.closest('.md-atom-view, .md-mark') != null)}`, + ) + } + + destroy() { + document.removeEventListener('selectionchange', this.#handleSelectionChange) + log('detached') + } + + #stamp(): string { + return (performance.now() - this.#start).toFixed(1) + } + + #describeSpot(): string { + const spot = findPerchSpot(this.#view.state) + return spot == null ? 'none' : `pos=${spot.pos} side=${spot.side}` + } + + readonly #handleSelectionChange = (): void => { + const sel = document.getSelection() + this.#eventCount += 1 + log( + this.#stamp(), + `selectionchange #${this.#eventCount}`, + `anchor=${describeDOMPosition(sel?.anchorNode ?? null, sel?.anchorOffset ?? 0)}`, + `pmHead=${this.#view.state.selection.head}`, + `focus=${this.#view.hasFocus()}`, + ) + this.#acceptBenignAnchor(sel) + } + + // Round two of the device experiment: beside the perch, iOS and + // prosemirror-view can disagree about which of several EQUALLY HEALTHY DOM + // positions holds the anchor (observed at a paragraph end, where iOS parks + // it after prosemirror-view's own `ProseMirror-separator` image while + // prosemirror-view insists on the slot before it; its equivalence scan + // hard-fails across any IMG, so it rewrote every frame). When the browser's + // landing maps to the exact selection prosemirror-view already holds and + // does not sit in hidden source text, adopt it as the observer's tracked + // selection so the next flush has nothing to correct. On iOS the flush runs + // after this listener, so adoption wins the race; on desktop the flush has + // usually already rewritten by now, making this a no-op. + #acceptBenignAnchor(sel: Selection | null): void { + const view = this.#view + if (sel == null || sel.anchorNode == null || !sel.isCollapsed) return + if (!view.hasFocus() || view.composing) return + const selection = view.state.selection + if (!isTextSelection(selection) || !selection.empty) return + if (findPerchSpot(view.state) == null) return + if (!view.dom.contains(sel.anchorNode)) return + const anchorEl = + sel.anchorNode.nodeType === Node.TEXT_NODE + ? sel.anchorNode.parentElement + : (sel.anchorNode as Element) + // A landing inside hidden source is the disease itself; never adopt it. + if (anchorEl == null || anchorEl.closest('.md-atom-view-content') != null) return + let pos: number + try { + pos = view.posAtDOM(sel.anchorNode, sel.anchorOffset) + } catch { + return + } + if (pos !== selection.head) return + const observer = (view as unknown as { domObserver?: { setCurSelection?: () => void } }) + .domObserver + if (observer?.setCurSelection == null) return + observer.setCurSelection() + log( + this.#stamp(), + `adopted benign anchor ${describeDOMPosition(sel.anchorNode, sel.anchorOffset)} for pos ${pos}`, + ) + } +} + +/** + * Experimental fix for the iOS DOM-anchor oscillation on atom boundaries: a + * caret-following "perch" widget that gives the DOM selection a real anchor + * point beside hidden atom sources. At most one widget exists at a time, so + * the whole-document decoration cost stays O(1). + */ +export function defineCaretPerch(): PlainExtension { + return definePlugin( + new Plugin({ + key, + props: { + decorations: buildDecorations, + }, + view: (view) => new PerchLogView(view), + }), + ) +} diff --git a/packages/core/src/extensions/extension.ts b/packages/core/src/extensions/extension.ts index 019e9002..e3fec38a 100644 --- a/packages/core/src/extensions/extension.ts +++ b/packages/core/src/extensions/extension.ts @@ -13,6 +13,7 @@ import { defineText } from '@prosekit/extensions/text' import { defineVirtualSelection } from '@prosekit/extensions/virtual-selection' import { defineAtomMarkNavigation } from './atom-mark-navigation.ts' +import { defineCaretPerch } from './caret-perch.ts' import { defineClipboard } from './clipboard/clipboard.ts' import { defineCodeBlockSyntaxHighlight } from './code-block-highlight.ts' import { defineCodeBlock } from './code-block.ts' @@ -80,6 +81,7 @@ function defineEditorExtensionImpl(options: EditorExtensionOptions) { defineAtomMarkNavigation({ marks: ATOM_SOURCE_MARK_NAMES.map((name) => ({ name, modes: ['hide', 'focus', 'show'] })), }), + defineCaretPerch(), // others defineBaseKeymap(), diff --git a/packages/core/src/style.css b/packages/core/src/style.css index 6dacfcd0..2c235242 100644 --- a/packages/core/src/style.css +++ b/packages/core/src/style.css @@ -785,6 +785,17 @@ /* atom marks (image, wikilink) */ .ProseMirror { + /* The caret perch (caret-perch.ts): an empty zero-width box that still + * spans the line height, giving the DOM selection real geometry to anchor + * beside a hidden atom source. Empty on purpose; a text node inside would + * be a landing spot iOS enters and ProseMirror cannot accept. */ + .md-caret-perch { + display: inline-block; + width: 0; + height: 1em; + vertical-align: text-bottom; + } + .md-atom-view { display: contents; } diff --git a/website/public/repro-hidden-anchor.html b/website/public/repro-hidden-anchor.html new file mode 100644 index 00000000..c7e87398 --- /dev/null +++ b/website/public/repro-hidden-anchor.html @@ -0,0 +1,120 @@ + + + +
+ Framework-free repro for the meowdown wikilink-bullet anchor oscillation. Tap a button to collapse + the DOM selection into (or beside) the hidden (font-size: 0) text, then watch whether this browser + relocates the anchor on its own. No editor library involved. +
+ ++ Cat care basics[[Cat care basics]] +
+plain text line
+