Skip to content

T-MNRO-md-engine-anchors

State: closed/done · Impact: high · Complexity: small

Selection-anchored chrome (bubble menu, link popover, table menu) needs to know what sits under the cursor and where it is on screen, and the engine exposes neither. This task adds a pure anchorContext(state), a view-level readAnchors(view) that adds rects, an anchorsListener extension, and the link helpers the popover edits with.

LocationRole today
packages/ts/obsidian-editor/src/decorations.ts#activeLinesThe only selection-derived query the engine exposes
packages/ts/obsidian-editor/src/clicks.tsResolves the link at a click position from the tree; not reusable for the cursor
packages/ts/obsidian-editor/src/autocomplete.ts#inCodeCode-context predicate the selection anchor reuses
packages/ts/obsidian-editor/src/vue/index.ts#useObsidianEditorReturns cursorLine; no anchors
solutions/determined/experiences/notes/app/components/NotesHoverPreview.vue#anchorToThe host computes rects from DOM elements because the engine offers none
packages/ts/obsidian-editor/test/helpers.ts#makeStateHeadless builder for the pure tests

The tree helpers module (enclosing, inCode, blockAt) comes from T-4TE2-md-engine-plugin-contract.

packages/ts/obsidian-editor/src/anchors.ts:

interface Anchors {
cursor: { pos: number; line: number }
selection: { from: number; to: number } | null // non-empty and not inside code
link: { from: number; to: number; kind: 'wikilink' | 'embed' | 'markdown'; target: string; alias?: string; resolution: LinkResolution } | null
table: { from: number; to: number; row: number; col: number; rows: number; cols: number; cell: { from: number; to: number } } | null
block: { from: number; to: number; name: string } | null // outermost block at the cursor line
}
anchorContext(state: EditorState): Anchors // pure, testable
interface Rect { top: number; left: number; bottom: number; right: number }
type AnchorsWithRects = Anchors & { rects: Record<'cursor' | 'selection' | 'link' | 'table' | 'block', Rect | null> }
readAnchors(view: EditorView): AnchorsWithRects // coordsAtPos at from and to, unioned
anchorsListener(cb: (a: AnchorsWithRects) => void): Extension

anchorsListener fires on selectionSet, docChanged, tree change (syntaxTree(state) identity), reconfigure, and scroll (EditorView.domEventHandlers({ scroll })). Not a StateField: rects are view-level.

tableAt(state, pos) joins the tree helpers module: row and column indices, row and column counts, and the cell range from Table, TableHeader, TableRow, TableCell nodes. It is shared with T-UYTR-md-engine-tables.

packages/ts/obsidian-editor/src/links.ts: setLinkAt(view, range, { kind: 'wikilink' | 'markdown'; target: string; alias?: string }) rewrites exactly range to alias or [alias](target); removeLinkAt(view, range) replaces the link with its display text. Both are swap-writer edits: bytes outside range are untouched.

useObsidianEditor returns anchors: ShallowRef<AnchorsWithRects> fed by anchorsListener.

  1. Write packages/ts/obsidian-editor/test/anchors.test.ts over inline fixtures: empty selection → selection: null; selection inside a fenced block → null; cursor inside A → link with kind: 'wikilink', target: 'Alpha', alias: 'A', resolved through the stub provider; cursor inside a markdown link → kind: 'markdown'; cursor in the second cell of the second row → table.row === 1, col === 1, rows, cols; cursor on a callout line → block.name === 'Blockquote'.
  2. Write packages/ts/obsidian-editor/test/links.test.ts: wikilink → markdown conversion keeps the alias; removeLinkAt leaves the display text; bytes outside the range are identical.
  3. Add tableAt to the tree helpers module; implement anchorContext.
  4. Implement readAnchors and anchorsListener; a happy-dom test (via the existing DOM setup) asserts the listener fires on selection change and that rects are null when coordsAtPos returns null.
  5. Implement the link helpers.
  6. Extend useObsidianEditor and the Vue test (anchors.value.link appears when the cursor enters a wikilink).
  7. Export from the package entry; bun test, tsc --noEmit.
LocationKindChange
packages/ts/obsidian-editor/src/anchors.tsnewAnchors, anchorContext, readAnchors, anchorsListener
packages/ts/obsidian-editor/src/links.tsnewsetLinkAt, removeLinkAt
packages/ts/obsidian-editor/src/tree.tsnewCreated by T-4TE2-md-engine-plugin-contract; gains tableAt
packages/ts/obsidian-editor/src/vue/index.tsmodifyanchors shallow ref
packages/ts/obsidian-editor/src/index.tsmodifyExport anchors and link helpers
packages/ts/obsidian-editor/test/anchors.test.tsnewPure anchor tests
packages/ts/obsidian-editor/test/links.test.tsnewLink helper tests
packages/ts/obsidian-editor/test/vue.test.tsmodifyanchors ref
  • AC-1: bun test packages/ts/obsidian-editor/test/anchors.test.ts and packages/ts/obsidian-editor/test/links.test.ts pass with the cases listed in Approach steps 1 and 2.
  • AC-2: anchorContext imports nothing from @codemirror/view (grep -n "@codemirror/view" over packages/ts/obsidian-editor/src/anchors.ts matches only the readAnchors / anchorsListener imports, typed as such in the test by calling anchorContext on a headless state).
  • AC-3: anchorsListener invokes its callback on selection change, document change and scroll (happy-dom test).
  • AC-4: setLinkAt and removeLinkAt leave every byte outside the given range unchanged over the three link fixtures in the links test.
  • AC-5: useObsidianEditor returns anchors; the Vue test proves anchors.value.link is non-null with the cursor inside Alpha.
  • AC-6: tsc --noEmit in the package is clean and the package entry exports anchorContext, readAnchors, anchorsListener, setLinkAt, removeLinkAt, and the Anchors, AnchorsWithRects, Rect types.

Member of milestone M-931L; engine step E4 of decision D-K3EM-markdown-editor-chrome-and-plugin-environment.

T-4TE2-md-engine-plugin-contract


← Back to Tasks