@lightworkai.official/debug-capture-angular 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.
@@ -0,0 +1,287 @@
1
+ /**
2
+ * The reporter's reply box — TipTap, the same editor the original gives them.
3
+ *
4
+ * TipTap ships no Angular binding, so this drives `@tiptap/core` directly:
5
+ * create the editor onto an element ref, destroy it on teardown, and ask it for
6
+ * state when Angular renders. That is a dozen lines, and it is still far less
7
+ * than re-implementing selection handling, list nesting and paste sanitising by
8
+ * hand — which is what this was before, and what it should never have been.
9
+ */
10
+ import {
11
+ ChangeDetectorRef, Component, ElementRef, EventEmitter, Input, Output, ViewChild,
12
+ type AfterViewInit, type OnDestroy,
13
+ } from "@angular/core";
14
+ import { NgFor, NgIf } from "@angular/common";
15
+ import { Editor } from "@tiptap/core";
16
+ import StarterKit from "@tiptap/starter-kit";
17
+ import Image from "@tiptap/extension-image";
18
+ import Placeholder from "@tiptap/extension-placeholder";
19
+ import { cleanHtml, htmlIsEmpty } from "@lightworkai.official/debug-capture";
20
+ import { IconComponent } from "./icon.component";
21
+ import { ImageAnnotatorDialogComponent } from "./image-annotator-dialog.component";
22
+ import { ICONS } from "./icons";
23
+
24
+ /**
25
+ * The toolbar's words.
26
+ *
27
+ * An open map because the toolbar is configurable — a host asking for `strike`
28
+ * needs a label for it, and enumerating every control here would mean editing
29
+ * this type every time one is added.
30
+ */
31
+ export type EditorLabels = Record<string, string>;
32
+
33
+ interface Tool {
34
+ key: keyof EditorLabels;
35
+ icon: string;
36
+ mark: string;
37
+ run: () => void;
38
+ separatorBefore?: boolean;
39
+ }
40
+
41
+ @Component({
42
+ selector: "lw-rich-reply-editor",
43
+ standalone: true,
44
+ imports: [NgFor, NgIf, IconComponent, ImageAnnotatorDialogComponent],
45
+ template: `
46
+ <div class="lw-editor" [class.lw-editor--disabled]="disabled">
47
+ <div class="lw-editor__tools">
48
+ <ng-container *ngFor="let tool of tools">
49
+ <span *ngIf="tool.separatorBefore" class="lw-editor__sep"></span>
50
+ <button
51
+ type="button"
52
+ class="lw-tool"
53
+ [class.lw-tool--on]="isActive(tool.mark)"
54
+ [title]="labels[tool.key]"
55
+ [attr.aria-label]="labels[tool.key]"
56
+ [attr.aria-pressed]="isActive(tool.mark)"
57
+ [disabled]="disabled"
58
+ (mousedown)="press($event, tool)"
59
+ >
60
+ <lw-icon [svg]="tool.icon" />
61
+ </button>
62
+ </ng-container>
63
+ </div>
64
+ <div #mount class="lw-editor__content"></div>
65
+ <input #file type="file" accept="image/*" hidden (change)="onFile($event)" />
66
+
67
+ <lw-image-annotator-dialog
68
+ *ngIf="pending"
69
+ [src]="pending"
70
+ [filename]="pendingName"
71
+ [locale]="locale"
72
+ (confirmed)="commitImage($event)"
73
+ (cancelled)="pending = null"
74
+ />
75
+ </div>
76
+ `,
77
+ })
78
+ export class RichReplyEditorComponent implements AfterViewInit, OnDestroy {
79
+ @Input({ required: true }) placeholder = "";
80
+ /** Support host origin — the sanitiser resolves inline image URLs against it. */
81
+ @Input({ required: true }) host = "";
82
+ @Input({ required: true }) labels!: EditorLabels;
83
+ @Input({ required: true }) uploadImage!: (file: File) => Promise<string>;
84
+ @Input() set disabledState(value: boolean) {
85
+ this.disabled = value;
86
+ this.editor?.setEditable(!value);
87
+ }
88
+ /** UI language, for the annotator's own labels. */
89
+ @Input() locale?: "th" | "en";
90
+ /** Content to start from — editing an existing message rather than writing one. */
91
+ @Input() initial?: string;
92
+ @Output() errored = new EventEmitter<string>();
93
+
94
+ /**
95
+ * The image waiting to be drawn on, as a same-origin data URI. Non-null means
96
+ * the annotator is open.
97
+ *
98
+ * Every route in — the picker, a paste, a drop — lands here first. The
99
+ * original does the same: pointing at the problem is most of what a
100
+ * screenshot is for, and annotating BEFORE upload keeps the canvas
101
+ * same-origin, so `toDataURL` is not tainted by a presigned URL.
102
+ */
103
+ protected pending: string | null = null;
104
+ protected pendingName = "screenshot.png";
105
+
106
+ protected disabled = false;
107
+ private editor: Editor | null = null;
108
+
109
+ @ViewChild("mount", { static: true }) private mount!: ElementRef<HTMLElement>;
110
+ @ViewChild("file", { static: true }) private file!: ElementRef<HTMLInputElement>;
111
+
112
+ protected readonly tools: Tool[] = [
113
+ { key: "bold", icon: ICONS.bold, mark: "bold", run: () => this.editor?.chain().focus().toggleBold().run() },
114
+ { key: "italic", icon: ICONS.italic, mark: "italic", run: () => this.editor?.chain().focus().toggleItalic().run() },
115
+ { key: "underline", icon: ICONS.underline, mark: "underline", run: () => this.editor?.chain().focus().toggleUnderline().run() },
116
+ { key: "bulletList", icon: ICONS.bulletList, mark: "bulletList", separatorBefore: true, run: () => this.editor?.chain().focus().toggleBulletList().run() },
117
+ { key: "orderedList", icon: ICONS.orderedList, mark: "orderedList", run: () => this.editor?.chain().focus().toggleOrderedList().run() },
118
+ { key: "link", icon: ICONS.link, mark: "link", run: () => this.toggleLink() },
119
+ { key: "attachImage", icon: ICONS.image, mark: "", separatorBefore: true, run: () => this.file.nativeElement.click() },
120
+ ];
121
+
122
+ constructor(private readonly cdr: ChangeDetectorRef) {}
123
+
124
+ ngAfterViewInit(): void {
125
+ this.editor = new Editor({
126
+ element: this.mount.nativeElement,
127
+ // StarterKit v3 already carries Underline and Link, so the toolbar the
128
+ // original defines needs only two extensions beyond it.
129
+ content: this.initial ?? "",
130
+ extensions: [
131
+ StarterKit,
132
+ Image.configure({ inline: false, allowBase64: false }),
133
+ Placeholder.configure({ placeholder: this.placeholder }),
134
+ ],
135
+ editorProps: {
136
+ attributes: { class: "lw-editor-body" },
137
+ /**
138
+ * A pasted screenshot is the normal case — Cmd+Shift+4, Cmd+V — and
139
+ * without this the only route is save-to-disk, find-the-file, pick-it.
140
+ */
141
+ handlePaste: (_view, event) => {
142
+ const image = Array.from(event.clipboardData?.files ?? []).find((f) => f.type.startsWith("image/"));
143
+ if (!image) return false;
144
+ event.preventDefault();
145
+ void this.addImage(image);
146
+ return true;
147
+ },
148
+ handleDrop: (_view, event) => {
149
+ const dropped = event as DragEvent;
150
+ const image = Array.from(dropped.dataTransfer?.files ?? []).find((f) => f.type.startsWith("image/"));
151
+ if (!image) return false;
152
+ dropped.preventDefault();
153
+ void this.addImage(image);
154
+ return true;
155
+ },
156
+ },
157
+ // TipTap mutates outside Angular's knowledge, so the toolbar's pressed
158
+ // state has to be asked for again whenever the selection moves.
159
+ onSelectionUpdate: () => this.cdr.markForCheck(),
160
+ onUpdate: () => this.cdr.markForCheck(),
161
+ });
162
+ this.editor.setEditable(!this.disabled);
163
+ }
164
+
165
+ ngOnDestroy(): void {
166
+ this.editor?.destroy();
167
+ this.editor = null;
168
+ }
169
+
170
+ /**
171
+ * Fired on `mousedown` with the default prevented, not on `click`: a toolbar
172
+ * button taking focus collapses the editor's selection, so by the time
173
+ * `click` fires the command has nothing to apply to.
174
+ */
175
+ protected press(event: MouseEvent, tool: Tool): void {
176
+ event.preventDefault();
177
+ if (this.disabled) return;
178
+ tool.run();
179
+ }
180
+
181
+ protected isActive(mark: string): boolean {
182
+ return Boolean(mark) && (this.editor?.isActive(mark) ?? false);
183
+ }
184
+
185
+ protected onFile(event: Event): void {
186
+ const input = event.target as HTMLInputElement;
187
+ const chosen = input.files?.[0];
188
+ // Cleared so choosing the same file twice still fires `change`.
189
+ input.value = "";
190
+ if (chosen) void this.addImage(chosen);
191
+ }
192
+
193
+ /**
194
+ * Open the annotator on a picked image rather than inserting it straight away.
195
+ *
196
+ * One dialog, everywhere. There was briefly a config hook letting a host swap
197
+ * in its own — which solved the symptom (two dialogs that looked different)
198
+ * by blessing the cause (two dialogs).
199
+ */
200
+ private async addImage(file: File): Promise<void> {
201
+ if (this.pending) return;
202
+ try {
203
+ this.pendingName = file.name || "screenshot.png";
204
+ this.pending = await readDataUri(file);
205
+ this.cdr.markForCheck();
206
+ } catch (e) {
207
+ this.errored.emit(e instanceof Error ? e.message : "unreadable file");
208
+ }
209
+ }
210
+
211
+ /** The annotated image comes back as a data URI; upload THAT and insert it. */
212
+ protected async commitImage(dataUri: string): Promise<void> {
213
+ this.pending = null;
214
+ try {
215
+ const src = await this.uploadImage(dataUriToFile(dataUri, this.pendingName));
216
+ this.editor?.chain().focus().setImage({ src }).run();
217
+ } catch (e) {
218
+ this.errored.emit(e instanceof Error ? e.message : "upload failed");
219
+ } finally {
220
+ this.cdr.markForCheck();
221
+ }
222
+ }
223
+
224
+ private toggleLink(): void {
225
+ if (!this.editor) return;
226
+ if (this.editor.getAttributes("link")["href"]) {
227
+ this.editor.chain().focus().unsetLink().run();
228
+ return;
229
+ }
230
+ const href = window.prompt(this.labels.linkPrompt, "https://");
231
+ if (!href) return;
232
+ // Only ordinary destinations. The outgoing sanitiser would drop a
233
+ // `javascript:` URL anyway, so allowing it here just shows the writer a
234
+ // link that silently vanishes when they send.
235
+ if (!/^https?:\/\//i.test(href)) {
236
+ this.errored.emit(`${this.labels.link}: http(s) only`);
237
+ return;
238
+ }
239
+ this.editor.chain().focus().extendMarkRange("link").setLink({ href }).run();
240
+ }
241
+
242
+ /**
243
+ * Sanitised HTML, or "" when there is nothing worth sending.
244
+ *
245
+ * `cleanHtml` is not belt-and-braces over TipTap's schema. The schema drops
246
+ * an `onclick`, but the Image extension takes any `src` it is given — so a
247
+ * screenshot pasted from a web page arrives with that page's remote image
248
+ * intact, and posting it would store a tracking pixel in the thread aimed at
249
+ * every future reader.
250
+ */
251
+ value(): string {
252
+ const html = cleanHtml(this.editor?.getHTML() ?? "", this.host);
253
+ return htmlIsEmpty(html) ? "" : html;
254
+ }
255
+
256
+ clear(): void {
257
+ this.editor?.commands.clearContent(true);
258
+ }
259
+ }
260
+
261
+ function readDataUri(file: File): Promise<string> {
262
+ return new Promise((resolve, reject) => {
263
+ const reader = new FileReader();
264
+ reader.addEventListener("load", () =>
265
+ typeof reader.result === "string" ? resolve(reader.result) : reject(new Error("unreadable file")),
266
+ );
267
+ reader.addEventListener("error", () => reject(new Error("unreadable file")));
268
+ reader.readAsDataURL(file);
269
+ });
270
+ }
271
+
272
+ /**
273
+ * Back to a File, because `uploadImage` takes one.
274
+ *
275
+ * The annotator hands back a data URI — it draws on a canvas, and a canvas
276
+ * exports one. Rebuilding a File keeps the upload contract identical for an
277
+ * annotated image and an untouched one, so the caller never has to care which
278
+ * it got.
279
+ */
280
+ function dataUriToFile(dataUri: string, name: string): File {
281
+ const [header, encoded] = dataUri.split(",");
282
+ const type = /data:([^;]+)/.exec(header ?? "")?.[1] ?? "image/png";
283
+ const binary = atob(encoded ?? "");
284
+ const bytes = new Uint8Array(binary.length);
285
+ for (let i = 0; i < binary.length; i += 1) bytes[i] = binary.charCodeAt(i);
286
+ return new File([bytes], name, { type });
287
+ }
@@ -0,0 +1,53 @@
1
+ import { Component, ElementRef, Input, ViewChild, type OnChanges } from "@angular/core";
2
+ import { NgIf } from "@angular/common";
3
+ import { renderBody } from "@lightworkai.official/debug-capture";
4
+
5
+ /**
6
+ * A message or description body, rendered safely.
7
+ *
8
+ * Deliberately NOT `[innerHTML]`, even though Angular sanitises that: bodies
9
+ * are HTML written by other people, and the core's walker is the one place all
10
+ * three framework packages agree on what an inline image may point at. Angular
11
+ * would keep a remote `<img>`, which is how a ticket becomes a tracking pixel
12
+ * aimed at whoever opens it.
13
+ */
14
+ @Component({
15
+ selector: "lw-ticket-body",
16
+ standalone: true,
17
+ imports: [NgIf],
18
+ template: `
19
+ <!-- Delegated rather than bound per image: this is sanitised HTML we inject,
20
+ not elements we built, so there is nothing to attach to. -->
21
+ <div #target class="lw-body" (click)="onClick($event)"></div>
22
+
23
+ <div *ngIf="zoomed" class="lw-zoom" role="dialog" aria-modal="true" (click)="zoomed = null">
24
+ <button type="button" class="lw-zoom__close" aria-label="close">✕</button>
25
+ <img [src]="zoomed" alt="" />
26
+ </div>
27
+ `,
28
+ })
29
+ export class TicketBodyComponent implements OnChanges {
30
+ @Input({ required: true }) html = "";
31
+ @Input({ required: true }) host = "";
32
+ @ViewChild("target", { static: true }) private target!: ElementRef<HTMLElement>;
33
+
34
+ /**
35
+ * The image being viewed full size, if any.
36
+ *
37
+ * Bodies are capped to a thumbnail so one screenshot cannot push a whole
38
+ * conversation off the screen — which is only reasonable if the full size is
39
+ * a click away.
40
+ */
41
+ protected zoomed: string | null = null;
42
+
43
+ protected onClick(event: MouseEvent): void {
44
+ const node = event.target as HTMLElement | null;
45
+ if (!node || node.tagName !== "IMG") return;
46
+ const src = node.getAttribute("src");
47
+ if (src) this.zoomed = src;
48
+ }
49
+
50
+ ngOnChanges(): void {
51
+ if (this.target) renderBody(this.target.nativeElement, this.html, this.host);
52
+ }
53
+ }