@lightworkai.official/debug-capture-vue 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (41) hide show
  1. package/README.md +52 -0
  2. package/dist/components/BugIcon.d.ts +3 -0
  3. package/dist/components/DebugCaptureInit.vue.d.ts +13 -0
  4. package/dist/components/ImageAnnotatorDialog.vue.d.ts +19 -0
  5. package/dist/components/MyTicketsPanel.vue.d.ts +17 -0
  6. package/dist/components/ReopenPanel.vue.d.ts +12 -0
  7. package/dist/components/ReportProblemButton.vue.d.ts +17 -0
  8. package/dist/components/RichReplyEditor.vue.d.ts +59 -0
  9. package/dist/components/StatusHero.vue.d.ts +10 -0
  10. package/dist/components/StatusPill.vue.d.ts +16 -0
  11. package/dist/components/TicketBody.vue.d.ts +6 -0
  12. package/dist/components/TicketConversation.vue.d.ts +22 -0
  13. package/dist/components/TicketDetail.vue.d.ts +28 -0
  14. package/dist/components/TicketScreenshots.vue.d.ts +8 -0
  15. package/dist/components/TicketTable.vue.d.ts +25 -0
  16. package/dist/components/TicketTimeline.vue.d.ts +11 -0
  17. package/dist/components/ToolbarButton.vue.d.ts +21 -0
  18. package/dist/components/icons.d.ts +38 -0
  19. package/dist/index.d.ts +24 -0
  20. package/dist/index.mjs +2996 -0
  21. package/dist/index.mjs.map +1 -0
  22. package/package.json +64 -0
  23. package/src/components/BugIcon.ts +23 -0
  24. package/src/components/DebugCaptureInit.vue +41 -0
  25. package/src/components/ImageAnnotatorDialog.vue +160 -0
  26. package/src/components/MyTicketsPanel.vue +312 -0
  27. package/src/components/README.md +12 -0
  28. package/src/components/ReopenPanel.vue +47 -0
  29. package/src/components/ReportProblemButton.vue +28 -0
  30. package/src/components/RichReplyEditor.vue +321 -0
  31. package/src/components/StatusHero.vue +85 -0
  32. package/src/components/StatusPill.vue +14 -0
  33. package/src/components/TicketBody.vue +50 -0
  34. package/src/components/TicketConversation.vue +227 -0
  35. package/src/components/TicketDetail.vue +130 -0
  36. package/src/components/TicketScreenshots.vue +47 -0
  37. package/src/components/TicketTable.vue +142 -0
  38. package/src/components/TicketTimeline.vue +86 -0
  39. package/src/components/ToolbarButton.vue +33 -0
  40. package/src/components/icons.ts +90 -0
  41. package/src/index.ts +34 -0
