Skip to content

fix(live-preview): make image rendering AST-driven and linked images navigable#150

Open
rotiokivv wants to merge 8 commits into
floatboatai:mainfrom
rotiokivv:fix/live-preview-image-ranges
Open

fix(live-preview): make image rendering AST-driven and linked images navigable#150
rotiokivv wants to merge 8 commits into
floatboatai:mainfrom
rotiokivv:fix/live-preview-image-ranges

Conversation

@rotiokivv

Copy link
Copy Markdown

Summary / 摘要

Image ranges in the live preview now come from the AST instead of a document-wide regex scan, which fixes garbled/blank rendering of linked images ([![alt](img)](url), the README-badge pattern), images being injected into code blocks, and truncated parenthesized URLs — and linked images are now clickable with the destination shown on hover.

Motivation / 背景与动机

  • Issue: none filed — found while exercising the live preview against common README patterns.
  • Roadmap (docs/ROADMAP.md): n/a (bug fix)
  • OpenSpec change: n/a (bug fix, per CONTRIBUTING §3.1)

The live preview located images with a regex over the whole document (collectImageRanges), blind to parse context. Three user-visible bugs shared that root cause:

  1. Linked images render garbage. For [![CI badge](…)](https://example.com) the link's marker arithmetic splits at the first ]( — inside the nested image — so the label rendered as ![CI badge with a fragment URL; the overlapping image/link decorations could also trip CM6 and degrade the whole view to raw markdown. This is the standard CI-badge pattern at the top of virtually every README.
  2. Code contexts are not literal. ```…``` and inline code containing ![alt](url) grew real image widgets — you couldn't write documentation about image syntax, and pasted code got corrupted visually.
  3. CommonMark URL edge cases. ![Wiki](https://en.wikipedia.org/wiki/Foo_(bar)) was truncated at the first ), leaving a broken image and a stray paren.

Only the parser knows whether ![…](…) sits in prose or inside a code span, and only the parser resolves balanced parens — so the fix is to emit image ranges from the AST walk like every other inline node and delete the regex scan.

Changes / 变更内容

  • packages/core:
    • live-preview-ranges.ts: delete collectImageRanges; add image to the AST-walk node set; skip reference-style images (no inline URL) so they stay raw text.
    • live-preview.ts: new linked-image branch — links whose label contains an image (at any depth, incl. inside emphasis/strong/delete) render as a single clickable widget (data-link-url + destination tooltip, including on inner <img>s, where it outranks renderer-set alt tooltips). Cursor-aware like standalone images: cursor out → replace widget; cursor in → editable source + block preview below (so a freshly pasted badge shows immediately). The widget must not swallow events (CM6 skips all domEventHandlers for ignoreEvent() === true widgets, which would kill the navigation handler). Link navigation now filters schemes: only http(s)/mailto: reach window.open; scheme-less GFM autolinks (www.…) get an https:// prefix instead of navigating relatively.
    • lezer-mdast-adapter.ts: images keep their CommonMark title (![a](url "title") — previously dropped, used by tooltip-rendering hosts); a Link node with no inline URL (reference-style [x][ref], empty [x]()) now yields url: "" instead of leaking the whole raw source as its URL (autolinks keep the node-text fallback).
  • apps/electron-demo:
    • electron/main.ts + new electron/link-policy.ts: the demo never installed a window-open handler, so link clicks spawned bare child BrowserWindows. http(s)/mailto: now route to the system browser via shell.openExternal; everything else is denied, and will-navigate is guarded the same way (same-page reloads stay allowed). The policy is a pure, unit-tested module.

Behavior notes

  • ![](url) (empty alt) no longer shows the URL as a text label next to the image — the adapter emits alt: "" (spec-consistent with remark) where the old regex emitted null and the default renderer fell back to the URL.
  • mailto: links in the demo used to open a broken child window; they now open the system mail handler.
  • Commit ce3e5d2 bundles the navigation fix with the hover-tooltip change; they land together because both exist to make the linked-image widget behave like a link.

Testing / 测试

  • pnpm test passes / 全绿 — 533 tests across 34 files
  • Affected packages build (pnpm build) / 受影响包构建通过(含 pnpm typecheck
  • New / updated vitest cases / 新增或更新的 vitest 用例:18 new cases
    • live-preview-ranges.test.ts: no image ranges in fenced/inline code or raw HTML blocks; balanced-paren URLs; all three CommonMark title delimiter forms (+ no-title → null); linked-image emits link + suppressed child ranges; reference-style images/links stay raw / get url: "".
    • live-preview-regressions.test.ts: linked image renders as clickable widget (attrs, tooltip on wrapper and <img>); cursor-in shows raw source + exactly one preview (parentSpans suppression); a dispatched mousedown calls window.open(url, "_blank", "noopener") — this is the only way to catch a regression to an event-swallowing widget, since the DOM is identical either way; scheme filtering (javascript: consumed, www.… prefixed); labels with images nested in emphasis; mixed text-and-multiple-images labels; reference-style linked image renders without a fake destination.
    • apps/electron-demo/test/link-policy.test.ts: external-target allowlist and same-page-reload rules.
  • Manual UI check in electron-demo / electron-demo 手动验证:
    • CI badge line: renders as clickable image; hover shows destination; click opens the system browser; cursor on the line shows editable source with live preview below; cursor away collapses to the widget.
    • ``` blocks and inline code show ![…](…) as literal text; **bold** and images now behave consistently in code contexts.
    • Parenthesized Wikipedia-style URLs load in full; multi-badge paragraphs, badges inside headings/lists/blockquotes, and mid-edit broken syntax (deleting the closing paren, then restoring) never blank the preview.

Compliance / 合规自检

  • CLA signed — will sign when the bot prompts
  • AI disclosure: AI assistance is described below.
    AI-assisted notes / AI 使用说明:
    AI assistance (Claude Code) was used for implementation and test drafting under my direction: I selected the bugs, reproduced them, drove the design decisions (AST-driven ranges over regex, matching the standalone-image cursor contract, event-swallowing tradeoffs), verified every scenario manually in the demo, and reviewed the final diff line by line. I can walk through and defend any part of the change.
  • New dependencies: none
  • No build artifacts committed
  • No secrets committed

Checklist / 自检清单

  • Title follows Conventional Commits
  • Public API changes update package README / types — n/a: no public API surface changed (adapter output enrichment only)
  • Touched live-preview-table.ts → n/a, not touched
  • New capability / breaking change → n/a, bug fixes only
  • Change aligns with project scope (GOVERNANCE.md §4)

Screenshots / Recordings · 截图或录屏 (UI changes)

Same document, electron-demo. Test document:

# Image rendering

[![CI badge](https://img.shields.io/badge/build-passing-green)](https://example.com)

```
![alt](https://example.com/img.png)
```

Inline `![x](https://example.com/y.png)` code

![parens](https://img.shields.io/badge/paren_(url)-works-blue)

Before (main): the linked image renders as the garbled label ![CI badge; image widgets are injected inside the fenced block and inline code; the parenthesized URL is truncated (404 badge not found + stray -works-blue) text).

before: garbled linked image, images inside code blocks, truncated URL

After (this branch): the badge renders as a clickable image (hover shows the destination, click opens the system browser); code contexts stay literal; the parenthesized URL loads in full.

after: badge renders, code stays literal, full URL loads

After, cursor on the badge line: the raw source stays editable with the live preview rendered right below — same contract as standalone images.

after: cursor on the badge line shows editable source with preview below

rotiokivv and others added 8 commits July 12, 2026 19:26
The live preview located images with a document-wide regex, blind to
parse context. Three user-visible bugs follow from that root cause:

- [![alt](img)](url) (e.g. README CI badges): the regex image range
  overlapped the link decoration, and the link marker arithmetic split
  at the first ](, garbling the label and the navigation URL. Links
  whose label contains an image now render as a clickable image widget.
- Image syntax inside fenced code blocks / inline code was rendered as
  a real image instead of staying literal text.
- URLs with balanced parentheses (.../Foo_(bar)) were truncated at the
  first ), breaking the image and leaving a stray paren.

Images are now emitted by the mdast walk like every other inline node.
The Lezer adapter preserves the image title so tooltip-dependent
renderers keep working, and reference-style images without an inline
URL stay raw text instead of becoming an empty-src widget.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Standalone images show source + a preview widget below when the cursor
is inside their range. Linked images rendered raw source only, so
pasting a badge line (cursor lands at its end, which counts as on the
link) looked broken until the cursor moved away. Mirror the standalone
image contract: cursor out - replace widget; cursor in - editable
source plus a block preview widget right below.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
CM6 skips every registered domEventHandler - including the
[data-link-url] mousedown handler that opens links - for events inside
widgets whose ignoreEvent() returns true, so the linked-image widget
rendered but never navigated. Match the plain-link widget contract
(ignoreEvent false); interactive chrome inside custom image renderers
already protects itself by stopping propagation.

Also surface the destination on hover: browsers resolve tooltips from
the innermost titled element, so an image-level tooltip set by a
renderer would mask the wrapper title. Inside a linked image the
destination outranks it.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The demo never installed a window-open handler, so every link click in
the live preview (window.open from the renderer) spawned a bare child
BrowserWindow instead of the user browser. Route http(s) targets
through shell.openExternal and deny all other window opens; apply the
same policy to will-navigate so stray <a href> navigations cannot
replace the editor page (current-URL reloads stay allowed).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Two gaps in the linked-image branch, both found by adversarial review:

- The image check only looked at the label top level, so
  [*![a](i)*](u) (image wrapped in emphasis) still fell into the
  marker-arithmetic path and rendered the garbage label the branch
  exists to prevent. Detect images at any depth and flatten wrapper
  nodes when rendering the label.
- A Link node with no URL child (reference-style [x][ref], empty [x]())
  fell back to the whole node source as its url, leaking raw markdown
  into data-link-url and the hover tooltip. Only autolinks keep the
  node-text fallback; such labels now render their image without
  pretending to navigate.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
data-link-url is authored document content, so clicking a link whose
destination is javascript:, file: or a custom scheme must not reach
window.open. Scheme-less GFM autolink literals (www.example.com) used
to be opened as relative paths and now get an https prefix instead.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…overage

The click-to-navigate behavior hinges on the widget NOT swallowing
events: CM6 skips every domEventHandler for events inside widgets whose
ignoreEvent() returns true, and the rendered DOM is identical either
way, so only a dispatched mousedown can catch that regression. Also
cover: scheme filtering, single-preview suppression in edit mode, mixed
text-and-images labels, all three CommonMark title delimiters, images
inside raw HTML blocks, and scope the stray-paren assertion to the
image line so unrelated renderer chrome cannot break it.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The window-open / will-navigate rules were inline closures over
mainWindow, so nothing could exercise them. Pull the pure decisions
into link-policy.ts with unit tests, and let mailto: reach the system
handler alongside http(s) — it was silently dead before.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@CLAassistant

CLAassistant commented Jul 12, 2026

Copy link
Copy Markdown

CLA assistant check
All committers have signed the CLA.

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.

2 participants