diff --git a/packages/core/src/extensions/atom-source-box.test.ts b/packages/core/src/extensions/atom-source-box.test.ts new file mode 100644 index 00000000..b12fe652 --- /dev/null +++ b/packages/core/src/extensions/atom-source-box.test.ts @@ -0,0 +1,79 @@ +import { TextSelection } from '@prosekit/pm/state' +import { describe, expect, it } from 'vitest' + +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]]' + +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))) +} + +function sourceTextNode(fixture: Fixture): Text { + const el = fixture.dom.querySelector('.md-atom-view-content')! + return el.firstChild as Text +} + +const ALL_MODES: MarkMode[] = ['hide', 'focus', 'show'] + +// iOS WebKit only anchors the DOM selection at positions whose text has +// inline box geometry (`RenderText::containsCaretOffset`); a geometryless +// anchor gets relocated every frame, fighting prosemirror-view's rewrite. +// These are the two halves of the fix's contract: the hidden source has +// geometry, and that geometry still occupies no layout space. +describe.each(ALL_MODES)('atom source box in %s mode', (mode) => { + it('gives every source text position caret geometry', () => { + using fixture = setup(mode) + const text = sourceTextNode(fixture) + for (const offset of [0, Math.floor(SOURCE.length / 2), SOURCE.length]) { + const range = document.createRange() + range.setStart(text, offset) + range.collapse(true) + const rects = Array.from(range.getClientRects()).filter((rect) => rect.height > 0) + expect(rects.length, `offset ${offset}`).toBeGreaterThan(0) + } + }) + + it('keeps the source box at zero width', () => { + using fixture = setup(mode) + const box = fixture.dom.querySelector('.md-atom-view-content')! + expect(box.getBoundingClientRect().width).toBe(0) + }) + + it('keeps the DOM anchor inside the source and PM does not fight it', () => { + using fixture = setup(mode) + dropCaret(fixture, findText(fixture.doc, SOURCE)) + const sel = document.getSelection()! + const el = + sel.anchorNode!.nodeType === Node.TEXT_NODE + ? sel.anchorNode!.parentElement! + : (sel.anchorNode as Element) + expect(el.closest('.md-atom-view-content')).not.toBeNull() + expect(getComputedStyle(el).fontSize).not.toBe('0px') + }) + + it('still draws the virtual caret at the label edge', async () => { + using fixture = setup(mode) + dropCaret(fixture, findText(fixture.doc, SOURCE) + SOURCE.length) + await new Promise((resolve) => queueMicrotask(() => resolve(undefined))) + const caret = document.querySelector('[data-testid="virtual-caret"]')! + const label = fixture.dom.querySelector('.md-atom-view-preview')! + const caretRect = caret.getBoundingClientRect() + const labelRect = label.getBoundingClientRect() + expect(Math.abs(caretRect.left - labelRect.right)).toBeLessThan(2) + expect(caretRect.height).toBeGreaterThan(10) + }) +}) diff --git a/packages/core/src/extensions/caret-rect.ts b/packages/core/src/extensions/caret-rect.ts index ad76e7a2..5c030de9 100644 --- a/packages/core/src/extensions/caret-rect.ts +++ b/packages/core/src/extensions/caret-rect.ts @@ -120,5 +120,8 @@ function findAtomPreviewElement(view: EditorView, insidePos: number): Element | * Undefined when the head has no measurable geometry at all. */ export function measureCaretScrollRect(view: EditorView): CaretRect | undefined { - return findCoordsCaretRect(view) ?? findAtomCaretRect(view) + // Atom first, mirroring the virtual caret's own measuring order: the atom + // source keeps real inline boxes now, so the coords probe would report the + // source box's geometry instead of the preview fragment the caret hugs. + return findAtomCaretRect(view) ?? findCoordsCaretRect(view) } diff --git a/packages/core/src/extensions/virtual-caret.ts b/packages/core/src/extensions/virtual-caret.ts index 5f6b0779..03c89358 100644 --- a/packages/core/src/extensions/virtual-caret.ts +++ b/packages/core/src/extensions/virtual-caret.ts @@ -137,7 +137,14 @@ class VirtualCaretView implements PluginView { return } - const nativeRect = findNativeCaretRect(view) + // Measure the atom preview first: the atom source keeps real inline boxes + // (style.css) so the DOM selection has geometry to anchor on, and the + // native and coords measurements would report that geometry, one shared + // point inside the source box for both unit edges. The preview + // measurement keeps the drawn caret hugging the correct label edge, the + // same rect it drew when the source measured as nothing. + const atomRect = findAtomCaretRect(view) + const nativeRect = atomRect == null ? findNativeCaretRect(view) : undefined // Use the native rect if it exists and the last input modality was touch. // This ensures that we can render the drag magnifier on touch devices. @@ -147,7 +154,7 @@ class VirtualCaretView implements PluginView { return } - const viewportRect = measureCaretRect(view, nativeRect) + const viewportRect = atomRect ?? measureCaretRect(view, nativeRect) let rect: CaretRect | undefined if (viewportRect != null) { const layerRect = this.#layer.getBoundingClientRect() diff --git a/packages/core/src/style.css b/packages/core/src/style.css index 6dacfcd0..a2be54ae 100644 --- a/packages/core/src/style.css +++ b/packages/core/src/style.css @@ -813,14 +813,24 @@ } } - /* The source keeps a zero-width inline box (font-size: 0, same as .md-mark) - * instead of leaving the box tree (display: none): a boxless source next to - * the contenteditable=false preview makes the browser relocate typing at a - * textblock start past the whole mark view. */ + /* The source keeps a zero-width inline box instead of leaving the box + * tree (display: none): a boxless source next to the contenteditable=false + * preview makes the browser relocate typing at a textblock start past the + * whole mark view. Unlike font-size: 0, the text keeps its natural size + * inside a width: 0 box, so every caret position in the source has real + * inline box geometry: iOS WebKit only anchors the DOM selection at + * positions with caret geometry and relocates a geometryless anchor every + * frame, fighting prosemirror-view's selection sync. The glyphs overflow + * the box invisibly (opacity) and untouchably (pointer-events), so taps + * aimed at content after the atom keep hitting that content. */ .md-atom-view-content { - font-size: 0; - letter-spacing: 0; + display: inline-block; + width: 0; + /* pre, not nowrap: nowrap collapses whitespace, and the browser then + * types NBSP instead of a plain space when the caret anchors in here. */ + white-space: pre; opacity: 0; + pointer-events: none; } } diff --git a/packages/core/src/utils/virtual-element.ts b/packages/core/src/utils/virtual-element.ts index 79d14ebe..d1098f8a 100644 --- a/packages/core/src/utils/virtual-element.ts +++ b/packages/core/src/utils/virtual-element.ts @@ -33,22 +33,23 @@ export function getVirtualElementFromRange(view: EditorView, range: PositionRang let lastRect = new DOMRect(0, 0, 0, 0) const getBoundingClientRect = (): DOMRect => { if (view.isDestroyed) return lastRect - // Bias both measurements into the range's own content. Measured outward - // (the default `side`), an edge that sits against hidden markdown syntax - // at a block boundary has no visible neighbor and yields a bogus - // zero rect, anchoring the popover at the viewport corner. An edge - // touching an atom unit (image, wikilink, file) has no measurable glyph on - // either side; its preview element is the visible geometry. An edge - // against plain hidden syntax anchors on the visible glyph past the run. + // Bias both measurements into the range's own content. An edge touching + // an atom unit (image, wikilink, file) anchors on its preview element, + // measured first: the source text keeps real inline boxes whose glyphs + // overflow the zero-width source box invisibly, so a coords probe there + // would report geometry far from anything visible. An edge that sits + // against hidden markdown syntax at a block boundary has no visible + // neighbor and yields a bogus zero rect (anchoring the popover at the + // viewport corner); it anchors on the visible glyph past the run. const start = - tryCoordsAtPos(view, range.from, 1) ?? findAtomEdgeRect(view, range.from, 1) ?? + tryCoordsAtPos(view, range.from, 1) ?? tryHiddenRunCoords(view, range.from, 1) ?? tryCoordsAtPos(view, range.from, -1) if (start == null) return lastRect const end = - tryCoordsAtPos(view, range.to, -1) ?? findAtomEdgeRect(view, range.to, -1) ?? + tryCoordsAtPos(view, range.to, -1) ?? tryHiddenRunCoords(view, range.to, -1) ?? tryCoordsAtPos(view, range.to, 1) if (end == null) return lastRect diff --git a/packages/react/src/components/editor.test.tsx b/packages/react/src/components/editor.test.tsx index 9cb33891..b03b63f1 100644 --- a/packages/react/src/components/editor.test.tsx +++ b/packages/react/src/components/editor.test.tsx @@ -357,12 +357,12 @@ describe('MeowdownEditor', () => { const screen = await render( , ) - await expect.element(source).toHaveStyle({ fontSize: '0px' }) + await expect.element(source).toHaveStyle({ width: '0px', opacity: '0' }) await screen.rerender( , ) - await expect.element(source).toHaveStyle({ fontSize: '0px' }) + await expect.element(source).toHaveStyle({ width: '0px', opacity: '0' }) }) // The test browser forces `prefers-reduced-motion: reduce`, which zeroes the 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 @@ + + + +iOS hidden-anchor relocation repro + + +

