diff --git a/ui/src/embedlink.test.ts b/ui/src/embedlink.test.ts index c25570b..f9eaad1 100644 --- a/ui/src/embedlink.test.ts +++ b/ui/src/embedlink.test.ts @@ -14,6 +14,9 @@ import { JSDOM } from "jsdom"; import { renderMarkdown } from "./render.ts"; +/** One loaded picture, the shape `state.embedImages` holds. */ +const PICTURES = new Map([["__Attachments/Screenshot 1.png", "data:image/png;base64,AAAA"]]); + (globalThis as unknown as { window: unknown }).window = new JSDOM("").window; let checks = 0; @@ -42,30 +45,75 @@ const labelled = renderMarkdown("see [[concepts/memory|how it works]]\n"); assertHas(labelled, 'data-target="concepts/memory"', "a `|` alias leaves the target alone"); assertHas(labelled, ">how it works", "…and is what the reader sees"); -// --- the embed form: the `!` is markup, not text -------------------------------------- +// --- the embed with no picture in hand: it reads as its link -------------------------- // -// `![[file]]` is the same link with the core's embed marker in front (link.rs). B2 has no -// inline viewer for it yet, so it renders as the link — but the `!` is *grammar*, and a -// grammar character that leaks into the prose is a rendering bug, not a partial feature. +// `![[file]]` is the same link with the core's embed marker in front (link.rs). Until the +// bytes arrive — and forever, for a file that is no image — it renders as the link; the +// `!` is *grammar*, and a grammar character that leaks into the prose is a rendering bug, +// not a partial feature. const embed = renderMarkdown("![[__Attachments/Screenshot 1.png]]\n"); assertNot(embed, ">!", "the embed marker never reaches the reader as text"); assertNot(embed, "

!", "…not even at the head of its own paragraph"); assertHas(embed, 'data-target="__Attachments/Screenshot 1.png"', "the embed carries its target"); assertHas(embed, ">__Attachments/Screenshot 1.png", "…and labels itself with it"); +assertNot(embed, "__Attachments/Screenshot 2.png", "…and still reads as the filename"); -assertNot(sized, ">400<", "the size hint is dropped, not shown"); +assertNot(sized, ">400<", "the size hint is never a label"); assertNot(sized, "

