diff options
Diffstat (limited to 'src/features/markdown/ui/Markdown.svelte')
| -rw-r--r-- | src/features/markdown/ui/Markdown.svelte | 125 |
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} |
