Skip to content

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

Merged
chasehuh merged 1 commit into
mainfrom
task/link-embed-edit
Aug 6, 2026
Merged

fix: link embed edit UX — Enter after label + arrow-to-unwrap source (Obsidian Live Preview feel)#87
chasehuh merged 1 commit into
mainfrom
task/link-embed-edit

Conversation

@chasehuh

@chasehuh chasehuh commented Aug 6, 2026

Copy link
Copy Markdown
Member

Closes #86.

Problem

Rolled-up [label](/n/id) links (#64) were read-only chips with a destructive edge.

Enter split the link. With the caret at the visual end of a rolled-up label — exactly where it lands after arrowing through the chip — Enter spliced the markup in half:

Enter@16 -> "- [mobidoo-reply\n- ](/n/abc)"    <- reported case
Enter@20 -> "- [mobidoo-reply](/n/\n- abc)"    <- inside the destination

A CommonMark inline link cannot survive a list-item break: [ and ](url) end up in different items, so the link is gone and its chrome becomes literal text. The user saw a blue chip, pressed Enter, and silently broke the note.

An invisible caret dead zone. hiddenLinkMarks emits four separate adjacent atomic ranges (], (, url, )), and CodeMirror's skipAtomicRanges only skips a position strictly inside one range:

if (pos > from && pos < to) { ... }   // @codemirror/view:3734

So the boundaries between them were legal caret positions. Measured:

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

Offsets 16, 17, 18, 24 and 25 all paint at the same x (everything between is replaced to zero width). Walking off the end of a link took five indistinguishable presses, and four of the five resting spots put Enter/Backspace inside link markup.

No edit affordance. The decoration fields rebuilt only on docChanged or a syntax-tree change — selection was not an input, so a link never unwrapped. Renaming a label meant deleting the chip and retyping it.

Enter

stateBrokenOutOfLink moves the caret past the closing ) when it sits strictly inside a link span, and the break is taken there. Every interior offset has the same destructive outcome, so there is no split worth honouring — Enter finishes the link and starts the next line after it.

Outside a list, continueMarkup declines and defaultKeymap's Enter would still run at the original caret, back inside the link, so the command falls back to insertNewlineAndIndent on the escaped state. rebaseOnto re-issues the result against the real state, so the caret move and the break are a single transaction — one undo step under Y.UndoManager, one CRDT update.

Boundary offsets are not interior, so breaking immediately before or after a link is untouched. Multi-cursor is left alone.

Unwrap

A new activeLinks state field tracks which links hold a selection endpoint. While a link is active its hide ranges are skipped — which drops the atomic ranges with them, since the atomic facet is derived from the same field — and it paints as cm-md-link-src / cm-md-link-src-label instead:

rolled up:   - mobidoo-reply▌
unwrapped:   - [mobidoo-reply▌](/n/abc)

The dead zone disappears as a consequence: the chrome is real visible text, so Left/Right step through it one character at a time, without loosening the atomic policy for rolled-up links.

Click opens, caret placement edits — never both. Source-mode marks use cm-md-link-src, a single class token, so tryOpenLinkAtPointer's el.closest(".cm-md-link") misses an unwrapped link and a plain click just moves the caret. (.cm-md-link--bare keeps opening: its spec carries cm-md-link as a separate token.) mousedown fires before the selection updates, so clicking a rolled-up chip always opens.

Deliberate choices, documented in code:

  • No idle timer. Re-roll is driven purely by selection position. A timer would make painted state a function of wall-clock time — untestable, and it fights IME.
  • Endpoints only. A sweep-select or ⌘A that spans a link leaves it rolled up; reflowing every link in the document because the user selected a paragraph would be worse than useless.
  • Read-only published notes never unwrap. There is nothing to edit on /p/…, so a reader dragging across a link should not see raw markup.

Performance

linkSpanAt uses resolveInner + a parent walk (O(log n)), not the full-document documentTree().iterate() — it runs on every selection change. activeLinks returns its previous array by identity when the span set is unchanged, so caret motion outside a link costs one reference compare in needsRebuild, not a decoration rebuild.

Not regressed

Tests

lib/editor/links.test.tslinkSpanAt boundaries; rolled-up vs unwrapped decorations; re-roll on leaving; atomic dropped while unwrapped (16 -> 17 -> 18 -> 19) and kept while rolled up (18 -> 24); sweep-select does not unwrap; read-only does not unwrap; click opens rolled up, places the caret unwrapped.

lib/editor/list-continue.test.ts (new) — Enter at the visual end of the label and at every other interior offset; unchanged at both boundaries; plain-paragraph fallback; single-transaction assertion; multi-cursor untouched; existing non-tight-list tightening.

379 tests pass, tsc --noEmit clean, pnpm build green, no new lint findings.

QA after merge

Hard-refresh agentnote.dev, then in a note with - [label](/n/{id}): arrow into the label (expands to source), keep arrowing (caret walks ] ( /n/… ) one char at a time, then re-rolls); Enter at the end of the label (new list item after the intact chip); edit the label and URL, arrow out, click it; type Korean into a label while unwrapped.

🤖 Generated with Claude Code

Rolled-up `[label](/n/id)` links were read-only chips with a destructive
edge: Enter at the visual end of a label spliced the markup in half
(`- [mobidoo-reply` / `- ](/n/abc)`), and there was no keyboard path to
edit a label or a URL at all.

Enter now never lands inside link markup. A CommonMark inline link cannot
survive a line break, so every interior offset has the same destructive
outcome; `stateBrokenOutOfLink` moves the caret past the closing `)` and
the break is taken there. Outside a list, `continueMarkup` declines and
stock Enter would still run at the original caret, so the command falls
back to `insertNewlineAndIndent` on the escaped state. The result is
rebased onto the real state, so the caret move and the break stay one
transaction — one undo step, one CRDT update.

Links also unwrap to full source while being edited, Obsidian Live
Preview style. A new `activeLinks` field tracks which links hold a
selection endpoint (via `resolveInner`, so caret motion stays O(log n)),
and their `hide` ranges are skipped — which drops the atomic ranges with
them, since the atomic facet is derived from the same field. That also
removes the invisible four-press dead zone the adjacent atomic ranges
created at `]` `(` url `)`, all of which painted at the same x.

Click still opens, caret placement now edits: source-mode marks use
`cm-md-link-src`, a single class token, so `tryOpenLinkAtPointer`'s
`.cm-md-link` lookup misses an unwrapped link and the click just moves
the caret. Read-only published notes never unwrap.

Closes #86
@vercel

vercel Bot commented Aug 6, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
memo Ready Ready Preview Aug 6, 2026 6:22am

Request Review

@chasehuh
chasehuh merged commit e9def50 into main Aug 6, 2026
2 checks passed
@chasehuh
chasehuh deleted the task/link-embed-edit branch August 6, 2026 06:22
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

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

1 participant