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("\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("\n", "Wikilink"), [], "a real Markdown image is not a wikilink");
+ assertEq(nodes("\n", "Image"), ["0:"], "…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}
`;
},
};
+// 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);