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.
| Location | Role today |
|---|---|
packages/ts/obsidian-editor/src/decorations.ts#activeLines | The only selection-derived query the engine exposes |
packages/ts/obsidian-editor/src/clicks.ts | Resolves the link at a click position from the tree; not reusable for the cursor |
packages/ts/obsidian-editor/src/autocomplete.ts#inCode | Code-context predicate the selection anchor reuses |
packages/ts/obsidian-editor/src/vue/index.ts#useObsidianEditor | Returns cursorLine; no anchors |
solutions/determined/experiences/notes/app/components/NotesHoverPreview.vue#anchorTo | The host computes rects from DOM elements because the engine offers none |
packages/ts/obsidian-editor/test/helpers.ts#makeState | Headless builder for the pure tests |
The tree helpers module (enclosing, inCode, blockAt) comes from
T-4TE2-md-engine-plugin-contract.
Proposed
Section titled “Proposed”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, testableinterface 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, unionedanchorsListener(cb: (a: AnchorsWithRects) => void): ExtensionanchorsListener 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.
Approach
Section titled “Approach”- Write
packages/ts/obsidian-editor/test/anchors.test.tsover inline fixtures: empty selection →selection: null; selection inside a fenced block →null; cursor insideA→ link withkind: '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'. - Write
packages/ts/obsidian-editor/test/links.test.ts: wikilink → markdown conversion keeps the alias;removeLinkAtleaves the display text; bytes outside the range are identical. - Add
tableAtto the tree helpers module; implementanchorContext. - Implement
readAnchorsandanchorsListener; a happy-dom test (via the existing DOM setup) asserts the listener fires on selection change and thatrectsarenullwhencoordsAtPosreturnsnull. - Implement the link helpers.
- Extend
useObsidianEditorand the Vue test (anchors.value.linkappears when the cursor enters a wikilink). - Export from the package entry;
bun test,tsc --noEmit.
Files to touch
Section titled “Files to touch”| Location | Kind | Change |
|---|---|---|
packages/ts/obsidian-editor/src/anchors.ts | new | Anchors, anchorContext, readAnchors, anchorsListener |
packages/ts/obsidian-editor/src/links.ts | new | setLinkAt, removeLinkAt |
packages/ts/obsidian-editor/src/tree.ts | new | Created by T-4TE2-md-engine-plugin-contract; gains tableAt |
packages/ts/obsidian-editor/src/vue/index.ts | modify | anchors shallow ref |
packages/ts/obsidian-editor/src/index.ts | modify | Export anchors and link helpers |
packages/ts/obsidian-editor/test/anchors.test.ts | new | Pure anchor tests |
packages/ts/obsidian-editor/test/links.test.ts | new | Link helper tests |
packages/ts/obsidian-editor/test/vue.test.ts | modify | anchors ref |
Acceptance criteria
Section titled “Acceptance criteria”- AC-1:
bun test packages/ts/obsidian-editor/test/anchors.test.tsandpackages/ts/obsidian-editor/test/links.test.tspass with the cases listed in Approach steps 1 and 2. - AC-2:
anchorContextimports nothing from@codemirror/view(grep -n "@codemirror/view"overpackages/ts/obsidian-editor/src/anchors.tsmatches only thereadAnchors/anchorsListenerimports, typed as such in the test by callinganchorContexton a headless state). - AC-3:
anchorsListenerinvokes its callback on selection change, document change and scroll (happy-dom test). - AC-4:
setLinkAtandremoveLinkAtleave every byte outside the given range unchanged over the three link fixtures in the links test. - AC-5:
useObsidianEditorreturnsanchors; the Vue test provesanchors.value.linkis non-null with the cursor insideAlpha. - AC-6:
tsc --noEmitin the package is clean and the package entry exportsanchorContext,readAnchors,anchorsListener,setLinkAt,removeLinkAt, and theAnchors,AnchorsWithRects,Recttypes.
Out of scope
Section titled “Out of scope”- The table widget and
table.*commands (T-UYTR-md-engine-tables); this task providestableAtand thetableanchor only. - Any overlay component (T-8ZHI-md-ui-overlays-bubble-link-table).
- Hover anchors for the reading view (T-RJVA-md-ui-find-outline-hover-lifts keeps DOM rects there).
Dependencies
Section titled “Dependencies”- T-4TE2-md-engine-plugin-contract for the tree helpers module.
- D-7VMX: anchors are read from the Lezer tree, never from the DOM text.
Discovery context
Section titled “Discovery context”Member of milestone M-931L; engine step E4 of decision D-K3EM-markdown-editor-chrome-and-plugin-environment.
Depends on
Section titled “Depends on”T-4TE2-md-engine-plugin-contract