Skip to content

fix: link embed edit UX — Enter after label + arrow-to-unwrap source (Obsidian Live Preview feel) #86

Description

@chasehuh

Summary

Rolled-up Markdown links ([label](/n/{id}), painted by agentnoteLinks() since #64) are read-only chips today: there is no keyboard path to edit the label or the URL, and pressing Enter with the caret at the visual end of a label splits the link markup in half. This issue adds Obsidian Live-Preview-grade link editing convenience — a link temporarily unwraps to its raw Markdown source while the caret is inside it, and Enter can never land inside link markup.

Why This Matters

[[-created sub-note links (#77/#80/#82) are now the primary way notes reference each other, so every note has rolled-up links in it. Once rolled up, those links are a dead end:

  • You cannot rename a link label without deleting the whole chip and retyping it.
  • You cannot repoint a link at a different note.
  • Enter at the end of a link — the single most common thing to do after inserting one in a list — corrupts the note, producing two lines of literal text where a link used to be.

That last one is a silent data-shape bug: the user sees a blue chip, presses Enter, and the chip becomes - [mobidoo-reply / - ](/n/abc). Nothing warns them.

Conversation Context

Reported by Chase against agentnote.dev with a screenshot of a rolled-up - mobidoo-reply sub-note link inside a Markdown list:

  1. "Enter at the end of the label splits in the middle." The caret sits at the visual end of the blue rolled-up link; Enter does not cleanly break the list item and the link markup gets spliced.
  2. "Arrowing into the link should temporarily break the rolled-up format" so the raw Markdown ([label](/n/id) chrome + URL) becomes editable. Today there is no "source expand while editing" affordance at all.

Explicit direction: build this as a best-practice link-embed editing model in the direction of Obsidian Live Preview / Notion mention editing — not a one-off Enter hack.

Current Behavior

Verified reproduction

Document (the screenshot case), with document offsets:

- [mobidoo-reply](/n/abc)
0 1 2                  24
    ^3            ^16 ^17
                    ^18   ^24
offset char role
0–1 - ListMark
2 [ LinkMark (hidden)
3–15 mobidoo-reply label (visible, .cm-md-link)
16 ] LinkMark (hidden)
17 ( LinkMark (hidden)
18–23 /n/abc URL (hidden)
24 ) LinkMark (hidden)

The Lezer tree is Link[2,25) containing LinkMark[2,3) LinkMark[16,17) LinkMark[17,18) URL[18,24) LinkMark[24,25).

Bug 1 — Enter splits the link. Running agentnoteInsertNewlineContinueMarkup (bound at Prec.highest in components/codemirror-editor.tsx:131-135) at each caret offset produces:

Enter@15 -> "- [mobidoo-repl\n- y](/n/abc)"     <- splits the label
Enter@16 -> "- [mobidoo-reply\n- ](/n/abc)"     <- REPORTED CASE (visual end of label)
Enter@17 -> "- [mobidoo-reply]\n- (/n/abc)"
Enter@24 -> "- [mobidoo-reply](/n/abc\n- )"     <- splits the URL
Enter@25 -> "- [mobidoo-reply](/n/abc)\n- "     <- the only correct one

A CommonMark inline link cannot survive a list-item break: [ and ](url) land in different list items, so the link is gone and the raw chrome becomes visible literal text.

Bug 2 — invisible caret dead zone. lib/editor/links.ts:286 declares the hidden ranges atomic:

provide: (field) => [
  EditorView.decorations.from(field),
  EditorView.atomicRanges.of((view) => view.state.field(field)),
],

but buildHideDecorations emits four separate adjacent ranges[16,17), [17,18), [18,24), [24,25) — and CodeMirror's skip logic (node_modules/@codemirror/view/dist/index.js:3729) only skips a position that is strictly inside a single range:

function skipAtomicRanges(atoms, pos, bias) {
    for (;;) {
        let moved = 0;
        for (let set of atoms) {
            set.between(pos - 1, pos + 1, (from, to, value) => {
                if (pos > from && pos < to) { ... }
            });
        }
        if (!moved) return pos;
    }
}

The boundaries between two adjacent atomic ranges are therefore legal caret positions. Measured with view.moveByChar:

moveRight: 14 -> 15 -> 16 -> 17 -> 18 -> 24 -> 25
moveLeft:  26 -> 25 -> 24 -> 18 -> 17 -> 16 -> 15

Offsets 16, 17, 18, 24 and 25 all paint at the same x coordinate (everything between them is Decoration.replaced to zero width). So walking off the end of a rolled-up link takes five indistinguishable Right presses, and four of the five resting spots put Enter/Backspace/typing inside link markup.

Bug 3 — no edit affordance. hiddenLinkMarks / visibleLinkMarks rebuild only on tr.docChanged or a syntax-tree change (needsRebuild, lib/editor/links.ts:274-276). Selection is not an input at all, so a link never unwraps. The only way to edit a label or URL today is to delete the chip and retype the whole thing.

What already works and must not regress

Desired Behavior

A. Enter never splits link markup

When the caret is strictly inside a link's Markdown span (from < pos < to) and Enter is pressed, the break is taken after the closing ) instead of at the caret:

- [mobidoo-reply](/n/abc)     caret at 16 (visual end of label)
                 Enter
- [mobidoo-reply](/n/abc)
- ▌

This holds for every interior offset (label, ], (, URL, )), inside list items and plain paragraphs, and produces one transaction so it is a single undo step and one CRDT update.

Rationale for "always break after the link" rather than "refuse to break": a Markdown inline link cannot span a list-item break, so no interior offset has a meaningful split. Jumping to the end is the only outcome that is both non-destructive and matches what a user pressing Enter at the end of a chip wants. Caret exactly at from (before [) or at to (after )) is not interior, so breaking before/after a link keeps working unchanged.

B. Temporary source unwrap while editing

A link unwraps — its [, ](, URL and ) chrome becomes visible, non-atomic, editable text — when the caret/selection is inside it, and re-rolls the moment the caret leaves.

rolled up:   - mobidoo-reply▌            (caret outside the span)
unwrapped:   - [mobidoo-reply▌](/n/abc)  (caret at 16, inside the span)

Because the chrome is no longer replaced or atomic while unwrapped, Right/Left step through ], (, /n/abc, ) as real visible characters — Bug 2's invisible dead zone disappears as a consequence, without loosening the atomic policy for rolled-up links.

C. Concrete edit-vs-open rule (the part to lock down)

gesture rolled up unwrapped
plain left-click on label open the note / URL (#64) place the caret — no navigation
Cmd/Ctrl/Shift/Alt-click native CM (unchanged) native CM (unchanged)
arrow / Home / End into the span unwrap stays unwrapped
shift-select with an endpoint inside the span unwrap stays unwrapped
caret leaves the span re-roll, same transaction

This is Obsidian Live Preview's rule verbatim: click navigates, caret placement edits, and the two are never the same gesture. Consequences that are intentional, not oversights:

  • You cannot open a link by clicking it while it is unwrapped. Arrow out (or click elsewhere), it re-rolls, then click.
  • Re-roll is driven purely by selection position — no idle timer. A timer would make the editor's painted state a function of wall-clock time, which is untestable, fights IME, and is not what Obsidian does.
  • A drag-selection that spans a link with both endpoints outside it leaves the link rolled up. Only an endpoint (anchor or head) strictly inside the span unwraps it. This keeps ⌘A / sweep-selects from reflowing the whole document into source.

D. Scope

  • Every agentnoteLinks() Markdown link (Link nodes), especially /n/{id} sub-note links.
  • Autolink (<https://…>) rides the same code path for free — its </> chrome unwraps identically.
  • Bare/scheme-less URLs have no hidden chrome and are unaffected.
  • Must work under yCollab (y-codemirror.next) — the CRDT path is the only path in production.

Source Of Truth

Internal repo/source

  • lib/editor/links.tsagentnoteLinks(), collectMarkdownLinks(), hiddenLinkMarks (+ atomicRanges), visibleLinkMarks, needsRebuild(), documentTree(), tryOpenLinkAtPointer(), hrefAtPos().
  • lib/editor/list-continue.tsagentnoteInsertNewlineContinueMarkup, tightenListContinue(), tightenListContinueInsert().
  • components/codemirror-editor.tsx:131-135 — the Prec.highest Enter binding; :172 — where agentnoteLinks() is mounted (edit and read-only); :180yCollab.
  • app/globals.css:765-774.cm-md-link styling.
  • lib/editor/links.test.ts — existing coverage for resolveHref, hrefAtPos, decorations, and fix: roll up [label](/n/…) chrome on first editor paint #84/fix: roll up [label](/n/…) chrome on first editor paint #85 first paint. The decorations() helper there reads decorations out of state (jsdom has no layout) and is the pattern new tests should reuse.

External source

  • node_modules/@codemirror/view/dist/index.js:3729 skipAtomicRanges — the strict pos > from && pos < to test that creates the between-adjacent-ranges dead zone.
  • node_modules/@codemirror/view/dist/index.js:1519, 8893EditorView.atomicRanges facet.
  • node_modules/@codemirror/commands/dist/index.d.ts:521insertNewlineAndIndent: StateCommand, the fallback for Enter in a non-list paragraph.
  • @lezer/markdown node names used: Link, Autolink, LinkMark, URL.
  • Obsidian Live Preview reference behavior: source-mode reveal follows the selection; click on a rendered link navigates.

Proposed Design

New state field: which links are being edited

export type LinkSpan = { from: number; to: number };

/**
 * The `[label](url)` / `<url>` span containing `pos`, when `pos` is *strictly*
 * inside it. Boundary offsets (before `[`, after `)`) return null so that
 * breaking before/after a link is untouched.
 */
export function linkSpanAt(state: EditorState, pos: number): LinkSpan | null;

Implementation: syntaxTree(state).resolveInner(pos, -1), walk .parent until a Link / Autolink node, then apply the strict node.from < pos < node.to test. Verified against the tree above — resolveInner(2, -1) stops at ListItem (no Link ancestor), resolveInner(25, -1) reaches Link[2,25) but fails the strict test. This is O(log n), unlike the existing full-document iterate().

const activeLinks = StateField.define<readonly LinkSpan[]>({ ... });

Recomputed only when tr.docChanged || tr.selection || syntaxTree advanced; returns the previous array by identity when the span set is unchanged, so the expensive decoration fields can compare by reference:

function needsRebuild(tr: Transaction): boolean {
  return (
    tr.docChanged ||
    syntaxTree(tr.startState) !== syntaxTree(tr.state) ||
    tr.startState.field(activeLinks) !== tr.state.field(activeLinks)
  );
}

activeLinks must be listed before hiddenLinkMarks / visibleLinkMarks in the extension array so it is already computed when they read tr.state. This is the same ordering constraint the existing needsRebuild comment documents for the language field.

Active spans are collected from selection endpoints only (range.head, plus range.anchor when non-empty) — see §C for why.

Decorations

collectMarkdownLinks(state, active) gains an active argument. For a link whose span is active:

  • skip its hide ranges → chrome becomes visible and non-atomic (the atomic facet is derived from hiddenLinkMarks, so one change buys both);
  • emit a source mark over the whole span, plus a second mark over the label, using new class names that do not contain the cm-md-link token:
    • .cm-md-link-src over [from, to) — dim accent, no underline, default cursor;
    • .cm-md-link-src-label over the label — full accent, so the label still reads as the link text.
  • keep pushing to hits so hrefAtPos() stays a pure "what does this position link to" helper and bare-link occupancy is unaffected.

The class-name choice is load-bearing: tryOpenLinkAtPointer matches el.closest(".cm-md-link"), and cm-md-link-src is a single class token, so an unwrapped link cannot match it. .cm-md-link--bare keeps working because its spec is two tokens ("cm-md-link cm-md-link--bare"). A redundant state-level guard (isLinkActive(view.state, pos)) is still added to tryOpenLinkAtPointer, because posAtCoords has a documented fallback path through view.posAtDOM and an accidental navigation while editing loses the user's place.

RangeSetBuilder ordering is safe: a span mark always starts one offset before its label mark ([ vs the first label char), so there are no equal-from ties to order.

Enter

agentnoteInsertNewlineContinueMarkup derives a link-escaped state, runs the existing commands against it, then re-issues the resulting transaction onto the real state so the caret move and the break are one transaction:

function stateBrokenOutOfLink(state: EditorState): EditorState {
  if (state.selection.ranges.length !== 1) return state; // multi-cursor: unchanged
  const range = state.selection.main;
  if (!range.empty) return state;
  const span = linkSpanAt(state, range.head);
  if (!span) return state;
  return state.update({ selection: EditorSelection.cursor(span.to) }).state;
}
const source = stateBrokenOutOfLink(state);
let ran = continueMarkup({ state: source, dispatch: capture });
if (!ran && source !== state) {
  // Plain paragraph: continueMarkup declines, and defaultKeymap's Enter would
  // still split the link. Break after it ourselves.
  ran = insertNewlineAndIndent({ state: source, dispatch: capture });
}
if (!ran || !tr) return false;
dispatch(rebaseOnto(state, tightenListContinue(source, tr)));

rebaseOnto returns the transaction unchanged when tr.startState === state, so the no-link path is byte-identical to today (including returning false so defaultKeymap still gets its turn). The changes are valid against state because a selection-only update() leaves doc identical.

Implementation Notes

Likely files to modify

  • lib/editor/links.tslinkSpanAt(), isLinkActive(), activeLinks field, active-aware collectMarkdownLinks() / builders, needsRebuild(), guard in tryOpenLinkAtPointer(), extension order in agentnoteLinks().
  • lib/editor/list-continue.tsstateBrokenOutOfLink(), rebaseOnto(), rework of agentnoteInsertNewlineContinueMarkup.
  • app/globals.css.cm-md-link-src / .cm-md-link-src-label next to the existing .cm-md-link block.

New files

  • lib/editor/list-continue.test.ts — Enter behavior around links (there is no test file for this module today).

Tests

lib/editor/links.test.ts:

  • linkSpanAt boundaries: 2 → null, 3/16/17/24 → {from:2,to:25}, 25 → null, 26 → null.
  • Rolled up with the caret outside: hidden ranges over [16,25) present, .cm-md-link label present, no .cm-md-link-src.
  • Caret at 16: no hidden ranges over the link, .cm-md-link-src present, no .cm-md-link.
  • Re-roll: move the caret to 26 and assert the hidden ranges are back.
  • Atomic policy: view.moveByChar from 16 steps 17, 18, 19… one char at a time while unwrapped, and the rolled-up case keeps its current skipping.
  • Click: rolled-up label still opens (existing tests); unwrapped label does not dispatch agentnote:open-note.
  • The fix: roll up [label](/n/…) chrome on first editor paint #84/fix: roll up [label](/n/…) chrome on first editor paint #85 first-paint tests must still pass untouched.

lib/editor/list-continue.test.ts:

  • Enter at 16 in - [mobidoo-reply](/n/abc)- [mobidoo-reply](/n/abc)\n- , caret at 28.
  • Enter at 20 (inside the URL) → same result.
  • Enter at 15 (inside the label) → same result.
  • Enter at 25 → unchanged from today.
  • Enter at 2 (before [) → splits before the link, link intact.
  • Enter inside a link in a plain (non-list) paragraph → newline after the link.
  • Multi-cursor → no link escape.
  • Existing tightenListContinueInsert non-tight-list collapsing still holds.

Edge Cases And Risks

  • IME / composition (CRDT path). Unwrapping adds and removes Decoration.replace ranges, which forces DOM reconstruction; doing that mid-composition can drop a Korean/Japanese composition. Mitigated by construction: the transition fires when the caret enters or leaves the span, and during composition the caret stays inside, so no decoration change happens mid-compose. Worth a lib/crdt smoke check that yCollab + unwrap coexist.
  • Undo granularity. The Enter fix must emit exactly one transaction; two (select then input) would make ⌘Z under Y.UndoManager leave the caret parked inside the link.
  • Reflow on unwrap. Revealing ](/n/8f2c…) widens the line and can change soft-wrap. Accepted — it is what Obsidian does — but avoid scrollIntoView on the re-roll transaction so the viewport does not jump.
  • Read-only / published views. agentnoteLinks() is mounted for readOnly too (components/codemirror-editor.tsx:172). EditorState.readOnly still allows selection, so a published note could unwrap on click-drag. Either accept it or gate the unwrap on !state.readOnly — pick one and state it in the PR.
  • Perf. activeLinks runs on every selection change; it must use resolveInner (O(log n)), never the full-document documentTree().iterate() — otherwise every arrow keypress becomes an O(doc) scan on long notes.
  • Partial parse (fix: roll up [label](/n/…) chrome on first editor paint #84/fix: roll up [label](/n/…) chrome on first editor paint #85). If the tree has not reached the caret yet, resolveInner finds no Link and the link stays rolled up. Self-correcting: needsRebuild already fires on parse progress and activeLinks recomputes on the same condition.
  • Backward compatibility. Stored text is untouched; this is purely a decoration + command change. Notes written before this land behave identically.

Non-Goals

Acceptance Criteria

  • Enter with the caret at the visual end of - [label](/n/id) inserts the newline after the full link; the link markup is intact and the new line continues the list.
  • Same for Enter at any interior offset — inside the label, on ], on (, inside the URL, on ) — in both list items and plain paragraphs.
  • Enter before [ or after ) behaves exactly as it does today.
  • The Enter fix produces a single transaction (one undo step, one CRDT update).
  • A link unwraps to full [label](url) source when a selection endpoint is strictly inside its span, and re-rolls when the caret leaves.
  • While unwrapped, the caret can rest on every chrome character (chrome is not atomic), so Left/Right step through it one character at a time.
  • While rolled up, plain left-click on the label still opens the note / URL (Editor: Obsidian-style clickable Markdown links (port chasehuh_com agentnoteLinks) #64 unchanged); while unwrapped, a plain left-click places the caret and does not navigate.
  • The atomic/caret policy and the click-vs-edit rule are documented in code comments in lib/editor/links.ts.
  • fix: roll up [label](/n/…) chrome on first editor paint #84/fix: roll up [label](/n/…) chrome on first editor paint #85 first-paint tests pass unmodified.
  • pnpm vitest run, tsc --noEmit, and pnpm build are green; no new lint findings.

QA Plan

  1. pnpm vitest run — new links.test.ts and list-continue.test.ts cases plus the full existing suite.
  2. npx tsc --noEmit and pnpm build.
  3. Manual on a dev server, in a note with - [mobidoo-reply](/n/{real-id}):
    • Arrow Right from inside the label → the link expands to source; keep arrowing → caret walks ], (, /n/…, ) one char at a time; one more Right → re-rolls.
    • With the caret at the end of the label, press Enter → new list item after the intact chip.
    • Edit the label text and the URL while unwrapped, arrow out → re-rolls with the new label; click it → opens the (new) target.
    • Click the rolled-up chip → still opens the note.
    • Type Korean into a label while unwrapped → no dropped composition.
    • Open the same note in two tabs (CRDT) and repeat → both converge.

Suggested PR Scope

M — one PR. The Enter fix and the unwrap share linkSpanAt() and the same span semantics; splitting them would mean landing a half-policy where Enter jumps out of a link the user cannot see the boundaries of. Land together.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions