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
91 changes: 91 additions & 0 deletions packages/core/src/extensions/caret-perch.test.ts
Original file line number Diff line number Diff line change
@@ -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`)

Check failure on line 81 in packages/core/src/extensions/caret-perch.test.ts

View workflow job for this annotation

GitHub Actions / test-mac-webkit

[@meowdown/core (webkit)] src/extensions/caret-perch.test.ts > caret perch editing > typing at the atom boundaries lands beside the source, without ZWSP

AssertionError: expected '[[Cat care basics]]Bplain trailing pa…' to contain 'A[[Cat care basics]]B' Expected: "A[[Cat care basics]]B" Received: "[[Cat care basics]]Bplain trailing paragraph" ❯ src/extensions/caret-perch.test.ts:81:46

Check failure on line 81 in packages/core/src/extensions/caret-perch.test.ts

View workflow job for this annotation

GitHub Actions / test-mac-webkit

[@meowdown/core (webkit)] src/extensions/caret-perch.test.ts > caret perch editing > typing at the atom boundaries lands beside the source, without ZWSP

AssertionError: expected '[[Cat care basics]]Bplain trailing pa…' to contain 'A[[Cat care basics]]B' Expected: "A[[Cat care basics]]B" Received: "[[Cat care basics]]Bplain trailing paragraph" ❯ src/extensions/caret-perch.test.ts:81:46

Check failure on line 81 in packages/core/src/extensions/caret-perch.test.ts

View workflow job for this annotation

GitHub Actions / test-mac-webkit

[@meowdown/core (webkit)] src/extensions/caret-perch.test.ts > caret perch editing > typing at the atom boundaries lands beside the source, without ZWSP

AssertionError: expected '[[Cat care basics]]Bplain trailing pa…' to contain 'A[[Cat care basics]]B' Expected: "A[[Cat care basics]]B" Received: "[[Cat care basics]]Bplain trailing paragraph" ❯ src/extensions/caret-perch.test.ts:81:46

Check failure on line 81 in packages/core/src/extensions/caret-perch.test.ts

View workflow job for this annotation

GitHub Actions / test-mac-webkit

[@meowdown/core (webkit)] src/extensions/caret-perch.test.ts > caret perch editing > typing at the atom boundaries lands beside the source, without ZWSP

AssertionError: expected '[[Cat care basics]]Bplain trailing pa…' to contain 'A[[Cat care basics]]B' Expected: "A[[Cat care basics]]B" Received: "[[Cat care basics]]Bplain trailing paragraph" ❯ src/extensions/caret-perch.test.ts:81:46
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')
})
})
218 changes: 218 additions & 0 deletions packages/core/src/extensions/caret-perch.ts
Original file line number Diff line number Diff line change
@@ -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),
}),
)
}
2 changes: 2 additions & 0 deletions packages/core/src/extensions/extension.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -80,6 +81,7 @@ function defineEditorExtensionImpl(options: EditorExtensionOptions) {
defineAtomMarkNavigation({
marks: ATOM_SOURCE_MARK_NAMES.map((name) => ({ name, modes: ['hide', 'focus', 'show'] })),
}),
defineCaretPerch(),

// others
defineBaseKeymap(),
Expand Down
11 changes: 11 additions & 0 deletions packages/core/src/style.css
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Expand Down
Loading
Loading