@lightworkai.official/debug-capture-react 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 +14 -12
- package/dist/SupportPortalButton.d.ts +20 -0
- package/dist/index.d.ts +3 -9
- package/dist/index.mjs +48 -1560
- package/package.json +2 -2
- package/src/SupportPortalButton.tsx +80 -0
- package/src/index.ts +12 -11
- package/dist/components/ImageAnnotatorDialog.d.ts +0 -11
- package/dist/components/MyTicketsPanel.d.ts +0 -11
- package/dist/components/ReopenPanel.d.ts +0 -7
- package/dist/components/RichReplyEditor.d.ts +0 -29
- package/dist/components/StatusHero.d.ts +0 -8
- package/dist/components/StatusPill.d.ts +0 -14
- package/dist/components/TicketBody.d.ts +0 -5
- package/dist/components/TicketConversation.d.ts +0 -13
- package/dist/components/TicketDetail.d.ts +0 -23
- package/dist/components/TicketScreenshots.d.ts +0 -6
- package/dist/components/TicketTable.d.ts +0 -18
- package/dist/components/TicketTimeline.d.ts +0 -14
- package/dist/components/ToolbarButton.d.ts +0 -18
- package/dist/components/icons.d.ts +0 -35
- package/src/components/ImageAnnotatorDialog.tsx +0 -160
- package/src/components/MyTicketsPanel.tsx +0 -321
- package/src/components/ReopenPanel.tsx +0 -52
- package/src/components/RichReplyEditor.tsx +0 -264
- package/src/components/StatusHero.tsx +0 -92
- package/src/components/StatusPill.tsx +0 -14
- package/src/components/TicketBody.tsx +0 -50
- package/src/components/TicketConversation.tsx +0 -177
- package/src/components/TicketDetail.tsx +0 -110
- package/src/components/TicketScreenshots.tsx +0 -41
- package/src/components/TicketTable.tsx +0 -161
- package/src/components/TicketTimeline.tsx +0 -81
- package/src/components/ToolbarButton.tsx +0 -37
- package/src/components/icons.tsx +0 -79
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@lightworkai.official/debug-capture-react",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.7.0",
|
|
4
4
|
"description": "React bindings for @lightworkai.official/debug-capture",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"module": "./dist/index.mjs",
|
|
@@ -24,7 +24,7 @@
|
|
|
24
24
|
},
|
|
25
25
|
"peerDependencies": {
|
|
26
26
|
"react": ">=17",
|
|
27
|
-
"@lightworkai.official/debug-capture": "^0.
|
|
27
|
+
"@lightworkai.official/debug-capture": "^0.7.0",
|
|
28
28
|
"@tiptap/react": "^3",
|
|
29
29
|
"@tiptap/starter-kit": "^3",
|
|
30
30
|
"@tiptap/extension-image": "^3",
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* "ดูปัญหาที่แจ้ง" — hands the current user to their tickets on the support host,
|
|
3
|
+
* already signed in.
|
|
4
|
+
*
|
|
5
|
+
* Unlike the reporter button this one awaits: the token comes from the host's
|
|
6
|
+
* own server at click time, because it lives about two minutes and is spent on
|
|
7
|
+
* arrival. So it has a pending state, and a failure path — most often "nobody
|
|
8
|
+
* is signed in", which is the host's question to answer and not ours.
|
|
9
|
+
*/
|
|
10
|
+
import { useState, type ButtonHTMLAttributes, type CSSProperties, type ReactNode } from "react";
|
|
11
|
+
import { openSupportPortal, showToast } from "@lightworkai.official/debug-capture";
|
|
12
|
+
|
|
13
|
+
export interface SupportPortalButtonProps extends Omit<ButtonHTMLAttributes<HTMLButtonElement>, "children"> {
|
|
14
|
+
children?: ReactNode | ((state: { pending: boolean }) => ReactNode);
|
|
15
|
+
label?: string;
|
|
16
|
+
/** Open in a new tab instead of navigating away. */
|
|
17
|
+
newTab?: boolean;
|
|
18
|
+
onError?: (err: unknown) => void;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/** Same reasoning as ReportProblemButton: presentable, and `className` replaces it whole. */
|
|
22
|
+
const DEFAULT_STYLE: CSSProperties = {
|
|
23
|
+
display: "inline-flex",
|
|
24
|
+
alignItems: "center",
|
|
25
|
+
justifyContent: "center",
|
|
26
|
+
gap: 6,
|
|
27
|
+
height: 38,
|
|
28
|
+
padding: "0 12px",
|
|
29
|
+
border: "none",
|
|
30
|
+
borderRadius: 8,
|
|
31
|
+
background: "transparent",
|
|
32
|
+
color: "currentColor",
|
|
33
|
+
font: "inherit",
|
|
34
|
+
cursor: "pointer",
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
export function SupportPortalButton({
|
|
38
|
+
children,
|
|
39
|
+
label = "ดูปัญหาที่แจ้ง",
|
|
40
|
+
newTab = false,
|
|
41
|
+
onError,
|
|
42
|
+
className,
|
|
43
|
+
style,
|
|
44
|
+
onClick,
|
|
45
|
+
disabled,
|
|
46
|
+
...rest
|
|
47
|
+
}: SupportPortalButtonProps) {
|
|
48
|
+
const [pending, setPending] = useState(false);
|
|
49
|
+
|
|
50
|
+
return (
|
|
51
|
+
<button
|
|
52
|
+
type="button"
|
|
53
|
+
aria-label={label}
|
|
54
|
+
title={label}
|
|
55
|
+
className={className}
|
|
56
|
+
// Pulled out of the spread deliberately: `{...rest}` after this would put
|
|
57
|
+
// the caller's undefined `style` back over the default and undo it.
|
|
58
|
+
style={className ? style : { ...DEFAULT_STYLE, ...style }}
|
|
59
|
+
disabled={disabled ?? pending}
|
|
60
|
+
{...rest}
|
|
61
|
+
onClick={async (event) => {
|
|
62
|
+
onClick?.(event);
|
|
63
|
+
if (event.defaultPrevented || pending) return;
|
|
64
|
+
setPending(true);
|
|
65
|
+
try {
|
|
66
|
+
await openSupportPortal(newTab ? { target: "_blank" } : {});
|
|
67
|
+
} catch (err) {
|
|
68
|
+
onError?.(err);
|
|
69
|
+
showToast("เปิดรายการปัญหาไม่สำเร็จ", "error");
|
|
70
|
+
} finally {
|
|
71
|
+
// Only reached when navigation did not happen; on success the page is
|
|
72
|
+
// already on its way out.
|
|
73
|
+
setPending(false);
|
|
74
|
+
}
|
|
75
|
+
}}
|
|
76
|
+
>
|
|
77
|
+
{typeof children === "function" ? children({ pending }) : (children ?? label)}
|
|
78
|
+
</button>
|
|
79
|
+
);
|
|
80
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -11,17 +11,18 @@ export type { DebugCaptureInitProps } from "./DebugCaptureInit";
|
|
|
11
11
|
export { ReportProblemButton } from "./ReportProblemButton";
|
|
12
12
|
export type { ReportProblemButtonProps } from "./ReportProblemButton";
|
|
13
13
|
export { DebugErrorBoundary } from "./DebugErrorBoundary";
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
export {
|
|
14
|
+
/*
|
|
15
|
+
* The reporter's own tickets are no longer components this package ships.
|
|
16
|
+
*
|
|
17
|
+
* They were: a list, a thread, a reply editor and an annotator dialog, in three
|
|
18
|
+
* frameworks, over a read API. It worked, and it meant every change to the way
|
|
19
|
+
* a ticket looks had to land in four repositories. The support host already
|
|
20
|
+
* renders that screen, so the library hands people to it instead — one button.
|
|
21
|
+
* See SupportPortalButton, and the core's `openSupportPortal`.
|
|
22
|
+
*/
|
|
23
|
+
export { SupportPortalButton } from "./SupportPortalButton";
|
|
24
|
+
export type { SupportPortalButtonProps } from "./SupportPortalButton";
|
|
24
25
|
|
|
25
26
|
// Re-exported so a host needs one import, not two.
|
|
26
27
|
export { configureDebugCapture, launch, onCrash } from "@lightworkai.official/debug-capture";
|
|
27
|
-
export type { DebugCaptureConfig, DebugReason, ErroredQuery
|
|
28
|
+
export type { DebugCaptureConfig, DebugReason, ErroredQuery } from "@lightworkai.official/debug-capture";
|
|
@@ -1,11 +0,0 @@
|
|
|
1
|
-
export interface ImageAnnotatorDialogProps {
|
|
2
|
-
/** The picked image, as a same-origin data URI. */
|
|
3
|
-
src: string;
|
|
4
|
-
/** Shown in the description, as the app's dialog does. */
|
|
5
|
-
filename?: string;
|
|
6
|
-
locale: "th" | "en" | undefined;
|
|
7
|
-
/** The annotated image, at full resolution. */
|
|
8
|
-
onConfirm: (dataUri: string) => void;
|
|
9
|
-
onCancel: () => void;
|
|
10
|
-
}
|
|
11
|
-
export declare function ImageAnnotatorDialog({ src, filename, locale, onConfirm, onCancel }: ImageAnnotatorDialogProps): import("react").JSX.Element;
|
|
@@ -1,11 +0,0 @@
|
|
|
1
|
-
import { type MyTicketDetail } from "@lightworkai.official/debug-capture";
|
|
2
|
-
export interface MyTicketsPanelProps {
|
|
3
|
-
/** Change it to re-read the list — after filing a report, say. */
|
|
4
|
-
refreshKey?: string | number;
|
|
5
|
-
/** Hide the panel's own heading when the host page already has one. */
|
|
6
|
-
hideTitle?: boolean;
|
|
7
|
-
onReplied?: (ticket: MyTicketDetail) => void;
|
|
8
|
-
onReopened?: (ticket: MyTicketDetail) => void;
|
|
9
|
-
onError?: (message: string) => void;
|
|
10
|
-
}
|
|
11
|
-
export declare function MyTicketsPanel({ refreshKey, hideTitle, onReplied, onReopened, onError }: MyTicketsPanelProps): import("react").JSX.Element;
|
|
@@ -1,7 +0,0 @@
|
|
|
1
|
-
import type { TicketStrings } from "@lightworkai.official/debug-capture";
|
|
2
|
-
export declare function ReopenPanel({ t, locale, busy, onReopen, }: {
|
|
3
|
-
t: TicketStrings;
|
|
4
|
-
locale: "th" | "en";
|
|
5
|
-
busy?: boolean;
|
|
6
|
-
onReopen: (note: string) => void;
|
|
7
|
-
}): import("react").JSX.Element;
|
|
@@ -1,29 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* The toolbar's words.
|
|
3
|
-
*
|
|
4
|
-
* An open map because the toolbar is configurable — a host asking for `strike`
|
|
5
|
-
* or `blockquote` needs a label for it, and enumerating every possible control
|
|
6
|
-
* here would mean editing this type every time one is added.
|
|
7
|
-
*/
|
|
8
|
-
export type EditorLabels = Record<string, string>;
|
|
9
|
-
export interface RichReplyEditorHandle {
|
|
10
|
-
/** Sanitised HTML, or "" when there is nothing worth sending. */
|
|
11
|
-
value: () => string;
|
|
12
|
-
clear: () => void;
|
|
13
|
-
focus: () => void;
|
|
14
|
-
}
|
|
15
|
-
export interface RichReplyEditorProps {
|
|
16
|
-
placeholder: string;
|
|
17
|
-
/** Support host origin — the sanitiser resolves inline image URLs against it. */
|
|
18
|
-
host: string;
|
|
19
|
-
disabled?: boolean;
|
|
20
|
-
labels: EditorLabels;
|
|
21
|
-
/** Store an image and return the URL to reference it by. */
|
|
22
|
-
uploadImage: (file: File) => Promise<string>;
|
|
23
|
-
onError: (message: string) => void;
|
|
24
|
-
/** UI language, for the annotator's own labels. */
|
|
25
|
-
locale?: "th" | "en";
|
|
26
|
-
/** Content to start from — editing an existing message rather than writing one. */
|
|
27
|
-
initial?: string;
|
|
28
|
-
}
|
|
29
|
-
export declare const RichReplyEditor: import("react").ForwardRefExoticComponent<RichReplyEditorProps & import("react").RefAttributes<RichReplyEditorHandle>>;
|
|
@@ -1,8 +0,0 @@
|
|
|
1
|
-
import type { MyTicketDetail, StatusMaps, TicketStrings } from "@lightworkai.official/debug-capture";
|
|
2
|
-
export declare function StatusHero({ ticket, maps, locale, host, }: {
|
|
3
|
-
ticket: MyTicketDetail;
|
|
4
|
-
maps: StatusMaps;
|
|
5
|
-
t: TicketStrings;
|
|
6
|
-
locale: "th" | "en";
|
|
7
|
-
host: string;
|
|
8
|
-
}): import("react").JSX.Element;
|
|
@@ -1,14 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* A status as the realm coloured it.
|
|
3
|
-
*
|
|
4
|
-
* The tone arrives as DATA — a realm adds lanes whenever it likes — so it is an
|
|
5
|
-
* inline style rather than a class. No stylesheet could know the names ahead of
|
|
6
|
-
* time.
|
|
7
|
-
*/
|
|
8
|
-
export declare function StatusPill({ label, tone }: {
|
|
9
|
-
label: string;
|
|
10
|
-
tone: {
|
|
11
|
-
fg: string;
|
|
12
|
-
bg: string;
|
|
13
|
-
};
|
|
14
|
-
}): import("react").JSX.Element;
|
|
@@ -1,13 +0,0 @@
|
|
|
1
|
-
import { type MyTicketDetail, type TicketStrings } from "@lightworkai.official/debug-capture";
|
|
2
|
-
export declare function TicketConversation({ ticket, t, host, timezone, locale, sending, uploadImage, onSend, onEdited, onError, }: {
|
|
3
|
-
ticket: MyTicketDetail;
|
|
4
|
-
t: TicketStrings;
|
|
5
|
-
host: string;
|
|
6
|
-
timezone: string;
|
|
7
|
-
locale: "th" | "en" | undefined;
|
|
8
|
-
sending?: boolean;
|
|
9
|
-
uploadImage: (file: File) => Promise<string>;
|
|
10
|
-
onSend: (body: string) => void;
|
|
11
|
-
onEdited: () => void;
|
|
12
|
-
onError: (message: string) => void;
|
|
13
|
-
}): import("react").JSX.Element;
|
|
@@ -1,23 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* One report: what was filed, where it stands, what happened, and the thread.
|
|
3
|
-
* The order is the original's.
|
|
4
|
-
*/
|
|
5
|
-
import { type MyTicketDetail, type RealmConfig, type StatusEvent, type StatusMaps, type TicketStrings } from "@lightworkai.official/debug-capture";
|
|
6
|
-
export declare function TicketDetail(props: {
|
|
7
|
-
ticket: MyTicketDetail;
|
|
8
|
-
history: StatusEvent[];
|
|
9
|
-
config: RealmConfig;
|
|
10
|
-
maps: StatusMaps;
|
|
11
|
-
t: TicketStrings;
|
|
12
|
-
host: string;
|
|
13
|
-
locale: "th" | "en" | undefined;
|
|
14
|
-
sending?: boolean;
|
|
15
|
-
reopening?: boolean;
|
|
16
|
-
uploadImage: (file: File) => Promise<string>;
|
|
17
|
-
attachmentUrl: (id: string) => Promise<string>;
|
|
18
|
-
onBack: () => void;
|
|
19
|
-
onSend: (body: string) => void;
|
|
20
|
-
onEdited: () => void;
|
|
21
|
-
onReopen: (note: string) => void;
|
|
22
|
-
onError: (message: string) => void;
|
|
23
|
-
}): import("react").JSX.Element;
|
|
@@ -1,6 +0,0 @@
|
|
|
1
|
-
import type { TicketAttachment } from "@lightworkai.official/debug-capture";
|
|
2
|
-
export declare function TicketScreenshots({ images, title, attachmentUrl, }: {
|
|
3
|
-
images: TicketAttachment[];
|
|
4
|
-
title: string;
|
|
5
|
-
attachmentUrl: (id: string) => Promise<string>;
|
|
6
|
-
}): import("react").JSX.Element;
|
|
@@ -1,18 +0,0 @@
|
|
|
1
|
-
import { type MyTicketListItem, type Sort, type SortKey, type StatusMaps, type TicketStrings } from "@lightworkai.official/debug-capture";
|
|
2
|
-
export interface TicketTableProps {
|
|
3
|
-
items: MyTicketListItem[];
|
|
4
|
-
maps: StatusMaps;
|
|
5
|
-
t: TicketStrings;
|
|
6
|
-
slug: string | undefined;
|
|
7
|
-
timezone: string;
|
|
8
|
-
locale: "th" | "en" | undefined;
|
|
9
|
-
keyword: string;
|
|
10
|
-
statusFilter: string;
|
|
11
|
-
sort: Sort;
|
|
12
|
-
page: number;
|
|
13
|
-
onOpen: (id: string) => void;
|
|
14
|
-
onSort: (key: SortKey) => void;
|
|
15
|
-
onPage: (page: number) => void;
|
|
16
|
-
onCount?: (count: number) => void;
|
|
17
|
-
}
|
|
18
|
-
export declare function TicketTable(props: TicketTableProps): import("react").JSX.Element;
|
|
@@ -1,14 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* The lifecycle, chronologically — including a reopen, which is the event a
|
|
3
|
-
* fixed happy-path progress bar hides and the one a reporter most wants to see
|
|
4
|
-
* recorded.
|
|
5
|
-
*/
|
|
6
|
-
import { type StatusEvent, type StatusMaps } from "@lightworkai.official/debug-capture";
|
|
7
|
-
export declare function TicketTimeline({ history, status, createdAt, maps, timezone, locale, }: {
|
|
8
|
-
history: StatusEvent[];
|
|
9
|
-
status: string;
|
|
10
|
-
createdAt: string;
|
|
11
|
-
maps: StatusMaps;
|
|
12
|
-
timezone: string;
|
|
13
|
-
locale: "th" | "en";
|
|
14
|
-
}): import("react").JSX.Element;
|
|
@@ -1,18 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* One editor toolbar button.
|
|
3
|
-
*
|
|
4
|
-
* It fires on `mousedown` with the default prevented, not on `click`, and that
|
|
5
|
-
* is the whole reason it is a component rather than a `<button>` repeated seven
|
|
6
|
-
* times: a toolbar button taking focus collapses the editor's selection, so by
|
|
7
|
-
* the time `click` fires the command has nothing to apply to. Getting that
|
|
8
|
-
* wrong is invisible until someone selects a word and presses B.
|
|
9
|
-
*/
|
|
10
|
-
import type { ReactNode } from "react";
|
|
11
|
-
export interface ToolbarButtonProps {
|
|
12
|
-
label: string;
|
|
13
|
-
active?: boolean;
|
|
14
|
-
disabled?: boolean;
|
|
15
|
-
onActivate: () => void;
|
|
16
|
-
children: ReactNode;
|
|
17
|
-
}
|
|
18
|
-
export declare function ToolbarButton({ label, active, disabled, onActivate, children }: ToolbarButtonProps): import("react").JSX.Element;
|
|
@@ -1,35 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* The panel's icons, drawn rather than imported.
|
|
3
|
-
*
|
|
4
|
-
* The original uses lucide, and a package that pulled an icon set in so a reply
|
|
5
|
-
* box could show a B would cost every consumer that dependency. These are the
|
|
6
|
-
* same lucide paths at the same 24-unit scale.
|
|
7
|
-
*/
|
|
8
|
-
import type { SVGProps } from "react";
|
|
9
|
-
export declare const BoldIcon: (props: SVGProps<SVGSVGElement>) => import("react").JSX.Element;
|
|
10
|
-
export declare const ItalicIcon: (props: SVGProps<SVGSVGElement>) => import("react").JSX.Element;
|
|
11
|
-
export declare const UnderlineIcon: (props: SVGProps<SVGSVGElement>) => import("react").JSX.Element;
|
|
12
|
-
export declare const BulletListIcon: (props: SVGProps<SVGSVGElement>) => import("react").JSX.Element;
|
|
13
|
-
export declare const OrderedListIcon: (props: SVGProps<SVGSVGElement>) => import("react").JSX.Element;
|
|
14
|
-
export declare const LinkIcon: (props: SVGProps<SVGSVGElement>) => import("react").JSX.Element;
|
|
15
|
-
export declare const ImageIcon: (props: SVGProps<SVGSVGElement>) => import("react").JSX.Element;
|
|
16
|
-
export declare const SendIcon: (props: SVGProps<SVGSVGElement>) => import("react").JSX.Element;
|
|
17
|
-
export declare const SearchIcon: (props: SVGProps<SVGSVGElement>) => import("react").JSX.Element;
|
|
18
|
-
export declare const ArrowLeftIcon: (props: SVGProps<SVGSVGElement>) => import("react").JSX.Element;
|
|
19
|
-
export declare const CheckIcon: (props: SVGProps<SVGSVGElement>) => import("react").JSX.Element;
|
|
20
|
-
export declare const MessageIcon: (props: SVGProps<SVGSVGElement>) => import("react").JSX.Element;
|
|
21
|
-
export declare const RotateIcon: (props: SVGProps<SVGSVGElement>) => import("react").JSX.Element;
|
|
22
|
-
export declare const AlertIcon: (props: SVGProps<SVGSVGElement>) => import("react").JSX.Element;
|
|
23
|
-
export declare const InboxIcon: (props: SVGProps<SVGSVGElement>) => import("react").JSX.Element;
|
|
24
|
-
export declare const ClockIcon: (props: SVGProps<SVGSVGElement>) => import("react").JSX.Element;
|
|
25
|
-
export declare const CheckCircleIcon: (props: SVGProps<SVGSVGElement>) => import("react").JSX.Element;
|
|
26
|
-
export declare const BanIcon: (props: SVGProps<SVGSVGElement>) => import("react").JSX.Element;
|
|
27
|
-
export declare const WrenchIcon: (props: SVGProps<SVGSVGElement>) => import("react").JSX.Element;
|
|
28
|
-
export declare const SquareIcon: (props: SVGProps<SVGSVGElement>) => import("react").JSX.Element;
|
|
29
|
-
export declare const ArrowUpRightIcon: (props: SVGProps<SVGSVGElement>) => import("react").JSX.Element;
|
|
30
|
-
export declare const HighlighterIcon: (props: SVGProps<SVGSVGElement>) => import("react").JSX.Element;
|
|
31
|
-
export declare const TypeIcon: (props: SVGProps<SVGSVGElement>) => import("react").JSX.Element;
|
|
32
|
-
export declare const TrashIcon: (props: SVGProps<SVGSVGElement>) => import("react").JSX.Element;
|
|
33
|
-
export declare const PencilIcon: (props: SVGProps<SVGSVGElement>) => import("react").JSX.Element;
|
|
34
|
-
export declare const CheckSmallIcon: (props: SVGProps<SVGSVGElement>) => import("react").JSX.Element;
|
|
35
|
-
export declare const XSmallIcon: (props: SVGProps<SVGSVGElement>) => import("react").JSX.Element;
|
|
@@ -1,160 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Draw on an image before it is sent.
|
|
3
|
-
*
|
|
4
|
-
* The original opens this the moment you pick a picture, and the reason is in
|
|
5
|
-
* its own comment: annotating BEFORE upload keeps the canvas same-origin, so
|
|
6
|
-
* `toDataURL` is not tainted by a cross-origin presigned URL. Pointing at the
|
|
7
|
-
* problem is also most of what a screenshot is for.
|
|
8
|
-
*
|
|
9
|
-
* The drawing itself is `createAnnotator` from the core — one canvas engine,
|
|
10
|
-
* shared by all three framework packages, because there is no declarative way
|
|
11
|
-
* to draw on a canvas. What is here is only the chrome.
|
|
12
|
-
*/
|
|
13
|
-
import { useEffect, useRef, useState } from "react";
|
|
14
|
-
import {
|
|
15
|
-
createAnnotator,
|
|
16
|
-
reporterStrings,
|
|
17
|
-
type Annotator,
|
|
18
|
-
type AnnotatorTool,
|
|
19
|
-
type ReporterStrings,
|
|
20
|
-
} from "@lightworkai.official/debug-capture";
|
|
21
|
-
import { ArrowUpRightIcon, HighlighterIcon, SquareIcon, TrashIcon, TypeIcon } from "./icons";
|
|
22
|
-
|
|
23
|
-
const TOOLS: { tool: AnnotatorTool; key: keyof ReporterStrings; Icon: typeof SquareIcon }[] = [
|
|
24
|
-
{ tool: "rect", key: "toolRect", Icon: SquareIcon },
|
|
25
|
-
{ tool: "arrow", key: "toolArrow", Icon: ArrowUpRightIcon },
|
|
26
|
-
{ tool: "highlight", key: "toolHighlight", Icon: HighlighterIcon },
|
|
27
|
-
{ tool: "text", key: "toolText", Icon: TypeIcon },
|
|
28
|
-
];
|
|
29
|
-
|
|
30
|
-
export interface ImageAnnotatorDialogProps {
|
|
31
|
-
/** The picked image, as a same-origin data URI. */
|
|
32
|
-
src: string;
|
|
33
|
-
/** Shown in the description, as the app's dialog does. */
|
|
34
|
-
filename?: string;
|
|
35
|
-
locale: "th" | "en" | undefined;
|
|
36
|
-
/** The annotated image, at full resolution. */
|
|
37
|
-
onConfirm: (dataUri: string) => void;
|
|
38
|
-
onCancel: () => void;
|
|
39
|
-
}
|
|
40
|
-
|
|
41
|
-
export function ImageAnnotatorDialog({ src, filename, locale, onConfirm, onCancel }: ImageAnnotatorDialogProps) {
|
|
42
|
-
const t = reporterStrings(locale);
|
|
43
|
-
const canvas = useRef<HTMLCanvasElement>(null);
|
|
44
|
-
// A ref, not state: the annotator holds a canvas and its own mutable state,
|
|
45
|
-
// and none of that should drive a render.
|
|
46
|
-
const annotator = useRef<Annotator | null>(null);
|
|
47
|
-
const [active, setActive] = useState<AnnotatorTool | null>(null);
|
|
48
|
-
const [loading, setLoading] = useState(true);
|
|
49
|
-
|
|
50
|
-
useEffect(() => {
|
|
51
|
-
const node = canvas.current;
|
|
52
|
-
if (!node) return;
|
|
53
|
-
const image = new Image();
|
|
54
|
-
image.addEventListener("load", () => {
|
|
55
|
-
// A backing store at the image's own size, BEFORE the annotator draws
|
|
56
|
-
// into it. Without this the canvas keeps its default 300x150, the image
|
|
57
|
-
// is squashed into that, and CSS then stretches the result — blurry, and
|
|
58
|
-
// the wrong shape.
|
|
59
|
-
node.width = image.naturalWidth;
|
|
60
|
-
node.height = image.naturalHeight;
|
|
61
|
-
annotator.current = createAnnotator(node, image, () => {
|
|
62
|
-
// The toolbar mirrors the engine, so it has to hear about a tool being
|
|
63
|
-
// CONSUMED as well as a shape being added.
|
|
64
|
-
setActive(null);
|
|
65
|
-
});
|
|
66
|
-
setLoading(false);
|
|
67
|
-
});
|
|
68
|
-
image.src = src;
|
|
69
|
-
return () => {
|
|
70
|
-
annotator.current?.destroy();
|
|
71
|
-
annotator.current = null;
|
|
72
|
-
};
|
|
73
|
-
}, [src]);
|
|
74
|
-
|
|
75
|
-
function pick(tool: AnnotatorTool): void {
|
|
76
|
-
const next = active === tool ? null : tool;
|
|
77
|
-
setActive(next);
|
|
78
|
-
annotator.current?.setTool(next);
|
|
79
|
-
}
|
|
80
|
-
|
|
81
|
-
return (
|
|
82
|
-
<div
|
|
83
|
-
className="lw-annotator"
|
|
84
|
-
role="dialog"
|
|
85
|
-
aria-modal="true"
|
|
86
|
-
onClick={(event) => {
|
|
87
|
-
if (event.target === event.currentTarget) onCancel();
|
|
88
|
-
}}
|
|
89
|
-
>
|
|
90
|
-
{/* Header, toolbar, canvas, help, footer — the same shape and the same
|
|
91
|
-
words as the app's own dialog, so the two are one thing in two places
|
|
92
|
-
rather than two things that resemble each other. */}
|
|
93
|
-
<div className="lw-annotator__panel">
|
|
94
|
-
<div className="lw-annotator__head">
|
|
95
|
-
<div className="lw-grow">
|
|
96
|
-
<h2 className="lw-annotator__title">{t.annotateTitle}</h2>
|
|
97
|
-
<p className="lw-annotator__desc">
|
|
98
|
-
{t.annotateHint}
|
|
99
|
-
{filename ? ` · ${filename}` : ""}
|
|
100
|
-
</p>
|
|
101
|
-
</div>
|
|
102
|
-
<button type="button" className="lw-annotator__close" aria-label={t.cancel} onClick={onCancel}>
|
|
103
|
-
✕
|
|
104
|
-
</button>
|
|
105
|
-
</div>
|
|
106
|
-
|
|
107
|
-
<div className="lw-annotator__tools">
|
|
108
|
-
{TOOLS.map(({ tool, key, Icon }) => (
|
|
109
|
-
<button
|
|
110
|
-
key={tool}
|
|
111
|
-
type="button"
|
|
112
|
-
className="lw-atool"
|
|
113
|
-
aria-pressed={active === tool}
|
|
114
|
-
disabled={loading}
|
|
115
|
-
onClick={() => pick(tool)}
|
|
116
|
-
>
|
|
117
|
-
<Icon /> {t[key] as string}
|
|
118
|
-
</button>
|
|
119
|
-
))}
|
|
120
|
-
<span className="lw-grow" />
|
|
121
|
-
{/* Right-aligned and on its own, like the app's. Deleting ONE shape is
|
|
122
|
-
the ✕ on the shape, which is what the help line below explains. */}
|
|
123
|
-
<button
|
|
124
|
-
type="button"
|
|
125
|
-
className="lw-atool lw-atool--plain"
|
|
126
|
-
disabled={loading}
|
|
127
|
-
onClick={() => {
|
|
128
|
-
annotator.current?.clear();
|
|
129
|
-
setActive(null);
|
|
130
|
-
}}
|
|
131
|
-
>
|
|
132
|
-
<TrashIcon /> {t.clearAll}
|
|
133
|
-
</button>
|
|
134
|
-
</div>
|
|
135
|
-
|
|
136
|
-
<div className="lw-annotator__canvas">
|
|
137
|
-
<div className="lw-annotator__canvasbox">
|
|
138
|
-
<canvas ref={canvas} />
|
|
139
|
-
</div>
|
|
140
|
-
</div>
|
|
141
|
-
|
|
142
|
-
<p className="lw-annotator__hint">{t.annotateHelp}</p>
|
|
143
|
-
|
|
144
|
-
<div className="lw-annotator__actions">
|
|
145
|
-
<span className="lw-grow" />
|
|
146
|
-
<button type="button" className="lw-btn" onClick={onCancel}>{t.cancel}</button>
|
|
147
|
-
<button
|
|
148
|
-
type="button"
|
|
149
|
-
className="lw-send"
|
|
150
|
-
disabled={loading}
|
|
151
|
-
// Full resolution, never the on-screen scale.
|
|
152
|
-
onClick={() => onConfirm(annotator.current?.toDataUrl() ?? src)}
|
|
153
|
-
>
|
|
154
|
-
{t.annotateApply}
|
|
155
|
-
</button>
|
|
156
|
-
</div>
|
|
157
|
-
</div>
|
|
158
|
-
</div>
|
|
159
|
-
);
|
|
160
|
-
}
|