@rose42/t3-feedback 0.1.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.
Files changed (106) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +516 -0
  3. package/dist/bridge/attachments.d.ts +27 -0
  4. package/dist/bridge/attachments.js +109 -0
  5. package/dist/bridge/format.d.ts +70 -0
  6. package/dist/bridge/format.js +358 -0
  7. package/dist/bridge/http.d.ts +31 -0
  8. package/dist/bridge/http.js +169 -0
  9. package/dist/bridge/index.d.ts +13 -0
  10. package/dist/bridge/index.js +12 -0
  11. package/dist/bridge/parse.d.ts +16 -0
  12. package/dist/bridge/parse.js +82 -0
  13. package/dist/bridge/paths.d.ts +10 -0
  14. package/dist/bridge/paths.js +10 -0
  15. package/dist/bridge/probeThreads.d.ts +13 -0
  16. package/dist/bridge/probeThreads.js +66 -0
  17. package/dist/bridge/readSource.d.ts +8 -0
  18. package/dist/bridge/readSource.js +64 -0
  19. package/dist/bridge/sendFeedback.d.ts +99 -0
  20. package/dist/bridge/sendFeedback.js +417 -0
  21. package/dist/cli/bin.d.ts +2 -0
  22. package/dist/cli/bin.js +40 -0
  23. package/dist/cli/constants.d.ts +4 -0
  24. package/dist/cli/constants.js +4 -0
  25. package/dist/cli/detect.d.ts +17 -0
  26. package/dist/cli/detect.js +118 -0
  27. package/dist/cli/index.d.ts +4 -0
  28. package/dist/cli/index.js +4 -0
  29. package/dist/cli/init.d.ts +18 -0
  30. package/dist/cli/init.js +165 -0
  31. package/dist/cli/pm.d.ts +12 -0
  32. package/dist/cli/pm.js +49 -0
  33. package/dist/cli/prompt.d.ts +2 -0
  34. package/dist/cli/prompt.js +19 -0
  35. package/dist/cli/wire/next.d.ts +8 -0
  36. package/dist/cli/wire/next.js +71 -0
  37. package/dist/cli/wire/node.d.ts +3 -0
  38. package/dist/cli/wire/node.js +76 -0
  39. package/dist/cli/wire/vite.d.ts +25 -0
  40. package/dist/cli/wire/vite.js +71 -0
  41. package/dist/client/activeThreads.d.ts +23 -0
  42. package/dist/client/activeThreads.js +89 -0
  43. package/dist/client/cropScreenshot.d.ts +39 -0
  44. package/dist/client/cropScreenshot.js +212 -0
  45. package/dist/client/customPlugins.d.ts +7 -0
  46. package/dist/client/customPlugins.js +38 -0
  47. package/dist/client/draft.d.ts +33 -0
  48. package/dist/client/draft.js +222 -0
  49. package/dist/client/index.d.ts +24 -0
  50. package/dist/client/index.js +20 -0
  51. package/dist/client/mount.d.ts +210 -0
  52. package/dist/client/mount.js +2408 -0
  53. package/dist/client/openInT3.d.ts +28 -0
  54. package/dist/client/openInT3.js +81 -0
  55. package/dist/client/readClipboardImage.d.ts +2 -0
  56. package/dist/client/readClipboardImage.js +2 -0
  57. package/dist/client/reportType.d.ts +43 -0
  58. package/dist/client/reportType.js +110 -0
  59. package/dist/client/selectBurstFrames.d.ts +21 -0
  60. package/dist/client/selectBurstFrames.js +101 -0
  61. package/dist/client/sessionThread.d.ts +26 -0
  62. package/dist/client/sessionThread.js +93 -0
  63. package/dist/client/threadPicker.d.ts +20 -0
  64. package/dist/client/threadPicker.js +51 -0
  65. package/dist/client/transport.d.ts +11 -0
  66. package/dist/client/transport.js +35 -0
  67. package/dist/client/ui/commentPanelPlugin.d.ts +46 -0
  68. package/dist/client/ui/commentPanelPlugin.js +126 -0
  69. package/dist/client/ui/defaultPlugins.d.ts +19 -0
  70. package/dist/client/ui/defaultPlugins.js +48 -0
  71. package/dist/client/ui/elementTargetChip.d.ts +64 -0
  72. package/dist/client/ui/elementTargetChip.js +185 -0
  73. package/dist/client/ui/elementTargetPanelPlugin.d.ts +43 -0
  74. package/dist/client/ui/elementTargetPanelPlugin.js +167 -0
  75. package/dist/client/ui/elementTargetPanelSession.d.ts +49 -0
  76. package/dist/client/ui/elementTargetPanelSession.js +279 -0
  77. package/dist/client/ui/elementTargetUi.d.ts +36 -0
  78. package/dist/client/ui/elementTargetUi.js +105 -0
  79. package/dist/client/ui/host.d.ts +112 -0
  80. package/dist/client/ui/host.js +132 -0
  81. package/dist/client/ui/html.d.ts +22 -0
  82. package/dist/client/ui/html.js +26 -0
  83. package/dist/client/ui/labeledPluginPreview.d.ts +8 -0
  84. package/dist/client/ui/labeledPluginPreview.js +71 -0
  85. package/dist/client/ui/resolveContributions.d.ts +13 -0
  86. package/dist/client/ui/resolveContributions.js +20 -0
  87. package/dist/client/ui/screenshotPanelPlugin.d.ts +39 -0
  88. package/dist/client/ui/screenshotPanelPlugin.js +212 -0
  89. package/dist/client/ui/screenshotPanelSession.d.ts +84 -0
  90. package/dist/client/ui/screenshotPanelSession.js +445 -0
  91. package/dist/client/ui/screenshotUi.d.ts +31 -0
  92. package/dist/client/ui/screenshotUi.js +130 -0
  93. package/dist/client/ui/shortcuts.d.ts +32 -0
  94. package/dist/client/ui/shortcuts.js +40 -0
  95. package/dist/client/ui/slots.d.ts +73 -0
  96. package/dist/client/ui/slots.js +112 -0
  97. package/dist/controls.d.ts +8 -0
  98. package/dist/controls.js +16 -0
  99. package/dist/index.d.ts +3 -0
  100. package/dist/index.js +2 -0
  101. package/dist/plugin.d.ts +9 -0
  102. package/dist/plugin.js +186 -0
  103. package/dist/types.d.ts +464 -0
  104. package/dist/types.js +283 -0
  105. package/docs/devcontainer.md +160 -0
  106. package/package.json +107 -0
