summaryrefslogtreecommitdiffhomepage
path: root/src/features/markdown/ui/MarkdownContent.svelte
blob: 6149d6b3ca0c909506559b2da4001222d85e92f1 (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
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>