summaryrefslogtreecommitdiffhomepage
path: root/src/features/markdown/ui/Markdown.svelte
diff options
context:
space:
mode:
Diffstat (limited to 'src/features/markdown/ui/Markdown.svelte')
-rw-r--r--src/features/markdown/ui/Markdown.svelte125
1 files changed, 21 insertions, 104 deletions
diff --git a/src/features/markdown/ui/Markdown.svelte b/src/features/markdown/ui/Markdown.svelte
index a0fd276..c33f9f6 100644
--- a/src/features/markdown/ui/Markdown.svelte
+++ b/src/features/markdown/ui/Markdown.svelte
@@ -1,116 +1,33 @@
<script lang="ts">
- import { type MarkdownImageOptions, renderMarkdown } from "../logic/markdown";
- import { advance, createStreamingMarkdown } from "../logic/streaming-markdown";
+ import type { MarkdownImageOptions } from "../logic/markdown";
+ import { splitTextFiles } from "../logic/text-file";
+ import type { TextFileClient } from "../types";
+ import MarkdownContent from "./MarkdownContent.svelte";
+ import TextFileEmbed from "./TextFileEmbed.svelte";
let {
text,
streaming = false,
image = undefined,
+ textFiles = 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;
+ image?: MarkdownImageOptions | undefined;
+ /** Explicit opt-in; embedded documents never receive this port. */
+ textFiles?: TextFileClient | 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);
- });
+ const parts = $derived(textFiles ? splitTextFiles(text, streaming) : null);
</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>
+{#if parts && textFiles}
+ {#each parts as part, i (i)}
+ {#if part.kind === "text-file"}
+ <TextFileEmbed reference={part.reference} client={textFiles} {image} />
+ {:else}
+ <MarkdownContent text={part.text} {streaming} {image} />
+ {/if}
+ {/each}
+{:else}
+ <MarkdownContent {text} {streaming} {image} />
+{/if}