@lightworkai.official/debug-capture-react 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.
- package/README.md +85 -0
- package/dist/DebugCaptureInit.d.ts +25 -0
- package/dist/DebugErrorBoundary.d.ts +26 -0
- package/dist/ReportProblemButton.d.ts +21 -0
- package/dist/components/ImageAnnotatorDialog.d.ts +11 -0
- package/dist/components/MyTicketsPanel.d.ts +11 -0
- package/dist/components/ReopenPanel.d.ts +7 -0
- package/dist/components/RichReplyEditor.d.ts +29 -0
- package/dist/components/StatusHero.d.ts +8 -0
- package/dist/components/StatusPill.d.ts +14 -0
- package/dist/components/TicketBody.d.ts +5 -0
- package/dist/components/TicketConversation.d.ts +13 -0
- package/dist/components/TicketDetail.d.ts +23 -0
- package/dist/components/TicketScreenshots.d.ts +6 -0
- package/dist/components/TicketTable.d.ts +18 -0
- package/dist/components/TicketTimeline.d.ts +14 -0
- package/dist/components/ToolbarButton.d.ts +18 -0
- package/dist/components/icons.d.ts +35 -0
- package/dist/crash.d.ts +5 -0
- package/dist/index.d.ts +23 -0
- package/dist/index.mjs +1705 -0
- package/package.json +52 -0
- package/src/DebugCaptureInit.tsx +56 -0
- package/src/DebugErrorBoundary.tsx +50 -0
- package/src/ReportProblemButton.tsx +79 -0
- package/src/components/ImageAnnotatorDialog.tsx +160 -0
- package/src/components/MyTicketsPanel.tsx +321 -0
- package/src/components/ReopenPanel.tsx +52 -0
- package/src/components/RichReplyEditor.tsx +264 -0
- package/src/components/StatusHero.tsx +92 -0
- package/src/components/StatusPill.tsx +14 -0
- package/src/components/TicketBody.tsx +50 -0
- package/src/components/TicketConversation.tsx +177 -0
- package/src/components/TicketDetail.tsx +110 -0
- package/src/components/TicketScreenshots.tsx +41 -0
- package/src/components/TicketTable.tsx +161 -0
- package/src/components/TicketTimeline.tsx +81 -0
- package/src/components/ToolbarButton.tsx +37 -0
- package/src/components/icons.tsx +79 -0
- package/src/crash.ts +15 -0
- package/src/index.ts +27 -0
|
@@ -0,0 +1,321 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* "ปัญหาที่แจ้ง" — the reports this person filed, and what became of them.
|
|
3
|
+
*
|
|
4
|
+
* Drop it on a route of your own — the reporter-facing counterpart to the
|
|
5
|
+
* agent's ticket list. It needs `identity` in the config, because the widget's
|
|
6
|
+
* realm key is public and can authorise filing a report but never reading one
|
|
7
|
+
* back — see the core README.
|
|
8
|
+
*
|
|
9
|
+
* This component owns fetching and view state and nothing else: the columns,
|
|
10
|
+
* the sorting, the sanitising and the dates all live in the core, shared with
|
|
11
|
+
* the Vue and Angular packages.
|
|
12
|
+
*/
|
|
13
|
+
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
|
14
|
+
import {
|
|
15
|
+
NoIdentityError, fetchRealmConfig, getAttachmentUrl, getConfig, getMyTicket,
|
|
16
|
+
getMyTicketHistory, listMyTickets, reopenMyTicket, replyToTicket, statusMaps,
|
|
17
|
+
ticketStrings, uploadInlineImage,
|
|
18
|
+
type MyTicketDetail, type MyTicketListItem, type RealmConfig, type Sort, type SortKey,
|
|
19
|
+
type StatusEvent,
|
|
20
|
+
} from "@lightworkai.official/debug-capture";
|
|
21
|
+
import { TicketTable } from "./TicketTable";
|
|
22
|
+
import { TicketDetail } from "./TicketDetail";
|
|
23
|
+
import { AlertIcon, SearchIcon } from "./icons";
|
|
24
|
+
|
|
25
|
+
export interface MyTicketsPanelProps {
|
|
26
|
+
/** Change it to re-read the list — after filing a report, say. */
|
|
27
|
+
refreshKey?: string | number;
|
|
28
|
+
/** Hide the panel's own heading when the host page already has one. */
|
|
29
|
+
hideTitle?: boolean;
|
|
30
|
+
onReplied?: (ticket: MyTicketDetail) => void;
|
|
31
|
+
onReopened?: (ticket: MyTicketDetail) => void;
|
|
32
|
+
onError?: (message: string) => void;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function safeConfig<T>(read: () => T, fallback: T): T {
|
|
36
|
+
try {
|
|
37
|
+
return read();
|
|
38
|
+
} catch {
|
|
39
|
+
return fallback;
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export function MyTicketsPanel({ refreshKey, hideTitle, onReplied, onReopened, onError }: MyTicketsPanelProps) {
|
|
44
|
+
const locale = safeConfig(() => getConfig().locale, undefined);
|
|
45
|
+
const host = safeConfig(() => getConfig().host, "");
|
|
46
|
+
const t = useMemo(() => ticketStrings(locale), [locale]);
|
|
47
|
+
|
|
48
|
+
const [items, setItems] = useState<MyTicketListItem[]>([]);
|
|
49
|
+
const [config, setConfig] = useState<RealmConfig | null>(null);
|
|
50
|
+
const [loading, setLoading] = useState(true);
|
|
51
|
+
const [error, setError] = useState<string | null>(null);
|
|
52
|
+
const [noIdentity, setNoIdentity] = useState(false);
|
|
53
|
+
|
|
54
|
+
const [keyword, setKeyword] = useState("");
|
|
55
|
+
const [applied, setApplied] = useState("");
|
|
56
|
+
const [statusFilter, setStatusFilter] = useState("");
|
|
57
|
+
const [sort, setSort] = useState<Sort>({ key: "createdAt", direction: "desc" });
|
|
58
|
+
const [page, setPage] = useState(1);
|
|
59
|
+
const [shown, setShown] = useState(0);
|
|
60
|
+
|
|
61
|
+
const [detail, setDetail] = useState<MyTicketDetail | null>(null);
|
|
62
|
+
const [history, setHistory] = useState<StatusEvent[]>([]);
|
|
63
|
+
const [detailError, setDetailError] = useState<string | null>(null);
|
|
64
|
+
const [detailLoading, setDetailLoading] = useState(false);
|
|
65
|
+
const [sending, setSending] = useState(false);
|
|
66
|
+
const [reopening, setReopening] = useState(false);
|
|
67
|
+
|
|
68
|
+
const maps = useMemo(() => statusMaps(config?.statusColumns ?? [], t.allStatuses), [config, t]);
|
|
69
|
+
const report = useRef(onError);
|
|
70
|
+
report.current = onError;
|
|
71
|
+
|
|
72
|
+
const load = useCallback(async () => {
|
|
73
|
+
setLoading(true);
|
|
74
|
+
setError(null);
|
|
75
|
+
setNoIdentity(false);
|
|
76
|
+
try {
|
|
77
|
+
// Config and list together: the labels are useless without the rows and
|
|
78
|
+
// the rows unreadable without the labels.
|
|
79
|
+
const [realm, list] = await Promise.all([fetchRealmConfig(), listMyTickets()]);
|
|
80
|
+
setConfig(realm);
|
|
81
|
+
setItems(list);
|
|
82
|
+
} catch (e) {
|
|
83
|
+
if (e instanceof NoIdentityError) setNoIdentity(true);
|
|
84
|
+
else setError(e instanceof Error ? e.message : t.listFailed);
|
|
85
|
+
} finally {
|
|
86
|
+
setLoading(false);
|
|
87
|
+
}
|
|
88
|
+
}, [t]);
|
|
89
|
+
|
|
90
|
+
useEffect(() => {
|
|
91
|
+
void load();
|
|
92
|
+
}, [load, refreshKey]);
|
|
93
|
+
|
|
94
|
+
// Debounced like the original: re-filtering per keystroke resets the page
|
|
95
|
+
// under the reader's hands.
|
|
96
|
+
useEffect(() => {
|
|
97
|
+
const timer = setTimeout(() => {
|
|
98
|
+
setApplied(keyword);
|
|
99
|
+
setPage(1);
|
|
100
|
+
}, 300);
|
|
101
|
+
return () => clearTimeout(timer);
|
|
102
|
+
}, [keyword]);
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* Re-read the ticket we are already looking at, in place.
|
|
106
|
+
*
|
|
107
|
+
* Deliberately separate from `open()`. That one clears the detail and raises
|
|
108
|
+
* the loading flag, which unmounts the whole view and remounts it — so
|
|
109
|
+
* sending a reply threw the reader back to the top of the page, away from the
|
|
110
|
+
* conversation they were in the middle of. It reads as the page reloading,
|
|
111
|
+
* because visually that is what happens.
|
|
112
|
+
*
|
|
113
|
+
* Setting state instead lets React reconcile what changed: the new message
|
|
114
|
+
* appears, the timeline updates, and everything else stays where it was.
|
|
115
|
+
*/
|
|
116
|
+
const reload = useCallback(
|
|
117
|
+
async (id: string) => {
|
|
118
|
+
try {
|
|
119
|
+
const [found, events] = await Promise.all([
|
|
120
|
+
getMyTicket(id),
|
|
121
|
+
// The timeline is a nicety, not the page: a ticket whose history
|
|
122
|
+
// fails to load should still show its conversation.
|
|
123
|
+
getMyTicketHistory(id).catch(() => [] as StatusEvent[]),
|
|
124
|
+
]);
|
|
125
|
+
setDetail(found);
|
|
126
|
+
setHistory(events);
|
|
127
|
+
return found;
|
|
128
|
+
} catch (e) {
|
|
129
|
+
setDetailError(e instanceof Error ? e.message : t.detailFailed);
|
|
130
|
+
return null;
|
|
131
|
+
}
|
|
132
|
+
},
|
|
133
|
+
[t],
|
|
134
|
+
);
|
|
135
|
+
|
|
136
|
+
/** Open a DIFFERENT ticket: tear the view down, because nothing on screen is its. */
|
|
137
|
+
const open = useCallback(
|
|
138
|
+
async (id: string) => {
|
|
139
|
+
setDetail(null);
|
|
140
|
+
setHistory([]);
|
|
141
|
+
setDetailError(null);
|
|
142
|
+
setDetailLoading(true);
|
|
143
|
+
try {
|
|
144
|
+
return await reload(id);
|
|
145
|
+
} finally {
|
|
146
|
+
setDetailLoading(false);
|
|
147
|
+
}
|
|
148
|
+
},
|
|
149
|
+
[reload],
|
|
150
|
+
);
|
|
151
|
+
|
|
152
|
+
function back(): void {
|
|
153
|
+
setDetail(null);
|
|
154
|
+
// Counts and badges move when a reply lands, so the list is re-read rather
|
|
155
|
+
// than restored from what it said before the detail opened.
|
|
156
|
+
void load();
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
function toggleSort(key: SortKey): void {
|
|
160
|
+
setSort((current) =>
|
|
161
|
+
current.key === key
|
|
162
|
+
? { key, direction: current.direction === "asc" ? "desc" : "asc" }
|
|
163
|
+
: // Dates open newest-first, text A→Z: the useful first click differs
|
|
164
|
+
// by what the column holds.
|
|
165
|
+
{ key, direction: key === "createdAt" || key === "updatedAt" ? "desc" : "asc" },
|
|
166
|
+
);
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
async function send(body: string): Promise<void> {
|
|
170
|
+
if (!detail || sending) return;
|
|
171
|
+
setSending(true);
|
|
172
|
+
try {
|
|
173
|
+
await replyToTicket(detail.id, body);
|
|
174
|
+
const fresh = await reload(detail.id);
|
|
175
|
+
if (fresh) onReplied?.(fresh);
|
|
176
|
+
} catch (e) {
|
|
177
|
+
report.current?.(e instanceof Error ? e.message : t.sendFailed);
|
|
178
|
+
} finally {
|
|
179
|
+
setSending(false);
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
async function reopen(note: string): Promise<void> {
|
|
184
|
+
if (!detail || reopening) return;
|
|
185
|
+
setReopening(true);
|
|
186
|
+
try {
|
|
187
|
+
await reopenMyTicket(detail.id, note);
|
|
188
|
+
const fresh = await reload(detail.id);
|
|
189
|
+
if (fresh) onReopened?.(fresh);
|
|
190
|
+
} catch (e) {
|
|
191
|
+
report.current?.(e instanceof Error ? e.message : t.reopenFailed);
|
|
192
|
+
} finally {
|
|
193
|
+
setReopening(false);
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
const uploadImage = useCallback(
|
|
198
|
+
async (file: File): Promise<string> => {
|
|
199
|
+
if (!detail) throw new Error("no ticket");
|
|
200
|
+
const dataUri = await readDataUri(file);
|
|
201
|
+
const stored = await uploadInlineImage(detail.id, dataUri, file.name);
|
|
202
|
+
return stored.url;
|
|
203
|
+
},
|
|
204
|
+
[detail],
|
|
205
|
+
);
|
|
206
|
+
|
|
207
|
+
const attachmentUrl = useCallback((id: string) => getAttachmentUrl(id).then((found) => found.url), []);
|
|
208
|
+
|
|
209
|
+
if (detailLoading) {
|
|
210
|
+
return (
|
|
211
|
+
<div className="lw-panel">
|
|
212
|
+
<div className="lw-skeleton" />
|
|
213
|
+
<div className="lw-skeleton" />
|
|
214
|
+
</div>
|
|
215
|
+
);
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
if (detailError) {
|
|
219
|
+
return <div className="lw-panel"><p className="lw-state lw-state--error">{detailError}</p></div>;
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
if (detail && config) {
|
|
223
|
+
return (
|
|
224
|
+
<div className="lw-panel">
|
|
225
|
+
<TicketDetail
|
|
226
|
+
ticket={detail}
|
|
227
|
+
history={history}
|
|
228
|
+
config={config}
|
|
229
|
+
maps={maps}
|
|
230
|
+
t={t}
|
|
231
|
+
host={host}
|
|
232
|
+
locale={locale}
|
|
233
|
+
sending={sending}
|
|
234
|
+
reopening={reopening}
|
|
235
|
+
uploadImage={uploadImage}
|
|
236
|
+
attachmentUrl={attachmentUrl}
|
|
237
|
+
onBack={back}
|
|
238
|
+
onSend={send}
|
|
239
|
+
onEdited={() => void reload(detail.id)}
|
|
240
|
+
onReopen={reopen}
|
|
241
|
+
onError={(message) => report.current?.(message)}
|
|
242
|
+
/>
|
|
243
|
+
</div>
|
|
244
|
+
);
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
return (
|
|
248
|
+
<div className="lw-panel">
|
|
249
|
+
{!hideTitle && <h2 className="lw-panel__title">{t.title}</h2>}
|
|
250
|
+
<p className="lw-panel__intro">{t.intro}</p>
|
|
251
|
+
|
|
252
|
+
<div className="lw-controls">
|
|
253
|
+
<div className="lw-search">
|
|
254
|
+
<SearchIcon className="lw-search__icon" />
|
|
255
|
+
<input
|
|
256
|
+
type="search"
|
|
257
|
+
className="lw-input lw-search__field"
|
|
258
|
+
placeholder={t.searchPlaceholder}
|
|
259
|
+
aria-label={t.searchLabel}
|
|
260
|
+
value={keyword}
|
|
261
|
+
onChange={(event) => setKeyword(event.target.value)}
|
|
262
|
+
/>
|
|
263
|
+
</div>
|
|
264
|
+
<select
|
|
265
|
+
className="lw-input"
|
|
266
|
+
value={statusFilter}
|
|
267
|
+
onChange={(event) => {
|
|
268
|
+
setStatusFilter(event.target.value);
|
|
269
|
+
setPage(1);
|
|
270
|
+
}}
|
|
271
|
+
>
|
|
272
|
+
{maps.options.map((option) => (
|
|
273
|
+
<option key={option.value} value={option.value}>{option.label}</option>
|
|
274
|
+
))}
|
|
275
|
+
</select>
|
|
276
|
+
<span className="lw-count">{t.count(shown)}</span>
|
|
277
|
+
</div>
|
|
278
|
+
|
|
279
|
+
{noIdentity ? (
|
|
280
|
+
<p className="lw-state">{t.signedOut}</p>
|
|
281
|
+
) : loading ? (
|
|
282
|
+
<div>
|
|
283
|
+
{[0, 1, 2, 3].map((n) => <div key={n} className="lw-skeleton" />)}
|
|
284
|
+
</div>
|
|
285
|
+
) : error ? (
|
|
286
|
+
<p className="lw-state lw-state--error">
|
|
287
|
+
<AlertIcon /> {error}
|
|
288
|
+
<button type="button" className="lw-retry" onClick={() => void load()}>{t.retry}</button>
|
|
289
|
+
</p>
|
|
290
|
+
) : (
|
|
291
|
+
<TicketTable
|
|
292
|
+
items={items}
|
|
293
|
+
maps={maps}
|
|
294
|
+
t={t}
|
|
295
|
+
slug={config?.realm.slug}
|
|
296
|
+
timezone={config?.timezone ?? "Asia/Bangkok"}
|
|
297
|
+
locale={locale}
|
|
298
|
+
keyword={applied}
|
|
299
|
+
statusFilter={statusFilter}
|
|
300
|
+
sort={sort}
|
|
301
|
+
page={page}
|
|
302
|
+
onOpen={(id) => void open(id)}
|
|
303
|
+
onSort={toggleSort}
|
|
304
|
+
onPage={setPage}
|
|
305
|
+
onCount={setShown}
|
|
306
|
+
/>
|
|
307
|
+
)}
|
|
308
|
+
</div>
|
|
309
|
+
);
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
function readDataUri(file: File): Promise<string> {
|
|
313
|
+
return new Promise((resolve, reject) => {
|
|
314
|
+
const reader = new FileReader();
|
|
315
|
+
reader.addEventListener("load", () =>
|
|
316
|
+
typeof reader.result === "string" ? resolve(reader.result) : reject(new Error("unreadable file")),
|
|
317
|
+
);
|
|
318
|
+
reader.addEventListener("error", () => reject(new Error("unreadable file")));
|
|
319
|
+
reader.readAsDataURL(file);
|
|
320
|
+
});
|
|
321
|
+
}
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* "Still a problem?" — a SEPARATE action from the chat, with its own endpoint
|
|
3
|
+
* and its own confirmation, only when the ticket is done and the realm allows it.
|
|
4
|
+
*/
|
|
5
|
+
import { useState } from "react";
|
|
6
|
+
import type { TicketStrings } from "@lightworkai.official/debug-capture";
|
|
7
|
+
import { RotateIcon } from "./icons";
|
|
8
|
+
|
|
9
|
+
export function ReopenPanel({
|
|
10
|
+
t, locale, busy, onReopen,
|
|
11
|
+
}: { t: TicketStrings; locale: "th" | "en"; busy?: boolean; onReopen: (note: string) => void }) {
|
|
12
|
+
const [open, setOpen] = useState(false);
|
|
13
|
+
const [note, setNote] = useState("");
|
|
14
|
+
|
|
15
|
+
if (!open) {
|
|
16
|
+
return (
|
|
17
|
+
<section className="lw-reopen">
|
|
18
|
+
<div className="lw-reopen__row">
|
|
19
|
+
<span className="lw-reopen__hint">{locale === "en" ? "Problem not fixed?" : "ปัญหายังไม่หาย?"}</span>
|
|
20
|
+
<button type="button" className="lw-reopen__open" onClick={() => setOpen(true)}>
|
|
21
|
+
<RotateIcon /> {t.reopen}
|
|
22
|
+
</button>
|
|
23
|
+
</div>
|
|
24
|
+
</section>
|
|
25
|
+
);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
return (
|
|
29
|
+
<section className="lw-reopen">
|
|
30
|
+
<p className="lw-reopen__title">{t.reopen}</p>
|
|
31
|
+
<textarea
|
|
32
|
+
className="lw-reopen__note"
|
|
33
|
+
rows={2}
|
|
34
|
+
value={note}
|
|
35
|
+
onChange={(event) => setNote(event.target.value)}
|
|
36
|
+
placeholder={
|
|
37
|
+
locale === "en"
|
|
38
|
+
? "Tell us what is still happening (optional)…"
|
|
39
|
+
: "บอกเราหน่อยว่ายังพบปัญหาอะไร (ไม่ระบุก็ได้)…"
|
|
40
|
+
}
|
|
41
|
+
/>
|
|
42
|
+
<div className="lw-reopen__actions">
|
|
43
|
+
<button type="button" className="lw-reopen__cancel" onClick={() => { setOpen(false); setNote(""); }}>
|
|
44
|
+
{locale === "en" ? "Cancel" : "ยกเลิก"}
|
|
45
|
+
</button>
|
|
46
|
+
<button type="button" className="lw-reopen__confirm" disabled={busy} onClick={() => onReopen(note.trim())}>
|
|
47
|
+
<RotateIcon /> {busy ? t.reopening : t.reopen}
|
|
48
|
+
</button>
|
|
49
|
+
</div>
|
|
50
|
+
</section>
|
|
51
|
+
);
|
|
52
|
+
}
|
|
@@ -0,0 +1,264 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The reporter's reply box — TipTap, the same editor the original gives them.
|
|
3
|
+
*
|
|
4
|
+
* This was hand-rolled on `contenteditable` + `document.execCommand` for one
|
|
5
|
+
* round, on the reasoning that a package should not ship a rich-text editor.
|
|
6
|
+
* That was wrong twice over: TipTap is a PEER dependency, so an app that
|
|
7
|
+
* already has it pays nothing, and re-implementing selection handling, list
|
|
8
|
+
* nesting and paste sanitising by hand is a maintenance bill with no upside.
|
|
9
|
+
*/
|
|
10
|
+
import { forwardRef, useCallback, useImperativeHandle, useRef, useState } from "react";
|
|
11
|
+
import { EditorContent, useEditor } from "@tiptap/react";
|
|
12
|
+
import StarterKit from "@tiptap/starter-kit";
|
|
13
|
+
import Image from "@tiptap/extension-image";
|
|
14
|
+
import Placeholder from "@tiptap/extension-placeholder";
|
|
15
|
+
import { cleanHtml, htmlIsEmpty } from "@lightworkai.official/debug-capture";
|
|
16
|
+
import { ToolbarButton } from "./ToolbarButton";
|
|
17
|
+
import { ImageAnnotatorDialog } from "./ImageAnnotatorDialog";
|
|
18
|
+
import {
|
|
19
|
+
BoldIcon, BulletListIcon, ImageIcon, ItalicIcon, LinkIcon, OrderedListIcon, UnderlineIcon,
|
|
20
|
+
} from "./icons";
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* The toolbar's words.
|
|
24
|
+
*
|
|
25
|
+
* An open map because the toolbar is configurable — a host asking for `strike`
|
|
26
|
+
* or `blockquote` needs a label for it, and enumerating every possible control
|
|
27
|
+
* here would mean editing this type every time one is added.
|
|
28
|
+
*/
|
|
29
|
+
export type EditorLabels = Record<string, string>;
|
|
30
|
+
|
|
31
|
+
export interface RichReplyEditorHandle {
|
|
32
|
+
/** Sanitised HTML, or "" when there is nothing worth sending. */
|
|
33
|
+
value: () => string;
|
|
34
|
+
clear: () => void;
|
|
35
|
+
focus: () => void;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export interface RichReplyEditorProps {
|
|
39
|
+
placeholder: string;
|
|
40
|
+
/** Support host origin — the sanitiser resolves inline image URLs against it. */
|
|
41
|
+
host: string;
|
|
42
|
+
disabled?: boolean;
|
|
43
|
+
labels: EditorLabels;
|
|
44
|
+
/** Store an image and return the URL to reference it by. */
|
|
45
|
+
uploadImage: (file: File) => Promise<string>;
|
|
46
|
+
onError: (message: string) => void;
|
|
47
|
+
/** UI language, for the annotator's own labels. */
|
|
48
|
+
locale?: "th" | "en";
|
|
49
|
+
/** Content to start from — editing an existing message rather than writing one. */
|
|
50
|
+
initial?: string;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export const RichReplyEditor = forwardRef<RichReplyEditorHandle, RichReplyEditorProps>(
|
|
54
|
+
function RichReplyEditor({ placeholder, host, disabled, labels, uploadImage, onError, locale, initial }, ref) {
|
|
55
|
+
const [uploading, setUploading] = useState(false);
|
|
56
|
+
const fileInput = useRef<HTMLInputElement>(null);
|
|
57
|
+
/**
|
|
58
|
+
* The image waiting to be drawn on, as a same-origin data URI. Non-null
|
|
59
|
+
* means the annotator is open.
|
|
60
|
+
*
|
|
61
|
+
* Every route in — the picker, a paste, a drop — lands here first. The
|
|
62
|
+
* original does the same: pointing at the problem is most of what a
|
|
63
|
+
* screenshot is for, and annotating BEFORE upload keeps the canvas
|
|
64
|
+
* same-origin, so `toDataURL` is not tainted by a presigned URL.
|
|
65
|
+
*/
|
|
66
|
+
const [pending, setPending] = useState<{ src: string; name: string } | null>(null);
|
|
67
|
+
const pendingRef = useRef(pending);
|
|
68
|
+
pendingRef.current = pending;
|
|
69
|
+
// Read inside TipTap's own callbacks, which capture the first render.
|
|
70
|
+
const uploadRef = useRef(uploadImage);
|
|
71
|
+
uploadRef.current = uploadImage;
|
|
72
|
+
const errorRef = useRef(onError);
|
|
73
|
+
errorRef.current = onError;
|
|
74
|
+
|
|
75
|
+
const editor = useEditor({
|
|
76
|
+
// StarterKit v3 already carries Underline and Link, so the toolbar the
|
|
77
|
+
// original defines needs only two extensions beyond it.
|
|
78
|
+
extensions: [
|
|
79
|
+
StarterKit,
|
|
80
|
+
Image.configure({ inline: false, allowBase64: false }),
|
|
81
|
+
Placeholder.configure({ placeholder }),
|
|
82
|
+
],
|
|
83
|
+
content: initial ?? "",
|
|
84
|
+
editable: !disabled,
|
|
85
|
+
immediatelyRender: false,
|
|
86
|
+
editorProps: {
|
|
87
|
+
attributes: { class: "lw-editor-body" },
|
|
88
|
+
/**
|
|
89
|
+
* A pasted screenshot is the normal case — Cmd+Shift+4, Cmd+V — and
|
|
90
|
+
* without this the only route is save-to-disk, find-the-file, pick-it.
|
|
91
|
+
*/
|
|
92
|
+
handlePaste: (_view, event) => {
|
|
93
|
+
const file = Array.from(event.clipboardData?.files ?? []).find((f) => f.type.startsWith("image/"));
|
|
94
|
+
if (!file) return false;
|
|
95
|
+
event.preventDefault();
|
|
96
|
+
void addImage(file);
|
|
97
|
+
return true;
|
|
98
|
+
},
|
|
99
|
+
handleDrop: (_view, event) => {
|
|
100
|
+
const dropped = event as DragEvent;
|
|
101
|
+
const file = Array.from(dropped.dataTransfer?.files ?? []).find((f) => f.type.startsWith("image/"));
|
|
102
|
+
if (!file) return false;
|
|
103
|
+
dropped.preventDefault();
|
|
104
|
+
void addImage(file);
|
|
105
|
+
return true;
|
|
106
|
+
},
|
|
107
|
+
},
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* Open the annotator on a picked image rather than inserting it straight
|
|
112
|
+
* away.
|
|
113
|
+
*
|
|
114
|
+
* One dialog, everywhere. There was briefly a config hook letting a host
|
|
115
|
+
* swap in its own — which solved the symptom (two dialogs that looked
|
|
116
|
+
* different) by blessing the cause (two dialogs).
|
|
117
|
+
*/
|
|
118
|
+
const addImage = useCallback(async (file: File) => {
|
|
119
|
+
if (pendingRef.current) return;
|
|
120
|
+
try {
|
|
121
|
+
setPending({ src: await readDataUri(file), name: file.name || "screenshot.png" });
|
|
122
|
+
} catch (e) {
|
|
123
|
+
errorRef.current(e instanceof Error ? e.message : "unreadable file");
|
|
124
|
+
}
|
|
125
|
+
}, []);
|
|
126
|
+
|
|
127
|
+
/** The annotated image comes back as a data URI; upload THAT and insert it. */
|
|
128
|
+
const commitImage = useCallback(
|
|
129
|
+
async (dataUri: string) => {
|
|
130
|
+
const name = pendingRef.current?.name ?? "screenshot.png";
|
|
131
|
+
setPending(null);
|
|
132
|
+
setUploading(true);
|
|
133
|
+
try {
|
|
134
|
+
const src = await uploadRef.current(dataUriToFile(dataUri, name));
|
|
135
|
+
editor?.chain().focus().setImage({ src }).run();
|
|
136
|
+
} catch (e) {
|
|
137
|
+
errorRef.current(e instanceof Error ? e.message : "upload failed");
|
|
138
|
+
} finally {
|
|
139
|
+
setUploading(false);
|
|
140
|
+
}
|
|
141
|
+
},
|
|
142
|
+
[editor],
|
|
143
|
+
);
|
|
144
|
+
useImperativeHandle(ref, () => ({
|
|
145
|
+
/*
|
|
146
|
+
* `cleanHtml` is not belt-and-braces over TipTap's schema. The schema
|
|
147
|
+
* drops an `onclick`, but the Image extension takes any `src` it is
|
|
148
|
+
* given — so a screenshot pasted from a web page arrives with that
|
|
149
|
+
* page's remote image intact, and posting it would store a tracking
|
|
150
|
+
* pixel in the thread aimed at every future reader.
|
|
151
|
+
*/
|
|
152
|
+
value: () => {
|
|
153
|
+
const html = cleanHtml(editor?.getHTML() ?? "", host);
|
|
154
|
+
return htmlIsEmpty(html) ? "" : html;
|
|
155
|
+
},
|
|
156
|
+
clear: () => editor?.commands.clearContent(true),
|
|
157
|
+
focus: () => editor?.commands.focus(),
|
|
158
|
+
}));
|
|
159
|
+
|
|
160
|
+
function toggleLink(): void {
|
|
161
|
+
if (!editor) return;
|
|
162
|
+
if (editor.getAttributes("link").href) {
|
|
163
|
+
editor.chain().focus().unsetLink().run();
|
|
164
|
+
return;
|
|
165
|
+
}
|
|
166
|
+
const href = window.prompt(labels.linkPrompt, "https://");
|
|
167
|
+
if (!href) return;
|
|
168
|
+
// Only ordinary destinations. The outgoing sanitiser would drop a
|
|
169
|
+
// `javascript:` URL anyway, so allowing it here just shows the writer a
|
|
170
|
+
// link that silently vanishes when they send.
|
|
171
|
+
if (!/^https?:\/\//i.test(href)) {
|
|
172
|
+
onError(`${labels.link}: http(s) only`);
|
|
173
|
+
return;
|
|
174
|
+
}
|
|
175
|
+
editor.chain().focus().extendMarkRange("link").setLink({ href }).run();
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
return (
|
|
179
|
+
<div className={`lw-editor${disabled ? " lw-editor--disabled" : ""}`}>
|
|
180
|
+
<div className="lw-editor__tools">
|
|
181
|
+
<ToolbarButton label={labels.bold ?? ""} active={editor?.isActive("bold")} disabled={disabled} onActivate={() => editor?.chain().focus().toggleBold().run()}>
|
|
182
|
+
<BoldIcon />
|
|
183
|
+
</ToolbarButton>
|
|
184
|
+
<ToolbarButton label={labels.italic ?? ""} active={editor?.isActive("italic")} disabled={disabled} onActivate={() => editor?.chain().focus().toggleItalic().run()}>
|
|
185
|
+
<ItalicIcon />
|
|
186
|
+
</ToolbarButton>
|
|
187
|
+
<ToolbarButton label={labels.underline ?? ""} active={editor?.isActive("underline")} disabled={disabled} onActivate={() => editor?.chain().focus().toggleUnderline().run()}>
|
|
188
|
+
<UnderlineIcon />
|
|
189
|
+
</ToolbarButton>
|
|
190
|
+
|
|
191
|
+
<span className="lw-editor__sep" />
|
|
192
|
+
|
|
193
|
+
<ToolbarButton label={labels.bulletList ?? ""} active={editor?.isActive("bulletList")} disabled={disabled} onActivate={() => editor?.chain().focus().toggleBulletList().run()}>
|
|
194
|
+
<BulletListIcon />
|
|
195
|
+
</ToolbarButton>
|
|
196
|
+
<ToolbarButton label={labels.orderedList ?? ""} active={editor?.isActive("orderedList")} disabled={disabled} onActivate={() => editor?.chain().focus().toggleOrderedList().run()}>
|
|
197
|
+
<OrderedListIcon />
|
|
198
|
+
</ToolbarButton>
|
|
199
|
+
<ToolbarButton label={labels.link ?? ""} active={editor?.isActive("link")} disabled={disabled} onActivate={toggleLink}>
|
|
200
|
+
<LinkIcon />
|
|
201
|
+
</ToolbarButton>
|
|
202
|
+
|
|
203
|
+
<span className="lw-editor__sep" />
|
|
204
|
+
|
|
205
|
+
<ToolbarButton label={labels.attachImage ?? ""} disabled={disabled || uploading} onActivate={() => fileInput.current?.click()}>
|
|
206
|
+
<ImageIcon />
|
|
207
|
+
</ToolbarButton>
|
|
208
|
+
</div>
|
|
209
|
+
|
|
210
|
+
<EditorContent editor={editor} className="lw-editor__content" />
|
|
211
|
+
<input
|
|
212
|
+
ref={fileInput}
|
|
213
|
+
type="file"
|
|
214
|
+
accept="image/*"
|
|
215
|
+
hidden
|
|
216
|
+
onChange={(event) => {
|
|
217
|
+
const file = event.target.files?.[0];
|
|
218
|
+
// Cleared so choosing the same file twice still fires `change`.
|
|
219
|
+
event.target.value = "";
|
|
220
|
+
if (file) void addImage(file);
|
|
221
|
+
}}
|
|
222
|
+
/>
|
|
223
|
+
|
|
224
|
+
{pending && (
|
|
225
|
+
<ImageAnnotatorDialog
|
|
226
|
+
src={pending.src}
|
|
227
|
+
filename={pending.name}
|
|
228
|
+
locale={locale}
|
|
229
|
+
onConfirm={(dataUri) => void commitImage(dataUri)}
|
|
230
|
+
onCancel={() => setPending(null)}
|
|
231
|
+
/>
|
|
232
|
+
)}
|
|
233
|
+
</div>
|
|
234
|
+
);
|
|
235
|
+
},
|
|
236
|
+
);
|
|
237
|
+
|
|
238
|
+
function readDataUri(file: File): Promise<string> {
|
|
239
|
+
return new Promise((resolve, reject) => {
|
|
240
|
+
const reader = new FileReader();
|
|
241
|
+
reader.addEventListener("load", () =>
|
|
242
|
+
typeof reader.result === "string" ? resolve(reader.result) : reject(new Error("unreadable file")),
|
|
243
|
+
);
|
|
244
|
+
reader.addEventListener("error", () => reject(new Error("unreadable file")));
|
|
245
|
+
reader.readAsDataURL(file);
|
|
246
|
+
});
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
/**
|
|
250
|
+
* Back to a File, because `uploadImage` takes one.
|
|
251
|
+
*
|
|
252
|
+
* The annotator hands back a data URI — it draws on a canvas, and a canvas
|
|
253
|
+
* exports one. Rebuilding a File keeps the upload contract identical for an
|
|
254
|
+
* annotated image and an untouched one, so the caller never has to care which
|
|
255
|
+
* it got.
|
|
256
|
+
*/
|
|
257
|
+
function dataUriToFile(dataUri: string, name: string): File {
|
|
258
|
+
const [header, encoded] = dataUri.split(",");
|
|
259
|
+
const type = /data:([^;]+)/.exec(header ?? "")?.[1] ?? "image/png";
|
|
260
|
+
const binary = atob(encoded ?? "");
|
|
261
|
+
const bytes = new Uint8Array(binary.length);
|
|
262
|
+
for (let i = 0; i < binary.length; i += 1) bytes[i] = binary.charCodeAt(i);
|
|
263
|
+
return new File([bytes], name, { type });
|
|
264
|
+
}
|