+ 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

+
+ + + + + +
+ + diff --git a/website/src/anchor-debug.ts b/website/src/anchor-debug.ts new file mode 100644 index 00000000..cb125faa --- /dev/null +++ b/website/src/anchor-debug.ts @@ -0,0 +1,60 @@ +// Temporary instrumentation for the iOS atom-anchor experiment. Attributes +// every selection movement to its writer: JS-initiated writes (ProseMirror) +// go through the wrapped Selection methods and log a `W` line at call time; +// a `selectionchange` with no recent `W` line means the browser moved the +// selection on its own. Delete with the experiment branch. + +const start = performance.now() +let lastWriteTime = -1 + +function stamp(): string { + return (performance.now() - start).toFixed(1) +} + +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: unknown, offset: unknown): string { + if (!(node instanceof Node)) return String(node) + if (node.nodeType === Node.TEXT_NODE) { + const text = JSON.stringify((node.nodeValue ?? '').slice(0, 24)) + return `text(${text})@${describeElement(node.parentElement)}+${String(offset)}` + } + return `${describeElement(node as Element)}+${String(offset)}` +} + +function log(...args: unknown[]): void { + console.log('[anchor-debug]', ...args) +} + +const METHODS = ['collapse', 'extend', 'setBaseAndExtent', 'addRange', 'removeAllRanges'] as const + +export function installAnchorDebug(): void { + for (const method of METHODS) { + // eslint-disable-next-line @typescript-eslint/unbound-method -- re-applied with the original `this` below + const original = Selection.prototype[method] as (this: Selection, ...args: unknown[]) => unknown + Object.defineProperty(Selection.prototype, method, { + value: function (this: Selection, ...args: unknown[]) { + lastWriteTime = performance.now() + log(stamp(), `W ${method}`, describeDOMPosition(args[0], args[1])) + return original.apply(this, args) + }, + }) + } + document.addEventListener('selectionchange', () => { + const sel = document.getSelection() + const sinceWrite = lastWriteTime < 0 ? Infinity : performance.now() - lastWriteTime + log( + stamp(), + `S anchor=${describeDOMPosition(sel?.anchorNode ?? null, sel?.anchorOffset ?? 0)}`, + sinceWrite < 30 + ? `js-write ${sinceWrite.toFixed(0)}ms ago` + : 'no recent js write (browser moved it)', + ) + }) + log('installed') +} diff --git a/website/src/app.tsx b/website/src/app.tsx index ababcb5e..8675441d 100644 --- a/website/src/app.tsx +++ b/website/src/app.tsx @@ -137,6 +137,12 @@ Drop a [link](https://github.com/prosekit/meowdown) and keep on writing. Label your notes with tags like #meow and #markdown. Type \`#\` followed by a letter to see suggestions. Connect notes with wikilinks like [[Daily journal]] and [[Reading list]]. Type \`[[\` to link another note. + +Bullets for the atom-source-box experiment (tap into each on the phone, then hold still for two seconds): + +- [[Cat care basics]] +- [[Daily journal]] with trailing text +- plain text bullet Select some text and click the sparkle button (or press \`Mod-Shift-J\`) to run a command on it. The result streams into a preview, and nothing changes until you accept it. Track things two ways. Type \`+ \` for a circle checkbox task, or \`[] \` for a square checkbox task: diff --git a/website/src/main.tsx b/website/src/main.tsx index 3f87b4a2..a706e4cf 100644 --- a/website/src/main.tsx +++ b/website/src/main.tsx @@ -1,8 +1,11 @@ import { StrictMode } from 'react' import { createRoot } from 'react-dom/client' +import { installAnchorDebug } from './anchor-debug.ts' import { App } from './app.tsx' +installAnchorDebug() + createRoot(document.getElementById('root')!).render(