summaryrefslogtreecommitdiffhomepage
path: root/src/features/markdown/ui/MarkdownContent.svelte
diff options
context:
space:
mode:
authorAdam Malczewski <[email protected]>2026-10-24 18:34:12 +0900
committerAdam Malczewski <[email protected]>2026-10-24 18:34:12 +0900
commit48cdc3d19a319981a8a4128be98db3c283f81790 (patch)
tree0fa6531f3b05c81348f9d3427e9021dbb4832b5d /src/features/markdown/ui/MarkdownContent.svelte
parentdaefad66bdf8dee132d701a3b43ce0d227dde241 (diff)
downloaddispatch-web-48cdc3d19a319981a8a4128be98db3c283f81790.tar.gz
dispatch-web-48cdc3d19a319981a8a4128be98db3c283f81790.zip
feat: render live text-file embeds in expandable Markdown blocks
Show rendered first/last six-line previews with expand, refresh and retry; bound and deduplicate context-guarded fetches. Verify 2586 tests twice with Node Web Storage compatibility flag, 2 live integration tests, typecheck, Biome and production build.
Diffstat (limited to 'src/features/markdown/ui/MarkdownContent.svelte')
-rw-r--r--src/features/markdown/ui/MarkdownContent.svelte116
1 files changed, 116 insertions, 0 deletions
diff --git a/src/features/markdown/ui/MarkdownContent.svelte b/src/features/markdown/ui/MarkdownContent.svelte
new file mode 100644
index 0000000..6149d6b
--- /dev/null
+++ b/src/features/markdown/ui/MarkdownContent.svelte
@@ -0,0 +1,116 @@
+<script lang="ts">
+ import { type MarkdownImageOptions, renderMarkdown } from "../logic/markdown";
+ import { advance, createStreamingMarkdown } from "../logic/streaming-markdown";
+
+ let {
+ text,
+ streaming = false,
+ image = undefined,
+ }: {
+ text: string;
+ /** Balance dangling delimiters while the message is still generating. */
+ streaming?: boolean;
+ /**
+ * Per-conversation local-image context: the backend HTTP base and the
+ * conversation cwd. Markdown image srcs that are local paths are rewritten
+ * through `${apiBase}/local-image?path=…` (relative paths resolve against
+ * `cwd`). Remote HTTP(S) images require a click; data, same-origin and
+ * backend images load inline. Omit it to skip local-path rewriting.
+ */
+ image?: MarkdownImageOptions | undefined;
+ } = $props();
+
+ // Keep the optional `image` out of the options object when absent
+ // (exactOptionalPropertyTypes). Identity is irrelevant: the streaming cache
+ // keys on apiBase/cwd values.
+ const renderOpts = $derived(image === undefined ? undefined : { image });
+
+ // Checkpoint streaming state: one per component instance (per streaming
+ // message). It caches the settled blocks' HTML so each delta re-renders only
+ // the live tail — never the settled prefix (see logic/streaming-markdown).
+ const streamingState = createStreamingMarkdown();
+
+ // While streaming: the checkpoint split (settled prefix + live tail), which
+ // equals today's full render at every step modulo inter-block newlines and
+ // highlight-on-close. When the message completes (or for every non-streaming
+ // chunk): ONE full authoritative render — today's exact behavior, so the
+ // final/persisted output is byte-identical by construction.
+ const rendered = $derived.by(() => {
+ if (!streaming) return { prefixHtml: "", tailHtml: renderMarkdown(text, renderOpts) };
+ return advance(streamingState, text, renderOpts);
+ });
+
+ let container: HTMLElement;
+
+ // One delegated listener on the stable container handles image consent and
+ // every code block's copy button — including blocks re-created when the tail html changes
+ // (streaming), since the listener lives on the container, not the buttons.
+ // Clipboard is the edge effect; absent (insecure context) → no-op.
+ $effect(() => {
+ const el = container;
+ if (el === undefined) return;
+
+ const onClick = (event: Event): void => {
+ const target = event.target;
+ if (!(target instanceof Element)) return;
+ const remote = target.closest<HTMLButtonElement>("button.remote-image[data-src]");
+ if (remote !== null && el.contains(remote)) {
+ event.preventDefault(); // A Markdown image may be wrapped in a link.
+ event.stopPropagation();
+ // data-* is authorable in raw HTML: validate again at the DOM edge.
+ let url: URL;
+ try {
+ url = new URL(remote.dataset.src ?? "");
+ } catch {
+ return;
+ }
+ if (url.protocol !== "http:" && url.protocol !== "https:") return;
+ const img = el.ownerDocument.createElement("img");
+ img.alt = remote.dataset.alt ?? "";
+ if (remote.dataset.title !== undefined) img.title = remote.dataset.title;
+ img.setAttribute("referrerpolicy", "no-referrer");
+ img.src = url.href;
+ remote.replaceWith(img);
+ return;
+ }
+ const button = target.closest<HTMLButtonElement>("[data-copy]");
+ if (button === null) return;
+
+ const code = button.closest(".code-block")?.querySelector("code")?.textContent ?? "";
+ const clipboard = navigator.clipboard;
+ if (clipboard === undefined) return;
+
+ void clipboard
+ .writeText(code)
+ .then(() => {
+ const prev = button.textContent;
+ button.textContent = "Copied";
+ setTimeout(() => {
+ button.textContent = prev;
+ }, 1200);
+ })
+ .catch(() => {
+ // Clipboard denied — leave the button as-is.
+ });
+ };
+
+ el.addEventListener("click", onClick);
+ return () => el.removeEventListener("click", onClick);
+ });
+</script>
+
+<div class="markdown-body" bind:this={container}>
+ {#if streaming}
+ <!-- Two sibling wrappers (no padding/border/min-height, so block margins
+ collapse across them exactly as if the blocks were direct children).
+ Svelte 5 skips the {@html} DOM write when the string is unchanged, so
+ the settled prefix's subtree is never re-parsed/re-created/re-laid-out;
+ only the tail's subtree is replaced per delta. Both strings are
+ DOMPurify-sanitized in the logic modules. -->
+ <div class="markdown-stream-prefix">{@html rendered.prefixHtml}</div>
+ <div class="markdown-stream-tail">{@html rendered.tailHtml}</div>
+ {:else}
+ <!-- {@html} is safe here: `html` is DOMPurify-sanitized inside renderMarkdown. -->
+ {@html rendered.tailHtml}
+ {/if}
+</div>