!", "…and the marker is still markup"); +// --- the embed with its picture: the image *is* the link's label ----------------------- + +const shown = renderMarkdown("![[__Attachments/Screenshot 1.png]]\n", PICTURES); +assertHas(shown, '__Attachments/Screenshot 1.png", "the path is no longer the label"); +assertNot(shown, "

!", "…and the marker is still markup"); + +// The `data:` src has to survive the sanitizer, not just the renderer — `renderMarkdown` +// runs DOMPurify over its own output (render.ts's postprocess hook), and a URL scheme it +// stripped would leave an `` with nothing to draw. +assertHas( + renderMarkdown("text ![[__Attachments/Screenshot 1.png]] text\n", PICTURES), + "data:image/png;base64,AAAA", + "the sanitizer admits a data: URL on an img (DOMPurify's DATA_URI_TAGS)", +); + +// --- the width hint, now that there is something to size ------------------------------- + +const wide = renderMarkdown("![[__Attachments/Screenshot 1.png|500]]\n", PICTURES); +assertHas(wide, 'width="500"', "`|500` is the width the picture is drawn at"); +assertNot(wide, "height=", "…and only the width: the aspect ratio is the CSS's to keep"); + +const odd = renderMarkdown("![[__Attachments/Screenshot 1.png|500x300]]\n", PICTURES); +assertHas(odd, "` rather than to a guess about its bytes. + +assertEq(imageMime("__Attachments/Shot.png"), "image/png", "png"); +assertEq(imageMime("a/b/PHOTO.JPEG"), "image/jpeg", "case-insensitive, and jpeg is jpg's type"); +assertEq(imageMime("drawing.svg"), "image/svg+xml", "svg is an image the webview renders"); +assertEq(imageMime("notes/paper.pdf"), null, "a class with no viewer names no picture"); +assertEq(imageMime("Makefile"), null, "…and neither does an extensionless file"); +assertEq( + imageDataUrl("shot.png", "AAAA"), + "data:image/png;base64,AAAA", + "the bytes become the one src shape the CSP admits", +); +assertEq(imageDataUrl("paper.pdf", "AAAA"), null, "no MIME type, no URL to hand an ``"); + +// --- scanning a body for embeds ---------------------------------------------------------- + +assertEq( + imageEmbedTargets("intro\n\n![[__Attachments/Pasted image 1.png|500]]\n\ntail\n"), + ["__Attachments/Pasted image 1.png"], + "the target is taken, the width hint is not part of it", +); +assertEq( + imageEmbedTargets("![[ a/shot.png ]]\n"), + ["a/shot.png"], + "trimmed, the same way the renderers resolve a hand-spaced target", +); +assertEq( + imageEmbedTargets("![[a.png]] ![[b.png]] ![[a.png]]\n"), + ["a.png", "b.png"], + "document order, and a picture embedded twice is read once", +); +assertEq( + imageEmbedTargets("see [[a/shot.png]] and [[b.png|Bee]]\n"), + [], + "a *link* to a picture is a link — the marker is the whole difference", +); +assertEq( + imageEmbedTargets("![[notes/paper.pdf]] ![[clip.mp4]] ![[some/note]]\n"), + [], + "an embed of something B2 can't draw asks for nothing", +); +assertEq( + imageEmbedTargets("![alt](shot.png)\n"), + [], + "Markdown's own image form is not a wikilink embed", +); +assertEq(imageEmbedTargets("brackets ![[not closed\n"), [], "an unclosed `[[` names nothing"); + +// --- the plan: what the app will actually hold ------------------------------------------- + +function res(path: string, over: Partial = {}): ResourceSummary { + return { path, class: "image", size: 1_000, mtime: 0, ...over }; +} + +const INVENTORY: ResourceSummary[] = [ + res("a.png"), + res("b.png"), + res("huge.png", { size: IMAGE_VIEWER_MAX_BYTES + 1 }), + res("paper.pdf", { class: "pdf" }), +]; + +assertEq( + inlineImagePlan(["a.png", "b.png"], INVENTORY), + ["a.png", "b.png"], + "two ordinary pictures, both read", +); +assertEq( + inlineImagePlan(["missing.png"], INVENTORY), + [], + "a file the vault has never inventoried is not asked for — the host would refuse it", +); +assertEq( + inlineImagePlan(["paper.pdf"], INVENTORY), + [], + "the core's class has the last word, whatever the extension claimed", +); +assertEq( + inlineImagePlan(["huge.png", "a.png"], INVENTORY), + ["a.png"], + "one picture over the per-image bound drops out; the note's others are unaffected", +); + +// The per-note budget, which is what a photo log costs. Taken in document order, so what +// the reader sees when the note opens is what got drawn. +const many = Array.from({ length: 8 }, (_, i) => + res(`p${i}.png`, { size: NOTE_IMAGES_MAX_BYTES / 4 }), +); +assertEq( + inlineImagePlan( + many.map((r) => r.path), + many, + ), + ["p0.png", "p1.png", "p2.png", "p3.png"], + "the budget is spent from the top of the note, and the rest read as their links", +); +assert( + NOTE_IMAGES_MAX_BYTES >= IMAGE_VIEWER_MAX_BYTES, + "a note must be able to hold at least one picture of the largest permitted size", +); +checks++; + +console.log(`embeds.test.ts: ${checks} checks passed`); diff --git a/ui/src/embeds.ts b/ui/src/embeds.ts new file mode 100644 index 0000000..a46b0fb --- /dev/null +++ b/ui/src/embeds.ts @@ -0,0 +1,187 @@ +// The `![[…]]` **image embed**: the grammar it shares with the plain wikilink, which +// targets name a picture this webview can draw, how wide to draw it, and which of a +// note's embeds the app will actually hold in memory. +// +// Pure string/number logic over the vault's own inventory — no DOM, no IPC — so node +// runs its test straight off the source (`npm test`), and the three surfaces that need +// these answers can share one set of them: the reading view (render.ts), the editor's +// live preview (livepreview.ts) and the resource card's viewer (render.ts again). +// +// The bytes themselves are *not* here. An embed's picture arrives over the same +// `read_resource` command the resource card uses, is base64 on the wire, and lands in +// `state.embedImages` keyed by vault-relative path; everything below decides **what to +// ask for** and **how to draw what came back**. + +import type { ResourceSummary } from "./types.ts"; + +/** The pictures a note has loaded: vault-relative path → the `data:` URL to draw it + * with. One map, read by the reading view and the live preview alike, so a note looks + * the same read or edited. */ +export type EmbedImages = ReadonlyMap; + +/** The empty map, shared — the "nothing loaded yet" value every surface degrades to + * (an embed with no picture reads as its link, which is what B2 showed before there + * was a viewer at all). */ +export const NO_EMBED_IMAGES: EmbedImages = new Map(); + +// --- the grammar --------------------------------------------------------------------- +// +// `[[target]]`, `[[target|label]]`, and each with the embed marker in front. Spelled +// **once** and given two anchorings, because the two readers of it must not drift: the +// reading view tokenizes at the head of the remaining source (marked hands it a suffix), +// and the loader scans a whole body for the images to fetch. A grammar that agreed on +// paper and disagreed in code would show a picture that was never asked for, or ask for +// one it then can't draw. +// +// Group 1 is the marker (`!` or empty), 2 the target, 3 the optional `|`-part. +const WIKILINK = String.raw`(!?)\[\[([^\]|]+)(?:\|([^\]]+))?\]\]`; + +/** The tokenizer's form — anchored at the head of the source it is handed. */ +export const WIKILINK_ANCHORED = new RegExp(`^${WIKILINK}`); + +/** The scanner's form. `g` is stateful (`lastIndex`), so this is only ever used with + * `matchAll`, which iterates a fresh walk rather than leaving the flag mid-string. */ +const WIKILINK_GLOBAL = new RegExp(WIKILINK, "g"); + +/** Both ends pinned — for a reader that already knows where the construct starts and + * ends. The editor's live preview matches this against a whole `Wikilink` node + * (livepreview.ts, which exports it under the name its handlers use). */ +export const WIKILINK_EXACT = new RegExp(`^${WIKILINK}$`); + +/** + * The display width an embed asks for — `![[shot.png|500]]` → 500, in CSS pixels. + * + * Only a bare integer is a width. The `|`-part means something different on either side + * of the marker (a plain wikilink's is its **label**), and even on an embed it is a + * free-text field: Obsidian's `|500x300` and anything a human typed by hand land here + * too. Refusing everything that isn't a width is what keeps a hint the app doesn't + * understand from being *drawn* — the image simply renders at its own size, which is + * the honest fallback and the one that can't distort the picture. + * + * Height is deliberately not taken even when written: the ask is a width that + * **maintains aspect ratio**, and that is one number plus `height: auto` in the CSS. + */ +export function embedWidth(hint: string | null | undefined): number | null { + if (!hint) return null; + const t = hint.trim(); + if (!/^[0-9]+$/.test(t)) return null; + const n = Number(t); + return n > 0 ? n : null; +} + +// --- what is a picture --------------------------------------------------------------- + +/** Extension → MIME type for the image classes this webview draws in place. + * Extension-only, the same rule the core classifies on (`resource.rs`) and the same + * table of extensions — no content sniffing, so a mislabeled file degrades to a broken + * `` rather than being guessed at. **Change it with `ResourceClass::Image`**: a + * class the core calls an image and this table doesn't know gets asked for, and then + * has nowhere to put its bytes. */ +const IMAGE_MIME: Record = { + png: "image/png", + jpg: "image/jpeg", + jpeg: "image/jpeg", + gif: "image/gif", + webp: "image/webp", + svg: "image/svg+xml", + avif: "image/avif", +}; + +/** The MIME type this path's extension names, or null when it names no image B2 draws. */ +export function imageMime(path: string): string | null { + const ext = path.includes(".") ? (path.split(".").pop() ?? "").toLowerCase() : ""; + return IMAGE_MIME[ext] ?? null; +} + +/** + * A resource's base64 bytes as the `src` an `` can use, or `null` when the + * extension names no image this webview renders. + * + * A `data:` URL rather than a path, because the webview's origin is the *app bundle*, + * not the vault: there is no URL by which it could fetch a vault file, and the CSP + * admits exactly this one shape (`img-src 'self' data:` in `tauri.conf.json`) — so the + * bytes travel through the same command seam as everything else the host lends the UI. + */ +export function imageDataUrl(path: string, base64: string): string | null { + const mime = imageMime(path); + return mime ? `data:${mime};base64,${base64}` : null; +} + +// --- how much of it B2 will hold ----------------------------------------------------- + +/** + * How large a *single* image B2 will pull across the IPC and hold on screen. + * + * The bytes cross as base64 (a third larger) and the `data:` URL lives in the webview's + * heap for as long as the document is open, so this is a memory bound, not a taste one. + * Past it the resource card keeps the *Open in system default* handoff it has always + * had, and an inline embed keeps reading as its link — both cost nothing and both still + * reach the file. Screenshots — what a vault actually accumulates — are a couple of + * megabytes. + */ +export const IMAGE_VIEWER_MAX_BYTES = 25 * 1024 * 1024; + +/** + * How much image B2 will hold for **one note**, across every embed in it. + * + * The per-image bound above says nothing about a note holding forty of them, and a + * photo log is a perfectly ordinary note. This is the bound on the sum, and it is what + * makes the memory cost of opening a note something that can be stated rather than + * discovered. Embeds are taken in document order until it is spent, so what the reader + * is looking at when the note opens is what got drawn; the rest read as their links. + */ +export const NOTE_IMAGES_MAX_BYTES = 96 * 1024 * 1024; + +/** + * Every image the note **embeds**, vault-relative, de-duplicated, in document order. + * + * A scan of the raw Markdown rather than a parse: this runs on every buffer change + * while editing, so it has to be cheap, and one regex walk over the body is. The cost + * of not parsing is precision — an `![[shot.png]]` written *inside a fenced code block* + * is prose to the renderer but a hit here, so its bytes get read and then never drawn. + * That is a wasted read of a file the vault already holds, which is the cheaper side of + * the trade; the expensive side would be re-lexing the note per keystroke. + * + * Only the marked-up form counts. A plain `[[shot.png]]` is a *link* to the picture and + * B2 keeps it one — the marker is the whole difference between naming a file and + * showing it. + */ +export function imageEmbedTargets(md: string): string[] { + const seen = new Set(); + for (const m of md.matchAll(WIKILINK_GLOBAL)) { + if (m[1] !== "!") continue; + const target = m[2].trim(); + if (imageMime(target)) seen.add(target); + } + return [...seen]; +} + +/** + * Which of those targets the app will actually fetch, in document order. + * + * Everything is decided off the **inventory** B2 already has in hand (`list_resources`, + * which carries each file's class and size), so planning costs no IPC and the bounds are + * applied before a single byte is read rather than after. Three ways a target drops out, + * and all three leave the embed reading as its link: + * + * - the vault has no such file — a typo, or a picture added since the last index pass + * (the host validates against the same inventory, so asking anyway would only fail); + * - the core doesn't call it an image, whatever its extension claims; + * - it is larger than one image may be, or the note has already spent its budget. + */ +export function inlineImagePlan( + targets: readonly string[], + resources: readonly ResourceSummary[], +): string[] { + const inventory = new Map(resources.map((r) => [r.path, r])); + const plan: string[] = []; + let budget = NOTE_IMAGES_MAX_BYTES; + for (const target of targets) { + const r = inventory.get(target); + if (!r || r.class !== "image") continue; + if (r.size > IMAGE_VIEWER_MAX_BYTES || r.size > budget) continue; + budget -= r.size; + plan.push(target); + } + return plan; +} diff --git a/ui/src/livepreview.test.ts b/ui/src/livepreview.test.ts index 25576cf..87a2060 100644 --- a/ui/src/livepreview.test.ts +++ b/ui/src/livepreview.test.ts @@ -27,6 +27,7 @@ import type { DecorationSet, WidgetType } from "@codemirror/view"; import { WIKILINK_RE, blockDecorations, + embedImagesField, inlineDecorations, isFollowClick, taskChecked, @@ -61,8 +62,22 @@ const LANG = markdown({ base: markdownLanguage, extensions: [wikilink] }); * Every fixture therefore ends in a newline: the caret sits alone on the trailing empty * line, where it reveals nothing. A default of 0 would silently reveal line 1 and make * half of these cases assert the *revealed* shape while reading like the concealed one. */ -function stateOf(doc: string, cursor = doc.length): EditorState { - return EditorState.create({ doc, selection: { anchor: cursor }, extensions: [LANG] }); +function stateOf(doc: string, cursor = doc.length, images?: Map): EditorState { + const extensions = images ? [LANG, embedImagesField.init(() => images)] : [LANG]; + return EditorState.create({ doc, selection: { anchor: cursor }, extensions }); +} + +/** One loaded picture, keyed as `state.embedImages` keys them (main.ts). A state built + * *without* the field is the app before any bytes arrived — and the shape most of this + * file wants, which is why it is the default. */ +const PICTURES = new Map([["a/shot.png", "data:image/png;base64,AAAA"]]); + +/** Just enough of livepreview.ts's `TableWidget` for the two checks that read one — the + * suite runs off the source, so these are the real objects, not stand-ins. */ +interface TableWidget { + md: string; + images: Map; + eq(o: TableWidget): boolean; } interface DecoSpec { @@ -76,8 +91,14 @@ interface DecoSpec { * `constructor.name` is honest here in a way it wouldn't be against a bundle: these are * livepreview.ts's own classes, and node runs the suite off the source. */ function widgetLabel(w: WidgetType): string { - const checked = (w as unknown as { checked?: boolean }).checked; - return checked === undefined ? w.constructor.name : `${w.constructor.name}(${checked ? "x" : " "})`; + const spec = w as unknown as { checked?: boolean; target?: string; width?: number | null }; + if (spec.checked !== undefined) return `${w.constructor.name}(${spec.checked ? "x" : " "})`; + // The image widget: which picture, and at what width — the two things about it that a + // wrong offset or a mis-read size hint would get wrong. + if (spec.target !== undefined) { + return `${w.constructor.name}(${spec.target}${spec.width === null ? "" : ` @${spec.width}`})`; + } + return w.constructor.name; } /** One decoration per line, as a string, so a mismatch reads as a diff rather than as two @@ -105,14 +126,14 @@ function show(doc: string, set: DecorationSet): string[] { /** The inline/line decorations over the whole document — the app passes the viewport here, * and a fixture that fits on a screen has no reason to. */ -function inline(doc: string, cursor?: number): string[] { - return show(doc, inlineDecorations(stateOf(doc, cursor), [{ from: 0, to: doc.length }])); +function inline(doc: string, cursor?: number, images?: Map): string[] { + return show(doc, inlineDecorations(stateOf(doc, cursor, images), [{ from: 0, to: doc.length }])); } /** The block decorations (tables) — a separate builder because CM6 forbids a ViewPlugin * from emitting a replace that spans a line break (spec §8). */ -function block(doc: string, cursor?: number): string[] { - return show(doc, blockDecorations(stateOf(doc, cursor))); +function block(doc: string, cursor?: number, images?: Map): string[] { + return show(doc, blockDecorations(stateOf(doc, cursor, images))); } /** Every node of one name in the real parse tree, as `offset:source`. */ @@ -197,10 +218,13 @@ check("empty content and a leading `|` are rejected, so `[[` never eats a plain }); check("WIKILINK_RE splits a node into target and label", () => { - assertEq(WIKILINK_RE.exec("[[a]]")?.slice(1), ["a", undefined], "no label"); - assertEq(WIKILINK_RE.exec("[[a/b|Label]]")?.slice(1), ["a/b", "Label"], "target|label"); + // Three groups: the embed marker, the target, the `|`-part. The marker leads because + // the node covers it — `![[a]]` is one Wikilink, not a `!` beside one. + assertEq(WIKILINK_RE.exec("[[a]]")?.slice(1), ["", "a", undefined], "no marker, no label"); + assertEq(WIKILINK_RE.exec("[[a/b|Label]]")?.slice(1), ["", "a/b", "Label"], "target|label"); + assertEq(WIKILINK_RE.exec("![[a/b.png|500]]")?.slice(1), ["!", "a/b.png", "500"], "the embed"); // A `|` past the first belongs to the label: the target is what can't contain one. - assertEq(WIKILINK_RE.exec("[[a|b|c]]")?.slice(1), ["a", "b|c"], "only the first `|` splits"); + assertEq(WIKILINK_RE.exec("[[a|b|c]]")?.slice(1), ["", "a", "b|c"], "only the first `|` splits"); // Anchored at both ends, because the decorator hands it a whole node's text and derives // offsets from the match — a floating match would place the label span wrong. assertEq(WIKILINK_RE.exec("[[a]] x"), null, "anchored: trailing text is not a match"); @@ -211,6 +235,7 @@ check("the parse rule accepts `[[a|]]`, the regex refuses it, and the text stays // the parser's "non-empty content, non-`|` first char" but not `([^\]]+)`. A node with // no match degrades to raw — never an error, and never a changed byte. assertEq(nodes("[[a|]]\n", "Wikilink"), ["0:[[a|]]"], "the node exists"); + assertEq(nodes("![[a|]]\n", "Wikilink"), ["0:![[a|]]"], "…and so does the embed's"); assertEq(WIKILINK_RE.exec("[[a|]]"), null, "the regex declines it"); assertEq(inline("[[a|]]\n"), [], "so nothing is decorated and the source shows through"); }); @@ -299,6 +324,95 @@ check("a wikilink shows its label, carrying the target for the click handler", ( ); }); +// --- the embed form, `![[…]]` --------------------------------------------------------- +// +// The marker belongs to the node (the parse rule claims it), which is what stops the +// standard Image parser taking `![[shot.png]]` as `![` + a reference link — the bug that +// left an embed reading as raw source in the editor while the reading view rendered it. + +check("the embed marker is part of the node, not an Image wrapping a link", () => { + assertEq(nodes("![[a/shot.png]]\n", "Wikilink"), ["0:![[a/shot.png]]"], "one node, marker in"); + assertEq(nodes("![[a/shot.png]]\n", "Image"), [], "the Image parser never gets it"); + // Markdown's own image form is untouched: after `!` there is one `[`, not two. + assertEq(nodes("![alt](u.png)\n", "Wikilink"), [], "a real Markdown image is not a wikilink"); + assertEq(nodes("![alt](u.png)\n", "Image"), ["0:![alt](u.png)"], "…and is still an Image"); +}); + +check("an embed with no picture reads as its link, marker concealed", () => { + // The reading view's rule, in the buffer (render.ts): the `!` is grammar, so it goes + // with the brackets — and an embed shows its *target*, because its `|`-part is a width + // rather than a label. A bare "500" where the filename belongs is the bug this pins. + assertEq( + inline("![[a/shot.png]]\n"), + ['hide 0-3 "![["', 'mark lp-wikilink →a/shot.png 3-13 "a/shot.png"', 'hide 13-15 "]]"'], + "no bytes in hand — the embed is its link", + ); + assertEq( + inline("![[a/shot.png|500]]\n"), + ['hide 0-3 "![["', 'mark lp-wikilink →a/shot.png 3-13 "a/shot.png"', 'hide 13-19 "|500]]"'], + "the width hint is concealed with the closing brackets, never shown as a label", + ); + assertEq( + inline("![[a/paper.pdf]]\n", undefined, PICTURES), + ['hide 0-3 "![["', 'mark lp-wikilink →a/paper.pdf 3-14 "a/paper.pdf"', 'hide 14-16 "]]"'], + "a target that is no picture stays a link however many pictures are loaded", + ); +}); + +check("an embed whose picture is loaded is replaced by it, width and all", () => { + assertEq( + inline("![[a/shot.png]]\n", undefined, PICTURES), + ['widget EmbedImageWidget(a/shot.png) 0-15 "![[a/shot.png]]"'], + "the whole construct, marker included, becomes the picture", + ); + assertEq( + inline("![[a/shot.png|500]]\n", undefined, PICTURES), + ['widget EmbedImageWidget(a/shot.png @500) 0-19 "![[a/shot.png|500]]"'], + "`|500` is the width it is drawn at", + ); + assertEq( + inline("![[a/shot.png|500x300]]\n", undefined, PICTURES), + ['widget EmbedImageWidget(a/shot.png) 0-23 "![[a/shot.png|500x300]]"'], + "a hint that isn't a width draws the picture at its own size", + ); + assertEq( + inline("see ![[a/shot.png]] ok\n", undefined, PICTURES), + ['widget EmbedImageWidget(a/shot.png) 4-19 "![[a/shot.png]]"'], + "an embed written mid-sentence is replaced in place, not lifted out of the line", + ); +}); + +check("the cursor on an embed reveals its source, picture or no picture", () => { + // The whole point of a live preview: the markup is always reachable. Clicking the + // picture is the same event — the widget declines to swallow it, so CodeMirror puts the + // caret in the replaced range and this is the state that results. + assertEq( + inline("![[a/shot.png]]\n", 5, PICTURES), + ['mark lp-wikilink →a/shot.png 3-13 "a/shot.png"'], + "the picture gives way to the bytes, and only the style survives", + ); + assertEq( + inline("![[a/shot.png]]\n", 0, PICTURES), + ['mark lp-wikilink →a/shot.png 3-13 "a/shot.png"'], + "the marker is inside the reveal range, so a caret before the `!` reveals too", + ); + assertEq( + inline("![[a/shot.png]]\n", 16, PICTURES), + ['widget EmbedImageWidget(a/shot.png) 0-15 "![[a/shot.png]]"'], + "…and a caret on the next line does not", + ); +}); + +check("a plain wikilink to a picture stays a link", () => { + // The marker is the whole difference between naming a file and showing it, and a note + // that meant to link must not sprout an image because another line embedded the file. + assertEq( + inline("[[a/shot.png]]\n", undefined, PICTURES), + ['hide 0-2 "[["', 'mark lp-wikilink →a/shot.png 2-12 "a/shot.png"', 'hide 12-14 "]]"'], + "no marker, no picture", + ); +}); + check("a wikilink's target is trimmed, and the label span is not", () => { // `[[ a/b | Label ]]` resolves to the same note as `[[a/b]]` — the trim is what makes a // hand-spaced link work. The visible label keeps its spacing: the bytes are the human's. @@ -413,6 +527,27 @@ check("the table widget carries the exact source it hides", () => { assertEq([it.from, it.to], [3, 32], "snapped to whole lines — a block replace must be"); }); +check("the table widget carries the note's pictures, and is re-keyed when they change", () => { + // A cell can hold an `![[image.png]]`, and "pixel-identical" has to hold inside a table + // too — so the widget renders with the same map the reading view draws from. It is a + // second key on the widget because the first (the markdown) does not change when bytes + // arrive: without it the table would keep the stale render it was built with. + const doc = "| a | b |\n| - | - |\n| ![[a/shot.png]] | 2 |\n"; + const widgetOf = (images?: Map): TableWidget => { + const it = blockDecorations(stateOf(doc, doc.length, images)).iter(); + const w = (it.value?.spec as DecoSpec).widget as unknown as TableWidget; + assert(w !== undefined, "a table is decorated"); + return w; + }; + assertEq( + widgetOf(PICTURES).images.get("a/shot.png"), + "data:image/png;base64,AAAA", + "the picture reaches the widget, so `renderMarkdown` can draw it in the cell", + ); + assert(!widgetOf().eq(widgetOf(PICTURES)), "bytes arriving re-key the widget"); + assert(widgetOf(PICTURES).eq(widgetOf(PICTURES)), "…and an unchanged map does not"); +}); + check("the table the cursor is inside stays raw source", () => { const doc = "| a | b |\n| - | - |\n| 1 | 2 |\n"; assertEq(block(doc, 12), [], "no widget over the table being edited"); diff --git a/ui/src/livepreview.ts b/ui/src/livepreview.ts index 4e1bc37..c6c7113 100644 --- a/ui/src/livepreview.ts +++ b/ui/src/livepreview.ts @@ -23,6 +23,7 @@ import { type EditorState, type Extension, type Range, + StateEffect, StateField, type Text, } from "@codemirror/state"; @@ -39,6 +40,12 @@ import type { InlineContext, MarkdownConfig } from "@lezer/markdown"; // Extension-qualified, unlike the app-only modules: node's test runner resolves // specifiers literally, and this file is in the suite now (livepreview.test.ts). import { externalUrl } from "./links.ts"; +import { + embedWidth, + NO_EMBED_IMAGES, + WIKILINK_EXACT, + type EmbedImages, +} from "./embeds.ts"; import { renderMarkdown } from "./render.ts"; // --- the wikilink tree extension (spec §4, insight §2.3) -------------------------- @@ -49,6 +56,7 @@ import { renderMarkdown } from "./render.ts"; // `Wikilink` node lets the *one* decoration engine style wikilinks uniformly with every // other construct, instead of a bolt-on. Positions are document-relative throughout. +const BANG = 33; // ! const OPEN = 91; // [ const CLOSE = 93; // ] const PIPE = 124; // | @@ -59,11 +67,20 @@ export const wikilink: MarkdownConfig = { parseInline: [ { name: "Wikilink", - // Before the standard Link parser so `[[` isn't first eaten as `[` + a link. + // Before the standard Link parser — and so before `Image`, which sits after it in + // the default inline order — so neither `[[` nor `![[` is first eaten as a link or + // a reference-style image. It *was* only ahead of `Link`, and the embed form paid + // for it: `![[shot.png]]` parsed as an Image wrapping a Link, no `Wikilink` node + // formed at all, and the editor showed an embed as raw text while the reading view + // rendered it. before: "Link", parse(cx: InlineContext, next: number, pos: number): number { - if (next !== OPEN || cx.char(pos + 1) !== OPEN) return -1; - const contentStart = pos + 2; + // The embed marker is part of the construct, so the node covers it: one node per + // wikilink however it was written, and no handler has to read a byte outside its + // own node to find out which form it is looking at. + const open = next === BANG ? pos + 1 : pos; + if (cx.char(open) !== OPEN || cx.char(open + 1) !== OPEN) return -1; + const contentStart = open + 2; const end = cx.end; // Scan for the closing `]]`; a wikilink spans no `]` or line break internally. let i = contentStart; @@ -81,13 +98,48 @@ export const wikilink: MarkdownConfig = { ], }; -/** The engine re-derives the `[[..]]` structure from the node text — its span is exactly - * the wikilink, so an anchored match yields the label/pipe offsets and the target. +/** The engine re-derives the `[[..]]` / `![[..]]` structure from the node text — its span + * is exactly the wikilink, so a whole-string match yields the marker, the target, and the + * label/pipe offsets. It is the reading view's grammar (embeds.ts) with both ends pinned: + * one spelling of what a wikilink *is*, so read and edit cannot drift. * * Deliberately *stricter* than the parse rule above, which accepts a `[[a|]]` the empty * `([^\]]+)` label group rejects. The two are allowed to disagree: the node exists, the * match fails, and the Wikilink handler leaves the text raw (spec §4). */ -export const WIKILINK_RE = /^\[\[([^\]|]+)(?:\|([^\]]+))?\]\]$/; +export const WIKILINK_RE = WIKILINK_EXACT; + +// --- the note's pictures (the `![[image.png]]` embed) -------------------------------- +// +// An embed's bytes arrive over IPC long after the editor mounted, and they arrive for +// the *document*, not for the editor — the reading view draws the same map (render.ts). +// So they enter the editor as ordinary editor state: a field main.ts writes with an +// effect, which every decoration pass then reads. That is what keeps `inlineDecorations` +// a pure function of `EditorState` (the property the whole suite leans on) instead of a +// function of whatever main.ts's module scope happened to hold at paint time. +// +// The field lives *outside* the live-preview compartment on purpose: which pictures the +// note has loaded is a fact about the document, not a viewing mode, so toggling `` to +// raw source and back must not drop them. + +/** Hand the editor the note's loaded pictures (path → `data:` URL). */ +export const setEmbedImages = StateEffect.define(); + +/** Where that map lives between paints. Add it to the editor's extensions once; the + * decorations read it, and nothing else in the editor knows about it. */ +export const embedImagesField = StateField.define({ + create: () => NO_EMBED_IMAGES, + update(images, tr) { + for (const e of tr.effects) if (e.is(setEmbedImages)) return e.value; + return images; + }, +}); + +/** The pictures this state carries, or none — `false` so a state assembled without the + * field (a test, or a future editor that doesn't want embeds) reads as empty rather + * than throwing. */ +function embedImagesOf(state: EditorState): EmbedImages { + return state.field(embedImagesField, false) ?? NO_EMBED_IMAGES; +} // --- the decoration engine (spec §4) ---------------------------------------------- @@ -185,21 +237,29 @@ class TaskWidget extends WidgetType { // click can't land a cursor inside; clicking the table (but not a wikilink) drops the // cursor at its start, revealing the raw source for editing. `from` is the table's // first-line start. +// +// It carries the note's pictures too, so an `![[image.png]]` in a *cell* draws there as +// well — "pixel-identical" has to hold inside a table or it doesn't hold. That is the +// second key in `eq`: the map is replaced wholesale by each `setEmbedImages` (main.ts +// snapshots it), so comparing it by identity rebuilds exactly when bytes land and never +// otherwise. class TableWidget extends WidgetType { readonly md: string; readonly from: number; - constructor(md: string, from: number) { + readonly images: EmbedImages; + constructor(md: string, from: number, images: EmbedImages) { super(); this.md = md; this.from = from; + this.images = images; } eq(o: TableWidget): boolean { - return o.md === this.md && o.from === this.from; + return o.md === this.md && o.from === this.from && o.images === this.images; } toDOM(view: EditorView): HTMLElement { const wrap = document.createElement("div"); wrap.className = "lp-table"; - wrap.innerHTML = renderMarkdown(this.md); + wrap.innerHTML = renderMarkdown(this.md, this.images); wrap.addEventListener("mousedown", (e) => { // Let a link click fall through to the app's own handlers rather than yanking the // caret out from under it: a wikilink to the follow path, a web link to the OS @@ -218,6 +278,49 @@ class TableWidget extends WidgetType { } } +// The picture of an `![[image.png]]` embed, drawn in the buffer's place — the editor's +// half of the reading view's inline image (render.ts). An inline replace, not a block +// widget: it spans no line break, so it belongs to the ViewPlugin with every other +// inline conceal, and an embed written mid-sentence stays mid-sentence. +// +// Clicking it **reveals the source**, and it does so without a line of code here: the +// widget declines to ignore the event (`ignoreEvent` → false, against `WidgetType`'s +// default), so CodeMirror handles the click itself and puts the cursor at the replaced +// range — which is precisely the reveal condition the decoration is computed from. The +// same is true of arrowing onto it. A widget that swallowed its clicks would be a hole +// in the buffer: a picture you cannot get a caret next to is a picture you cannot edit +// the markup of. +// +// It carries `data-target` like every other wikilink, so ⌘-click follows it to the +// resource card through the plugin's own mousedown handler (`isFollowClick`). +class EmbedImageWidget extends WidgetType { + readonly src: string; + readonly target: string; + readonly width: number | null; + constructor(src: string, target: string, width: number | null) { + super(); + this.src = src; + this.target = target; + this.width = width; + } + eq(o: EmbedImageWidget): boolean { + return o.src === this.src && o.target === this.target && o.width === this.width; + } + toDOM(): HTMLElement { + const img = document.createElement("img"); + img.className = "lp-embed-image"; + img.src = this.src; + // The filename, for the reading view's reason: it is all B2 knows about the picture. + img.alt = this.target.split("/").pop() ?? this.target; + img.setAttribute("data-target", this.target); + if (this.width !== null) img.width = this.width; + return img; + } + ignoreEvent(): boolean { + return false; + } +} + /** Is this GFM task marker checked? `[x]` and `[X]` are; the third spelling the grammar * emits a `TaskMarker` for, `[ ]`, is not — and nothing else is a marker at all. */ export function taskChecked(marker: string): boolean { @@ -257,6 +360,7 @@ function handleNode( node: SyntaxNodeRef, doc: Text, sel: EditorSelection, + images: EmbedImages, decos: Range[], ): boolean | void { const name = node.name; @@ -310,14 +414,40 @@ function handleNode( // Wikilinks: show the label (accent, carrying `data-target` for mod-click follow), // conceal `[[`/`[[target|` and `]]`. A node whose text the anchored grammar rejects // (an odd `[[a|]]`) degrades to raw — never an error, never a changed byte (spec §4). + // + // The **embed** form `![[…]]` is the same node one byte to the right: the `!` is + // plain text to the grammar (the tree has no node for it), so the construct's real + // span starts at `node.from - 1` and every offset below is taken from that. Three + // things follow from the marker, and all three are the reading view's rules — + // read and edit must not disagree about what a note says (render.ts): + // + // • the `|`-part is a display **width**, not a label, so an embed shows its + // *target* where a plain wikilink shows its label (never a bare "500"); + // • with the picture in hand, the whole thing is replaced by the picture; + // • without one, it reads as its link — with the `!` concealed, because the + // marker is grammar and a grammar character in the prose is a rendering bug. case "Wikilink": { const raw = doc.sliceString(node.from, node.to); const m = WIKILINK_RE.exec(raw); if (!m) return; - const target = m[1].trim(); - const labelStart = m[2] === undefined ? node.from + 2 : node.from + 2 + m[1].length + 1; - const labelEnd = node.to - 2; + const embed = m[1] === "!"; + const target = m[2].trim(); const revealed = touches(sel, node.from, node.to); + const src = embed ? images.get(target) : undefined; + if (src !== undefined && !revealed) { + decos.push( + Decoration.replace({ + widget: new EmbedImageWidget(src, target, embedWidth(m[3])), + }).range(node.from, node.to), + ); + return; + } + // Offsets into the raw text, which the match is anchored to: the target always + // starts just past the (optional) marker and `[[`, and it is what an embed shows. + const open = node.from + m[1].length + 2; + const targetEnd = open + m[2].length; + const labelStart = embed || m[3] === undefined ? open : targetEnd + 1; + const labelEnd = embed ? targetEnd : node.to - 2; decos.push( Decoration.mark({ class: "lp-wikilink", attributes: { "data-target": target } }).range( labelStart, @@ -415,9 +545,10 @@ export function inlineDecorations( const decos: Range[] = []; const sel = state.selection; const doc = state.doc; + const images = embedImagesOf(state); const tree = syntaxTree(state); for (const { from, to } of ranges) { - tree.iterate({ from, to, enter: (node) => handleNode(node, doc, sel, decos) }); + tree.iterate({ from, to, enter: (node) => handleNode(node, doc, sel, images, decos) }); } // `sort: true` orders line/mark/replace decorations for us — the one place ordering // across the mixed decoration kinds is fiddly to get right by hand. @@ -437,6 +568,7 @@ export function blockDecorations(state: EditorState): DecorationSet { const decos: Range[] = []; const sel = state.selection; const doc = state.doc; + const images = embedImagesOf(state); syntaxTree(state).iterate({ enter: (node) => { if (node.name !== "Table") return; // keep descending to reach any nested table @@ -447,7 +579,7 @@ export function blockDecorations(state: EditorState): DecorationSet { if (!touches(sel, from, to)) { decos.push( Decoration.replace({ - widget: new TableWidget(doc.sliceString(from, to), from), + widget: new TableWidget(doc.sliceString(from, to), from, images), block: true, }).range(from, to), ); @@ -461,8 +593,11 @@ export function blockDecorations(state: EditorState): DecorationSet { const blockField = StateField.define({ create: (state) => blockDecorations(state), update(deco, tr) { - // Reveal keys on the selection, so a bare cursor move recomputes too. - return tr.docChanged || tr.selection ? blockDecorations(tr.state) : deco; + // Reveal keys on the selection, so a bare cursor move recomputes too — and on the + // note's pictures, which change no text and move no cursor (the ViewPlugin below + // watches the same effect, for the same reason). + const pictures = tr.effects.some((e) => e.is(setEmbedImages)); + return tr.docChanged || tr.selection || pictures ? blockDecorations(tr.state) : deco; }, provide: (f) => EditorView.decorations.from(f), }); @@ -490,6 +625,9 @@ export function isFollowClick(e: Pick): boolean { * `lp-body` class that swaps the editor to the reading view's proportional voice * (spec §3, §5). ⌘-click a wikilink follows it via `onFollow`; a plain click falls * through to place the cursor, as an editor must (spec §3). + * + * Not included: `embedImagesField`. It holds a fact about the *document*, so the editor + * adds it once (main.ts) and it survives the `` swap that reconfigures this. */ export function livePreview(onFollow: (target: string) => void): Extension { const plugin = ViewPlugin.fromClass( @@ -499,7 +637,12 @@ export function livePreview(onFollow: (target: string) => void): Extension { this.decorations = inlineDecorations(view.state, view.visibleRanges); } update(u: ViewUpdate): void { - if (u.docChanged || u.selectionSet || u.viewportChanged) { + // …and when a picture lands: the effect changes no text and moves no cursor, so + // without this the note keeps reading as its links until the next keystroke. + const pictures = u.transactions.some((tr) => + tr.effects.some((e) => e.is(setEmbedImages)), + ); + if (u.docChanged || u.selectionSet || u.viewportChanged || pictures) { this.decorations = inlineDecorations(u.view.state, u.view.visibleRanges); } } diff --git a/ui/src/main.ts b/ui/src/main.ts index abe5c0a..3878b61 100644 --- a/ui/src/main.ts +++ b/ui/src/main.ts @@ -57,7 +57,13 @@ import { sideArrowMove, sideNavFor, sideRowIndex, sideRows } from "./sidenav"; import { answerMessage, chatHistory, errorMessage, userMessage } from "./chat"; import { isSettingsTab, tabMove, tabNavFor, tabStep, type SettingsTabId } from "./settingstabs"; import { externalUrl, isInPageAnchor } from "./links"; -import { livePreview, wikilink } from "./livepreview"; +import { embedImagesField, livePreview, setEmbedImages, wikilink } from "./livepreview"; +import { + imageDataUrl, + imageEmbedTargets, + IMAGE_VIEWER_MAX_BYTES, + inlineImagePlan, +} from "./embeds"; import { b2Highlighter, highlightCodeBlocks, resolveLang } from "./highlight"; import { noteTarget, wikiCandidates, wikiInsertion, wikiQueryAt } from "./wikicomplete"; import { @@ -108,13 +114,11 @@ import { type Direction, } from "./zoom"; import { reconcileIndex } from "./reconcile"; -import type { ResourceExplainView } from "./types"; +import type { ResourceExplainView, ResourceSummary } from "./types"; import { contextMenuHtml, embedBannerHtml, escapeHtml, - imageDataUrl, - IMAGE_VIEWER_MAX_BYTES, modalHtml, notePaneHtml, reindexDisabled, @@ -416,6 +420,9 @@ function render(): void { syncOverlayFocus(); syncFind(noteSwapped); if (noteSwapped) void paintCodeHighlights(); + // The open note's `![[image.png]]` embeds. Skipped while editing — there the buffer, + // not the last-saved body, is what the pictures answer to (`scheduleImageScan`). + if (!state.editing) void syncNoteImages(state.current?.path ?? null, state.current?.body ?? ""); } /** The reading view's half of syntax highlighting (highlight.ts): a post-render pass over @@ -629,6 +636,111 @@ async function loadResourceImage(r: ResourceExplainView): Promise } } +// --- the note's inline pictures (`![[image.png]]`) --------------------------------- +// +// An embed draws the file it names (render.ts, livepreview.ts), and the bytes for that +// come over the same `read_resource` command the resource card uses. What is *here* is +// the reconciliation: which pictures the open document should be holding, and the reads +// that close the gap. +// +// It is driven off the document rather than off each navigation, for the reason +// `paintCodeHighlights` is: there are a dozen ways a note's body reaches the pane (open, +// back/forward, save, an external change, a rename), and a loader wired into each of +// them is a loader that will be forgotten by the thirteenth. One reconcile at the tail of +// `render()` covers all of them, and it is cheap because it is memoized on the exact body +// it last ran against — the repaint an arriving picture *causes* does no work at all. +// +// The map is the budget: `inlineImagePlan` (embeds.ts) decides what may be held, and +// anything the document no longer embeds is dropped, so a long editing session can't +// accumulate pictures past the bound. + +/** The document the held pictures belong to, the exact body they were planned from, and + * the inventory they were planned against — together, the memo that makes the + * `render()`-tail call free. The inventory is in there because the tree's file list + * arrives *after* the first note can be on screen (`loadVault`), and a plan made against + * an empty one has to be made again rather than remembered. */ +let imagesOwner: string | null = null; +let imagesBody: string | null = null; +let imagesInventory: readonly ResourceSummary[] | null = null; +/** Which plan is the current one. Bumped by every reconcile that gets past the memo, and + * checked again after the reads — a read is only allowed to store what the *latest* plan + * still wants. Without it, a picture deleted from the buffer mid-read comes back after + * the newer plan pruned it, and the memo then holds that stale entry in the map for as + * long as the body doesn't change again: the per-note budget quietly stops bounding. */ +let imagesGeneration = 0; +/** Debounce for the *buffer* scan while editing — a keystroke can add an embed, and the + * answer is worth a moment's wait rather than a scan per character. */ +let imageScanTimer: number | undefined; +const IMAGE_SCAN_MS = 400; + +/** Hand the live editor the pictures the note holds now — a no-op when not editing, and + * when the editor is in raw-source mode (the field is there, nothing reads it). */ +function pushNoteImages(): void { + // A copy, not the live map: what the editor holds is `EditorState`, and state that + // changes under CodeMirror without a transaction is state its decorations can read + // twice and get two answers from. + editorView?.dispatch({ effects: setEmbedImages.of(new Map(state.embedImages)) }); +} + +/** + * Reconcile `state.embedImages` against `body` — the open note's, or the editor's live + * buffer while editing. A null `owner` means the pane holds no note (a resource card, an + * empty pane), which drops every picture. + * + * Repaints only when a picture actually arrived, and only after the read — so this is + * safe to call from the tail of `render()` without re-entering it. A read that a newer + * reconcile has superseded stores nothing (`imagesGeneration`). + */ +async function syncNoteImages(owner: string | null, body: string): Promise { + if (imagesOwner === owner && imagesBody === body && imagesInventory === state.resources) return; + imagesOwner = owner; + imagesBody = owner === null ? null : body; + imagesInventory = state.resources; + const generation = ++imagesGeneration; + const plan = owner === null ? [] : inlineImagePlan(imageEmbedTargets(body), state.resources); + const keep = new Set(plan); + for (const path of [...state.embedImages.keys()]) { + if (!keep.has(path)) state.embedImages.delete(path); + } + const missing = plan.filter((path) => !state.embedImages.has(path)); + if (missing.length === 0) return; + // A failed read is not an error the reader needs told about: the embed keeps reading + // as its link, which still opens the file. Same posture as `loadResourceImage`. + const loaded = await Promise.all( + missing.map(async (path) => { + try { + return [path, imageDataUrl(path, await api.readResource(path))] as const; + } catch { + return [path, null] as const; + } + }), + ); + // Superseded while we were reading — another note, or another keystroke in this one. + // Whatever the newer plan still wants, it asked for itself. + if (imagesGeneration !== generation) return; + let arrived = false; + for (const [path, url] of loaded) { + if (url !== null) { + state.embedImages.set(path, url); + arrived = true; + } + } + if (!arrived) return; + pushNoteImages(); + render(); +} + +/** The editing half: a keystroke can add or remove an embed, so the *buffer* — not the + * last-saved body — is what the held pictures are reconciled against. */ +function scheduleImageScan(): void { + window.clearTimeout(imageScanTimer); + imageScanTimer = window.setTimeout(() => { + const n = state.current; + if (!n || !editorView) return; + void syncNoteImages(n.path, editorView.state.doc.toString()); + }, IMAGE_SCAN_MS); +} + /** The resource sibling of `loadNote` — same core/commit split, for `openResource` * and back/forward. Discovery doesn't apply (resources have no chunks until file-type * slice 3), so the side pane clears. */ @@ -3343,12 +3455,19 @@ function mountEditor(body: string): void { // The note pane is an overflow scroll container; render tooltips fixed on // so a menu near the pane's bottom edge isn't clipped by it. tooltips({ position: "fixed", parent: document.body }), + // The note's loaded pictures, so live preview can draw its `![[image.png]]` + // embeds. Outside `lpCompartment` on purpose (livepreview.ts): they are a fact + // about the document, and the `` swap must not drop them. **Ahead** of the + // compartment, because live preview's `blockField` reads this one inside its own + // `update` — and a CodeMirror field may only read a field defined before it. + embedImagesField, lpCompartment.of(livePreviewConf()), // Find-in-note (⌘F) match decorations — inert (null) until the bar sets a query. findField, EditorView.updateListener.of((u) => { if (u.docChanged) { scheduleAutosave(); + scheduleImageScan(); // a typed or deleted embed changes what to hold // An edit reshapes the match set (the field already recomputed) — keep the // bar's count pill in step. if (findOpen) syncEditorFind(u.view); @@ -3358,6 +3477,7 @@ function mountEditor(body: string): void { parent: el("editor-host"), }); editorView.focus(); + pushNoteImages(); // whatever the reading view already loaded, without a second read paintEditor(); // An open find bar carries across the mount (Edit clicked, or a conflict reload): // same query, editor engine. diff --git a/ui/src/render.ts b/ui/src/render.ts index 4d7a644..76969a1 100644 --- a/ui/src/render.ts +++ b/ui/src/render.ts @@ -18,6 +18,12 @@ import { marked, type Tokens, type TokenizerAndRendererExtension } from "marked" // resolve there. tsc rewrites nothing (noEmit). import { escapeHtml } from "./escape.ts"; import { sanitizeHtml } from "./sanitize.ts"; +import { + embedWidth, + NO_EMBED_IMAGES, + WIKILINK_ANCHORED, + type EmbedImages, +} from "./embeds.ts"; import { RELATION_VERBS, type AppState, type SideSection } from "./state.ts"; import { allDirs, canMoveInto, renamePrefill } from "./move.ts"; import { shouldPromptEmbedInstall } from "./embedreminder.ts"; @@ -91,12 +97,18 @@ export { escapeHtml }; // MVP's in-app navigation (spec §4) — the buffer stays byte-honest Markdown. // // `![[target]]` is the **embed** form of the same link — the core reads the `!` as the -// embed marker and records it on the edge (`link.rs`). B2 has no inline embed viewer, so -// it renders the link; but the marker is *grammar*, so it must be consumed rather than -// left beside the anchor as a stray `!`. The `|`-part changes meaning with the marker: on -// a plain wikilink it is the display label, on an embed it is a display **size** -// (`![[shot.png|400]]`), which B2 does not support — so an embed labels itself with its -// target and drops the hint, rather than showing "400" where the filename belongs. +// embed marker and records it on the edge (`link.rs`). Where the target is a picture the +// note has loaded, the embed *shows* it: the anchor stays (so the image is still the +// link, and clicking it still opens the resource card), and the `` becomes its +// label. Everything else about an embed is unchanged — with no picture in hand (not an +// image, not indexed, too large, or simply not read yet) it reads as its link, which is +// what B2 showed before there was a viewer. +// +// The marker is *grammar* either way, so it is consumed rather than left beside the +// anchor as a stray `!`. The `|`-part changes meaning with it: on a plain wikilink it is +// the display **label**, on an embed a display **width** (`![[shot.png|500]]`, aspect +// ratio kept — `embedWidth` in embeds.ts). So an embed never labels itself "500"; it +// draws itself 500px wide, or falls back to naming its target. const wikilink: TokenizerAndRendererExtension = { name: "wikilink", level: "inline", @@ -108,24 +120,46 @@ const wikilink: TokenizerAndRendererExtension = { return i > 0 && src[i - 1] === "!" ? i - 1 : i; }, tokenizer(src: string) { - const m = /^(!?)\[\[([^\]|]+)(?:\|([^\]]+))?\]\]/.exec(src); + const m = WIKILINK_ANCHORED.exec(src); if (!m) return undefined; const embed = m[1] === "!"; const target = m[2].trim(); return { type: "wikilink", raw: m[0], + embed, target, + width: embed ? embedWidth(m[3]) : null, label: embed ? target : (m[3] ?? m[2]).trim(), } as Tokens.Generic; }, renderer(token: Tokens.Generic) { - return `${escapeHtml(String(token.label))}`; + const target = String(token.target); + const anchor = ``; + const src = token.embed ? renderImages.get(target) : undefined; + if (!src) return `${anchor}${escapeHtml(String(token.label))}`; + // The alt text is the filename: it is all B2 knows about the picture, and it is what + // the embed would have read as had the bytes not arrived (the resource card's viewer + // makes the same choice, for the same reason). + const name = target.split("/").pop() ?? target; + const width = typeof token.width === "number" ? ` width="${token.width}"` : ""; + return `${anchor}${escapeHtml(
+      name,
+    )}`; }, }; +// The pictures the *current* `renderMarkdown` call may draw. +// +// A module-local rather than a parameter because `marked`'s extensions are registered +// once, globally (the `marked.use` below), so the renderer above has no way to be handed +// per-call data. It is safe to hold it here for exactly one reason, and the reason is +// worth stating: `marked.parse(…, { async: false })` runs to completion synchronously, +// so between the assignment and the `finally` no other render can interleave. Every +// caller still passes its images as an argument — the seam is `renderMarkdown`, and this +// variable never outlives one of its calls. +let renderImages: EmbedImages = NO_EMBED_IMAGES; + // Wrap each table in a scroll box so a wide one scrolls *within* its column instead of // stretching the pane. The table itself must stay a real `display: table` (the wrapper // is what's `display: block; overflow-x: auto`) — a `display: block` table splits @@ -150,9 +184,21 @@ marked.use({ hooks: { postprocess: (html: string) => sanitizeHtml(wrapTables(html)) }, }); -/** Note body → the HTML the panes write into the DOM. Sanitized (see the hook above). */ -export function renderMarkdown(md: string): string { - return marked.parse(md, { async: false }) as string; +/** + * Note body → the HTML the panes write into the DOM. Sanitized (see the hook above). + * + * `images` is what an `![[picture.png]]` embed draws with — the note's loaded pictures, + * keyed by vault-relative path (`state.embedImages`). Omitted, every embed reads as its + * link, which is both the honest state before the bytes arrive and what a caller with no + * pictures to offer wants. + */ +export function renderMarkdown(md: string, images: EmbedImages = NO_EMBED_IMAGES): string { + renderImages = images; + try { + return marked.parse(md, { async: false }) as string; + } finally { + renderImages = NO_EMBED_IMAGES; + } } // --- file tree -------------------------------------------------------------------- @@ -485,49 +531,11 @@ function noteBarHtml(state: AppState, note: NoteView): string { `; } -// --- the image viewer (file-type slice 2) --------------------------------------------- - -/** Extension → MIME type for the image classes the card renders in place. Extension-only, - * the same rule the core classifies on (`resource.rs`) and the same table of extensions — - * no content sniffing, so a mislabeled file degrades to a broken `` rather than - * being guessed at. **Change it with `ResourceClass::Image`**: a class the core calls an - * image and this table doesn't know reaches the card, asks for its bytes, and then has - * nowhere to put them. */ -const IMAGE_MIME: Record = { - png: "image/png", - jpg: "image/jpeg", - jpeg: "image/jpeg", - gif: "image/gif", - webp: "image/webp", - svg: "image/svg+xml", - avif: "image/avif", -}; - -/** - * How large an image the card will pull across the IPC and hold on screen. - * - * The bytes cross as base64 (a third larger) and the `data:` URL lives in the webview's - * heap for as long as the card is open, so this is a memory bound, not a taste one. Past - * it the card keeps the *Open in system default* handoff it has always had, which costs - * nothing and shows the file at full fidelity in a real image app. Screenshots — what a - * vault actually accumulates — are a couple of megabytes. - */ -export const IMAGE_VIEWER_MAX_BYTES = 25 * 1024 * 1024; - -/** - * A resource's base64 bytes as the `src` an `` can use, or `null` when the - * extension names no image this webview renders. - * - * A `data:` URL rather than a path, because the webview's origin is the *app bundle*, - * not the vault: there is no URL by which it could fetch a vault file, and the CSP - * admits exactly this one shape (`img-src 'self' data:` in `tauri.conf.json`) — so the - * bytes travel through the same command seam as everything else the host lends the UI. - */ -export function imageDataUrl(path: string, base64: string): string | null { - const ext = path.includes(".") ? (path.split(".").pop() ?? "").toLowerCase() : ""; - const mime = IMAGE_MIME[ext]; - return mime ? `data:${mime};base64,${base64}` : null; -} +// --- the resource card + its image viewer (file-type slice 2) ------------------------ +// +// What an image *is* — the extension table, the `data:` URL, the size bound — moved to +// embeds.ts when the reading view grew an inline viewer of its own: one answer, three +// surfaces (the card, the reading view, the editor's live preview). /** Human-readable byte count for the card ("67 B", "1.4 KB", "3.2 MB"). */ function formatSize(bytes: number): string { @@ -545,8 +553,8 @@ function formatSize(bytes: number): string { // for it, and it *replaces* the "no viewer" line rather than the card, so the metadata, // the backlinks and the OS handoff stay put — the handoff is still how you get to a real // image app, and it is the whole card when the file is too large to hold on screen -// (`IMAGE_VIEWER_MAX_BYTES`) or its bytes could not be read. Every other class is still -// the fallback card, and `binary` is its permanent catch-all. +// (`IMAGE_VIEWER_MAX_BYTES`, embeds.ts) or its bytes could not be read. Every other +// class is still the fallback card, and `binary` is its permanent catch-all. function resourceCardHtml(r: ResourceExplainView, image: string | null): string { const modified = r.mtime ? new Date(r.mtime * 1000).toLocaleString() : "—"; const backlinks = r.backlinks.length @@ -608,7 +616,7 @@ export function notePaneHtml(state: AppState): string { : ""; const body = state.sourceOpen ? `

${escapeHtml(n.body)}
` - : renderMarkdown(n.body); + : renderMarkdown(n.body, state.embedImages); return `${noteBarHtml(state, n)}
diff --git a/ui/src/resourceview.test.ts b/ui/src/resourceview.test.ts index 9214870..f0f16ba 100644 --- a/ui/src/resourceview.test.ts +++ b/ui/src/resourceview.test.ts @@ -1,5 +1,6 @@ -// Tests for the resource card's **image viewer** (render.ts) — the `data:` URL rule and -// what the card shows in each of its states. Run directly: +// Tests for the resource card's **image viewer** — the `data:` URL rule (embeds.ts, +// shared with the inline `![[…]]` embed) and what the card shows in each of its states +// (render.ts). Run directly: // node --experimental-strip-types src/resourceview.test.ts // Hand-rolled asserts, the sanitize.test.ts / render.test.ts idiom. // @@ -8,7 +9,8 @@ // is here for the same reason it is in render.test.ts. import { JSDOM } from "jsdom"; -import { imageDataUrl, IMAGE_VIEWER_MAX_BYTES, notePaneHtml } from "./render.ts"; +import { imageDataUrl, IMAGE_VIEWER_MAX_BYTES } from "./embeds.ts"; +import { notePaneHtml } from "./render.ts"; import { state, type AppState } from "./state.ts"; import type { ResourceExplainView } from "./types.ts"; diff --git a/ui/src/state.ts b/ui/src/state.ts index 1bda5b6..93038c5 100644 --- a/ui/src/state.ts +++ b/ui/src/state.ts @@ -178,6 +178,18 @@ export interface AppState { * never paint one document's picture over another's card. */ resourceImage: string | null; + /** + * The open **note's** pictures: vault-relative path → `data:` URL, one entry per + * `![[image.png]]` embed whose bytes were read (embeds.ts decides which ones are + * worth reading — see `inlineImagePlan`). The reading view and the editor's live + * preview draw from the same map, so a note looks the same read or edited; an embed + * with no entry reads as its link. + * + * Keyed by path rather than by occurrence because a note that embeds the same picture + * twice should cost one read. Cleared when the pane changes document, for + * `resourceImage`'s reason: nothing of one note's may ever paint into another's. + */ + embedImages: Map; /** Whether the note pane's frontmatter drawer is expanded (sticky across notes). */ frontmatterOpen: boolean; /** @@ -387,6 +399,7 @@ export const state: AppState = { current: null, currentResource: null, resourceImage: null, + embedImages: new Map(), frontmatterOpen: false, fmEditing: false, sourceOpen: false, diff --git a/ui/style.css b/ui/style.css index df0a230..9124f52 100644 --- a/ui/style.css +++ b/ui/style.css @@ -1112,6 +1112,33 @@ body.is-resizing { background: var(--accent-soft); } +/* The inline image an `![[picture.png]]` embed draws (render.ts). The embed is still a + link — clicking it opens the resource card — but the picture *is* the label now, so + the link's dashed underline and hover wash come off: they would frame the image + rather than mark a word. The accent outline on hover is the same affordance said in + the one way that reads over a photograph. + + Never scaled *up*: `|500` sets a width the image is drawn at, `max-width` keeps it + inside the column whatever the note asked for, and `height: auto` is what makes the + aspect ratio hold in both directions. */ +.note-body a.wikilink:has(> .embed-image), +.note-body a.wikilink:has(> .embed-image):hover { + border-bottom: none; + background: none; +} + +.note-body .embed-image { + max-width: 100%; + height: auto; + border-radius: 6px; + vertical-align: middle; +} + +.note-body a.wikilink:hover > .embed-image { + outline: 2px solid var(--accent-soft); + outline-offset: 2px; +} + .note-body code { font-family: ui-monospace, "SF Mono", Menlo, monospace; font-size: 0.88em; @@ -1428,6 +1455,18 @@ body.is-resizing { cursor: pointer; } +/* The same picture in the buffer's place (livepreview.ts). Sized like the reading + view's, because read and edit must not disagree about what a note looks like. The + text cursor is the honest pointer here: clicking it puts the caret beside the markup + and the raw `![[…]]` comes back, exactly as every other concealed construct does. */ +.editor-host .lp-embed-image { + max-width: 100%; + height: auto; + border-radius: 6px; + vertical-align: middle; + cursor: text; +} + /* Blockquote — the reading view's border + muted, applied per concealed-`>` line. */ .editor-host .lp-quote { border-left: 3px solid var(--border);