@lightworkai.official/debug-capture-vue 0.6.0 → 0.7.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 (35) hide show
  1. package/README.md +12 -8
  2. package/dist/components/{ToolbarButton.vue.d.ts → SupportPortalButton.vue.d.ts} +12 -7
  3. package/dist/index.d.ts +2 -8
  4. package/dist/index.mjs +762 -2319
  5. package/dist/index.mjs.map +1 -1
  6. package/package.json +2 -2
  7. package/src/components/SupportPortalButton.vue +50 -0
  8. package/src/index.ts +11 -16
  9. package/dist/components/ImageAnnotatorDialog.vue.d.ts +0 -19
  10. package/dist/components/MyTicketsPanel.vue.d.ts +0 -17
  11. package/dist/components/ReopenPanel.vue.d.ts +0 -12
  12. package/dist/components/RichReplyEditor.vue.d.ts +0 -59
  13. package/dist/components/StatusHero.vue.d.ts +0 -10
  14. package/dist/components/StatusPill.vue.d.ts +0 -16
  15. package/dist/components/TicketBody.vue.d.ts +0 -6
  16. package/dist/components/TicketConversation.vue.d.ts +0 -22
  17. package/dist/components/TicketDetail.vue.d.ts +0 -28
  18. package/dist/components/TicketScreenshots.vue.d.ts +0 -8
  19. package/dist/components/TicketTable.vue.d.ts +0 -25
  20. package/dist/components/TicketTimeline.vue.d.ts +0 -11
  21. package/dist/components/icons.d.ts +0 -38
  22. package/src/components/ImageAnnotatorDialog.vue +0 -160
  23. package/src/components/MyTicketsPanel.vue +0 -312
  24. package/src/components/ReopenPanel.vue +0 -47
  25. package/src/components/RichReplyEditor.vue +0 -321
  26. package/src/components/StatusHero.vue +0 -85
  27. package/src/components/StatusPill.vue +0 -14
  28. package/src/components/TicketBody.vue +0 -50
  29. package/src/components/TicketConversation.vue +0 -227
  30. package/src/components/TicketDetail.vue +0 -130
  31. package/src/components/TicketScreenshots.vue +0 -47
  32. package/src/components/TicketTable.vue +0 -142
  33. package/src/components/TicketTimeline.vue +0 -86
  34. package/src/components/ToolbarButton.vue +0 -33
  35. package/src/components/icons.ts +0 -90