@@ -0,0 +1,321 @@
1
+ <script setup lang="ts">
2
+ /**
3
+ * The reporter's reply box — TipTap, the same editor the original gives them.
4
+ *
5
+ * This was hand-rolled on `contenteditable` + `document.execCommand` for one
6
+ * round, on the reasoning that a package should not ship a rich-text editor.
7
+ * That was wrong twice over: TipTap is a PEER dependency, so an app that
8
+ * already has it (both of ours do) pays nothing, and re-implementing selection
9
+ * handling, list nesting and paste sanitising by hand is a maintenance bill
10
+ * with no upside. The original uses TipTap; so do we.
11
+ */
12
+ import { computed, onBeforeUnmount, ref, watch } from "vue";
13
+ import { EditorContent, useEditor } from "@tiptap/vue-3";
14
+ import StarterKit from "@tiptap/starter-kit";
15
+ import Image from "@tiptap/extension-image";
16
+ import Placeholder from "@tiptap/extension-placeholder";
17
+ import { NOTE_TOOLBAR, cleanHtml, htmlIsEmpty, type EditorTool } from "@lightworkai.official/debug-capture";
18
+ import ToolbarButton from "./ToolbarButton.vue";
19
+ import ImageAnnotatorDialog from "./ImageAnnotatorDialog.vue";
20
+ import {
21
+ BoldIcon, ItalicIcon, UnderlineIcon, StrikeIcon, CodeIcon, BulletListIcon,
22
+ OrderedListIcon, QuoteIcon, LinkIcon, ImageIcon,
23
+ } from "./icons";
24
+
25
+ const props = withDefaults(
26
+ defineProps<{
27
+ placeholder: string;
28
+ /** Support host origin — the sanitiser resolves inline image URLs against it. */
29
+ host: string;
30
+ disabled?: boolean;
31
+ /** Which controls to show. Defaults to the original's NOTE_TOOLBAR. */
32
+ tools?: EditorTool[];
33
+ /** Hide the toolbar until focused or non-empty. */
34
+ compact?: boolean;
35
+ labels: Record<string, string>;
36
+ /**
37
+ * Store an image and return the URL to reference it by.
38
+ *
39
+ * Optional, and the fallback is not "no images" — it is the annotated data
40
+ * URI carried in the body for the server to store on arrival. A composer on
41
+ * a ticket that does not exist yet has nowhere to upload to, and that is a
42
+ * reason to move the work, not to take the feature away.
43
+ */
44
+ uploadImage?: (file: File) => Promise<string>;
45
+ /** UI language, for the annotator's own labels. */
46
+ locale?: "th" | "en";
47
+ /** Content to start from — editing an existing message rather than writing one. */
48
+ initial?: string;
49
+ }>(),
50
+ { tools: () => NOTE_TOOLBAR, compact: false },
51
+ );
52
+
53
+ /**
54
+ * Two-way binding, for a host that would rather drive this from a ref than call
55
+ * `value()`. Optional: the panel uses the imperative API, the app uses v-model.
56
+ */
57
+ const model = defineModel<string>({ required: false });
58
+
59
+ const emit = defineEmits<{ (e: "error", message: string): void }>();
60
+
61
+ const focused = ref(false);
62
+ const uploading = ref(false);
63
+ const fileInput = ref<HTMLInputElement | null>(null);
64
+ /**
65
+ * The image waiting to be drawn on, as a same-origin data URI. Non-null means
66
+ * the annotator is open.
67
+ *
68
+ * Every route in — the picker, a paste, a drop — lands here first. The original
69
+ * does the same, and for two reasons: pointing at the problem is most of what a
70
+ * screenshot is for, and annotating BEFORE upload keeps the canvas same-origin,
71
+ * so `toDataURL` is not tainted by a cross-origin presigned URL.
72
+ */
73
+ const pending = ref<string | null>(null);
74
+ const pendingName = ref("screenshot.png");
75
+
76
+ const editor = useEditor({
77
+ // StarterKit v3 already carries Underline and Link, so the toolbar the
78
+ // original defines needs only two extensions beyond it.
79
+ extensions: [
80
+ StarterKit,
81
+ Image.configure({ inline: false, allowBase64: false }),
82
+ Placeholder.configure({ placeholder: () => props.placeholder }),
83
+ ],
84
+ content: props.initial ?? model.value ?? "",
85
+ onUpdate: ({ editor: e }) => {
86
+ // TipTap's "empty" document is still <p></p>; report it as empty so a send
87
+ // button bound to the model stays disabled.
88
+ if (model.value !== undefined || props.compact) model.value = e.isEmpty ? "" : e.getHTML();
89
+ },
90
+ onFocus: () => (focused.value = true),
91
+ onBlur: () => (focused.value = false),
92
+ editorProps: {
93
+ attributes: { class: "lw-editor-body" },
94
+ /**
95
+ * A pasted screenshot is the normal case — Cmd+Shift+4, Cmd+V — and
96
+ * without this the only route is save-to-disk, find-the-file, pick-it.
97
+ */
98
+ handlePaste: (_view, event) => {
99
+ const file = Array.from(event.clipboardData?.files ?? []).find((f) => f.type.startsWith("image/"));
100
+ if (!file) return false;
101
+ event.preventDefault();
102
+ void addImage(file);
103
+ return true;
104
+ },
105
+ handleDrop: (_view, event) => {
106
+ const dropped = event as DragEvent;
107
+ const file = Array.from(dropped.dataTransfer?.files ?? []).find((f) => f.type.startsWith("image/"));
108
+ if (!file) return false;
109
+ dropped.preventDefault();
110
+ void addImage(file);
111
+ return true;
112
+ },
113
+ },
114
+ });
115
+
116
+ watch(
117
+ () => props.disabled,
118
+ (off) => editor.value?.setEditable(!off),
119
+ );
120
+
121
+ // The parent clears the box after a successful send.
122
+ watch(model, (next) => {
123
+ const current = editor.value;
124
+ if (!current || current.isDestroyed) return;
125
+ if (next === "" && !current.isEmpty) current.commands.clearContent();
126
+ });
127
+
128
+ /**
129
+ * `compact` hides the toolbar until there is something to format.
130
+ *
131
+ * Note what this makes load-bearing: the toolbar UNMOUNTS on blur, so a button
132
+ * that acted on `click` would disappear between mousedown and click and the
133
+ * click would never land. ToolbarButton fires on mousedown for exactly this.
134
+ */
135
+ const showToolbar = computed(() => !props.compact || focused.value || !editor.value?.isEmpty);
136
+
137
+ interface ToolSpec {
138
+ name: string;
139
+ icon: unknown;
140
+ label: string;
141
+ run: () => void;
142
+ }
143
+
144
+ const ALL: Record<EditorTool, () => ToolSpec> = {
145
+ bold: () => ({ name: "bold", icon: BoldIcon, label: props.labels.bold!, run: () => editor.value?.chain().focus().toggleBold().run() }),
146
+ italic: () => ({ name: "italic", icon: ItalicIcon, label: props.labels.italic!, run: () => editor.value?.chain().focus().toggleItalic().run() }),
147
+ underline: () => ({ name: "underline", icon: UnderlineIcon, label: props.labels.underline!, run: () => editor.value?.chain().focus().toggleUnderline().run() }),
148
+ strike: () => ({ name: "strike", icon: StrikeIcon, label: props.labels.strike!, run: () => editor.value?.chain().focus().toggleStrike().run() }),
149
+ code: () => ({ name: "code", icon: CodeIcon, label: props.labels.code!, run: () => editor.value?.chain().focus().toggleCode().run() }),
150
+ bulletList: () => ({ name: "bulletList", icon: BulletListIcon, label: props.labels.bulletList!, run: () => editor.value?.chain().focus().toggleBulletList().run() }),
151
+ orderedList: () => ({ name: "orderedList", icon: OrderedListIcon, label: props.labels.orderedList!, run: () => editor.value?.chain().focus().toggleOrderedList().run() }),
152
+ blockquote: () => ({ name: "blockquote", icon: QuoteIcon, label: props.labels.blockquote!, run: () => editor.value?.chain().focus().toggleBlockquote().run() }),
153
+ link: () => ({ name: "link", icon: LinkIcon, label: props.labels.link!, run: toggleLink }),
154
+ image: () => ({ name: "image", icon: ImageIcon, label: props.labels.attachImage!, run: pickImage }),
155
+ };
156
+
157
+ /** The chosen controls, with `image` split off so it can sit after a separator. */
158
+ const marks = computed(() => props.tools.filter((t) => t !== "image").map((t) => ALL[t]()));
159
+ const hasImage = computed(() => props.tools.includes("image"));
160
+
161
+ onBeforeUnmount(() => editor.value?.destroy());
162
+
163
+ /**
164
+ * Open the annotator on a picked image rather than inserting it straight away.
165
+ *
166
+ * One dialog, everywhere. There was briefly a config hook letting a host swap
167
+ * in its own — which solved the symptom (two dialogs that looked different) by
168
+ * blessing the cause (two dialogs). This component IS the one dialog now; the
169
+ * app uses it too.
170
+ */
171
+ async function addImage(file: File): Promise<void> {
172
+ if (uploading.value || pending.value) return;
173
+ try {
174
+ pendingName.value = file.name || "screenshot.png";
175
+ pending.value = await readDataUri(file);
176
+ } catch (e) {
177
+ emit("error", e instanceof Error ? e.message : "unreadable file");
178
+ }
179
+ }
180
+
181
+ /** The annotated image comes back as a data URI; upload THAT and insert it. */
182
+ async function commitImage(dataUri: string): Promise<void> {
183
+ pending.value = null;
184
+ uploading.value = true;
185
+ try {
186
+ /*
187
+ * No uploader means the host wants the image inline — a composer on a
188
+ * ticket that does not exist yet, with nowhere to upload to. The data URI
189
+ * travels with the body and the server swaps it for a stored one once
190
+ * there is somewhere to put it.
191
+ */
192
+ const src = props.uploadImage
193
+ ? await props.uploadImage(dataUriToFile(dataUri, pendingName.value))
194
+ : dataUri;
195
+ editor.value?.chain().focus().setImage({ src, alt: pendingName.value }).run();
196
+ } catch (e) {
197
+ emit("error", e instanceof Error ? e.message : "upload failed");
198
+ } finally {
199
+ uploading.value = false;
200
+ }
201
+ }
202
+
203
+ function readDataUri(file: File): Promise<string> {
204
+ return new Promise((resolve, reject) => {
205
+ const reader = new FileReader();
206
+ reader.addEventListener("load", () =>
207
+ typeof reader.result === "string" ? resolve(reader.result) : reject(new Error("unreadable file")),
208
+ );
209
+ reader.addEventListener("error", () => reject(new Error("unreadable file")));
210
+ reader.readAsDataURL(file);
211
+ });
212
+ }
213
+
214
+ /**
215
+ * Back to a File, because `uploadImage` takes one.
216
+ *
217
+ * The annotator hands back a data URI — it draws on a canvas, and a canvas
218
+ * exports one. Rebuilding a File here keeps the upload contract identical for
219
+ * an annotated image and an untouched one, so the caller never has to care
220
+ * which it got.
221
+ */
222
+ function dataUriToFile(dataUri: string, name: string): File {
223
+ const [header, encoded] = dataUri.split(",");
224
+ const type = /data:([^;]+)/.exec(header ?? "")?.[1] ?? "image/png";
225
+ const binary = atob(encoded ?? "");
226
+ const bytes = new Uint8Array(binary.length);
227
+ for (let i = 0; i < binary.length; i += 1) bytes[i] = binary.charCodeAt(i);
228
+ return new File([bytes], name, { type });
229
+ }
230
+
231
+ function pickImage(): void {
232
+ fileInput.value?.click();
233
+ }
234
+
235
+ function onFile(event: Event): void {
236
+ const input = event.target as HTMLInputElement;
237
+ const file = input.files?.[0];
238
+ // Cleared so choosing the same file twice still fires `change`.
239
+ input.value = "";
240
+ if (file) void addImage(file);
241
+ }
242
+
243
+ function toggleLink(): void {
244
+ const current = editor.value;
245
+ if (!current) return;
246
+ const existing = current.getAttributes("link").href as string | undefined;
247
+ if (existing) {
248
+ current.chain().focus().unsetLink().run();
249
+ return;
250
+ }
251
+ const href = window.prompt(props.labels.linkPrompt, "https://");
252
+ if (!href) return;
253
+ // Only ordinary destinations. The outgoing sanitiser would drop a
254
+ // `javascript:` URL anyway, so allowing it here just shows the writer a link
255
+ // that silently vanishes when they send.
256
+ if (!/^https?:\/\//i.test(href)) {
257
+ emit("error", `${props.labels.link}: http(s) only`);
258
+ return;
259
+ }
260
+ current.chain().focus().extendMarkRange("link").setLink({ href }).run();
261
+ }
262
+
263
+ /**
264
+ * Sanitised HTML, or "" when there is nothing worth sending.
265
+ *
266
+ * `cleanHtml` is not belt-and-braces over TipTap's schema. The schema drops an
267
+ * `onclick`, but the Image extension takes any `src` it is given — so a
268
+ * screenshot pasted from a web page arrives with that page's remote image
269
+ * intact, and posting it would store a tracking pixel in the thread aimed at
270
+ * every future reader. The allowlist is what narrows an image to our own.
271
+ */
272
+ function value(): string {
273
+ const html = cleanHtml(editor.value?.getHTML() ?? "", props.host);
274
+ return htmlIsEmpty(html) ? "" : html;
275
+ }
276
+
277
+ function clear(): void {
278
+ editor.value?.commands.clearContent(true);
279
+ }
280
+
281
+ defineExpose({ value, clear, focus: () => editor.value?.commands.focus() });
282
+ </script>
283
+
284
+ <template>
285
+ <div class="lw-editor" :class="{ 'lw-editor--disabled': disabled }">
286
+ <!-- `compact` unmounts this on blur, which is why every button fires on
287
+ mousedown: on click it would disappear between the two and never
288
+ land. -->
289
+ <div v-if="showToolbar" class="lw-editor__tools">
290
+ <ToolbarButton
291
+ v-for="tool in marks"
292
+ :key="tool.name"
293
+ :label="tool.label"
294
+ :active="editor?.isActive(tool.name)"
295
+ :disabled="disabled"
296
+ @activate="tool.run()"
297
+ >
298
+ <component :is="tool.icon" />
299
+ </ToolbarButton>
300
+
301
+ <template v-if="hasImage">
302
+ <span class="lw-editor__sep" />
303
+ <ToolbarButton :label="labels.attachImage ?? ''" :disabled="disabled || uploading" @activate="pickImage">
304
+ <ImageIcon />
305
+ </ToolbarButton>
306
+ </template>
307
+ <span v-if="uploading" class="lw-editor__uploading">{{ labels.uploading ?? "" }}</span>
308
+ </div>
309
+
310
+ <EditorContent :editor="editor" class="lw-editor__content" />
311
+ <input ref="fileInput" type="file" accept="image/*" hidden @change="onFile" />
312
+
313
+ <ImageAnnotatorDialog
314
+ :src="pending"
315
+ :filename="pendingName"
316
+ :locale="locale"
317
+ @confirm="commitImage"
318
+ @cancel="pending = null"
319
+ />
320
+ </div>
321
+ </template>
@@ -0,0 +1,85 @@
1
+ <script setup lang="ts">
2
+ /**
3
+ * Where the report stands, in one banner.
4
+ *
5
+ * The status LINE is config-first: an awaiting lane's "please reply" wins, then
6
+ * the lane's own description (so a realm's custom lane carries its own words),
7
+ * then our default copy, then the bare label.
8
+ */
9
+ import { computed } from "vue";
10
+ import type { MyTicketDetail, StatusMaps, TicketStrings } from "@lightworkai.official/debug-capture";
11
+ import TicketBody from "./TicketBody.vue";
12
+ import { BanIcon, CheckCircleIcon, ClockIcon, InboxIcon, MessageIcon, WrenchIcon } from "./icons";
13
+
14
+ const props = defineProps<{
15
+ ticket: MyTicketDetail;
16
+ maps: StatusMaps;
17
+ t: TicketStrings;
18
+ locale: "th" | "en";
19
+ host: string;
20
+ }>();
21
+
22
+ /** Only the SYSTEM statuses get a tone; an admin-added lane falls back to OPEN. */
23
+ const TONES: Record<string, { icon: unknown; border: string; bg: string; fg: string; accent: string }> = {
24
+ OPEN: { icon: InboxIcon, border: "#bfdbfe", bg: "#eff6ff", fg: "#1e3a8a", accent: "#3b82f6" },
25
+ IN_PROGRESS: { icon: ClockIcon, border: "#fde68a", bg: "#fffbeb", fg: "#78350f", accent: "#f59e0b" },
26
+ RESOLVED: { icon: CheckCircleIcon, border: "#a7f3d0", bg: "#ecfdf5", fg: "#064e3b", accent: "#10b981" },
27
+ CLOSED: { icon: CheckCircleIcon, border: "#e5e7eb", bg: "#f9fafb", fg: "#1f2937", accent: "#9ca3af" },
28
+ WONT_FIX: { icon: BanIcon, border: "#e5e7eb", bg: "#f9fafb", fg: "#374151", accent: "#9ca3af" },
29
+ };
30
+ const AWAITING = { icon: MessageIcon, border: "#fcd34d", bg: "#fffbeb", fg: "#78350f", accent: "#f59e0b" };
31
+
32
+ const MESSAGES: Record<string, { th: string; en: string }> = {
33
+ OPEN: { th: "รับเรื่องแล้ว — รอทีมงานตรวจสอบ", en: "Received — waiting for the team to look" },
34
+ IN_PROGRESS: { th: "ทีมงานกำลังดำเนินการแก้ไข", en: "The team is working on it" },
35
+ RESOLVED: { th: "แก้ไขเรียบร้อยแล้ว", en: "Fixed" },
36
+ CLOSED: { th: "ปิดเรื่องแล้ว", en: "Closed" },
37
+ WONT_FIX: { th: "พิจารณาแล้ว — ยังไม่ได้ดำเนินการในขณะนี้", en: "Reviewed — not being worked on for now" },
38
+ };
39
+
40
+ const awaiting = computed(() => props.maps.isAwaitingReply(props.ticket.status));
41
+ const tone = computed(() => (awaiting.value ? AWAITING : (TONES[props.ticket.status] ?? TONES.OPEN!)));
42
+
43
+ const headline = computed(() => {
44
+ if (awaiting.value) {
45
+ return props.locale === "en"
46
+ ? "The team needs more information from you"
47
+ : "ทีมงานขอข้อมูลเพิ่มเติม — รอการตอบกลับจากคุณ";
48
+ }
49
+ const described = props.maps.columnOf(props.ticket.status)?.description?.trim();
50
+ return described || MESSAGES[props.ticket.status]?.[props.locale] || props.maps.labelOf(props.ticket.status);
51
+ });
52
+
53
+ /*
54
+ * The solution note, only when the thread does not already carry it. A resolved
55
+ * ticket usually has the team's answer as their last message, and printing the
56
+ * note here as well says the same words twice on one screen. Kept as the
57
+ * fallback for a ticket whose note never reached the conversation.
58
+ */
59
+ const showNote = computed(
60
+ () =>
61
+ !awaiting.value &&
62
+ !props.ticket.messages.some((m) => m.authorRole === "AGENT") &&
63
+ props.maps.isClosed(props.ticket.status) &&
64
+ Boolean(props.ticket.resolutionNote?.trim()),
65
+ );
66
+ </script>
67
+
68
+ <template>
69
+ <section class="lw-hero" :style="{ borderColor: tone.border, background: tone.bg }">
70
+ <component :is="tone.icon" class="lw-hero__icon" :style="{ color: tone.accent }" />
71
+ <div class="lw-hero__body">
72
+ <p class="lw-hero__kicker">
73
+ {{ awaiting ? (locale === "en" ? "AWAITING YOUR REPLY" : "รอการตอบกลับจากคุณ") : locale === "en" ? "LATEST STATUS" : "สถานะล่าสุด" }}
74
+ </p>
75
+ <p class="lw-hero__headline" :style="{ color: tone.fg }">{{ headline }}</p>
76
+ <p v-if="awaiting" class="lw-hero__nudge">
77
+ {{ locale === "en" ? "Please reply to the team in the conversation below" : "กรุณาตอบกลับให้ทีมงานในช่องแชทด้านล่าง" }}
78
+ </p>
79
+ <div v-if="showNote" class="lw-hero__note">
80
+ <WrenchIcon class="lw-hero__wrench" />
81
+ <TicketBody :html="ticket.resolutionNote ?? ''" :host="host" />
82
+ </div>
83
+ </div>
84
+ </section>
85
+ </template>
@@ -0,0 +1,14 @@
1
+ <script setup lang="ts">
2
+ /**
3
+ * A status as the realm coloured it.
4
+ *
5
+ * The tone arrives as DATA — a realm adds lanes whenever it likes — so it is an
6
+ * inline style rather than a class. There is no stylesheet that could know the
7
+ * names in advance.
8
+ */
9
+ defineProps<{ label: string; tone: { fg: string; bg: string } }>();
10
+ </script>
11
+
12
+ <template>
13
+ <span class="lw-status" :style="{ background: tone.bg, color: tone.fg }">{{ label }}</span>
14
+ </template>
@@ -0,0 +1,50 @@
1
+ <script setup lang="ts">
2
+ /**
3
+ * A message or description body, rendered safely.
4
+ *
5
+ * Deliberately NOT `v-html`. Bodies are HTML written by other people and shown
6
+ * inside the HOST application's page — markup that ran here would run with the
7
+ * host's origin, against the host's session. `renderBody` parses into an inert
8
+ * document, walks it against an allowlist and fills this node; `v-html` would
9
+ * hand the string straight to innerHTML.
10
+ */
11
+ import { onMounted, ref, watch } from "vue";
12
+ import { renderBody } from "@lightworkai.official/debug-capture";
13
+
14
+ const props = defineProps<{ html: string; host: string }>();
15
+ const target = ref<HTMLElement | null>(null);
16
+ /**
17
+ * The image being viewed full size, if any.
18
+ *
19
+ * Bodies are capped to a thumbnail so one screenshot cannot push a whole
20
+ * conversation off the screen — which is only reasonable if the full size is a
21
+ * click away.
22
+ *
23
+ * Delegated from the container rather than bound per image: this is sanitised
24
+ * HTML we inject, not elements we built, so there is nothing to attach to.
25
+ */
26
+ const zoomed = ref<string | null>(null);
27
+
28
+ function onClick(event: MouseEvent): void {
29
+ const node = event.target as HTMLElement | null;
30
+ if (!node || node.tagName !== "IMG") return;
31
+ const src = node.getAttribute("src");
32
+ if (src) zoomed.value = src;
33
+ }
34
+
35
+ function paint(): void {
36
+ if (target.value) renderBody(target.value, props.html, props.host);
37
+ }
38
+
39
+ onMounted(paint);
40
+ watch(() => [props.html, props.host], paint);
41
+ </script>
42
+
43
+ <template>
44
+ <div ref="target" class="lw-body" @click="onClick" />
45
+
46
+ <div v-if="zoomed" class="lw-zoom" role="dialog" aria-modal="true" @click="zoomed = null">
47
+ <button type="button" class="lw-zoom__close" aria-label="close">✕</button>
48
+ <img :src="zoomed" alt="" />
49
+ </div>
50
+ </template>