@@ -0,0 +1,84 @@
1
+ import { type ElementTarget } from "@rose42/feedback-capture";
2
+ import type { T3FeedbackScreenshotConfig } from "../../types.js";
3
+ import type { FeedbackDraftGalleryItem } from "../draft.js";
4
+ import type { T3FeedbackBusyReason } from "./host.js";
5
+ import type { ScreenshotUiApi } from "./screenshotUi.js";
6
+ export type ScreenshotAfterPickMode = "crop" | "capture" | "none";
7
+ export type ScreenshotPanelSessionCopy = {
8
+ screenshotSectionLabel: string;
9
+ captureLabel: string;
10
+ recordLabel: string;
11
+ changeRegionLabel: string;
12
+ removeImageLabel: string;
13
+ discardScreenshotLabel: string;
14
+ insertClipboardTitle: string;
15
+ galleryFullStatus: string;
16
+ galleryFullTooltip: string;
17
+ capturingStatus: string;
18
+ captureFailedStatus: string;
19
+ screenshotAttachedStatus: string;
20
+ pastedAttachedStatus: string;
21
+ autoCapturingStatus: string;
22
+ autoCroppedStatus: string;
23
+ autoCapturedStatus: string;
24
+ cropHint: string;
25
+ cropHintSeeded: string;
26
+ cropLabel: string;
27
+ useFullLabel: string;
28
+ cancelLabel: string;
29
+ recordingStatus: string;
30
+ stopRecordLabel: string;
31
+ recordFailedStatus: string;
32
+ pickBurstHint: string;
33
+ pickBurstConfirmLabel: string;
34
+ burstAttachedStatus: string;
35
+ };
36
+ export type BoundScreenshotPanelSession = {
37
+ dispose: () => void;
38
+ syncGallery: (options?: {
39
+ focusCropIndex?: number;
40
+ }) => void;
41
+ /** Refresh capture/record disabled state (+ optional sibling tools). */
42
+ syncAttachmentTools: () => void;
43
+ attachScreenshot: (sourceDataUrl: string, attachedStatus: string, mode?: "capture" | "direct") => Promise<void>;
44
+ hideClipboardOffer: () => void;
45
+ remainingSlots: () => number;
46
+ silentCropToElement: (sourceDataUrl: string, paddingPx: number) => Promise<string>;
47
+ runShotFlow: () => Promise<void>;
48
+ runRecordFlow: () => Promise<void>;
49
+ clearGallery: () => void;
50
+ };
51
+ /**
52
+ * Wire screenshot UI contributions: gallery, crop, paste, clipboard offer, capture/record.
53
+ * Owned by the screenshot panel plugin; binds from ui.mount via host context.
54
+ */
55
+ export declare function bindScreenshotPanelSession(options: {
56
+ form: HTMLFormElement;
57
+ root: HTMLElement;
58
+ backdrop: HTMLElement;
59
+ ui: ScreenshotUiApi;
60
+ copy: ScreenshotPanelSessionCopy;
61
+ screenshotConfig: T3FeedbackScreenshotConfig;
62
+ captureTitle: string;
63
+ recordTitle: string;
64
+ getGallery: () => FeedbackDraftGalleryItem[];
65
+ setGallery: (next: FeedbackDraftGalleryItem[]) => void;
66
+ setStatus: (message: string, kind?: "" | "ok" | "err") => void;
67
+ persistDraft: () => void;
68
+ focusComment: () => void;
69
+ isOpen: () => boolean;
70
+ /** Current element target (draft extra or live pick) for crop seeding. */
71
+ getElementTarget: () => ElementTarget | undefined;
72
+ registerBusy: (reason: T3FeedbackBusyReason) => () => void;
73
+ /** e.g. footer pick visibility when gallery tools sync. */
74
+ syncSiblingTools?: () => void;
75
+ startCapture?: boolean;
76
+ startRecord?: boolean;
77
+ /**
78
+ * When set (and mode is not `"none"`), register an after-pick handler that
79
+ * captures / crops via the host registry.
80
+ */
81
+ afterPick?: ScreenshotAfterPickMode;
82
+ afterPickCropPaddingPx?: number;
83
+ registerAfterPick?: (handler: (target: ElementTarget) => void | Promise<void>) => () => void;
84
+ }): BoundScreenshotPanelSession;
@@ -0,0 +1,445 @@
1
+ import { attachImagePasteHandler, captureFrameBurst, captureScreenshot, cropDataUrl, elementBoxToCropRect, } from "@rose42/feedback-capture";
2
+ import { selectScreenshotRegion } from "../cropScreenshot.js";
3
+ import { readClipboardImageDataUrl } from "../readClipboardImage.js";
4
+ import { selectBurstFrames } from "../selectBurstFrames.js";
5
+ import { CLOSE_ICON_SVG, CROP_ICON_SVG, escapeHtml } from "./html.js";
6
+ function loadImageNaturalSize(dataUrl) {
7
+ return new Promise((resolve, reject) => {
8
+ const img = new Image();
9
+ img.onload = () => resolve({ width: img.naturalWidth, height: img.naturalHeight });
10
+ img.onerror = () => reject(new Error("Failed to load image dimensions"));
11
+ img.src = dataUrl;
12
+ });
13
+ }
14
+ /**
15
+ * Wire screenshot UI contributions: gallery, crop, paste, clipboard offer, capture/record.
16
+ * Owned by the screenshot panel plugin; binds from ui.mount via host context.
17
+ */
18
+ export function bindScreenshotPanelSession(options) {
19
+ const { form, root, backdrop, ui, copy, screenshotConfig, captureTitle, recordTitle, getGallery, setGallery, setStatus, persistDraft, focusComment, isOpen, getElementTarget, registerBusy, syncSiblingTools, } = options;
20
+ const { clipboardOffer, clipboardPreview, previewWrap, galleryEl, screenshotCountLabel, footerShot, footerRecord, } = ui;
21
+ const maxImages = screenshotConfig.maxImages;
22
+ let clipboardDataUrl;
23
+ const remainingSlots = () => Math.max(0, maxImages - getGallery().length);
24
+ const clipboardAlreadyAttached = () => {
25
+ if (!clipboardDataUrl)
26
+ return false;
27
+ const gallery = getGallery();
28
+ return gallery.some((g) => g.dataUrl === clipboardDataUrl || g.sourceDataUrl === clipboardDataUrl);
29
+ };
30
+ const canOfferClipboard = () => !!clipboardDataUrl && remainingSlots() > 0 && !clipboardAlreadyAttached();
31
+ const syncClipboardOffer = () => {
32
+ if (canOfferClipboard() && getGallery().length === 0 && clipboardDataUrl) {
33
+ clipboardPreview.src = clipboardDataUrl;
34
+ clipboardOffer.hidden = false;
35
+ }
36
+ else {
37
+ clipboardOffer.hidden = true;
38
+ }
39
+ };
40
+ const syncAttachmentTools = () => {
41
+ const gallery = getGallery();
42
+ const full = remainingSlots() === 0;
43
+ const hideFooterCapture = gallery.length > 0;
44
+ if (footerShot)
45
+ footerShot.hidden = hideFooterCapture;
46
+ if (footerRecord)
47
+ footerRecord.hidden = hideFooterCapture;
48
+ const shotTitle = full ? copy.galleryFullTooltip : captureTitle;
49
+ const recTitle = full ? copy.galleryFullTooltip : recordTitle;
50
+ const shotAria = full ? copy.galleryFullTooltip : copy.captureLabel;
51
+ const recAria = full ? copy.galleryFullTooltip : copy.recordLabel;
52
+ form.querySelectorAll('[data-act="shot"]').forEach((btn) => {
53
+ btn.disabled = full;
54
+ btn.title = shotTitle;
55
+ btn.setAttribute("aria-label", shotAria);
56
+ });
57
+ form.querySelectorAll('[data-act="record"]').forEach((btn) => {
58
+ btn.disabled = full;
59
+ btn.title = recTitle;
60
+ btn.setAttribute("aria-label", recAria);
61
+ });
62
+ syncSiblingTools?.();
63
+ };
64
+ const syncGallery = (syncOptions) => {
65
+ const gallery = getGallery();
66
+ galleryEl.replaceChildren();
67
+ previewWrap.hidden = gallery.length === 0;
68
+ screenshotCountLabel.textContent = `${copy.screenshotSectionLabel} ${gallery.length}/${maxImages}`;
69
+ const croppable = screenshotConfig.crop === "ui";
70
+ gallery.forEach((item, index) => {
71
+ const thumb = document.createElement("div");
72
+ thumb.className = croppable ? "t3f-thumb t3f-thumb-croppable" : "t3f-thumb";
73
+ const img = document.createElement("img");
74
+ img.src = item.dataUrl;
75
+ img.alt = `Screenshot ${index + 1}`;
76
+ const tools = document.createElement("div");
77
+ tools.className = "t3f-thumb-tools";
78
+ let cropBtn;
79
+ if (croppable) {
80
+ cropBtn = document.createElement("button");
81
+ cropBtn.type = "button";
82
+ cropBtn.setAttribute("aria-label", copy.changeRegionLabel);
83
+ cropBtn.title = copy.changeRegionLabel;
84
+ cropBtn.innerHTML = CROP_ICON_SVG;
85
+ cropBtn.addEventListener("click", (event) => {
86
+ event.stopPropagation();
87
+ void cropGalleryItem(index);
88
+ });
89
+ tools.append(cropBtn);
90
+ }
91
+ const removeBtn = document.createElement("button");
92
+ removeBtn.type = "button";
93
+ removeBtn.className = "t3f-thumb-remove";
94
+ removeBtn.setAttribute("aria-label", copy.removeImageLabel);
95
+ removeBtn.title = copy.removeImageLabel;
96
+ removeBtn.innerHTML = CLOSE_ICON_SVG;
97
+ removeBtn.addEventListener("click", (event) => {
98
+ event.stopPropagation();
99
+ setGallery(getGallery().filter((_, i) => i !== index));
100
+ syncGallery();
101
+ persistDraft();
102
+ setStatus("");
103
+ });
104
+ tools.append(removeBtn);
105
+ if (croppable) {
106
+ thumb.addEventListener("click", () => {
107
+ void cropGalleryItem(index);
108
+ });
109
+ }
110
+ thumb.append(img, tools);
111
+ galleryEl.append(thumb);
112
+ if (syncOptions?.focusCropIndex === index && cropBtn) {
113
+ cropBtn.focus();
114
+ }
115
+ });
116
+ if (canOfferClipboard() && gallery.length > 0 && clipboardDataUrl) {
117
+ const offerThumb = document.createElement("button");
118
+ offerThumb.type = "button";
119
+ offerThumb.className = "t3f-thumb t3f-thumb-clipboard";
120
+ offerThumb.setAttribute("aria-label", copy.insertClipboardTitle);
121
+ offerThumb.title = copy.insertClipboardTitle;
122
+ offerThumb.dataset.act = "clipboard-insert";
123
+ const offerImg = document.createElement("img");
124
+ offerImg.src = clipboardDataUrl;
125
+ offerImg.alt = "";
126
+ offerThumb.append(offerImg);
127
+ galleryEl.append(offerThumb);
128
+ }
129
+ syncClipboardOffer();
130
+ syncAttachmentTools();
131
+ };
132
+ const hideClipboardOffer = () => {
133
+ clipboardDataUrl = undefined;
134
+ clipboardPreview.removeAttribute("src");
135
+ clipboardOffer.hidden = true;
136
+ };
137
+ const clearGallery = () => {
138
+ setGallery([]);
139
+ syncGallery();
140
+ persistDraft();
141
+ };
142
+ const seedRectFor = async (sourceDataUrl, paddingPx) => {
143
+ const target = getElementTarget();
144
+ if (!target)
145
+ return undefined;
146
+ try {
147
+ const natural = await loadImageNaturalSize(sourceDataUrl);
148
+ return elementBoxToCropRect(target.boundingBox, target.viewport, natural, {
149
+ paddingPx,
150
+ });
151
+ }
152
+ catch {
153
+ return undefined;
154
+ }
155
+ };
156
+ const silentCropToElement = async (sourceDataUrl, paddingPx) => {
157
+ const rect = await seedRectFor(sourceDataUrl, paddingPx);
158
+ if (!rect)
159
+ return sourceDataUrl;
160
+ try {
161
+ return await cropDataUrl(sourceDataUrl, rect);
162
+ }
163
+ catch {
164
+ return sourceDataUrl;
165
+ }
166
+ };
167
+ const cropGalleryItem = async (index) => {
168
+ const gallery = getGallery();
169
+ const item = gallery[index];
170
+ if (!item)
171
+ return;
172
+ const releaseCropping = registerBusy("cropping");
173
+ try {
174
+ const initialRect = await seedRectFor(item.sourceDataUrl, screenshotConfig.cropPaddingPx);
175
+ const result = await selectScreenshotRegion({
176
+ parent: root,
177
+ dataUrl: item.sourceDataUrl,
178
+ allowDiscard: true,
179
+ ...(initialRect ? { initialRect } : {}),
180
+ copy: {
181
+ hint: initialRect ? copy.cropHintSeeded : copy.cropHint,
182
+ cropLabel: copy.cropLabel,
183
+ useFullLabel: copy.useFullLabel,
184
+ cancelLabel: copy.cancelLabel,
185
+ discardLabel: copy.discardScreenshotLabel,
186
+ },
187
+ });
188
+ if (!isOpen())
189
+ return;
190
+ if (result.action === "cancel")
191
+ return;
192
+ if (result.action === "discard") {
193
+ setGallery(getGallery().filter((_, i) => i !== index));
194
+ syncGallery();
195
+ persistDraft();
196
+ setStatus("");
197
+ focusComment();
198
+ return;
199
+ }
200
+ setGallery(getGallery().map((g, i) => i === index ? { dataUrl: result.dataUrl, sourceDataUrl: item.sourceDataUrl } : g));
201
+ syncGallery();
202
+ persistDraft();
203
+ setStatus(copy.screenshotAttachedStatus);
204
+ focusComment();
205
+ }
206
+ finally {
207
+ releaseCropping();
208
+ }
209
+ };
210
+ const attachScreenshot = async (sourceDataUrl, attachedStatus, mode = "capture") => {
211
+ if (remainingSlots() === 0) {
212
+ setStatus(copy.galleryFullStatus, "err");
213
+ return;
214
+ }
215
+ let dataUrl = sourceDataUrl;
216
+ if (mode === "capture" && screenshotConfig.crop === "auto") {
217
+ dataUrl = getElementTarget()
218
+ ? await silentCropToElement(sourceDataUrl, screenshotConfig.cropPaddingPx)
219
+ : sourceDataUrl;
220
+ }
221
+ if (remainingSlots() === 0) {
222
+ setStatus(copy.galleryFullStatus, "err");
223
+ return;
224
+ }
225
+ setGallery([...getGallery(), { dataUrl, sourceDataUrl }]);
226
+ const focusCropIndex = mode === "capture" && screenshotConfig.crop === "ui" ? getGallery().length - 1 : undefined;
227
+ syncGallery(focusCropIndex !== undefined ? { focusCropIndex } : undefined);
228
+ persistDraft();
229
+ setStatus(attachedStatus);
230
+ if (focusCropIndex === undefined) {
231
+ focusComment();
232
+ }
233
+ };
234
+ const runShotFlow = async () => {
235
+ if (remainingSlots() === 0) {
236
+ setStatus(copy.galleryFullStatus, "err");
237
+ return;
238
+ }
239
+ setStatus(copy.capturingStatus);
240
+ const dataUrl = await captureScreenshot({ hideElements: [root] });
241
+ if (!dataUrl) {
242
+ setStatus(copy.captureFailedStatus, "err");
243
+ return;
244
+ }
245
+ hideClipboardOffer();
246
+ await attachScreenshot(dataUrl, copy.screenshotAttachedStatus);
247
+ };
248
+ const runRecordFlow = async () => {
249
+ const slots = remainingSlots();
250
+ if (slots === 0) {
251
+ setStatus(copy.galleryFullStatus, "err");
252
+ return;
253
+ }
254
+ const releaseRecording = registerBusy("recording");
255
+ backdrop.hidden = true;
256
+ setStatus(copy.recordingStatus);
257
+ hideClipboardOffer();
258
+ const overlay = document.createElement("div");
259
+ overlay.className = "t3f-record-overlay";
260
+ overlay.innerHTML = `
261
+ <div class="t3f-record-card">
262
+ <div class="t3f-burst-hint">${escapeHtml(copy.recordingStatus)}</div>
263
+ <div class="t3f-burst-actions">
264
+ <button type="button" class="t3f-btn primary" data-act="stop-record">${escapeHtml(copy.stopRecordLabel)}</button>
265
+ </div>
266
+ </div>
267
+ `;
268
+ root.appendChild(overlay);
269
+ let stopFn;
270
+ overlay.querySelector('[data-act="stop-record"]')?.addEventListener("click", () => {
271
+ stopFn?.();
272
+ });
273
+ try {
274
+ const frames = await captureFrameBurst({
275
+ hideElements: [root, overlay],
276
+ onRecording: ({ stop }) => {
277
+ stopFn = stop;
278
+ },
279
+ });
280
+ overlay.remove();
281
+ if (!isOpen())
282
+ return;
283
+ backdrop.hidden = false;
284
+ if (!frames || frames.length === 0) {
285
+ setStatus(copy.recordFailedStatus, "err");
286
+ return;
287
+ }
288
+ const picked = await selectBurstFrames({
289
+ parent: root,
290
+ frames,
291
+ maxSelect: slots,
292
+ copy: {
293
+ hint: copy.pickBurstHint,
294
+ confirmLabel: copy.pickBurstConfirmLabel,
295
+ cancelLabel: copy.cancelLabel,
296
+ },
297
+ });
298
+ if (!isOpen())
299
+ return;
300
+ if (!picked || picked.length === 0) {
301
+ setStatus("");
302
+ return;
303
+ }
304
+ for (const dataUrl of picked) {
305
+ if (remainingSlots() === 0)
306
+ break;
307
+ setGallery([...getGallery(), { dataUrl, sourceDataUrl: dataUrl }]);
308
+ }
309
+ syncGallery();
310
+ persistDraft();
311
+ setStatus(copy.burstAttachedStatus);
312
+ focusComment();
313
+ }
314
+ finally {
315
+ overlay.remove();
316
+ releaseRecording();
317
+ if (isOpen())
318
+ backdrop.hidden = false;
319
+ }
320
+ };
321
+ const insertClipboardOffer = async () => {
322
+ if (!clipboardDataUrl)
323
+ return;
324
+ const dataUrl = clipboardDataUrl;
325
+ hideClipboardOffer();
326
+ await attachScreenshot(dataUrl, copy.pastedAttachedStatus, "direct");
327
+ };
328
+ form.querySelector('[data-act="clear-shot"]')?.addEventListener("click", () => {
329
+ clearGallery();
330
+ setStatus("");
331
+ });
332
+ form.querySelectorAll('[data-act="shot"]').forEach((el) => {
333
+ el.addEventListener("click", () => {
334
+ void runShotFlow();
335
+ });
336
+ });
337
+ form.querySelectorAll('[data-act="record"]').forEach((el) => {
338
+ el.addEventListener("click", () => {
339
+ void runRecordFlow();
340
+ });
341
+ });
342
+ form.querySelector('[data-act="clipboard-dismiss"]')?.addEventListener("click", () => {
343
+ hideClipboardOffer();
344
+ });
345
+ form.querySelectorAll('[data-act="clipboard-insert"]').forEach((el) => {
346
+ el.addEventListener("click", () => {
347
+ void insertClipboardOffer();
348
+ });
349
+ });
350
+ galleryEl.addEventListener("click", (event) => {
351
+ const hit = event.target?.closest?.("button.t3f-thumb-clipboard");
352
+ if (!hit || !galleryEl.contains(hit))
353
+ return;
354
+ void insertClipboardOffer();
355
+ });
356
+ const detachPaste = attachImagePasteHandler(form, (dataUrl) => {
357
+ hideClipboardOffer();
358
+ void attachScreenshot(dataUrl, copy.pastedAttachedStatus, "direct");
359
+ });
360
+ void readClipboardImageDataUrl().then((dataUrl) => {
361
+ if (!isOpen() || !dataUrl || !clipboardOffer.isConnected)
362
+ return;
363
+ clipboardDataUrl = dataUrl;
364
+ if (getGallery().length > 0) {
365
+ syncGallery();
366
+ }
367
+ else {
368
+ syncClipboardOffer();
369
+ }
370
+ });
371
+ if (options.startCapture) {
372
+ queueMicrotask(() => {
373
+ void runShotFlow();
374
+ });
375
+ }
376
+ else if (options.startRecord) {
377
+ queueMicrotask(() => {
378
+ void runRecordFlow();
379
+ });
380
+ }
381
+ if (getGallery().length > 0) {
382
+ syncGallery();
383
+ }
384
+ else {
385
+ syncAttachmentTools();
386
+ }
387
+ let detachAfterPick;
388
+ const afterPickMode = options.afterPick ?? "none";
389
+ if (afterPickMode !== "none" && options.registerAfterPick) {
390
+ const cropPaddingPx = options.afterPickCropPaddingPx ?? screenshotConfig.cropPaddingPx;
391
+ detachAfterPick = options.registerAfterPick(async (pickedTarget) => {
392
+ if (!isOpen())
393
+ return;
394
+ if (remainingSlots() === 0) {
395
+ setStatus(copy.galleryFullStatus, "err");
396
+ return;
397
+ }
398
+ setStatus(copy.autoCapturingStatus);
399
+ const dataUrl = await captureScreenshot({ hideElements: [root] });
400
+ if (!dataUrl) {
401
+ setStatus(copy.captureFailedStatus, "err");
402
+ focusComment();
403
+ return;
404
+ }
405
+ hideClipboardOffer();
406
+ if (afterPickMode === "capture") {
407
+ await attachScreenshot(dataUrl, copy.autoCapturedStatus, "direct");
408
+ return;
409
+ }
410
+ let cropped = dataUrl;
411
+ try {
412
+ const natural = await loadImageNaturalSize(dataUrl);
413
+ const rect = elementBoxToCropRect(pickedTarget.boundingBox, pickedTarget.viewport, natural, { paddingPx: cropPaddingPx });
414
+ if (rect)
415
+ cropped = await cropDataUrl(dataUrl, rect);
416
+ }
417
+ catch {
418
+ cropped = dataUrl;
419
+ }
420
+ await attachScreenshot(cropped, copy.autoCroppedStatus, "direct");
421
+ const gallery = getGallery();
422
+ const last = gallery[gallery.length - 1];
423
+ if (last) {
424
+ setGallery(gallery.map((g, i) => i === gallery.length - 1 ? { dataUrl: g.dataUrl, sourceDataUrl: dataUrl } : g));
425
+ syncGallery();
426
+ persistDraft();
427
+ }
428
+ });
429
+ }
430
+ return {
431
+ dispose: () => {
432
+ detachAfterPick?.();
433
+ detachPaste();
434
+ },
435
+ syncGallery,
436
+ syncAttachmentTools,
437
+ attachScreenshot,
438
+ hideClipboardOffer,
439
+ remainingSlots,
440
+ silentCropToElement,
441
+ runShotFlow,
442
+ runRecordFlow,
443
+ clearGallery,
444
+ };
445
+ }
@@ -0,0 +1,31 @@
1
+ import type { T3FeedbackUiContribution } from "./slots.js";
2
+ export type ScreenshotUiCopy = {
3
+ screenshotSectionLabel: string;
4
+ captureLabel: string;
5
+ recordLabel: string;
6
+ clearScreenshotLabel: string;
7
+ insertClipboardLabel: string;
8
+ insertClipboardTitle: string;
9
+ dismissClipboardLabel: string;
10
+ };
11
+ export type ScreenshotUiApi = {
12
+ clipboardOffer: HTMLDivElement;
13
+ clipboardPreview: HTMLImageElement;
14
+ previewWrap: HTMLDivElement;
15
+ galleryEl: HTMLDivElement;
16
+ screenshotCountLabel: HTMLDivElement;
17
+ footerShot: HTMLButtonElement | null;
18
+ footerRecord: HTMLButtonElement | null;
19
+ };
20
+ /**
21
+ * Built-in screenshot UI: clipboard offer + gallery card + footer capture/record.
22
+ * Status lives on the shell (always present).
23
+ */
24
+ export declare function createScreenshotUiContributions(options: {
25
+ copy: ScreenshotUiCopy;
26
+ captureTitle: string;
27
+ recordTitle: string;
28
+ }): {
29
+ contributions: T3FeedbackUiContribution[];
30
+ getApi: () => ScreenshotUiApi | undefined;
31
+ };
@@ -0,0 +1,130 @@
1
+ import { CAMERA_ICON_SVG, closeButtonHtml, escapeHtml, iconButtonHtml, IMAGE_PLUS_ICON_SVG, RECORD_ICON_SVG, } from "./html.js";
2
+ /**
3
+ * Built-in screenshot UI: clipboard offer + gallery card + footer capture/record.
4
+ * Status lives on the shell (always present).
5
+ */
6
+ export function createScreenshotUiContributions(options) {
7
+ let api;
8
+ let clipboardOffer;
9
+ let clipboardPreview;
10
+ let previewWrap;
11
+ let galleryEl;
12
+ let screenshotCountLabel;
13
+ let footerShot;
14
+ let footerRecord;
15
+ const tryPublish = () => {
16
+ if (!clipboardOffer ||
17
+ !clipboardPreview ||
18
+ !previewWrap ||
19
+ !galleryEl ||
20
+ !screenshotCountLabel ||
21
+ footerShot === undefined ||
22
+ footerRecord === undefined) {
23
+ return;
24
+ }
25
+ api = {
26
+ clipboardOffer,
27
+ clipboardPreview,
28
+ previewWrap,
29
+ galleryEl,
30
+ screenshotCountLabel,
31
+ footerShot,
32
+ footerRecord,
33
+ };
34
+ };
35
+ const attachments = {
36
+ id: "builtin:screenshot",
37
+ slot: "attachments",
38
+ order: 20,
39
+ mount: (container) => {
40
+ const { copy, captureTitle, recordTitle } = options;
41
+ container.innerHTML = `
42
+ <div class="t3f-clipboard-offer" hidden>
43
+ <button type="button" class="t3f-clipboard-preview-btn" data-act="clipboard-insert" aria-label="${escapeHtml(copy.insertClipboardTitle)}" title="${escapeHtml(copy.insertClipboardTitle)}">
44
+ <img class="t3f-clipboard-preview" alt="" />
45
+ </button>
46
+ <div class="t3f-clipboard-offer-actions">
47
+ <button type="button" class="t3f-btn t3f-btn-tool primary" data-act="clipboard-insert" aria-label="${escapeHtml(copy.insertClipboardTitle)}" title="${escapeHtml(copy.insertClipboardTitle)}">${IMAGE_PLUS_ICON_SVG}<span>${escapeHtml(copy.insertClipboardLabel)}</span></button>
48
+ ${closeButtonHtml({
49
+ act: "clipboard-dismiss",
50
+ label: copy.dismissClipboardLabel,
51
+ })}
52
+ </div>
53
+ </div>
54
+ <div class="t3f-section t3f-preview-wrap" hidden>
55
+ <div class="t3f-section-head">
56
+ <div class="t3f-section-label" data-screenshot-count></div>
57
+ <div class="t3f-section-tools">
58
+ ${iconButtonHtml({
59
+ act: "shot",
60
+ label: copy.captureLabel,
61
+ icon: CAMERA_ICON_SVG,
62
+ titleAttr: captureTitle,
63
+ })}
64
+ ${iconButtonHtml({
65
+ act: "record",
66
+ label: copy.recordLabel,
67
+ icon: RECORD_ICON_SVG,
68
+ titleAttr: recordTitle,
69
+ })}
70
+ ${closeButtonHtml({ act: "clear-shot", label: copy.clearScreenshotLabel })}
71
+ </div>
72
+ </div>
73
+ <div class="t3f-gallery"></div>
74
+ </div>
75
+ `;
76
+ clipboardOffer = container.querySelector(".t3f-clipboard-offer");
77
+ clipboardPreview = container.querySelector(".t3f-clipboard-preview");
78
+ previewWrap = container.querySelector(".t3f-preview-wrap");
79
+ galleryEl = container.querySelector(".t3f-gallery");
80
+ screenshotCountLabel = container.querySelector("[data-screenshot-count]");
81
+ tryPublish();
82
+ return () => {
83
+ clipboardOffer = undefined;
84
+ clipboardPreview = undefined;
85
+ previewWrap = undefined;
86
+ galleryEl = undefined;
87
+ screenshotCountLabel = undefined;
88
+ if (footerShot === undefined && footerRecord === undefined)
89
+ api = undefined;
90
+ };
91
+ },
92
+ };
93
+ const actionsTools = {
94
+ id: "builtin:screenshot-tools",
95
+ slot: "actionsTools",
96
+ order: 20,
97
+ mount: (container) => {
98
+ const { copy, captureTitle, recordTitle } = options;
99
+ container.innerHTML = `
100
+ ${iconButtonHtml({
101
+ act: "shot",
102
+ label: copy.captureLabel,
103
+ icon: CAMERA_ICON_SVG,
104
+ className: "t3f-footer-shot",
105
+ titleAttr: captureTitle,
106
+ })}
107
+ ${iconButtonHtml({
108
+ act: "record",
109
+ label: copy.recordLabel,
110
+ icon: RECORD_ICON_SVG,
111
+ className: "t3f-footer-record",
112
+ titleAttr: recordTitle,
113
+ })}
114
+ `;
115
+ footerShot = container.querySelector(".t3f-footer-shot");
116
+ footerRecord = container.querySelector(".t3f-footer-record");
117
+ tryPublish();
118
+ return () => {
119
+ footerShot = undefined;
120
+ footerRecord = undefined;
121
+ if (!clipboardOffer)
122
+ api = undefined;
123
+ };
124
+ },
125
+ };
126
+ return {
127
+ contributions: [attachments, actionsTools],
128
+ getApi: () => api,
129
+ };
130
+ }