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
79 changes: 79 additions & 0 deletions packages/core/src/extensions/atom-source-box.test.ts
Original file line number Diff line number Diff line change
@@ -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<HTMLElement>('[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)
})
})
5 changes: 4 additions & 1 deletion packages/core/src/extensions/caret-rect.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
11 changes: 9 additions & 2 deletions packages/core/src/extensions/virtual-caret.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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()
Expand Down
22 changes: 16 additions & 6 deletions packages/core/src/style.css
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
}

Expand Down
19 changes: 10 additions & 9 deletions packages/core/src/utils/virtual-element.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 2 additions & 2 deletions packages/react/src/components/editor.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -357,12 +357,12 @@ describe('MeowdownEditor', () => {
const screen = await render(
<MeowdownEditor initialMarkdown="A [[link]]" mode="hide" editorClassName="first" />,
)
await expect.element(source).toHaveStyle({ fontSize: '0px' })
await expect.element(source).toHaveStyle({ width: '0px', opacity: '0' })

await screen.rerender(
<MeowdownEditor initialMarkdown="A [[link]]" mode="hide" editorClassName="second" />,
)
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
Expand Down
120 changes: 120 additions & 0 deletions website/public/repro-hidden-anchor.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,120 @@
<!doctype html>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>iOS hidden-anchor relocation repro</title>
<style>
body {
font:
17px/1.5 -apple-system,
sans-serif;
margin: 12px;
}
[contenteditable] {
border: 1px solid #999;
padding: 8px;
}
.list-item {
display: flex;
}
.list-marker {
user-select: none;
-webkit-user-select: none;
color: #888;
margin-right: 6px;
}
.atom {
display: contents;
}
.preview {
user-select: none;
-webkit-user-select: none;
background: #e0ecff;
border-radius: 4px;
padding: 0 4px;
}
.source {
font-size: 0;
letter-spacing: 0;
opacity: 0;
}
#log {
font:
11px/1.4 ui-monospace,
monospace;
white-space: pre-wrap;
margin-top: 8px;
}
button {
font-size: 15px;
padding: 6px 10px;
margin: 8px 4px 0 0;
}
</style>

<p>
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.
</p>

<div id="editor" contenteditable="true">
<div class="list-item">
<div class="list-marker" contenteditable="false">&bull;</div>
<div class="list-content">
<p id="para">
<span class="atom"
><span class="preview" contenteditable="false">Cat care basics</span
><span class="source">[[Cat care basics]]</span></span
>
</p>
</div>
</div>
<p>plain text line</p>
</div>

<button id="collapse-hidden">into hidden text</button>
<button id="collapse-para">at p offset 0</button>
<button id="clear-log">clear</button>

<div id="log"></div>

<script>
const logEl = document.getElementById('log')
const t0 = performance.now()
function describeEl(el) {
if (el == null || el.nodeType !== 1) return String(el && el.nodeName).toLowerCase()
return (
el.nodeName.toLowerCase() +
(el.className ? '.' + String(el.className).split(' ').join('.') : '')
)
}
function describe(node, offset) {
if (node == null) return 'null'
const name =
node.nodeType === 3
? 'text(' + JSON.stringify(node.nodeValue.slice(0, 20)) + ')@' + describeEl(node.parentNode)
: describeEl(node)
return name + '+' + offset
}
function log(msg) {
logEl.textContent += (performance.now() - t0).toFixed(1).padStart(8) + ' ' + msg + '\n'
}
document.addEventListener('selectionchange', () => {
const sel = getSelection()
log('selectionchange anchor=' + describe(sel.anchorNode, sel.anchorOffset))
})
function set(node, offset) {
document.getElementById('editor').focus()
getSelection().collapse(node, offset)
log('SET anchor=' + describe(node, offset))
}
document.getElementById('collapse-hidden').addEventListener('click', () => {
set(document.querySelector('.source').firstChild, 0)
})
document.getElementById('collapse-para').addEventListener('click', () => {
set(document.getElementById('para'), 0)
})
document.getElementById('clear-log').addEventListener('click', () => {
logEl.textContent = ''
})
</script>
60 changes: 60 additions & 0 deletions website/src/anchor-debug.ts
Original file line number Diff line number Diff line change
@@ -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')
}
6 changes: 6 additions & 0 deletions website/src/app.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
Loading
Loading