@lightworkai.official/debug-capture 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 +241 -0
- package/dist/budget.d.ts +22 -0
- package/dist/bundle.d.ts +20 -0
- package/dist/capture/actionTrail.d.ts +7 -0
- package/dist/capture/cause.d.ts +3 -0
- package/dist/capture/consoleBuffer.d.ts +4 -0
- package/dist/capture/crashWatcher.d.ts +1 -0
- package/dist/capture/networkBuffer.d.ts +4 -0
- package/dist/capture/redact.d.ts +9 -0
- package/dist/capture/stepCorrelation.d.ts +41 -0
- package/dist/config.d.ts +217 -0
- package/dist/context.d.ts +2 -0
- package/dist/debug-capture.js +116 -0
- package/dist/embed.d.ts +1 -0
- package/dist/index.d.ts +45 -0
- package/dist/index.mjs +129 -0
- package/dist/install.d.ts +5 -0
- package/dist/mytickets/api.d.ts +115 -0
- package/dist/mytickets/format.d.ts +84 -0
- package/dist/mytickets/sanitize.d.ts +66 -0
- package/dist/mytickets/strings.d.ts +74 -0
- package/dist/mytickets/toolbar.d.ts +17 -0
- package/dist/reporter.d.ts +27 -0
- package/dist/screenshot.d.ts +7 -0
- package/dist/signature.d.ts +18 -0
- package/dist/submit.d.ts +8 -0
- package/dist/types.d.ts +175 -0
- package/dist/ui/annotator.d.ts +34 -0
- package/dist/ui/arrow.d.ts +25 -0
- package/dist/ui/element.d.ts +9 -0
- package/dist/ui/strings.d.ts +42 -0
- package/dist/ui/styles.d.ts +13 -0
- package/dist/ui/toast.d.ts +11 -0
- package/package.json +53 -0
- package/src/budget.ts +62 -0
- package/src/bundle.ts +274 -0
- package/src/capture/actionTrail.ts +693 -0
- package/src/capture/cause.ts +49 -0
- package/src/capture/consoleBuffer.ts +80 -0
- package/src/capture/crashWatcher.ts +61 -0
- package/src/capture/networkBuffer.ts +315 -0
- package/src/capture/redact.ts +117 -0
- package/src/capture/stepCorrelation.ts +160 -0
- package/src/config.ts +299 -0
- package/src/context.ts +81 -0
- package/src/embed.ts +35 -0
- package/src/index.ts +109 -0
- package/src/install.ts +59 -0
- package/src/mytickets/api.ts +226 -0
- package/src/mytickets/format.ts +191 -0
- package/src/mytickets/sanitize.ts +221 -0
- package/src/mytickets/strings.ts +217 -0
- package/src/mytickets/toolbar.ts +48 -0
- package/src/reporter.ts +53 -0
- package/src/screenshot.ts +238 -0
- package/src/signature.ts +40 -0
- package/src/styles.css +400 -0
- package/src/submit.ts +143 -0
- package/src/types.ts +169 -0
- package/src/ui/annotator.ts +698 -0
- package/src/ui/arrow.ts +62 -0
- package/src/ui/element.ts +362 -0
- package/src/ui/strings.ts +113 -0
- package/src/ui/styles.ts +96 -0
- package/src/ui/toast.ts +138 -0
package/src/ui/arrow.ts
ADDED
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Where an arrow's shaft stops and its head begins.
|
|
3
|
+
*
|
|
4
|
+
* The shaft used to run all the way to the tip with the head painted over it,
|
|
5
|
+
* which leaves a spur: the shaft has width, the head's base edge is angled, and
|
|
6
|
+
* the corners of a thick line poke out past that edge on one side. It reads as a
|
|
7
|
+
* notch in the arrowhead, and once seen it cannot be unseen.
|
|
8
|
+
*
|
|
9
|
+
* So the shaft stops at the head's base instead. Pure geometry, kept out of the
|
|
10
|
+
* canvas so it can be checked without one.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
export interface Point {
|
|
14
|
+
x: number;
|
|
15
|
+
y: number;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export interface ArrowPath {
|
|
19
|
+
/** Null when the arrow is shorter than its own head — then it is head only. */
|
|
20
|
+
shaft: { from: Point; to: Point } | null;
|
|
21
|
+
/** Tip first, then the two base corners. */
|
|
22
|
+
head: [Point, Point, Point];
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** Half-angle of the head. 30° gives a head that reads as an arrow at any size. */
|
|
26
|
+
const SPREAD = Math.PI / 6;
|
|
27
|
+
/**
|
|
28
|
+
* Stop the shaft a hair inside the base rather than exactly on it. Meeting
|
|
29
|
+
* exactly leaves a light seam where two antialiased edges abut.
|
|
30
|
+
*/
|
|
31
|
+
const OVERLAP = 0.92;
|
|
32
|
+
|
|
33
|
+
export function arrowPath(from: Point, to: Point, headLength: number): ArrowPath {
|
|
34
|
+
const dx = to.x - from.x;
|
|
35
|
+
const dy = to.y - from.y;
|
|
36
|
+
const length = Math.hypot(dx, dy);
|
|
37
|
+
const angle = Math.atan2(dy, dx);
|
|
38
|
+
|
|
39
|
+
const head: [Point, Point, Point] = [
|
|
40
|
+
{ x: to.x, y: to.y },
|
|
41
|
+
{
|
|
42
|
+
x: to.x - headLength * Math.cos(angle - SPREAD),
|
|
43
|
+
y: to.y - headLength * Math.sin(angle - SPREAD),
|
|
44
|
+
},
|
|
45
|
+
{
|
|
46
|
+
x: to.x - headLength * Math.cos(angle + SPREAD),
|
|
47
|
+
y: to.y - headLength * Math.sin(angle + SPREAD),
|
|
48
|
+
},
|
|
49
|
+
];
|
|
50
|
+
|
|
51
|
+
// Distance from tip to the middle of the base edge — where the shaft belongs.
|
|
52
|
+
const inset = headLength * Math.cos(SPREAD) * OVERLAP;
|
|
53
|
+
if (length <= inset) return { shaft: null, head };
|
|
54
|
+
|
|
55
|
+
return {
|
|
56
|
+
shaft: {
|
|
57
|
+
from,
|
|
58
|
+
to: { x: to.x - inset * Math.cos(angle), y: to.y - inset * Math.sin(angle) },
|
|
59
|
+
},
|
|
60
|
+
head,
|
|
61
|
+
};
|
|
62
|
+
}
|
|
@@ -0,0 +1,362 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `<lw-debug-reporter>` — the whole reporter UI, once.
|
|
3
|
+
*
|
|
4
|
+
* Written as a custom element rather than three framework components on
|
|
5
|
+
* purpose. The dialog and its annotator are the bulk of this package; three
|
|
6
|
+
* ports would be three sets of bugs in the same canvas code, drifting apart the
|
|
7
|
+
* first time one of them got a fix. The framework packages wrap THIS.
|
|
8
|
+
*
|
|
9
|
+
* The element renders nothing until something calls `launch()`. Mount it once,
|
|
10
|
+
* app-wide, and forget it.
|
|
11
|
+
*/
|
|
12
|
+
import { buildBundle } from "../bundle";
|
|
13
|
+
import { getConfig } from "../config";
|
|
14
|
+
import { onLaunch } from "../reporter";
|
|
15
|
+
import { captureScreen } from "../screenshot";
|
|
16
|
+
import { submitBundle } from "../submit";
|
|
17
|
+
import type { DebugBundle, DebugReason, ErroredQuery } from "../types";
|
|
18
|
+
import { createAnnotator, type Annotator, type AnnotatorTool } from "./annotator";
|
|
19
|
+
import { strings, type Strings } from "./strings";
|
|
20
|
+
import { showToast } from "./toast";
|
|
21
|
+
import { CSS } from "./styles";
|
|
22
|
+
|
|
23
|
+
export const REPORTER_TAG = "lw-debug-reporter";
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Failed data-layer queries the host wants on the record — react-query, Apollo,
|
|
27
|
+
* whatever it uses. Set by the host; read at capture time. A callback rather
|
|
28
|
+
* than an import, because this package will not take a data library as a
|
|
29
|
+
* dependency to read two fields off it.
|
|
30
|
+
*/
|
|
31
|
+
let erroredQueries: () => ErroredQuery[] = () => [];
|
|
32
|
+
export function setErroredQueriesSource(read: () => ErroredQuery[]): void {
|
|
33
|
+
erroredQueries = read;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const TOOLS: { tool: AnnotatorTool; key: keyof Strings }[] = [
|
|
37
|
+
{ tool: "rect", key: "toolRect" },
|
|
38
|
+
{ tool: "arrow", key: "toolArrow" },
|
|
39
|
+
{ tool: "highlight", key: "toolHighlight" },
|
|
40
|
+
{ tool: "text", key: "toolText" },
|
|
41
|
+
];
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* The element class is built INSIDE this function, never at module scope.
|
|
45
|
+
*
|
|
46
|
+
* `class X extends HTMLElement` evaluates the moment the module loads, and on a
|
|
47
|
+
* server there is no HTMLElement — so a top-level class here crashed any app
|
|
48
|
+
* that rendered on the server. Next, Nuxt and Angular Universal all do: a
|
|
49
|
+
* `'use client'` component is still server-rendered for the first paint, so
|
|
50
|
+
* importing this package anywhere in the tree took the whole route down with
|
|
51
|
+
* "ReferenceError: HTMLElement is not defined".
|
|
52
|
+
*
|
|
53
|
+
* It compiled and built perfectly, which is the trap — a bundler never
|
|
54
|
+
* evaluates the class, so nothing fails until a request hits a real server.
|
|
55
|
+
*/
|
|
56
|
+
function createReporterClass(): CustomElementConstructor {
|
|
57
|
+
return class DebugReporterElement extends HTMLElement {
|
|
58
|
+
private root: ShadowRoot;
|
|
59
|
+
private stop: (() => void) | null = null;
|
|
60
|
+
private annotator: Annotator | null = null;
|
|
61
|
+
private bundle: DebugBundle | null = null;
|
|
62
|
+
private busy = false;
|
|
63
|
+
|
|
64
|
+
constructor() {
|
|
65
|
+
super();
|
|
66
|
+
// attachShadow is allowed here. Setting an attribute is NOT: the custom
|
|
67
|
+
// elements spec requires an element to gain no attributes and no children
|
|
68
|
+
// during construction, and document.createElement enforces it by throwing
|
|
69
|
+
// "NotSupportedError: The result must not have attributes". Marking the
|
|
70
|
+
// element in the constructor cost every consumer a crash at mount.
|
|
71
|
+
this.root = this.attachShadow({ mode: "open" });
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
connectedCallback(): void {
|
|
75
|
+
// Excluded from the DOM screenshot fallback, which would otherwise
|
|
76
|
+
// photograph the dialog asking for the photograph. Set on connect, which
|
|
77
|
+
// is the first point the spec allows an attribute.
|
|
78
|
+
this.dataset.debugReporter = "true";
|
|
79
|
+
this.stop = onLaunch((reason) => void this.open(reason));
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
disconnectedCallback(): void {
|
|
83
|
+
this.stop?.();
|
|
84
|
+
this.stop = null;
|
|
85
|
+
this.close();
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Must run synchronously from the click that triggered it: `getDisplayMedia`
|
|
90
|
+
* is gated on a user gesture, and a single `await` before it loses that
|
|
91
|
+
* gesture and the browser refuses with an error that explains nothing.
|
|
92
|
+
*/
|
|
93
|
+
private async open(reason: DebugReason): Promise<void> {
|
|
94
|
+
if (this.busy) return;
|
|
95
|
+
this.busy = true;
|
|
96
|
+
const t = strings(getConfig().locale);
|
|
97
|
+
|
|
98
|
+
/*
|
|
99
|
+
* Capture BEFORE anything of ours is on screen.
|
|
100
|
+
*
|
|
101
|
+
* This used to paint a "กำลังเก็บภาพหน้าจอ…" panel first, and
|
|
102
|
+
* getDisplayMedia photographs the tab — so every report arrived with our
|
|
103
|
+
* own dialog sitting in the middle of the evidence, covering whatever the
|
|
104
|
+
* user was about to point at. The browser's own share prompt is the
|
|
105
|
+
* feedback here; a second one of ours costs the screenshot.
|
|
106
|
+
*/
|
|
107
|
+
const shot = await captureScreen();
|
|
108
|
+
if (shot.method === "cancelled") {
|
|
109
|
+
// The user dismissed the share picker. That is a decision, not an error:
|
|
110
|
+
// close, and never quietly fall back to a DOM capture they refused.
|
|
111
|
+
this.close();
|
|
112
|
+
return;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
this.bundle = buildBundle({
|
|
116
|
+
reason,
|
|
117
|
+
note: "",
|
|
118
|
+
screenshotDataUrl: shot.dataUrl,
|
|
119
|
+
screenshotMethod: shot.method,
|
|
120
|
+
erroredQueries: safeQueries(),
|
|
121
|
+
});
|
|
122
|
+
this.renderForm(t);
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
private close(): void {
|
|
126
|
+
this.annotator?.destroy();
|
|
127
|
+
this.annotator = null;
|
|
128
|
+
this.bundle = null;
|
|
129
|
+
this.busy = false;
|
|
130
|
+
this.root.replaceChildren();
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
private shell(t: Strings): HTMLElement {
|
|
134
|
+
const style = document.createElement("style");
|
|
135
|
+
style.textContent = CSS;
|
|
136
|
+
const backdrop = document.createElement("div");
|
|
137
|
+
backdrop.className = "backdrop";
|
|
138
|
+
const panel = document.createElement("div");
|
|
139
|
+
panel.className = "panel";
|
|
140
|
+
panel.setAttribute("role", "dialog");
|
|
141
|
+
panel.setAttribute("aria-modal", "true");
|
|
142
|
+
panel.setAttribute("aria-label", t.title);
|
|
143
|
+
backdrop.append(panel);
|
|
144
|
+
this.root.replaceChildren(style, backdrop);
|
|
145
|
+
return panel;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/** A bare panel with one line — used when there is nothing to show but a
|
|
149
|
+
* message. Deliberately never shown DURING capture: see open(). */
|
|
150
|
+
private renderShell(t: Strings, message: string): void {
|
|
151
|
+
const panel = this.shell(t);
|
|
152
|
+
const head = el("div", "head");
|
|
153
|
+
head.append(el("h2", "", t.title));
|
|
154
|
+
panel.append(head, el("p", "status", message));
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
private renderForm(t: Strings): void {
|
|
158
|
+
const bundle = this.bundle;
|
|
159
|
+
if (!bundle) return;
|
|
160
|
+
const panel = this.shell(t);
|
|
161
|
+
|
|
162
|
+
const head = el("div", "head");
|
|
163
|
+
const closeBtn = button("✕", "icon");
|
|
164
|
+
closeBtn.setAttribute("aria-label", t.close);
|
|
165
|
+
closeBtn.addEventListener("click", () => this.close());
|
|
166
|
+
head.append(el("h2", "", t.title), closeBtn);
|
|
167
|
+
|
|
168
|
+
const summary = document.createElement("input");
|
|
169
|
+
summary.type = "text";
|
|
170
|
+
summary.placeholder = t.summaryPlaceholder;
|
|
171
|
+
const summaryLabel = labelFor(t.summaryLabel, t.summaryRequired, true);
|
|
172
|
+
|
|
173
|
+
const detail = document.createElement("textarea");
|
|
174
|
+
detail.placeholder = t.detailPlaceholder;
|
|
175
|
+
|
|
176
|
+
const meta = el("div", "meta");
|
|
177
|
+
meta.innerHTML = "";
|
|
178
|
+
const { context } = bundle;
|
|
179
|
+
for (const [key, value] of [
|
|
180
|
+
[t.reporter, `${context.user.fullName ?? "—"} (${context.user.email ?? "—"})`],
|
|
181
|
+
[t.page, context.route.href],
|
|
182
|
+
// No version row. It read "vundefined · undefined" once version and
|
|
183
|
+
// environment became optional, and it was telling the person filing the
|
|
184
|
+
// report something only the person reading it needs — both still travel
|
|
185
|
+
// with the bundle.
|
|
186
|
+
[
|
|
187
|
+
t.captured,
|
|
188
|
+
`${bundle.network.length} network · ${bundle.console.length} console · ${bundle.actionTrail.length} actions`,
|
|
189
|
+
],
|
|
190
|
+
] as [string, string][]) {
|
|
191
|
+
const row = document.createElement("div");
|
|
192
|
+
// textContent, not innerHTML: every one of these values is attacker-
|
|
193
|
+
// influenced (a URL, a display name, a captured label).
|
|
194
|
+
const b = document.createElement("b");
|
|
195
|
+
b.textContent = `${key}: `;
|
|
196
|
+
row.append(b, document.createTextNode(value));
|
|
197
|
+
meta.append(row);
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
const status = el("p", "status", "");
|
|
201
|
+
const submitBtn = button(t.submit, "primary");
|
|
202
|
+
submitBtn.disabled = true;
|
|
203
|
+
summary.addEventListener("input", () => {
|
|
204
|
+
submitBtn.disabled = summary.value.trim().length === 0;
|
|
205
|
+
});
|
|
206
|
+
|
|
207
|
+
panel.append(head, summaryLabel, summary, labelFor(t.detailLabel), detail, meta);
|
|
208
|
+
if (bundle.screenshotDataUrl) panel.append(...this.screenshotSection(t, bundle.screenshotDataUrl));
|
|
209
|
+
else panel.append(el("p", "status", t.noScreenshot));
|
|
210
|
+
|
|
211
|
+
const foot = el("div", "foot");
|
|
212
|
+
const cancel = button(t.close);
|
|
213
|
+
cancel.addEventListener("click", () => this.close());
|
|
214
|
+
foot.append(cancel, status, el("span", "grow"), submitBtn);
|
|
215
|
+
panel.append(foot);
|
|
216
|
+
|
|
217
|
+
submitBtn.addEventListener("click", async () => {
|
|
218
|
+
submitBtn.disabled = true;
|
|
219
|
+
status.dataset.tone = "";
|
|
220
|
+
status.textContent = t.submitting;
|
|
221
|
+
try {
|
|
222
|
+
const filed = await submitBundle(summary.value.trim(), {
|
|
223
|
+
...bundle,
|
|
224
|
+
note: detail.value,
|
|
225
|
+
// Take the annotated image, not the raw capture — the drawing IS the
|
|
226
|
+
// report for most people.
|
|
227
|
+
screenshotDataUrl: this.annotator?.hasShapes()
|
|
228
|
+
? this.annotator.toDataUrl()
|
|
229
|
+
: bundle.screenshotDataUrl,
|
|
230
|
+
});
|
|
231
|
+
// Cancelable: a host with its own toast system calls preventDefault()
|
|
232
|
+
// and shows its own. Ours is the default, not the only option.
|
|
233
|
+
const announced = this.dispatchEvent(
|
|
234
|
+
new CustomEvent("lw-report-submitted", {
|
|
235
|
+
detail: filed,
|
|
236
|
+
bubbles: true,
|
|
237
|
+
composed: true,
|
|
238
|
+
cancelable: true,
|
|
239
|
+
}),
|
|
240
|
+
);
|
|
241
|
+
// Close first, then announce. The dialog is what the user was looking
|
|
242
|
+
// at, and a confirmation written into a panel that then closes says
|
|
243
|
+
// nothing — which is why filing a ticket appeared to do nothing.
|
|
244
|
+
this.close();
|
|
245
|
+
if (announced) showToast(`${t.submitted} — #${filed.number}`, "success");
|
|
246
|
+
} catch (error) {
|
|
247
|
+
const message = error instanceof Error ? error.message : t.failed;
|
|
248
|
+
status.dataset.tone = "error";
|
|
249
|
+
status.textContent = message;
|
|
250
|
+
// Also a toast: the dialog is long, and on a small screen its footer
|
|
251
|
+
// is below the fold at the moment the button is pressed.
|
|
252
|
+
showToast(`${t.failed} — ${message}`, "error");
|
|
253
|
+
submitBtn.disabled = false;
|
|
254
|
+
}
|
|
255
|
+
});
|
|
256
|
+
|
|
257
|
+
summary.focus();
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
private screenshotSection(t: Strings, dataUrl: string): HTMLElement[] {
|
|
261
|
+
const label = labelFor(t.annotateHint);
|
|
262
|
+
const tools = el("div", "tools");
|
|
263
|
+
const canvas = document.createElement("canvas");
|
|
264
|
+
const wrap = el("div", "shot");
|
|
265
|
+
wrap.append(canvas);
|
|
266
|
+
|
|
267
|
+
const toolButtons: HTMLButtonElement[] = [];
|
|
268
|
+
const setPressed = (active: HTMLButtonElement | null) => {
|
|
269
|
+
for (const b of toolButtons) b.setAttribute("aria-pressed", String(b === active));
|
|
270
|
+
};
|
|
271
|
+
|
|
272
|
+
for (const { tool, key } of TOOLS) {
|
|
273
|
+
const btn = button(t[key]);
|
|
274
|
+
btn.setAttribute("aria-pressed", "false");
|
|
275
|
+
btn.addEventListener("click", () => {
|
|
276
|
+
const pressed = btn.getAttribute("aria-pressed") === "true";
|
|
277
|
+
setPressed(pressed ? null : btn);
|
|
278
|
+
this.annotator?.setTool(pressed ? null : tool);
|
|
279
|
+
});
|
|
280
|
+
toolButtons.push(btn);
|
|
281
|
+
tools.append(btn);
|
|
282
|
+
}
|
|
283
|
+
tools.append(el("span", "sep"));
|
|
284
|
+
const del = button(t.deleteSelected);
|
|
285
|
+
del.addEventListener("click", () => this.annotator?.deleteSelected());
|
|
286
|
+
const clear = button(t.clearAll);
|
|
287
|
+
clear.addEventListener("click", () => {
|
|
288
|
+
this.annotator?.clear();
|
|
289
|
+
setPressed(null);
|
|
290
|
+
});
|
|
291
|
+
tools.append(del, clear);
|
|
292
|
+
|
|
293
|
+
const image = new Image();
|
|
294
|
+
image.onload = () => {
|
|
295
|
+
// A backing store at the image's own size keeps the drawing crisp; CSS
|
|
296
|
+
// scales it down to the dialog.
|
|
297
|
+
canvas.width = image.naturalWidth;
|
|
298
|
+
canvas.height = image.naturalHeight;
|
|
299
|
+
this.annotator = createAnnotator(canvas, image, () => {
|
|
300
|
+
setPressed(null);
|
|
301
|
+
// Enabled only with something selected, so it is obvious that ลบที่เลือก
|
|
302
|
+
// acts on a selection and not on the whole drawing.
|
|
303
|
+
del.disabled = !this.annotator?.hasSelection();
|
|
304
|
+
});
|
|
305
|
+
};
|
|
306
|
+
image.src = dataUrl;
|
|
307
|
+
|
|
308
|
+
return [label, tools, el("p", "status", t.annotateHelp), wrap];
|
|
309
|
+
}
|
|
310
|
+
};
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
function safeQueries(): ErroredQuery[] {
|
|
314
|
+
try {
|
|
315
|
+
return erroredQueries();
|
|
316
|
+
} catch {
|
|
317
|
+
return [];
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
function el(tag: string, className = "", text = ""): HTMLElement {
|
|
322
|
+
const node = document.createElement(tag);
|
|
323
|
+
if (className) node.className = className;
|
|
324
|
+
if (text) node.textContent = text;
|
|
325
|
+
return node;
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
function button(text: string, className = ""): HTMLButtonElement {
|
|
329
|
+
const node = document.createElement("button");
|
|
330
|
+
node.type = "button";
|
|
331
|
+
node.textContent = text;
|
|
332
|
+
if (className) node.className = className;
|
|
333
|
+
return node;
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
function labelFor(text: string, hint = "", required = false): HTMLElement {
|
|
337
|
+
const label = document.createElement("label");
|
|
338
|
+
label.append(document.createTextNode(text));
|
|
339
|
+
if (required) {
|
|
340
|
+
const star = document.createElement("span");
|
|
341
|
+
star.className = "req";
|
|
342
|
+
star.textContent = " *";
|
|
343
|
+
label.append(star);
|
|
344
|
+
}
|
|
345
|
+
if (hint) {
|
|
346
|
+
const span = document.createElement("span");
|
|
347
|
+
span.className = "hint";
|
|
348
|
+
span.textContent = ` ${hint}`;
|
|
349
|
+
label.append(span);
|
|
350
|
+
}
|
|
351
|
+
return label;
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
/**
|
|
355
|
+
* Idempotent, and the ONLY thing that touches HTMLElement. A second app-wide
|
|
356
|
+
* mount must not throw on a taken tag name, and a server must be able to import
|
|
357
|
+
* this module without evaluating a browser class.
|
|
358
|
+
*/
|
|
359
|
+
export function defineReporterElement(): void {
|
|
360
|
+
if (typeof window === "undefined" || customElements.get(REPORTER_TAG)) return;
|
|
361
|
+
customElements.define(REPORTER_TAG, createReporterClass());
|
|
362
|
+
}
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The reporter's own words. Thai by default because that is who files these,
|
|
3
|
+
* with English for hosts that need it.
|
|
4
|
+
*
|
|
5
|
+
* Deliberately a flat map, not a translation framework: this is one dialog, and
|
|
6
|
+
* an i18n dependency in a package every host installs would be a tax on all of
|
|
7
|
+
* them for thirty strings.
|
|
8
|
+
*/
|
|
9
|
+
export interface Strings {
|
|
10
|
+
title: string;
|
|
11
|
+
summaryLabel: string;
|
|
12
|
+
summaryRequired: string;
|
|
13
|
+
summaryPlaceholder: string;
|
|
14
|
+
detailLabel: string;
|
|
15
|
+
detailPlaceholder: string;
|
|
16
|
+
annotateTitle: string;
|
|
17
|
+
annotateHint: string;
|
|
18
|
+
/** Footer of the standalone annotator dialog — NOT the reporter's own submit. */
|
|
19
|
+
annotateApply: string;
|
|
20
|
+
cancel: string;
|
|
21
|
+
toolRect: string;
|
|
22
|
+
toolArrow: string;
|
|
23
|
+
toolHighlight: string;
|
|
24
|
+
toolText: string;
|
|
25
|
+
deleteSelected: string;
|
|
26
|
+
clearAll: string;
|
|
27
|
+
annotateHelp: string;
|
|
28
|
+
capturing: string;
|
|
29
|
+
noScreenshot: string;
|
|
30
|
+
cancelled: string;
|
|
31
|
+
close: string;
|
|
32
|
+
submit: string;
|
|
33
|
+
submitting: string;
|
|
34
|
+
submitted: string;
|
|
35
|
+
crashed: string;
|
|
36
|
+
crashAction: string;
|
|
37
|
+
failed: string;
|
|
38
|
+
captured: string;
|
|
39
|
+
reporter: string;
|
|
40
|
+
page: string;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const th: Strings = {
|
|
44
|
+
title: "รายงานปัญหา",
|
|
45
|
+
summaryLabel: "หัวข้อปัญหา",
|
|
46
|
+
summaryRequired: "(ต้องกรอกก่อนแจ้งปัญหา)",
|
|
47
|
+
summaryPlaceholder: "สรุปปัญหาสั้น ๆ เช่น กดบันทึกร่างแล้ว error",
|
|
48
|
+
detailLabel: "อธิบายปัญหา (ไม่บังคับ)",
|
|
49
|
+
detailPlaceholder: "เกิดอะไรขึ้น? กำลังทำอะไรอยู่ตอนที่พบปัญหา?",
|
|
50
|
+
annotateTitle: "วาดบนรูป",
|
|
51
|
+
annotateHint: "ทำเครื่องหมายจุดที่มีปัญหาก่อนแนบ — วงกรอบ ชี้ลูกศร ไฮไลต์ หรือพิมพ์ข้อความ",
|
|
52
|
+
annotateApply: "ใช้รูปนี้",
|
|
53
|
+
cancel: "ยกเลิก",
|
|
54
|
+
toolRect: "กรอบ",
|
|
55
|
+
toolArrow: "ลูกศร",
|
|
56
|
+
toolHighlight: "ไฮไลต์",
|
|
57
|
+
toolText: "ข้อความ",
|
|
58
|
+
deleteSelected: "ลบที่เลือก",
|
|
59
|
+
clearAll: "ล้างทั้งหมด",
|
|
60
|
+
annotateHelp:
|
|
61
|
+
"เลือกเครื่องมือแล้วลากบนรูป · ไม่ได้เลือกเครื่องมือ = ย้าย/ปรับขนาดสิ่งที่วาดไว้ · กดกากบาทมุมขวาบนของรูปทรงเพื่อลบ",
|
|
62
|
+
capturing: "กำลังเก็บภาพหน้าจอ…",
|
|
63
|
+
noScreenshot: "ไม่มีภาพหน้าจอ — ยังส่งรายงานได้",
|
|
64
|
+
cancelled: "ยกเลิกการเก็บภาพหน้าจอแล้ว",
|
|
65
|
+
close: "ปิด",
|
|
66
|
+
submit: "แจ้งปัญหา",
|
|
67
|
+
submitting: "กำลังส่ง…",
|
|
68
|
+
submitted: "แจ้งปัญหาสำเร็จ",
|
|
69
|
+
crashed: "เกิดข้อผิดพลาดในระบบ",
|
|
70
|
+
crashAction: "แจ้งปัญหา",
|
|
71
|
+
failed: "ส่งรายงานไม่สำเร็จ",
|
|
72
|
+
captured: "เก็บข้อมูลแล้ว",
|
|
73
|
+
reporter: "ผู้แจ้ง",
|
|
74
|
+
page: "หน้า",
|
|
75
|
+
};
|
|
76
|
+
|
|
77
|
+
const en: Strings = {
|
|
78
|
+
title: "Report a problem",
|
|
79
|
+
summaryLabel: "Summary",
|
|
80
|
+
summaryRequired: "(required)",
|
|
81
|
+
summaryPlaceholder: "One line, e.g. saving a draft returns an error",
|
|
82
|
+
detailLabel: "Details (optional)",
|
|
83
|
+
detailPlaceholder: "What happened? What were you doing at the time?",
|
|
84
|
+
annotateTitle: "Draw on the image",
|
|
85
|
+
annotateHint: "Mark what went wrong before attaching — box it, point at it, highlight it, or type on it",
|
|
86
|
+
annotateApply: "Use this image",
|
|
87
|
+
cancel: "Cancel",
|
|
88
|
+
toolRect: "Box",
|
|
89
|
+
toolArrow: "Arrow",
|
|
90
|
+
toolHighlight: "Highlight",
|
|
91
|
+
toolText: "Text",
|
|
92
|
+
deleteSelected: "Delete selected",
|
|
93
|
+
clearAll: "Clear all",
|
|
94
|
+
annotateHelp:
|
|
95
|
+
"Pick a tool and drag on the image · no tool selected = move/resize what you drew · press the ✕ on a shape to delete it",
|
|
96
|
+
capturing: "Capturing the screen…",
|
|
97
|
+
noScreenshot: "No screenshot — you can still send the report",
|
|
98
|
+
cancelled: "Screen capture cancelled",
|
|
99
|
+
close: "Close",
|
|
100
|
+
submit: "Send report",
|
|
101
|
+
submitting: "Sending…",
|
|
102
|
+
submitted: "Report sent",
|
|
103
|
+
crashed: "Something went wrong",
|
|
104
|
+
crashAction: "Report it",
|
|
105
|
+
failed: "Could not send the report",
|
|
106
|
+
captured: "Captured",
|
|
107
|
+
reporter: "Reporter",
|
|
108
|
+
page: "Page",
|
|
109
|
+
};
|
|
110
|
+
|
|
111
|
+
export function strings(locale: "th" | "en" | undefined): Strings {
|
|
112
|
+
return locale === "en" ? en : th;
|
|
113
|
+
}
|
package/src/ui/styles.ts
ADDED
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The dialog's stylesheet, as a string for the shadow root.
|
|
3
|
+
*
|
|
4
|
+
* A shadow root is the whole reason this is a custom element. The widget drops
|
|
5
|
+
* into apps we do not control, next to Tailwind or Bootstrap or Angular
|
|
6
|
+
* Material, and any of them would otherwise reach in and restyle it — or be
|
|
7
|
+
* restyled BY it. Inside a shadow root neither can happen, so there is no reset
|
|
8
|
+
* to fight, no specificity race, and no class-name prefix scheme to maintain.
|
|
9
|
+
*
|
|
10
|
+
* Everything is explicit for the same reason: the host's `body` font and colours
|
|
11
|
+
* do not inherit past the boundary, so stating them is not belt-and-braces.
|
|
12
|
+
*/
|
|
13
|
+
export const CSS = `
|
|
14
|
+
:host { all: initial; }
|
|
15
|
+
* { box-sizing: border-box; }
|
|
16
|
+
|
|
17
|
+
.backdrop {
|
|
18
|
+
position: fixed; inset: 0; z-index: 2147483000;
|
|
19
|
+
display: flex; align-items: center; justify-content: center;
|
|
20
|
+
padding: 24px; background: rgba(15, 23, 42, 0.55);
|
|
21
|
+
font-family: system-ui, -apple-system, "Segoe UI", Roboto, "Noto Sans Thai", sans-serif;
|
|
22
|
+
color: #0f172a;
|
|
23
|
+
}
|
|
24
|
+
.panel {
|
|
25
|
+
display: flex; flex-direction: column; gap: 16px;
|
|
26
|
+
width: min(820px, 100%); max-height: 90vh; overflow-y: auto;
|
|
27
|
+
/* No bottom padding: the footer supplies its own and sits flush, so it can
|
|
28
|
+
stick to the panel's edge without a gap under it. */
|
|
29
|
+
padding: 24px 24px 0; border-radius: 16px; background: #fff;
|
|
30
|
+
box-shadow: 0 24px 60px rgba(15, 23, 42, 0.28);
|
|
31
|
+
}
|
|
32
|
+
.head { display: flex; align-items: flex-start; gap: 12px; }
|
|
33
|
+
h2 { margin: 0; font-size: 20px; font-weight: 700; flex: 1; }
|
|
34
|
+
label { display: block; font-size: 14px; font-weight: 600; margin-bottom: 6px; }
|
|
35
|
+
.req { color: #dc2626; }
|
|
36
|
+
.hint { font-weight: 400; color: #64748b; }
|
|
37
|
+
input[type="text"], textarea {
|
|
38
|
+
width: 100%; padding: 10px 12px; font: inherit; font-size: 14px;
|
|
39
|
+
border: 1px solid #cbd5e1; border-radius: 10px; background: #fff; color: inherit;
|
|
40
|
+
}
|
|
41
|
+
input[type="text"]:focus, textarea:focus {
|
|
42
|
+
outline: none; border-color: #2563eb; box-shadow: 0 0 0 3px rgba(37, 99, 235, 0.18);
|
|
43
|
+
}
|
|
44
|
+
textarea { min-height: 88px; resize: vertical; }
|
|
45
|
+
|
|
46
|
+
.meta {
|
|
47
|
+
padding: 12px 14px; border-radius: 10px; background: #f8fafc;
|
|
48
|
+
font-size: 13px; line-height: 1.7; color: #475569; word-break: break-all;
|
|
49
|
+
}
|
|
50
|
+
.meta b { color: #0f172a; font-weight: 600; }
|
|
51
|
+
|
|
52
|
+
.tools { display: flex; flex-wrap: wrap; gap: 8px; align-items: center; }
|
|
53
|
+
.sep { width: 1px; align-self: stretch; background: #e2e8f0; margin: 0 4px; }
|
|
54
|
+
button {
|
|
55
|
+
display: inline-flex; align-items: center; gap: 6px; cursor: pointer;
|
|
56
|
+
padding: 8px 14px; font: inherit; font-size: 14px; font-weight: 500;
|
|
57
|
+
border: 1px solid #cbd5e1; border-radius: 10px; background: #fff; color: inherit;
|
|
58
|
+
}
|
|
59
|
+
button:hover:not(:disabled) { background: #f1f5f9; }
|
|
60
|
+
button:disabled { opacity: 0.5; cursor: not-allowed; }
|
|
61
|
+
button[aria-pressed="true"] { border-color: #2563eb; background: #eff6ff; color: #1d4ed8; }
|
|
62
|
+
button.primary { background: #2563eb; border-color: #2563eb; color: #fff; }
|
|
63
|
+
button.primary:hover:not(:disabled) { background: #1d4ed8; }
|
|
64
|
+
button.icon { padding: 6px 10px; border-color: transparent; font-size: 18px; line-height: 1; }
|
|
65
|
+
|
|
66
|
+
/* The on-image text editor. Absolutely placed over the canvas and matched to
|
|
67
|
+
the shape it becomes — same weight, same red, same white plate — so what you
|
|
68
|
+
type is what gets drawn, at the position you clicked. Sized in JS, because
|
|
69
|
+
the font size tracks the image's own scale. */
|
|
70
|
+
.text-edit {
|
|
71
|
+
position: absolute; z-index: 2; margin: 0; padding: 2px 4px;
|
|
72
|
+
font-family: system-ui, sans-serif; font-weight: 600; line-height: 1.25;
|
|
73
|
+
color: #e11d48; background: rgba(255, 255, 255, 0.92);
|
|
74
|
+
border: 1px dashed #e11d48; border-radius: 4px; outline: none;
|
|
75
|
+
box-shadow: 0 2px 10px rgba(15, 23, 42, 0.18);
|
|
76
|
+
}
|
|
77
|
+
.text-edit::placeholder { color: rgba(225, 29, 72, 0.45); }
|
|
78
|
+
|
|
79
|
+
canvas { width: 100%; height: auto; display: block; border: 1px solid #e2e8f0; border-radius: 10px; touch-action: none; }
|
|
80
|
+
.shot { position: relative; }
|
|
81
|
+
.status { font-size: 13px; color: #64748b; }
|
|
82
|
+
.status[data-tone="error"] { color: #dc2626; }
|
|
83
|
+
/* Pinned to the bottom of the scrolling panel.
|
|
84
|
+
A screenshot is tall — often taller than the viewport — so แจ้งปัญหา sat
|
|
85
|
+
below the fold and the last step of filing a report was "scroll to the end
|
|
86
|
+
and find the button". Sticky, with its own ground and a rule above it so the
|
|
87
|
+
annotated image scrolls underneath rather than bleeding into it. */
|
|
88
|
+
.foot {
|
|
89
|
+
position: sticky; bottom: 0; z-index: 3;
|
|
90
|
+
display: flex; align-items: center; gap: 12px;
|
|
91
|
+
margin: 4px -24px 0; padding: 12px 24px;
|
|
92
|
+
background: #fff; border-top: 1px solid #e2e8f0;
|
|
93
|
+
border-radius: 0 0 16px 16px;
|
|
94
|
+
}
|
|
95
|
+
.foot .grow { flex: 1; }
|
|
96
|
+
`;
|