@devaccel-labs/app-comments-toolbar 1.1.0 → 1.3.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 (214) hide show
  1. package/README.md +148 -30
  2. package/dist/react.js +417 -64
  3. package/dist/server.js +342 -14
  4. package/dist/types/capture/api.d.ts +30 -0
  5. package/dist/types/capture/editor.d.ts +10 -0
  6. package/dist/types/capture/ignore.d.ts +19 -0
  7. package/dist/types/capture/index.d.ts +1 -0
  8. package/dist/types/capture/picker.d.ts +9 -0
  9. package/dist/types/capture/picture.d.ts +29 -0
  10. package/dist/types/capture/surface.d.ts +39 -0
  11. package/dist/types/core/index.d.ts +2 -2
  12. package/dist/types/core/resolve.d.ts +2 -1
  13. package/dist/types/core/types.d.ts +11 -0
  14. package/dist/types/debug-context.d.ts +7 -0
  15. package/dist/types/dictation.d.ts +9 -0
  16. package/dist/types/embed/controller.d.ts +61 -0
  17. package/dist/types/embed/index.d.ts +1 -0
  18. package/dist/types/embed/options.d.ts +35 -0
  19. package/dist/types/help/index.d.ts +1 -0
  20. package/dist/types/help/panel-index.d.ts +1 -0
  21. package/dist/types/help/panel-styles.d.ts +1 -0
  22. package/dist/types/help/panel.d.ts +4 -0
  23. package/dist/types/help/pill-styles.d.ts +11 -0
  24. package/dist/types/help/pill.d.ts +44 -0
  25. package/dist/types/help/react.d.ts +12 -0
  26. package/dist/types/help/requests.d.ts +2 -0
  27. package/dist/types/help/types.d.ts +119 -0
  28. package/dist/types/help/wire.d.ts +61 -0
  29. package/dist/types/native-wire.d.ts +63 -0
  30. package/dist/types/native.d.ts +43 -0
  31. package/dist/types/react.d.ts +7 -0
  32. package/dist/types/replay.d.ts +6 -0
  33. package/dist/types/runtime/api.d.ts +250 -0
  34. package/dist/types/runtime/attachment-model.d.ts +16 -0
  35. package/dist/types/runtime/attachments.d.ts +43 -7
  36. package/dist/types/runtime/boot.d.ts +39 -0
  37. package/dist/types/runtime/browser-label.d.ts +5 -0
  38. package/dist/types/runtime/build-id.d.ts +1 -0
  39. package/dist/types/runtime/capture-ui.d.ts +45 -21
  40. package/dist/types/runtime/clarity-model.d.ts +260 -0
  41. package/dist/types/runtime/clarity-ui.d.ts +62 -0
  42. package/dist/types/runtime/client.d.ts +6 -197
  43. package/dist/types/runtime/composer-size.d.ts +30 -0
  44. package/dist/types/runtime/dashboard.d.ts +17 -2
  45. package/dist/types/runtime/debug-context-core.d.ts +88 -0
  46. package/dist/types/runtime/debug-context-host.d.ts +39 -0
  47. package/dist/types/runtime/debug-context-model.d.ts +352 -0
  48. package/dist/types/runtime/debug-context-redact.d.ts +48 -0
  49. package/dist/types/runtime/debug-context.d.ts +36 -0
  50. package/dist/types/runtime/details-model.d.ts +7 -0
  51. package/dist/types/runtime/details.d.ts +7 -5
  52. package/dist/types/runtime/dictation-engine.d.ts +98 -0
  53. package/dist/types/runtime/dictation-model.d.ts +28 -0
  54. package/dist/types/runtime/dictation-ui.d.ts +56 -0
  55. package/dist/types/runtime/dictation.d.ts +66 -0
  56. package/dist/types/runtime/draft-dock.d.ts +32 -0
  57. package/dist/types/runtime/draft-model.d.ts +104 -0
  58. package/dist/types/runtime/draft-store.d.ts +15 -0
  59. package/dist/types/runtime/edits.d.ts +1 -1
  60. package/dist/types/runtime/font.d.ts +16 -0
  61. package/dist/types/runtime/format.d.ts +30 -1
  62. package/dist/types/runtime/host-watch.d.ts +20 -0
  63. package/dist/types/runtime/icons.d.ts +16 -2
  64. package/dist/types/runtime/index.d.ts +2 -0
  65. package/dist/types/runtime/ink.d.ts +8 -0
  66. package/dist/types/runtime/kind-model.d.ts +14 -0
  67. package/dist/types/runtime/live-model.d.ts +25 -0
  68. package/dist/types/runtime/loader.d.ts +5 -3
  69. package/dist/types/runtime/markup-editor.d.ts +54 -0
  70. package/dist/types/runtime/markup-model.d.ts +126 -0
  71. package/dist/types/runtime/markup.d.ts +69 -0
  72. package/dist/types/runtime/mention-parse.d.ts +18 -0
  73. package/dist/types/runtime/mentions.d.ts +4 -4
  74. package/dist/types/runtime/page-background.d.ts +9 -0
  75. package/dist/types/runtime/page-image.d.ts +64 -11
  76. package/dist/types/runtime/placement.d.ts +6 -3
  77. package/dist/types/runtime/record.d.ts +18 -2
  78. package/dist/types/runtime/related.d.ts +1 -1
  79. package/dist/types/runtime/replay-model.d.ts +80 -0
  80. package/dist/types/runtime/replay.d.ts +5 -38
  81. package/dist/types/runtime/screen.d.ts +47 -0
  82. package/dist/types/runtime/screenshot.d.ts +60 -8
  83. package/dist/types/runtime/session-source.d.ts +45 -0
  84. package/dist/types/runtime/session.d.ts +20 -18
  85. package/dist/types/runtime/shield.d.ts +9 -0
  86. package/dist/types/runtime/shot.d.ts +45 -0
  87. package/dist/types/runtime/sketch.d.ts +26 -0
  88. package/dist/types/runtime/styles.d.ts +1 -1
  89. package/dist/types/runtime/sync.d.ts +1 -0
  90. package/dist/types/runtime/thread-model.d.ts +18 -0
  91. package/dist/types/runtime/ticket-form-model.d.ts +57 -0
  92. package/dist/types/runtime/ticket-form.d.ts +9 -0
  93. package/dist/types/runtime/toolbar.d.ts +37 -1
  94. package/dist/types/runtime/ui-scale.d.ts +17 -0
  95. package/dist/types/runtime/update.d.ts +32 -8
  96. package/dist/types/runtime/use-debug-context.d.ts +27 -0
  97. package/dist/types/server/index.d.ts +22 -0
  98. package/dist/types/server/support.d.ts +20 -0
  99. package/dist/types/server.d.ts +2 -0
  100. package/dist/types/vite/plugin.d.ts +15 -0
  101. package/dist/vite.js +121 -41
  102. package/package.json +24 -11
  103. package/src/capture/api.ts +128 -0
  104. package/src/capture/editor.tsx +178 -0
  105. package/src/capture/ignore.ts +38 -0
  106. package/src/capture/index.ts +27 -0
  107. package/src/capture/picker.tsx +171 -0
  108. package/src/capture/picture.ts +90 -0
  109. package/src/capture/surface.tsx +185 -0
  110. package/src/compiler/annotate.ts +202 -0
  111. package/src/compiler/babel.d.ts +11 -0
  112. package/src/compiler/enabled.ts +55 -0
  113. package/src/compiler/index.ts +2 -0
  114. package/src/core/index.ts +8 -0
  115. package/src/core/resolve.ts +307 -0
  116. package/src/core/types.ts +58 -0
  117. package/src/debug-context.ts +47 -0
  118. package/src/dictation.ts +17 -0
  119. package/src/embed/controller.ts +215 -0
  120. package/src/embed/index.ts +68 -0
  121. package/src/embed/options.ts +100 -0
  122. package/src/help/index.ts +38 -0
  123. package/src/help/panel-index.ts +17 -0
  124. package/src/help/panel-styles.ts +123 -0
  125. package/src/help/panel.ts +840 -0
  126. package/src/help/pill-styles.ts +75 -0
  127. package/src/help/pill.ts +338 -0
  128. package/src/help/react.tsx +143 -0
  129. package/src/help/requests.ts +111 -0
  130. package/src/help/types.ts +124 -0
  131. package/src/help/wire.ts +55 -0
  132. package/src/native-wire.ts +124 -0
  133. package/src/native.ts +282 -0
  134. package/src/react.ts +27 -0
  135. package/src/replay.ts +13 -0
  136. package/src/runtime/api.ts +531 -0
  137. package/src/runtime/attachment-model.ts +42 -0
  138. package/src/runtime/attachments.tsx +1260 -0
  139. package/src/runtime/boot.ts +95 -0
  140. package/src/runtime/browser-label.ts +31 -0
  141. package/src/runtime/build-id.ts +10 -0
  142. package/src/runtime/capture-ui.tsx +306 -0
  143. package/src/runtime/capture.ts +508 -0
  144. package/src/runtime/clarity-model.ts +634 -0
  145. package/src/runtime/clarity-ui.tsx +454 -0
  146. package/src/runtime/client.ts +151 -0
  147. package/src/runtime/composer-size.tsx +146 -0
  148. package/src/runtime/dashboard.tsx +1130 -0
  149. package/src/runtime/debug-context-core.ts +681 -0
  150. package/src/runtime/debug-context-host.ts +56 -0
  151. package/src/runtime/debug-context-model.ts +1319 -0
  152. package/src/runtime/debug-context-redact.ts +259 -0
  153. package/src/runtime/debug-context.ts +613 -0
  154. package/src/runtime/details-model.ts +30 -0
  155. package/src/runtime/details.tsx +395 -0
  156. package/src/runtime/dictation-engine.ts +616 -0
  157. package/src/runtime/dictation-model.ts +79 -0
  158. package/src/runtime/dictation-ui.tsx +363 -0
  159. package/src/runtime/dictation.ts +271 -0
  160. package/src/runtime/dock.ts +108 -0
  161. package/src/runtime/draft-dock.tsx +124 -0
  162. package/src/runtime/draft-model.ts +251 -0
  163. package/src/runtime/draft-store.ts +149 -0
  164. package/src/runtime/edits.tsx +165 -0
  165. package/src/runtime/font.ts +42 -0
  166. package/src/runtime/format.ts +129 -0
  167. package/src/runtime/freshness.ts +23 -0
  168. package/src/runtime/handoff.ts +76 -0
  169. package/src/runtime/host-watch.ts +64 -0
  170. package/src/runtime/icons.tsx +295 -0
  171. package/src/runtime/idle.ts +20 -0
  172. package/src/runtime/index.ts +11 -0
  173. package/src/runtime/ink.ts +6 -0
  174. package/src/runtime/kind-model.ts +20 -0
  175. package/src/runtime/live-model.ts +32 -0
  176. package/src/runtime/loader.tsx +87 -0
  177. package/src/runtime/markup-editor.tsx +1333 -0
  178. package/src/runtime/markup-model.ts +485 -0
  179. package/src/runtime/markup.ts +382 -0
  180. package/src/runtime/members.ts +58 -0
  181. package/src/runtime/mention-parse.ts +56 -0
  182. package/src/runtime/mentions.tsx +205 -0
  183. package/src/runtime/page-background.ts +51 -0
  184. package/src/runtime/page-image.ts +1592 -0
  185. package/src/runtime/placement.ts +336 -0
  186. package/src/runtime/record.ts +402 -0
  187. package/src/runtime/redact.ts +30 -0
  188. package/src/runtime/related.ts +34 -0
  189. package/src/runtime/render-page.ts +2 -0
  190. package/src/runtime/replay-model.ts +232 -0
  191. package/src/runtime/replay.ts +173 -0
  192. package/src/runtime/runtime-version.ts +7 -0
  193. package/src/runtime/runtime.tsx +51 -0
  194. package/src/runtime/screen.ts +182 -0
  195. package/src/runtime/screenshot.ts +260 -0
  196. package/src/runtime/session-source.ts +112 -0
  197. package/src/runtime/session.ts +108 -0
  198. package/src/runtime/shield.ts +34 -0
  199. package/src/runtime/shot.tsx +422 -0
  200. package/src/runtime/sketch.ts +139 -0
  201. package/src/runtime/styles.ts +1541 -0
  202. package/src/runtime/sync.ts +300 -0
  203. package/src/runtime/thread-model.ts +71 -0
  204. package/src/runtime/ticket-form-model.ts +128 -0
  205. package/src/runtime/ticket-form.tsx +342 -0
  206. package/src/runtime/toolbar.tsx +4427 -0
  207. package/src/runtime/ui-scale.ts +75 -0
  208. package/src/runtime/update.ts +98 -0
  209. package/src/runtime/use-debug-context.ts +81 -0
  210. package/src/server/index.ts +133 -0
  211. package/src/server/support.ts +499 -0
  212. package/src/server.ts +4 -0
  213. package/src/vite/plugin.ts +102 -0
  214. package/src/vite.ts +1 -0
