@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.
- package/README.md +12 -8
- package/dist/components/{ToolbarButton.vue.d.ts → SupportPortalButton.vue.d.ts} +12 -7
- package/dist/index.d.ts +2 -8
- package/dist/index.mjs +762 -2319
- package/dist/index.mjs.map +1 -1
- package/package.json +2 -2
- package/src/components/SupportPortalButton.vue +50 -0
- package/src/index.ts +11 -16
- package/dist/components/ImageAnnotatorDialog.vue.d.ts +0 -19
- package/dist/components/MyTicketsPanel.vue.d.ts +0 -17
- package/dist/components/ReopenPanel.vue.d.ts +0 -12
- package/dist/components/RichReplyEditor.vue.d.ts +0 -59
- package/dist/components/StatusHero.vue.d.ts +0 -10
- package/dist/components/StatusPill.vue.d.ts +0 -16
- package/dist/components/TicketBody.vue.d.ts +0 -6
- package/dist/components/TicketConversation.vue.d.ts +0 -22
- package/dist/components/TicketDetail.vue.d.ts +0 -28
- package/dist/components/TicketScreenshots.vue.d.ts +0 -8
- package/dist/components/TicketTable.vue.d.ts +0 -25
- package/dist/components/TicketTimeline.vue.d.ts +0 -11
- package/dist/components/icons.d.ts +0 -38
- package/src/components/ImageAnnotatorDialog.vue +0 -160
- package/src/components/MyTicketsPanel.vue +0 -312
- package/src/components/ReopenPanel.vue +0 -47
- package/src/components/RichReplyEditor.vue +0 -321
- package/src/components/StatusHero.vue +0 -85
- package/src/components/StatusPill.vue +0 -14
- package/src/components/TicketBody.vue +0 -50
- package/src/components/TicketConversation.vue +0 -227
- package/src/components/TicketDetail.vue +0 -130
- package/src/components/TicketScreenshots.vue +0 -47
- package/src/components/TicketTable.vue +0 -142
- package/src/components/TicketTimeline.vue +0 -86
- package/src/components/ToolbarButton.vue +0 -33
- 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>
|