@@ -1,312 +0,0 @@
1
- <script setup lang="ts">
2
- /**
3
- * "ปัญหาที่แจ้ง" — the reports this person filed, and what became of them.
4
- *
5
- * Drop it on a route of your own — the reporter-facing counterpart to the
6
- * agent's ticket list. It needs `identity` in the config, because the widget's
7
- * realm key is public and can authorise filing a report but never reading one
8
- * back — see the core README.
9
- *
10
- * This component owns fetching and view state and nothing else: the columns,
11
- * the sorting, the sanitising and the dates all live in the core, shared with
12
- * the React and Angular packages.
13
- */
14
- import { computed, onMounted, ref, watch } from "vue";
15
- import {
16
- NoIdentityError,
17
- fetchRealmConfig,
18
- getAttachmentUrl,
19
- getConfig,
20
- getMyTicket,
21
- getMyTicketHistory,
22
- listMyTickets,
23
- reopenMyTicket,
24
- replyToTicket,
25
- statusMaps,
26
- ticketStrings,
27
- uploadInlineImage,
28
- type MyTicketDetail,
29
- type MyTicketListItem,
30
- type RealmConfig,
31
- type SortKey,
32
- type StatusEvent,
33
- } from "@lightworkai.official/debug-capture";
34
- import TicketTable from "./TicketTable.vue";
35
- import TicketDetail from "./TicketDetail.vue";
36
- import { AlertIcon, SearchIcon } from "./icons";
37
-
38
- const props = defineProps<{
39
- /** Change it to re-read the list — after filing a report, say. */
40
- refreshKey?: string | number;
41
- /** Hide the panel's own heading when the host page already has one. */
42
- hideTitle?: boolean;
43
- }>();
44
-
45
- const emit = defineEmits<{
46
- (e: "replied", ticket: MyTicketDetail): void;
47
- (e: "reopened", ticket: MyTicketDetail): void;
48
- (e: "error", message: string): void;
49
- }>();
50
-
51
- const locale = computed(() => {
52
- try {
53
- return getConfig().locale;
54
- } catch {
55
- return undefined;
56
- }
57
- });
58
- const host = computed(() => {
59
- try {
60
- return getConfig().host;
61
- } catch {
62
- return "";
63
- }
64
- });
65
- const t = computed(() => ticketStrings(locale.value));
66
-
67
- const items = ref<MyTicketListItem[]>([]);
68
- const config = ref<RealmConfig | null>(null);
69
- const loading = ref(true);
70
- const error = ref<string | null>(null);
71
- const noIdentity = ref(false);
72
-
73
- const keyword = ref("");
74
- const applied = ref("");
75
- const statusFilter = ref("");
76
- const sort = ref<{ key: SortKey; direction: "asc" | "desc" }>({ key: "createdAt", direction: "desc" });
77
- const page = ref(1);
78
-
79
- const detail = ref<MyTicketDetail | null>(null);
80
- const history = ref<StatusEvent[]>([]);
81
- const detailError = ref<string | null>(null);
82
- const detailLoading = ref(false);
83
- const sending = ref(false);
84
- const reopening = ref(false);
85
-
86
- const maps = computed(() => statusMaps(config.value?.statusColumns ?? [], t.value.allStatuses));
87
- const table = ref<InstanceType<typeof TicketTable> | null>(null);
88
- const shownCount = computed(() => table.value?.count ?? 0);
89
-
90
- // Debounced like the original: re-filtering per keystroke resets the page under
91
- // the reader's hands.
92
- let debounce: ReturnType<typeof setTimeout> | null = null;
93
- watch(keyword, (next) => {
94
- if (debounce) clearTimeout(debounce);
95
- debounce = setTimeout(() => {
96
- applied.value = next;
97
- page.value = 1;
98
- }, 300);
99
- });
100
- watch(statusFilter, () => (page.value = 1));
101
- watch(() => props.refreshKey, () => void load());
102
-
103
- onMounted(load);
104
-
105
- async function load(): Promise<void> {
106
- loading.value = true;
107
- error.value = null;
108
- noIdentity.value = false;
109
- try {
110
- // Config and list together: the labels are useless without the rows and the
111
- // rows unreadable without the labels.
112
- const [realm, list] = await Promise.all([fetchRealmConfig(), listMyTickets()]);
113
- config.value = realm;
114
- items.value = list;
115
- } catch (e) {
116
- if (e instanceof NoIdentityError) noIdentity.value = true;
117
- else error.value = e instanceof Error ? e.message : t.value.listFailed;
118
- } finally {
119
- loading.value = false;
120
- }
121
- }
122
-
123
- /** Open a DIFFERENT ticket: tear the view down, because nothing on screen is its. */
124
- async function open(id: string): Promise<void> {
125
- detail.value = null;
126
- history.value = [];
127
- detailError.value = null;
128
- detailLoading.value = true;
129
- try {
130
- await reload(id);
131
- } finally {
132
- detailLoading.value = false;
133
- }
134
- }
135
-
136
- /**
137
- * Re-read the ticket we are already looking at, in place.
138
- *
139
- * Deliberately NOT `open()`. That nulls the detail and raises the loading flag,
140
- * which unmounts the whole view and remounts it — so sending a reply threw the
141
- * reader back to the top of the page, away from the conversation they were in
142
- * the middle of. It reads as the page reloading, because visually that is what
143
- * happens.
144
- *
145
- * Updating the refs instead lets Vue patch what changed: the new message
146
- * appears, the timeline updates, and everything else stays exactly where it was.
147
- */
148
- async function reload(id: string): Promise<void> {
149
- try {
150
- const [found, events] = await Promise.all([
151
- getMyTicket(id),
152
- // The timeline is a nicety, not the page: a ticket whose history fails to
153
- // load should still show its conversation.
154
- getMyTicketHistory(id).catch(() => [] as StatusEvent[]),
155
- ]);
156
- detail.value = found;
157
- history.value = events;
158
- } catch (e) {
159
- detailError.value = e instanceof Error ? e.message : t.value.detailFailed;
160
- }
161
- }
162
-
163
- function back(): void {
164
- detail.value = null;
165
- // Counts and badges move when a reply lands, so the list is re-read rather
166
- // than restored from what it said before the detail opened.
167
- void load();
168
- }
169
-
170
- function toggleSort(key: SortKey): void {
171
- sort.value =
172
- sort.value.key === key
173
- ? { key, direction: sort.value.direction === "asc" ? "desc" : "asc" }
174
- : // Dates open newest-first, text A→Z: the useful first click differs by
175
- // what the column holds.
176
- { key, direction: key === "createdAt" || key === "updatedAt" ? "desc" : "asc" };
177
- }
178
-
179
- async function send(body: string): Promise<void> {
180
- const ticket = detail.value;
181
- if (!ticket || sending.value) return;
182
- sending.value = true;
183
- try {
184
- await replyToTicket(ticket.id, body);
185
- await reload(ticket.id);
186
- if (detail.value) emit("replied", detail.value);
187
- } catch (e) {
188
- emit("error", e instanceof Error ? e.message : t.value.sendFailed);
189
- } finally {
190
- sending.value = false;
191
- }
192
- }
193
-
194
- async function reopen(note: string): Promise<void> {
195
- const ticket = detail.value;
196
- if (!ticket || reopening.value) return;
197
- reopening.value = true;
198
- try {
199
- await reopenMyTicket(ticket.id, note);
200
- await reload(ticket.id);
201
- if (detail.value) emit("reopened", detail.value);
202
- } catch (e) {
203
- emit("error", e instanceof Error ? e.message : t.value.reopenFailed);
204
- } finally {
205
- reopening.value = false;
206
- }
207
- }
208
-
209
- function uploadImage(file: File): Promise<string> {
210
- const ticket = detail.value;
211
- if (!ticket) return Promise.reject(new Error("no ticket"));
212
- return readDataUri(file).then((dataUri) =>
213
- uploadInlineImage(ticket.id, dataUri, file.name).then((stored) => stored.url),
214
- );
215
- }
216
-
217
- function attachmentUrl(id: string): Promise<string> {
218
- return getAttachmentUrl(id).then((found) => found.url);
219
- }
220
-
221
- function readDataUri(file: File): Promise<string> {
222
- return new Promise((resolve, reject) => {
223
- const reader = new FileReader();
224
- reader.addEventListener("load", () =>
225
- typeof reader.result === "string" ? resolve(reader.result) : reject(new Error("unreadable file")),
226
- );
227
- reader.addEventListener("error", () => reject(new Error("unreadable file")));
228
- reader.readAsDataURL(file);
229
- });
230
- }
231
- </script>
232
-
233
- <template>
234
- <div class="lw-panel">
235
- <template v-if="!detail && !detailLoading">
236
- <h2 v-if="!hideTitle" class="lw-panel__title">{{ t.title }}</h2>
237
- <p class="lw-panel__intro">{{ t.intro }}</p>
238
-
239
- <div class="lw-controls">
240
- <div class="lw-search">
241
- <SearchIcon class="lw-search__icon" />
242
- <input
243
- v-model="keyword"
244
- type="search"
245
- class="lw-input lw-search__field"
246
- :placeholder="t.searchPlaceholder"
247
- :aria-label="t.searchLabel"
248
- />
249
- </div>
250
- <select v-model="statusFilter" class="lw-input">
251
- <option v-for="option in maps.options" :key="option.value" :value="option.value">
252
- {{ option.label }}
253
- </option>
254
- </select>
255
- <span class="lw-count">{{ t.count(shownCount) }}</span>
256
- </div>
257
-
258
- <p v-if="noIdentity" class="lw-state">{{ t.signedOut }}</p>
259
- <div v-else-if="loading" class="lw-skeletons">
260
- <div v-for="n in 4" :key="n" class="lw-skeleton" />
261
- </div>
262
- <p v-else-if="error" class="lw-state lw-state--error">
263
- <AlertIcon /> {{ error }}
264
- <button type="button" class="lw-retry" @click="load">{{ t.retry }}</button>
265
- </p>
266
- <TicketTable
267
- v-else
268
- ref="table"
269
- :items="items"
270
- :maps="maps"
271
- :t="t"
272
- :slug="config?.realm.slug"
273
- :timezone="config?.timezone ?? 'Asia/Bangkok'"
274
- :locale="locale"
275
- :keyword="applied"
276
- :status-filter="statusFilter"
277
- :sort="sort"
278
- :page="page"
279
- @open="open"
280
- @sort="toggleSort"
281
- @page="page = $event"
282
- />
283
- </template>
284
-
285
- <div v-else-if="detailLoading" class="lw-skeletons">
286
- <div class="lw-skeleton" />
287
- <div class="lw-skeleton" />
288
- </div>
289
-
290
- <p v-else-if="detailError" class="lw-state lw-state--error">{{ detailError }}</p>
291
-
292
- <TicketDetail
293
- v-else-if="detail && config"
294
- :ticket="detail"
295
- :history="history"
296
- :config="config"
297
- :maps="maps"
298
- :t="t"
299
- :host="host"
300
- :locale="locale"
301
- :sending="sending"
302
- :reopening="reopening"
303
- :upload-image="uploadImage"
304
- :attachment-url="attachmentUrl"
305
- @back="back"
306
- @edited="detail && reload(detail.id)"
307
- @send="send"
308
- @reopen="reopen"
309
- @error="emit('error', $event)"
310
- />
311
- </div>
312
- </template>
@@ -1,47 +0,0 @@
1
- <script setup lang="ts">
2
- /**
3
- * "Still a problem?" — a SEPARATE action from the chat, with its own endpoint
4
- * and its own confirmation, only when the ticket is done and the realm allows it.
5
- */
6
- import { ref } from "vue";
7
- import type { TicketStrings } from "@lightworkai.official/debug-capture";
8
- import { RotateIcon } from "./icons";
9
-
10
- defineProps<{ t: TicketStrings; locale: "th" | "en"; busy?: boolean }>();
11
- const emit = defineEmits<{ (e: "reopen", note: string): void }>();
12
-
13
- const open = ref(false);
14
- const note = ref("");
15
-
16
- function confirm(): void {
17
- emit("reopen", note.value.trim());
18
- }
19
- </script>
20
-
21
- <template>
22
- <section class="lw-reopen">
23
- <div v-if="!open" class="lw-reopen__row">
24
- <span class="lw-reopen__hint">{{ locale === "en" ? "Problem not fixed?" : "ปัญหายังไม่หาย?" }}</span>
25
- <button type="button" class="lw-reopen__open" @click="open = true">
26
- <RotateIcon /> {{ t.reopen }}
27
- </button>
28
- </div>
29
- <div v-else>
30
- <p class="lw-reopen__title">{{ t.reopen }}</p>
31
- <textarea
32
- v-model="note"
33
- rows="2"
34
- class="lw-reopen__note"
35
- :placeholder="locale === 'en' ? 'Tell us what is still happening (optional)…' : 'บอกเราหน่อยว่ายังพบปัญหาอะไร (ไม่ระบุก็ได้)…'"
36
- />
37
- <div class="lw-reopen__actions">
38
- <button type="button" class="lw-reopen__cancel" @click="open = false; note = ''">
39
- {{ locale === "en" ? "Cancel" : "ยกเลิก" }}
40
- </button>
41
- <button type="button" class="lw-reopen__confirm" :disabled="busy" @click="confirm">
42
- <RotateIcon /> {{ busy ? t.reopening : t.reopen }}
43
- </button>
44
- </div>
45
- </div>
46
- </section>
47
- </template>
@@ -1,321 +0,0 @@
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>