@@ -0,0 +1,1260 @@
1
+ import {
2
+ useCallback,
3
+ useEffect,
4
+ useLayoutEffect,
5
+ useRef,
6
+ useState,
7
+ type ButtonHTMLAttributes,
8
+ type CSSProperties,
9
+ type ClipboardEvent,
10
+ type MouseEvent,
11
+ type PointerEvent as ReactPointerEvent,
12
+ } from "react";
13
+ import { uploadAttachment, type Attachment, type Capture } from "./client.js";
14
+ import type { MarkupShape } from "./markup.js";
15
+ import { MarkupEditor, type MarkupResult } from "./markup-editor.js";
16
+ import type { Session } from "./session.js";
17
+ import { parseReplay, replayLine, type ReplayEvent, type ReplayFile } from "./replay.js";
18
+ import * as Icon from "./icons.js";
19
+ import {
20
+ ATTACHMENT_MAX_BYTES,
21
+ ATTACHMENT_MAX_FILES,
22
+ ATTACHMENT_TYPES,
23
+ formatSize,
24
+ isReplayFile,
25
+ } from "./attachment-model.js";
26
+
27
+ export { ATTACHMENT_MAX_BYTES, formatSize, isReplayFile } from "./attachment-model.js";
28
+
29
+ const MAX_BYTES = ATTACHMENT_MAX_BYTES;
30
+ const MAX_FILES = ATTACHMENT_MAX_FILES;
31
+ const ZOOM_MIN = 1;
32
+ const ZOOM_MAX = 4;
33
+ const ZOOM_STEP = 0.25;
34
+ const ACCEPT = ATTACHMENT_TYPES.join(",");
35
+ const ALLOWED = new Set(ATTACHMENT_TYPES);
36
+
37
+ /** The file an image was marked up from, so markup stays editable until the message is sent. */
38
+ export type MarkupSource = { blob: Blob; url: string; name: string; shapes: MarkupShape[] };
39
+
40
+ export type Pending = {
41
+ key: string;
42
+ name: string;
43
+ size: number;
44
+ contentType: string;
45
+ preview: string | null;
46
+ status: "uploading" | "ready" | "failed";
47
+ blob: Blob;
48
+ id?: string;
49
+ error?: string;
50
+ capture?: Capture | undefined;
51
+ markup?: MarkupSource | undefined;
52
+ };
53
+
54
+ function release(item: Pending) {
55
+ if (item.preview) URL.revokeObjectURL(item.preview);
56
+ if (item.markup && item.markup.url !== item.preview) URL.revokeObjectURL(item.markup.url);
57
+ }
58
+
59
+ /** Files attached to one draft. Each upload starts immediately so posting doesn't wait on the network. */
60
+ export function useAttachments(
61
+ session: Session | null,
62
+ memberToken: string | null,
63
+ onError: (message: string) => void,
64
+ environment?: string,
65
+ ) {
66
+ const [items, setItems] = useState<Pending[]>([]);
67
+ const itemsRef = useRef(items);
68
+ useEffect(() => {
69
+ itemsRef.current = items;
70
+ }, [items]);
71
+ /** Callers of `settled`, told after every status change. */
72
+ const waiters = useRef<Array<() => void>>([]);
73
+
74
+ const patch = useCallback((key: string, next: Partial<Pending>) => {
75
+ const apply = (current: Pending[]) =>
76
+ current.map((item) => (item.key === key ? { ...item, ...next } : item));
77
+ // The ref is kept in step at once, so `settled` sees the outcome before React renders it.
78
+ itemsRef.current = apply(itemsRef.current);
79
+ setItems(apply);
80
+ const told = waiters.current;
81
+ waiters.current = [];
82
+ for (const tell of told) tell();
83
+ }, []);
84
+
85
+ /** Resolves with the files once none of them is still uploading. */
86
+ const settled = useCallback(
87
+ () =>
88
+ new Promise<Pending[]>((resolve) => {
89
+ const check = () => {
90
+ if (itemsRef.current.some((item) => item.status === "uploading")) {
91
+ waiters.current.push(check);
92
+ return;
93
+ }
94
+ resolve(itemsRef.current);
95
+ };
96
+ check();
97
+ }),
98
+ [],
99
+ );
100
+
101
+ const upload = useCallback(
102
+ (key: string, blob: Blob, name: string, capture?: Capture, markedUp = false) => {
103
+ if (!session || !memberToken) return;
104
+ uploadAttachment(session, memberToken, blob, name, environment, capture, markedUp)
105
+ .then((attachment) => patch(key, { status: "ready", id: attachment.id }))
106
+ .catch((caught) => {
107
+ const message = caught instanceof Error ? caught.message : "Upload failed.";
108
+ patch(key, { status: "failed", error: message });
109
+ onError(`${name}: ${message}`);
110
+ });
111
+ },
112
+ [environment, memberToken, onError, patch, session],
113
+ );
114
+
115
+ /** Attaches `files` and starts their uploads. Returns the key of each file taken. */
116
+ const add = useCallback(
117
+ (files: Array<{ blob: Blob; name: string; capture?: Capture }>): string[] => {
118
+ if (!session || !memberToken) return [];
119
+ const keys: string[] = [];
120
+ const room = MAX_FILES - itemsRef.current.length;
121
+ if (files.length > room) onError(`Up to ${MAX_FILES} files per message.`);
122
+ for (const { blob, name, capture } of files.slice(0, Math.max(0, room))) {
123
+ if (!ALLOWED.has(blob.type)) {
124
+ onError(`${name}: that file type isn’t supported.`);
125
+ continue;
126
+ }
127
+ if (blob.size > MAX_BYTES) {
128
+ onError(`${name} is over 10 MB.`);
129
+ continue;
130
+ }
131
+ const key = crypto.randomUUID();
132
+ const previewable =
133
+ blob.type.startsWith("image/") ||
134
+ blob.type.startsWith("video/") ||
135
+ (blob.type === "application/json" && name.startsWith("replay-"));
136
+ const preview = previewable ? URL.createObjectURL(blob) : null;
137
+ const item: Pending = {
138
+ key,
139
+ name,
140
+ size: blob.size,
141
+ contentType: blob.type,
142
+ preview,
143
+ status: "uploading",
144
+ blob,
145
+ capture,
146
+ };
147
+ itemsRef.current = [...itemsRef.current, item];
148
+ setItems((current) => [...current, item]);
149
+ keys.push(key);
150
+ upload(key, blob, name, capture);
151
+ }
152
+ return keys;
153
+ },
154
+ [memberToken, onError, session, upload],
155
+ );
156
+
157
+ /** Puts files back from a saved draft, keys and markup included, and uploads them again. */
158
+ const restore = useCallback(
159
+ (
160
+ files: Array<{
161
+ key: string;
162
+ blob: Blob;
163
+ name: string;
164
+ capture?: Capture | undefined;
165
+ markup?: { blob: Blob; name: string; shapes: MarkupShape[] } | undefined;
166
+ }>,
167
+ ) => {
168
+ if (!session || !memberToken) return;
169
+ const next: Pending[] = files.slice(0, MAX_FILES).map((file) => {
170
+ const previewable =
171
+ file.blob.type.startsWith("image/") ||
172
+ file.blob.type.startsWith("video/") ||
173
+ (file.blob.type === "application/json" && file.name.startsWith("replay-"));
174
+ return {
175
+ key: file.key,
176
+ name: file.name,
177
+ size: file.blob.size,
178
+ contentType: file.blob.type,
179
+ preview: previewable ? URL.createObjectURL(file.blob) : null,
180
+ status: "uploading",
181
+ blob: file.blob,
182
+ capture: file.capture,
183
+ markup: file.markup
184
+ ? {
185
+ blob: file.markup.blob,
186
+ url: URL.createObjectURL(file.markup.blob),
187
+ name: file.markup.name,
188
+ shapes: file.markup.shapes,
189
+ }
190
+ : undefined,
191
+ };
192
+ });
193
+ itemsRef.current = [...itemsRef.current, ...next];
194
+ setItems((current) => [...current, ...next]);
195
+ // A restored file with markup is the flattened, drawn-on copy.
196
+ for (const item of next)
197
+ upload(item.key, item.blob, item.name, item.capture, item.markup !== undefined);
198
+ },
199
+ [memberToken, session, upload],
200
+ );
201
+
202
+ /**
203
+ * Swaps an image for its marked-up copy, or back to the original when the markup was cleared, and uploads the
204
+ * result. The swap takes a new key, so the earlier upload finishing late can't mark the new file ready.
205
+ * Returns the new key, or null when the file is gone.
206
+ */
207
+ const replace = useCallback(
208
+ (key: string, result: MarkupResult) => {
209
+ const item = itemsRef.current.find((candidate) => candidate.key === key);
210
+ if (!item || !session || !memberToken) return null;
211
+ if (!result.blob && !item.markup) return key;
212
+ if (result.blob && result.blob.size > MAX_BYTES) {
213
+ onError(`${result.name} is over 10 MB.`);
214
+ return null;
215
+ }
216
+ const source = item.markup ?? {
217
+ blob: item.blob,
218
+ url: item.preview ?? URL.createObjectURL(item.blob),
219
+ name: item.name,
220
+ shapes: [],
221
+ };
222
+ const next: Pending = result.blob
223
+ ? {
224
+ key: crypto.randomUUID(),
225
+ name: result.name,
226
+ size: result.blob.size,
227
+ contentType: result.blob.type,
228
+ preview: URL.createObjectURL(result.blob),
229
+ status: "uploading",
230
+ blob: result.blob,
231
+ capture: item.capture,
232
+ markup: { ...source, shapes: result.shapes },
233
+ }
234
+ : {
235
+ key: crypto.randomUUID(),
236
+ name: source.name,
237
+ size: source.blob.size,
238
+ contentType: source.blob.type,
239
+ preview: source.url,
240
+ status: "uploading",
241
+ blob: source.blob,
242
+ capture: item.capture,
243
+ };
244
+ // The original's URL lives on in `next`; only an earlier marked-up copy is let go.
245
+ if (item.markup && item.preview) URL.revokeObjectURL(item.preview);
246
+ itemsRef.current = itemsRef.current.map((candidate) =>
247
+ candidate.key === key ? next : candidate,
248
+ );
249
+ setItems((current) => current.map((candidate) => (candidate.key === key ? next : candidate)));
250
+ upload(next.key, next.blob, next.name, next.capture, next.markup !== undefined);
251
+ return next.key;
252
+ },
253
+ [memberToken, onError, session, upload],
254
+ );
255
+
256
+ const remove = useCallback((key: string) => {
257
+ itemsRef.current = itemsRef.current.filter((candidate) => candidate.key !== key);
258
+ setItems((current) => {
259
+ const item = current.find((candidate) => candidate.key === key);
260
+ if (item) release(item);
261
+ return current.filter((candidate) => candidate.key !== key);
262
+ });
263
+ }, []);
264
+
265
+ const reset = useCallback(() => {
266
+ for (const item of itemsRef.current) release(item);
267
+ itemsRef.current = [];
268
+ setItems([]);
269
+ }, []);
270
+
271
+ useEffect(() => () => itemsRef.current.forEach(release), []);
272
+
273
+ return {
274
+ items,
275
+ add,
276
+ replace,
277
+ remove,
278
+ reset,
279
+ restore,
280
+ settled,
281
+ ids: items.filter((item) => item.status === "ready" && item.id).map((item) => item.id!),
282
+ uploading: items.some((item) => item.status === "uploading"),
283
+ };
284
+ }
285
+
286
+ export function filesFromClipboard(event: ClipboardEvent) {
287
+ return [...event.clipboardData.files].map((file) => ({
288
+ blob: file as Blob,
289
+ name: file.name || "pasted-image.png",
290
+ }));
291
+ }
292
+
293
+ export type PreviewFile = {
294
+ id: string;
295
+ name: string;
296
+ contentType: string;
297
+ url: string;
298
+ capture?: Capture | undefined;
299
+ /** A draft image's unmarked original and its markup, for the editor. */
300
+ markup?: { src: string; name: string; shapes: MarkupShape[] } | undefined;
301
+ };
302
+
303
+ function canPreview(item: Pending) {
304
+ return (
305
+ Boolean(item.preview) &&
306
+ (item.contentType.startsWith("image/") ||
307
+ item.contentType.startsWith("video/") ||
308
+ isReplayFile(item))
309
+ );
310
+ }
311
+
312
+ /** Images, videos, and replays waiting in a draft, in tray order, for the preview beside the composer. */
313
+ export function pendingPreviews(items: Pending[]): PreviewFile[] {
314
+ return items.filter(canPreview).map((item) => ({
315
+ id: item.key,
316
+ name: item.name,
317
+ contentType: item.contentType,
318
+ url: item.preview!,
319
+ capture: item.capture,
320
+ markup: item.contentType.startsWith("image/")
321
+ ? {
322
+ src: item.markup?.url ?? item.preview!,
323
+ name: item.markup?.name ?? item.name,
324
+ shapes: item.markup?.shapes ?? [],
325
+ }
326
+ : undefined,
327
+ }));
328
+ }
329
+
330
+ export function AttachmentTray({
331
+ items,
332
+ activeKey,
333
+ onPreview,
334
+ onRemove,
335
+ }: {
336
+ items: Pending[];
337
+ activeKey?: string | null;
338
+ onPreview?: (item: Pending) => void;
339
+ onRemove: (key: string) => void;
340
+ }) {
341
+ if (!items.length) return null;
342
+ return (
343
+ <div className="cs-tray" data-cs-ui="attachment-tray">
344
+ {items.map((item) => {
345
+ const visual = canPreview(item);
346
+ const open = item.key === activeKey;
347
+ const thumb =
348
+ item.preview && item.contentType.startsWith("video/") ? (
349
+ <video src={item.preview} muted />
350
+ ) : item.preview && item.contentType.startsWith("image/") ? (
351
+ <img src={item.preview} alt="" />
352
+ ) : isReplayFile(item) ? (
353
+ <Icon.Undo />
354
+ ) : (
355
+ <Icon.FileIcon />
356
+ );
357
+ return (
358
+ <div
359
+ key={item.key}
360
+ className={`cs-pending is-${item.status}${open ? " is-open" : ""}`}
361
+ data-cs-ui="pending-file"
362
+ data-status={item.status}
363
+ title={item.error ?? item.name}
364
+ >
365
+ {visual && onPreview ? (
366
+ <button
367
+ type="button"
368
+ className="cs-pending-open"
369
+ aria-label={`View ${item.name}`}
370
+ aria-pressed={open}
371
+ onClick={() => onPreview(item)}
372
+ >
373
+ {thumb}
374
+ <span className="cs-pending-name">{item.name}</span>
375
+ </button>
376
+ ) : (
377
+ <>
378
+ {thumb}
379
+ <span className="cs-pending-name">{item.name}</span>
380
+ </>
381
+ )}
382
+ <button
383
+ type="button"
384
+ className="cs-pending-remove"
385
+ aria-label={`Remove ${item.name}`}
386
+ onClick={(event) => {
387
+ event.stopPropagation();
388
+ onRemove(item.key);
389
+ }}
390
+ >
391
+ <Icon.Close />
392
+ </button>
393
+ </div>
394
+ );
395
+ })}
396
+ </div>
397
+ );
398
+ }
399
+
400
+ /** Same as the bar: the first tip waits, then the rest show at once while the pointer stays here. */
401
+ function useWarmTips() {
402
+ const timer = useRef(0);
403
+ // allow-react-loops: spread onto one element as event handlers; nothing depends on their identity
404
+ return {
405
+ onPointerOver: (event: ReactPointerEvent<HTMLSpanElement>) => {
406
+ const group = event.currentTarget;
407
+ window.clearTimeout(timer.current);
408
+ if (
409
+ !(event.target instanceof Element) ||
410
+ !event.target.closest("button") ||
411
+ group.hasAttribute("data-warm")
412
+ )
413
+ return;
414
+ timer.current = window.setTimeout(() => group.setAttribute("data-warm", ""), 350);
415
+ },
416
+ onPointerLeave: (event: ReactPointerEvent<HTMLSpanElement>) => {
417
+ const group = event.currentTarget;
418
+ window.clearTimeout(timer.current);
419
+ timer.current = window.setTimeout(() => group.removeAttribute("data-warm"), 600);
420
+ },
421
+ };
422
+ }
423
+
424
+ export function AttachButton({
425
+ onPointerDown,
426
+ onPointerLeave,
427
+ ...props
428
+ }: ButtonHTMLAttributes<HTMLButtonElement>) {
429
+ return (
430
+ <button
431
+ type="button"
432
+ {...props}
433
+ onPointerDown={(event) => {
434
+ event.currentTarget.setAttribute("data-tipless", "");
435
+ onPointerDown?.(event);
436
+ }}
437
+ onPointerLeave={(event) => {
438
+ event.currentTarget.removeAttribute("data-tipless");
439
+ onPointerLeave?.(event);
440
+ }}
441
+ />
442
+ );
443
+ }
444
+
445
+ export function AttachButtons({
446
+ onFiles,
447
+ onSnapshot,
448
+ onRecord,
449
+ onReplay,
450
+ capturing,
451
+ }: {
452
+ onFiles: (files: Array<{ blob: Blob; name: string }>) => void;
453
+ onSnapshot: () => void;
454
+ onRecord: () => void;
455
+ onReplay: (() => void) | null;
456
+ capturing: boolean;
457
+ }) {
458
+ const input = useRef<HTMLInputElement>(null);
459
+ const warmTips = useWarmTips();
460
+ return (
461
+ <span className="cs-attach" {...warmTips}>
462
+ <input
463
+ ref={input}
464
+ type="file"
465
+ multiple
466
+ accept={ACCEPT}
467
+ hidden
468
+ data-cs-ui="file-input"
469
+ onChange={(event) => {
470
+ onFiles(
471
+ [...(event.target.files ?? [])].map((file) => ({
472
+ blob: file as Blob,
473
+ name: file.name,
474
+ })),
475
+ );
476
+ event.target.value = "";
477
+ }}
478
+ />
479
+ <AttachButton
480
+ className="cs-icon-button cs-plus"
481
+ data-cs-ui="attach"
482
+ data-tip="Attach file"
483
+ aria-label="Attach files"
484
+ onClick={() => input.current?.click()}
485
+ >
486
+ <Icon.Paperclip />
487
+ </AttachButton>
488
+ <AttachButton
489
+ className="cs-icon-button"
490
+ data-cs-ui="snapshot"
491
+ data-tip="Screenshot"
492
+ aria-label="Attach a screenshot of the page"
493
+ disabled={capturing}
494
+ onClick={onSnapshot}
495
+ >
496
+ <Icon.Camera />
497
+ </AttachButton>
498
+ <AttachButton
499
+ className="cs-icon-button cs-record-button"
500
+ data-cs-ui="record-start"
501
+ data-tip="Record"
502
+ aria-label="Record the page"
503
+ disabled={capturing}
504
+ onClick={onRecord}
505
+ >
506
+ <Icon.Record />
507
+ </AttachButton>
508
+ {onReplay ? (
509
+ <AttachButton
510
+ className="cs-icon-button"
511
+ data-cs-ui="attach-replay"
512
+ data-tip="Replay"
513
+ aria-label="Attach recent activity"
514
+ disabled={capturing}
515
+ onClick={onReplay}
516
+ >
517
+ <Icon.Undo />
518
+ </AttachButton>
519
+ ) : null}
520
+ </span>
521
+ );
522
+ }
523
+
524
+ function pageLabel(href: string) {
525
+ const url = new URL(href);
526
+ return `${url.host}${url.pathname === "/" ? "" : url.pathname}${url.search}${url.hash}`;
527
+ }
528
+
529
+ function stopRow(event: MouseEvent) {
530
+ event.stopPropagation();
531
+ }
532
+
533
+ function Screenshot({
534
+ file,
535
+ capture,
536
+ open,
537
+ onPreview,
538
+ }: {
539
+ file: Attachment;
540
+ capture: Capture;
541
+ open: boolean;
542
+ onPreview: (file: Attachment) => void;
543
+ }) {
544
+ const details = [
545
+ capture.viewport ? `${capture.viewport.width}×${capture.viewport.height}` : null,
546
+ capture.browser,
547
+ ].filter(Boolean);
548
+ return (
549
+ <figure className={open ? "cs-shot is-open" : "cs-shot"} data-cs-ui="screenshot">
550
+ <button
551
+ type="button"
552
+ className="cs-shot-image"
553
+ title={file.name}
554
+ aria-label={`View ${file.name}`}
555
+ aria-pressed={open}
556
+ data-cs-ui="attachment"
557
+ onClick={(event) => {
558
+ stopRow(event);
559
+ onPreview(file);
560
+ }}
561
+ >
562
+ <img src={file.url} alt={`Screenshot of ${pageLabel(capture.url)}`} loading="lazy" />
563
+ </button>
564
+ <figcaption className="cs-shot-meta">
565
+ <a
566
+ href={capture.url}
567
+ target="_blank"
568
+ rel="noreferrer noopener"
569
+ title={capture.url}
570
+ data-cs-ui="capture-url"
571
+ onClick={stopRow}
572
+ >
573
+ {pageLabel(capture.url)}
574
+ </a>
575
+ {details.length ? <span data-cs-ui="capture-details">{details.join(" · ")}</span> : null}
576
+ </figcaption>
577
+ </figure>
578
+ );
579
+ }
580
+
581
+ function replayOffset(event: ReplayEvent, start: number) {
582
+ const seconds = Math.max(0, Math.round((event.t - start) / 1000));
583
+ return `${Math.floor(seconds / 60)}:${String(seconds % 60).padStart(2, "0")}`;
584
+ }
585
+
586
+ function ReplayBody({ url, linked }: { url: string; linked?: boolean }) {
587
+ const [replay, setReplay] = useState<ReplayFile | null>(null);
588
+ const [failed, setFailed] = useState(false);
589
+ const [loadedUrl, setLoadedUrl] = useState(url);
590
+ if (loadedUrl !== url) {
591
+ setLoadedUrl(url);
592
+ setReplay(null);
593
+ setFailed(false);
594
+ }
595
+ useEffect(() => {
596
+ let cancel = false;
597
+ fetch(url)
598
+ .then((response) => (response.ok ? response.json() : Promise.reject(new Error("missing"))))
599
+ .then((body) => {
600
+ if (cancel) return;
601
+ const parsed = parseReplay(body);
602
+ if (parsed) setReplay(parsed);
603
+ else setFailed(true);
604
+ })
605
+ .catch(() => {
606
+ if (!cancel) setFailed(true);
607
+ });
608
+ return () => {
609
+ cancel = true;
610
+ };
611
+ }, [url]);
612
+ if (failed) {
613
+ return (
614
+ <p className="cs-replay-status">
615
+ Couldn’t read this replay.
616
+ {linked ? (
617
+ <>
618
+ {" "}
619
+ <a href={url} target="_blank" rel="noreferrer noopener" onClick={stopRow}>
620
+ Open the file
621
+ </a>
622
+ </>
623
+ ) : null}
624
+ </p>
625
+ );
626
+ }
627
+ if (!replay) return <p className="cs-replay-status">Loading recent activity…</p>;
628
+ const start = replay.events[0]?.t ?? 0;
629
+ return (
630
+ <>
631
+ <p className="cs-replay-title">Recent activity</p>
632
+ <ol>
633
+ {replay.events.map((event, index) => (
634
+ <li key={`${event.t}-${index}`}>
635
+ <time>{replayOffset(event, start)}</time>
636
+ <span>{replayLine(event)}</span>
637
+ </li>
638
+ ))}
639
+ </ol>
640
+ </>
641
+ );
642
+ }
643
+
644
+ function ReplayAttachment({
645
+ file,
646
+ open,
647
+ onPreview,
648
+ }: {
649
+ file: Attachment;
650
+ open: boolean;
651
+ onPreview: (file: Attachment) => void;
652
+ }) {
653
+ return (
654
+ <button
655
+ type="button"
656
+ className={open ? "cs-replay is-open" : "cs-replay"}
657
+ data-cs-ui="replay"
658
+ aria-label={`View ${file.name}`}
659
+ aria-pressed={open}
660
+ onClick={(event) => {
661
+ stopRow(event);
662
+ onPreview(file);
663
+ }}
664
+ >
665
+ <ReplayBody url={file.url} />
666
+ </button>
667
+ );
668
+ }
669
+
670
+ function isVideo(file: { contentType: string }) {
671
+ return file.contentType.startsWith("video/");
672
+ }
673
+
674
+ function clampZoom(value: number) {
675
+ if (!Number.isFinite(value)) return ZOOM_MIN;
676
+ return Math.min(ZOOM_MAX, Math.max(ZOOM_MIN, value));
677
+ }
678
+
679
+ function stepZoom(value: number, direction: 1 | -1) {
680
+ const stepped = Math.round(value * 4) / 4;
681
+ return clampZoom(stepped + direction * ZOOM_STEP);
682
+ }
683
+
684
+ function clampPan(
685
+ pan: { x: number; y: number },
686
+ zoom: number,
687
+ box: { width: number; height: number } | null,
688
+ ) {
689
+ if (zoom <= 1 || !box) return { x: 0, y: 0 };
690
+ const maxX = ((zoom - 1) * box.width) / 2;
691
+ const maxY = ((zoom - 1) * box.height) / 2;
692
+ return {
693
+ x: Math.min(maxX, Math.max(-maxX, pan.x)),
694
+ y: Math.min(maxY, Math.max(-maxY, pan.y)),
695
+ };
696
+ }
697
+
698
+ /** Images, videos, and replays in the order a thread shows them, so preview arrows follow the thread. */
699
+ export function visualAttachments(messages: Array<{ attachments?: Attachment[] }> | undefined) {
700
+ if (!messages) return [];
701
+ return messages.flatMap((message) => {
702
+ const files = message.attachments ?? [];
703
+ return [
704
+ ...files.filter((file) => file.capture && file.contentType.startsWith("image/")),
705
+ ...files.filter(isVideo),
706
+ ...files.filter(isReplayFile),
707
+ ...files.filter((file) => !file.capture && file.contentType.startsWith("image/")),
708
+ ];
709
+ });
710
+ }
711
+
712
+ export function MessageAttachments({
713
+ attachments,
714
+ activeId,
715
+ onPreview,
716
+ }: {
717
+ attachments?: Attachment[];
718
+ activeId?: string | null;
719
+ onPreview: (file: Attachment) => void;
720
+ }) {
721
+ if (!attachments?.length) return null;
722
+ const shots = attachments.filter((file) => file.capture && file.contentType.startsWith("image/"));
723
+ const videos = attachments.filter(isVideo);
724
+ const replays = attachments.filter(isReplayFile);
725
+ const images = attachments.filter(
726
+ (file) => !file.capture && file.contentType.startsWith("image/"),
727
+ );
728
+ const voices = attachments.filter((file) => file.contentType.startsWith("audio/"));
729
+ const others = attachments.filter(
730
+ (file) =>
731
+ !file.contentType.startsWith("image/") &&
732
+ !file.contentType.startsWith("audio/") &&
733
+ !isVideo(file) &&
734
+ !isReplayFile(file),
735
+ );
736
+ const open = (file: Attachment) => (event: MouseEvent) => {
737
+ stopRow(event);
738
+ onPreview(file);
739
+ };
740
+ return (
741
+ <div className="cs-files" data-cs-ui="attachments">
742
+ {shots.map((file) => (
743
+ <Screenshot
744
+ key={file.id}
745
+ file={file}
746
+ capture={file.capture!}
747
+ open={file.id === activeId}
748
+ onPreview={onPreview}
749
+ />
750
+ ))}
751
+ {videos.map((file) => (
752
+ <figure
753
+ key={file.id}
754
+ className={file.id === activeId ? "cs-clip is-open" : "cs-clip"}
755
+ data-cs-ui="recording"
756
+ >
757
+ <button
758
+ type="button"
759
+ className="cs-clip-open"
760
+ data-cs-ui="attachment"
761
+ aria-label={`Play ${file.name}`}
762
+ aria-pressed={file.id === activeId}
763
+ onClick={open(file)}
764
+ >
765
+ <video src={file.url} muted playsInline preload="metadata" />
766
+ <span className="cs-clip-play">
767
+ <Icon.Play />
768
+ </span>
769
+ </button>
770
+ {file.capture ? (
771
+ <figcaption className="cs-shot-meta">
772
+ <a
773
+ href={file.capture.url}
774
+ target="_blank"
775
+ rel="noreferrer noopener"
776
+ title={file.capture.url}
777
+ data-cs-ui="capture-url"
778
+ onClick={stopRow}
779
+ >
780
+ {pageLabel(file.capture.url)}
781
+ </a>
782
+ </figcaption>
783
+ ) : null}
784
+ </figure>
785
+ ))}
786
+ {replays.map((file) => (
787
+ <ReplayAttachment
788
+ key={file.id}
789
+ file={file}
790
+ open={file.id === activeId}
791
+ onPreview={onPreview}
792
+ />
793
+ ))}
794
+ {images.length ? (
795
+ <div className="cs-thumbs">
796
+ {images.map((file) => (
797
+ <button
798
+ key={file.id}
799
+ type="button"
800
+ className={file.id === activeId ? "cs-thumb is-open" : "cs-thumb"}
801
+ title={file.name}
802
+ aria-label={`View ${file.name}`}
803
+ aria-pressed={file.id === activeId}
804
+ data-cs-ui="attachment"
805
+ onClick={open(file)}
806
+ >
807
+ <img src={file.url} alt={file.name} loading="lazy" />
808
+ </button>
809
+ ))}
810
+ </div>
811
+ ) : null}
812
+ {voices.map((file) => (
813
+ // A dictation take kept as audio: plays in place.
814
+ <audio
815
+ key={file.id}
816
+ className="cs-voice"
817
+ data-cs-ui="voice"
818
+ aria-label={file.name}
819
+ src={file.url}
820
+ controls
821
+ preload="metadata"
822
+ onClick={stopRow}
823
+ />
824
+ ))}
825
+ {others.map((file) => (
826
+ <a
827
+ key={file.id}
828
+ href={file.url}
829
+ target="_blank"
830
+ rel="noreferrer noopener"
831
+ className="cs-file"
832
+ data-cs-ui="attachment"
833
+ onClick={stopRow}
834
+ >
835
+ <Icon.FileIcon />
836
+ <span className="cs-file-name">{file.name}</span>
837
+ <span className="cs-file-size">{formatSize(file.size)}</span>
838
+ </a>
839
+ ))}
840
+ </div>
841
+ );
842
+ }
843
+
844
+ /**
845
+ * The image, video, or replay open beside a thread. Arrows walk the thread's other previewable files. With
846
+ * `onMarkup`, a draft image can be marked up; `onMarkingUp` hears when the editor opens and closes.
847
+ */
848
+ export function MediaPane({
849
+ files,
850
+ fileId,
851
+ onSelect,
852
+ onClose,
853
+ onMarkup,
854
+ onMarkingUp,
855
+ }: {
856
+ files: PreviewFile[];
857
+ fileId: string;
858
+ onSelect: (fileId: string) => void;
859
+ onClose: () => void;
860
+ onMarkup?: (fileId: string, result: MarkupResult) => void;
861
+ onMarkingUp?: (open: boolean) => void;
862
+ }) {
863
+ const pane = useRef<HTMLElement>(null);
864
+ const stage = useRef<HTMLDivElement>(null);
865
+ const [size, setSize] = useState<{ w: number; h: number } | null>(null);
866
+ const [zoom, setZoom] = useState(1);
867
+ const [pan, setPan] = useState({ x: 0, y: 0 });
868
+ const [panning, setPanning] = useState(false);
869
+ const zoomRef = useRef(1);
870
+ const panRef = useRef(pan);
871
+ useLayoutEffect(() => {
872
+ zoomRef.current = zoom;
873
+ panRef.current = pan;
874
+ }, [pan, zoom]);
875
+ const [zoomFile, setZoomFile] = useState(fileId);
876
+ const [marking, setMarking] = useState(false);
877
+ if (zoomFile !== fileId) {
878
+ setZoomFile(fileId);
879
+ setZoom(1);
880
+ setPan({ x: 0, y: 0 });
881
+ setPanning(false);
882
+ setMarking(false);
883
+ }
884
+ const markingUp = useRef(onMarkingUp);
885
+ useLayoutEffect(() => {
886
+ markingUp.current = onMarkingUp;
887
+ });
888
+ useEffect(() => {
889
+ if (!marking) return;
890
+ markingUp.current?.(true);
891
+ return () => markingUp.current?.(false);
892
+ }, [marking]);
893
+ // The grip sits on the bottom corner away from the card, so dragging it never pushes the pane over the card.
894
+ const [gripX, setGripX] = useState<"left" | "right">("right");
895
+ // allow-react-loops: measures after every commit on purpose; the setter below keeps the previous state when nothing moved
896
+ useLayoutEffect(() => {
897
+ const node = pane.current;
898
+ if (!node) return;
899
+ const card = node.parentElement?.closest(".cs-media-pop")?.parentElement;
900
+ const own = node.getBoundingClientRect();
901
+ const other = card?.getBoundingClientRect();
902
+ const x = other && own.left + own.width / 2 < other.left + other.width / 2 ? "left" : "right";
903
+ setGripX(x);
904
+ });
905
+ useEffect(() => {
906
+ // The toolbar stops presses at the window, so the grip listens there too.
907
+ let drag: {
908
+ sx: number;
909
+ sy: number;
910
+ w: number;
911
+ h: number;
912
+ max: { w: number; h: number };
913
+ } | null = null;
914
+ const onDown = (event: PointerEvent) => {
915
+ const node = pane.current;
916
+ const target = event.target;
917
+ if (
918
+ event.button !== 0 ||
919
+ !node ||
920
+ !(target instanceof Element) ||
921
+ !target.closest(".cs-media-resize") ||
922
+ !node.contains(target)
923
+ )
924
+ return;
925
+ const root = node.closest("#cs-root")?.getBoundingClientRect();
926
+ const box = node.getBoundingClientRect();
927
+ drag = {
928
+ sx: event.clientX,
929
+ sy: event.clientY,
930
+ w: box.width,
931
+ h: box.height,
932
+ max: {
933
+ w: (root?.width ?? window.innerWidth) - 16,
934
+ h: (root?.height ?? window.innerHeight) - 16,
935
+ },
936
+ };
937
+ if (target instanceof HTMLElement) target.setPointerCapture(event.pointerId);
938
+ };
939
+ const onMove = (event: PointerEvent) => {
940
+ if (!drag) return;
941
+ const dx = event.clientX - drag.sx;
942
+ const dy = event.clientY - drag.sy;
943
+ const w = drag.w + (gripX === "right" ? dx : -dx);
944
+ const h = drag.h + dy;
945
+ setSize({
946
+ w: Math.round(Math.min(drag.max.w, Math.max(220, w))),
947
+ h: Math.round(Math.min(drag.max.h, Math.max(200, h))),
948
+ });
949
+ };
950
+ const onUp = () => {
951
+ drag = null;
952
+ };
953
+ window.addEventListener("pointerdown", onDown, true);
954
+ window.addEventListener("pointermove", onMove, true);
955
+ window.addEventListener("pointerup", onUp, true);
956
+ window.addEventListener("pointercancel", onUp, true);
957
+ return () => {
958
+ window.removeEventListener("pointerdown", onDown, true);
959
+ window.removeEventListener("pointermove", onMove, true);
960
+ window.removeEventListener("pointerup", onUp, true);
961
+ window.removeEventListener("pointercancel", onUp, true);
962
+ };
963
+ }, [gripX]);
964
+ const viewing = files.some((item) => item.id === fileId);
965
+ const previewingReplay = files.some((item) => item.id === fileId && isReplayFile(item));
966
+ const editing = marking && Boolean(onMarkup);
967
+ useEffect(() => {
968
+ const node = stage.current;
969
+ if (!node || !viewing || previewingReplay || editing) return;
970
+ const box = () => node.getBoundingClientRect();
971
+ const apply = (next: number, origin: { x: number; y: number }) => {
972
+ const level = clampZoom(next);
973
+ const shifted = clampPan(origin, level, box());
974
+ zoomRef.current = level;
975
+ panRef.current = shifted;
976
+ setZoom(level);
977
+ setPan(shifted);
978
+ };
979
+ const onWheel = (event: WheelEvent) => {
980
+ event.preventDefault();
981
+ const current = zoomRef.current;
982
+ const pixels =
983
+ event.deltaMode === 1
984
+ ? event.deltaY * 16
985
+ : event.deltaMode === 2
986
+ ? event.deltaY * box().height
987
+ : event.deltaY;
988
+ const next = clampZoom(current * Math.exp(-pixels * 0.0018));
989
+ if (next === current) return;
990
+ const frame = box();
991
+ const cx = event.clientX - (frame.left + frame.width / 2);
992
+ const cy = event.clientY - (frame.top + frame.height / 2);
993
+ const ratio = next / current;
994
+ const origin = panRef.current;
995
+ apply(next, {
996
+ x: cx - (cx - origin.x) * ratio,
997
+ y: cy - (cy - origin.y) * ratio,
998
+ });
999
+ };
1000
+ const onKey = (event: KeyboardEvent) => {
1001
+ if (event.metaKey || event.ctrlKey || event.altKey) return;
1002
+ const target = event.target;
1003
+ if (
1004
+ target instanceof HTMLElement &&
1005
+ (target.isContentEditable || target.closest("input, textarea, select"))
1006
+ )
1007
+ return;
1008
+ const zoomIn = event.key === "+" || event.key === "=";
1009
+ const zoomOut = event.key === "-" || event.key === "_";
1010
+ if (!zoomIn && !zoomOut) return;
1011
+ event.preventDefault();
1012
+ apply(stepZoom(zoomRef.current, zoomIn ? 1 : -1), panRef.current);
1013
+ };
1014
+ let drag: {
1015
+ sx: number;
1016
+ sy: number;
1017
+ x: number;
1018
+ y: number;
1019
+ moved: boolean;
1020
+ pointerId: number;
1021
+ } | null = null;
1022
+ const onDown = (event: PointerEvent) => {
1023
+ if (event.button !== 0 || zoomRef.current <= 1) return;
1024
+ const target = event.target;
1025
+ if (
1026
+ !(target instanceof Element) ||
1027
+ !node.contains(target) ||
1028
+ target.closest("button, .cs-media-resize")
1029
+ )
1030
+ return;
1031
+ if (target instanceof HTMLVideoElement) {
1032
+ const rect = target.getBoundingClientRect();
1033
+ if (event.clientY > rect.bottom - 36 * zoomRef.current) return;
1034
+ }
1035
+ drag = {
1036
+ sx: event.clientX,
1037
+ sy: event.clientY,
1038
+ x: panRef.current.x,
1039
+ y: panRef.current.y,
1040
+ moved: false,
1041
+ pointerId: event.pointerId,
1042
+ };
1043
+ };
1044
+ const onMove = (event: PointerEvent) => {
1045
+ if (!drag || event.pointerId !== drag.pointerId) return;
1046
+ const dx = event.clientX - drag.sx;
1047
+ const dy = event.clientY - drag.sy;
1048
+ if (!drag.moved) {
1049
+ if (Math.hypot(dx, dy) < 4) return;
1050
+ drag.moved = true;
1051
+ setPanning(true);
1052
+ try {
1053
+ node.setPointerCapture(event.pointerId);
1054
+ } catch {
1055
+ // The press can end before capture lands.
1056
+ }
1057
+ }
1058
+ setPan(clampPan({ x: drag.x + dx, y: drag.y + dy }, zoomRef.current, box()));
1059
+ };
1060
+ const onUp = (event: PointerEvent) => {
1061
+ if (!drag || event.pointerId !== drag.pointerId) return;
1062
+ drag = null;
1063
+ setPanning(false);
1064
+ };
1065
+ node.addEventListener("wheel", onWheel, { capture: true, passive: false });
1066
+ window.addEventListener("keydown", onKey);
1067
+ window.addEventListener("pointerdown", onDown, true);
1068
+ window.addEventListener("pointermove", onMove, true);
1069
+ window.addEventListener("pointerup", onUp, true);
1070
+ window.addEventListener("pointercancel", onUp, true);
1071
+ return () => {
1072
+ node.removeEventListener("wheel", onWheel, true);
1073
+ window.removeEventListener("keydown", onKey);
1074
+ window.removeEventListener("pointerdown", onDown, true);
1075
+ window.removeEventListener("pointermove", onMove, true);
1076
+ window.removeEventListener("pointerup", onUp, true);
1077
+ window.removeEventListener("pointercancel", onUp, true);
1078
+ };
1079
+ }, [editing, previewingReplay, viewing]);
1080
+ const index = files.findIndex((file) => file.id === fileId);
1081
+ const file = files[index];
1082
+ if (!file) return null;
1083
+ const markup = onMarkup && file.contentType.startsWith("image/") ? file.markup : undefined;
1084
+ const replay = isReplayFile(file);
1085
+ const many = files.length > 1;
1086
+ const step = (delta: number) => onSelect(files[(index + delta + files.length) % files.length].id);
1087
+ const applyZoom = (next: number) => {
1088
+ const level = clampZoom(next);
1089
+ const shifted = clampPan(panRef.current, level, stage.current?.getBoundingClientRect() ?? null);
1090
+ zoomRef.current = level;
1091
+ panRef.current = shifted;
1092
+ setZoom(level);
1093
+ setPan(shifted);
1094
+ };
1095
+ const fitted = zoom <= 1.01;
1096
+ const maxed = zoom >= ZOOM_MAX - 0.01;
1097
+ const sized: CSSProperties | undefined = size
1098
+ ? {
1099
+ width: size.w,
1100
+ height: size.h,
1101
+ flex: "none",
1102
+ position: "absolute",
1103
+ top: 0,
1104
+ [gripX === "right" ? "left" : "right"]: 0,
1105
+ }
1106
+ : undefined;
1107
+ const grip = (
1108
+ <button
1109
+ type="button"
1110
+ className={`cs-media-resize is-bottom-${gripX}`}
1111
+ aria-label="Resize preview"
1112
+ />
1113
+ );
1114
+ if (editing && markup) {
1115
+ return (
1116
+ <aside
1117
+ ref={pane}
1118
+ className="cs-media cs-hit"
1119
+ data-cs-ui="attachment-preview"
1120
+ data-attachment-id={file.id}
1121
+ aria-label={`Mark up ${markup.name}`}
1122
+ style={sized}
1123
+ >
1124
+ <MarkupEditor
1125
+ key={file.id}
1126
+ src={markup.src}
1127
+ name={markup.name}
1128
+ shapes={markup.shapes}
1129
+ maxBytes={MAX_BYTES}
1130
+ onCancel={() => setMarking(false)}
1131
+ onSave={(result) => {
1132
+ setMarking(false);
1133
+ onMarkup?.(file.id, result);
1134
+ }}
1135
+ />
1136
+ {grip}
1137
+ </aside>
1138
+ );
1139
+ }
1140
+ return (
1141
+ <aside
1142
+ ref={pane}
1143
+ className="cs-media cs-hit"
1144
+ data-cs-ui="attachment-preview"
1145
+ data-attachment-id={file.id}
1146
+ aria-label={file.name}
1147
+ style={sized}
1148
+ >
1149
+ <header className="cs-media-bar">
1150
+ <span className="cs-media-name" title={file.name}>
1151
+ {file.name}
1152
+ </span>
1153
+ {many ? (
1154
+ <span className="cs-media-count">
1155
+ {index + 1} of {files.length}
1156
+ </span>
1157
+ ) : null}
1158
+ {markup ? (
1159
+ <button
1160
+ type="button"
1161
+ className="cs-media-markup"
1162
+ data-cs-ui="markup-open"
1163
+ aria-label={`Mark up ${markup.name}`}
1164
+ onClick={() => setMarking(true)}
1165
+ >
1166
+ <Icon.Markup />
1167
+ Mark up
1168
+ </button>
1169
+ ) : null}
1170
+ {replay ? null : (
1171
+ <div className="cs-media-zoom" role="group" aria-label="Zoom">
1172
+ <button
1173
+ type="button"
1174
+ className="cs-icon-button"
1175
+ aria-label="Zoom out"
1176
+ disabled={fitted}
1177
+ onClick={() => applyZoom(stepZoom(zoomRef.current, -1))}
1178
+ >
1179
+ <Icon.ZoomOut />
1180
+ </button>
1181
+ <button
1182
+ type="button"
1183
+ className="cs-media-zoom-level"
1184
+ aria-label="Reset zoom"
1185
+ disabled={fitted}
1186
+ onClick={() => applyZoom(1)}
1187
+ >
1188
+ {Math.round(zoom * 100)}%
1189
+ </button>
1190
+ <button
1191
+ type="button"
1192
+ className="cs-icon-button"
1193
+ aria-label="Zoom in"
1194
+ disabled={maxed}
1195
+ onClick={() => applyZoom(stepZoom(zoomRef.current, 1))}
1196
+ >
1197
+ <Icon.ZoomIn />
1198
+ </button>
1199
+ </div>
1200
+ )}
1201
+ <button
1202
+ type="button"
1203
+ className="cs-icon-button"
1204
+ aria-label="Close preview"
1205
+ onClick={onClose}
1206
+ >
1207
+ <Icon.Close />
1208
+ </button>
1209
+ </header>
1210
+ <div
1211
+ ref={stage}
1212
+ className={`cs-media-stage${replay ? " is-replay" : ""}${zoom > 1 ? " is-zoomed" : ""}${panning ? " is-panning" : ""}`}
1213
+ >
1214
+ {many ? (
1215
+ <button
1216
+ type="button"
1217
+ className="cs-media-nav is-prev"
1218
+ aria-label="Previous attachment"
1219
+ onClick={() => step(-1)}
1220
+ >
1221
+ <Icon.ChevronLeft />
1222
+ </button>
1223
+ ) : null}
1224
+ {replay ? (
1225
+ <div className="cs-replay" data-cs-ui="replay">
1226
+ <ReplayBody url={file.url} linked />
1227
+ </div>
1228
+ ) : isVideo(file) ? (
1229
+ <video
1230
+ key={file.id}
1231
+ src={file.url}
1232
+ controls
1233
+ autoPlay
1234
+ playsInline
1235
+ style={{ transform: `translate(${pan.x}px, ${pan.y}px) scale(${zoom})` }}
1236
+ />
1237
+ ) : (
1238
+ <img
1239
+ key={file.id}
1240
+ src={file.url}
1241
+ alt={file.name}
1242
+ draggable={false}
1243
+ style={{ transform: `translate(${pan.x}px, ${pan.y}px) scale(${zoom})` }}
1244
+ />
1245
+ )}
1246
+ {many ? (
1247
+ <button
1248
+ type="button"
1249
+ className="cs-media-nav is-next"
1250
+ aria-label="Next attachment"
1251
+ onClick={() => step(1)}
1252
+ >
1253
+ <Icon.ChevronRight />
1254
+ </button>
1255
+ ) : null}
1256
+ </div>
1257
+ {grip}
1258
+ </aside>
1259
+ );
1260
+ }