@devaccel-labs/app-comments-toolbar 1.0.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.
- package/README.md +152 -18
- package/dist/react.js +516 -61
- package/dist/server.js +342 -14
- package/dist/types/capture/api.d.ts +30 -0
- package/dist/types/capture/editor.d.ts +10 -0
- package/dist/types/capture/ignore.d.ts +19 -0
- package/dist/types/capture/index.d.ts +1 -0
- package/dist/types/capture/picker.d.ts +9 -0
- package/dist/types/capture/picture.d.ts +29 -0
- package/dist/types/capture/surface.d.ts +39 -0
- package/dist/types/core/index.d.ts +2 -2
- package/dist/types/core/resolve.d.ts +2 -1
- package/dist/types/core/types.d.ts +11 -0
- package/dist/types/debug-context.d.ts +7 -0
- package/dist/types/dictation.d.ts +9 -0
- package/dist/types/embed/controller.d.ts +61 -0
- package/dist/types/embed/index.d.ts +1 -0
- package/dist/types/embed/options.d.ts +35 -0
- package/dist/types/help/index.d.ts +1 -0
- package/dist/types/help/panel-index.d.ts +1 -0
- package/dist/types/help/panel-styles.d.ts +1 -0
- package/dist/types/help/panel.d.ts +4 -0
- package/dist/types/help/pill-styles.d.ts +11 -0
- package/dist/types/help/pill.d.ts +44 -0
- package/dist/types/help/react.d.ts +12 -0
- package/dist/types/help/requests.d.ts +2 -0
- package/dist/types/help/types.d.ts +119 -0
- package/dist/types/help/wire.d.ts +61 -0
- package/dist/types/native-wire.d.ts +63 -0
- package/dist/types/native.d.ts +43 -0
- package/dist/types/react.d.ts +8 -1
- package/dist/types/replay.d.ts +6 -0
- package/dist/types/runtime/api.d.ts +250 -0
- package/dist/types/runtime/attachment-model.d.ts +16 -0
- package/dist/types/runtime/attachments.d.ts +43 -7
- package/dist/types/runtime/boot.d.ts +39 -0
- package/dist/types/runtime/browser-label.d.ts +5 -0
- package/dist/types/runtime/build-id.d.ts +7 -0
- package/dist/types/runtime/capture-ui.d.ts +45 -21
- package/dist/types/runtime/clarity-model.d.ts +260 -0
- package/dist/types/runtime/clarity-ui.d.ts +62 -0
- package/dist/types/runtime/client.d.ts +6 -197
- package/dist/types/runtime/composer-size.d.ts +30 -0
- package/dist/types/runtime/dashboard.d.ts +17 -2
- package/dist/types/runtime/debug-context-core.d.ts +88 -0
- package/dist/types/runtime/debug-context-host.d.ts +39 -0
- package/dist/types/runtime/debug-context-model.d.ts +352 -0
- package/dist/types/runtime/debug-context-redact.d.ts +48 -0
- package/dist/types/runtime/debug-context.d.ts +36 -0
- package/dist/types/runtime/details-model.d.ts +7 -0
- package/dist/types/runtime/details.d.ts +7 -5
- package/dist/types/runtime/dictation-engine.d.ts +98 -0
- package/dist/types/runtime/dictation-model.d.ts +28 -0
- package/dist/types/runtime/dictation-ui.d.ts +56 -0
- package/dist/types/runtime/dictation.d.ts +66 -0
- package/dist/types/runtime/draft-dock.d.ts +32 -0
- package/dist/types/runtime/draft-model.d.ts +104 -0
- package/dist/types/runtime/draft-store.d.ts +15 -0
- package/dist/types/runtime/edits.d.ts +1 -1
- package/dist/types/runtime/font.d.ts +16 -0
- package/dist/types/runtime/format.d.ts +30 -1
- package/dist/types/runtime/host-watch.d.ts +20 -0
- package/dist/types/runtime/icons.d.ts +16 -2
- package/dist/types/runtime/index.d.ts +3 -1
- package/dist/types/runtime/ink.d.ts +8 -0
- package/dist/types/runtime/kind-model.d.ts +14 -0
- package/dist/types/runtime/live-model.d.ts +25 -0
- package/dist/types/runtime/loader.d.ts +5 -11
- package/dist/types/runtime/markup-editor.d.ts +54 -0
- package/dist/types/runtime/markup-model.d.ts +126 -0
- package/dist/types/runtime/markup.d.ts +69 -0
- package/dist/types/runtime/mention-parse.d.ts +18 -0
- package/dist/types/runtime/mentions.d.ts +4 -4
- package/dist/types/runtime/page-background.d.ts +9 -0
- package/dist/types/runtime/page-image.d.ts +123 -0
- package/dist/types/runtime/placement.d.ts +6 -3
- package/dist/types/runtime/record.d.ts +18 -2
- package/dist/types/runtime/related.d.ts +1 -1
- package/dist/types/runtime/replay-model.d.ts +80 -0
- package/dist/types/runtime/replay.d.ts +5 -38
- package/dist/types/runtime/runtime.d.ts +6 -0
- package/dist/types/runtime/screen.d.ts +47 -0
- package/dist/types/runtime/screenshot.d.ts +60 -6
- package/dist/types/runtime/session-source.d.ts +45 -0
- package/dist/types/runtime/session.d.ts +20 -18
- package/dist/types/runtime/shield.d.ts +9 -0
- package/dist/types/runtime/shot.d.ts +45 -0
- package/dist/types/runtime/sketch.d.ts +26 -0
- package/dist/types/runtime/styles.d.ts +1 -1
- package/dist/types/runtime/sync.d.ts +1 -0
- package/dist/types/runtime/thread-model.d.ts +18 -0
- package/dist/types/runtime/ticket-form-model.d.ts +57 -0
- package/dist/types/runtime/ticket-form.d.ts +9 -0
- package/dist/types/runtime/toolbar.d.ts +37 -1
- package/dist/types/runtime/ui-scale.d.ts +17 -0
- package/dist/types/runtime/update.d.ts +37 -0
- package/dist/types/runtime/use-debug-context.d.ts +27 -0
- package/dist/types/server/index.d.ts +22 -0
- package/dist/types/server/support.d.ts +20 -0
- package/dist/types/server.d.ts +2 -0
- package/dist/types/vite/plugin.d.ts +15 -0
- package/dist/vite.js +121 -41
- package/package.json +26 -13
- package/src/capture/api.ts +128 -0
- package/src/capture/editor.tsx +178 -0
- package/src/capture/ignore.ts +38 -0
- package/src/capture/index.ts +27 -0
- package/src/capture/picker.tsx +171 -0
- package/src/capture/picture.ts +90 -0
- package/src/capture/surface.tsx +185 -0
- package/src/compiler/annotate.ts +202 -0
- package/src/compiler/babel.d.ts +11 -0
- package/src/compiler/enabled.ts +55 -0
- package/src/compiler/index.ts +2 -0
- package/src/core/index.ts +8 -0
- package/src/core/resolve.ts +307 -0
- package/src/core/types.ts +58 -0
- package/src/debug-context.ts +47 -0
- package/src/dictation.ts +17 -0
- package/src/embed/controller.ts +215 -0
- package/src/embed/index.ts +68 -0
- package/src/embed/options.ts +100 -0
- package/src/help/index.ts +38 -0
- package/src/help/panel-index.ts +17 -0
- package/src/help/panel-styles.ts +123 -0
- package/src/help/panel.ts +840 -0
- package/src/help/pill-styles.ts +75 -0
- package/src/help/pill.ts +338 -0
- package/src/help/react.tsx +143 -0
- package/src/help/requests.ts +111 -0
- package/src/help/types.ts +124 -0
- package/src/help/wire.ts +55 -0
- package/src/native-wire.ts +124 -0
- package/src/native.ts +282 -0
- package/src/react.ts +27 -0
- package/src/replay.ts +13 -0
- package/src/runtime/api.ts +531 -0
- package/src/runtime/attachment-model.ts +42 -0
- package/src/runtime/attachments.tsx +1260 -0
- package/src/runtime/boot.ts +95 -0
- package/src/runtime/browser-label.ts +31 -0
- package/src/runtime/build-id.ts +10 -0
- package/src/runtime/capture-ui.tsx +306 -0
- package/src/runtime/capture.ts +508 -0
- package/src/runtime/clarity-model.ts +634 -0
- package/src/runtime/clarity-ui.tsx +454 -0
- package/src/runtime/client.ts +151 -0
- package/src/runtime/composer-size.tsx +146 -0
- package/src/runtime/dashboard.tsx +1130 -0
- package/src/runtime/debug-context-core.ts +681 -0
- package/src/runtime/debug-context-host.ts +56 -0
- package/src/runtime/debug-context-model.ts +1319 -0
- package/src/runtime/debug-context-redact.ts +259 -0
- package/src/runtime/debug-context.ts +613 -0
- package/src/runtime/details-model.ts +30 -0
- package/src/runtime/details.tsx +395 -0
- package/src/runtime/dictation-engine.ts +616 -0
- package/src/runtime/dictation-model.ts +79 -0
- package/src/runtime/dictation-ui.tsx +363 -0
- package/src/runtime/dictation.ts +271 -0
- package/src/runtime/dock.ts +108 -0
- package/src/runtime/draft-dock.tsx +124 -0
- package/src/runtime/draft-model.ts +251 -0
- package/src/runtime/draft-store.ts +149 -0
- package/src/runtime/edits.tsx +165 -0
- package/src/runtime/font.ts +42 -0
- package/src/runtime/format.ts +129 -0
- package/src/runtime/freshness.ts +23 -0
- package/src/runtime/handoff.ts +76 -0
- package/src/runtime/host-watch.ts +64 -0
- package/src/runtime/icons.tsx +295 -0
- package/src/runtime/idle.ts +20 -0
- package/src/runtime/index.ts +11 -0
- package/src/runtime/ink.ts +6 -0
- package/src/runtime/kind-model.ts +20 -0
- package/src/runtime/live-model.ts +32 -0
- package/src/runtime/loader.tsx +87 -0
- package/src/runtime/markup-editor.tsx +1333 -0
- package/src/runtime/markup-model.ts +485 -0
- package/src/runtime/markup.ts +382 -0
- package/src/runtime/members.ts +58 -0
- package/src/runtime/mention-parse.ts +56 -0
- package/src/runtime/mentions.tsx +205 -0
- package/src/runtime/page-background.ts +51 -0
- package/src/runtime/page-image.ts +1592 -0
- package/src/runtime/placement.ts +336 -0
- package/src/runtime/record.ts +402 -0
- package/src/runtime/redact.ts +30 -0
- package/src/runtime/related.ts +34 -0
- package/src/runtime/render-page.ts +2 -0
- package/src/runtime/replay-model.ts +232 -0
- package/src/runtime/replay.ts +173 -0
- package/src/runtime/runtime-version.ts +7 -0
- package/src/runtime/runtime.tsx +51 -0
- package/src/runtime/screen.ts +182 -0
- package/src/runtime/screenshot.ts +260 -0
- package/src/runtime/session-source.ts +112 -0
- package/src/runtime/session.ts +108 -0
- package/src/runtime/shield.ts +34 -0
- package/src/runtime/shot.tsx +422 -0
- package/src/runtime/sketch.ts +139 -0
- package/src/runtime/styles.ts +1541 -0
- package/src/runtime/sync.ts +300 -0
- package/src/runtime/thread-model.ts +71 -0
- package/src/runtime/ticket-form-model.ts +128 -0
- package/src/runtime/ticket-form.tsx +342 -0
- package/src/runtime/toolbar.tsx +4427 -0
- package/src/runtime/ui-scale.ts +75 -0
- package/src/runtime/update.ts +98 -0
- package/src/runtime/use-debug-context.ts +81 -0
- package/src/server/index.ts +133 -0
- package/src/server/support.ts +499 -0
- package/src/server.ts +4 -0
- package/src/vite/plugin.ts +102 -0
- package/src/vite.ts +1 -0
- package/dist/chunks/chunk-CSKXLF33.js +0 -153
- package/dist/chunks/chunk-HUM5FQII.js +0 -160
- package/dist/chunks/render-page-76MLIT6M.js +0 -1421
- package/dist/chunks/runtime-JX75TKER.js +0 -9267
- package/dist/chunks/screenshot-66ARTBGR.js +0 -12
|
@@ -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
|
+
}
|