@bendyline/squisq-video-react 2.2.10 → 2.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.
@@ -0,0 +1,401 @@
1
+ import {
2
+ useFrameCapture
3
+ } from "./chunk-YTEDBL6F.js";
4
+
5
+ // src/CoverImageExportModal.tsx
6
+ import { useCallback, useId, useRef, useState } from "react";
7
+ import { useModalDialog } from "@bendyline/squisq-react";
8
+ import { jsx, jsxs } from "react/jsx-runtime";
9
+ var MIN_DIMENSION = 64;
10
+ var MAX_DIMENSION = 7680;
11
+ var MAX_PIXELS = 33177600;
12
+ var COVER_IMAGE_SIZE_PRESETS = [{ label: "YouTube cover", width: 1280, height: 720 }];
13
+ var FORMAT_DETAILS = {
14
+ png: { extension: "png", mime: "image/png", label: "PNG \u2014 lossless" },
15
+ jpeg: { extension: "jpg", mime: "image/jpeg", label: "JPEG \u2014 smaller file" },
16
+ webp: { extension: "webp", mime: "image/webp", label: "WebP \u2014 compact" }
17
+ };
18
+ function validateCoverImageDimensions(width, height) {
19
+ if (!Number.isSafeInteger(width) || !Number.isSafeInteger(height)) {
20
+ return "Width and height must be whole numbers.";
21
+ }
22
+ if (width < MIN_DIMENSION || height < MIN_DIMENSION || width > MAX_DIMENSION || height > MAX_DIMENSION) {
23
+ return `Width and height must be between ${MIN_DIMENSION} and ${MAX_DIMENSION} pixels.`;
24
+ }
25
+ if (width * height > MAX_PIXELS) {
26
+ return "Resolution is too large. Choose a size at or below 33 megapixels.";
27
+ }
28
+ return null;
29
+ }
30
+ function coverImageFilename(requestedName, format) {
31
+ const extension = FORMAT_DETAILS[format].extension;
32
+ const base = requestedName?.replace(/\.[^.]+$/, "").replace(/[<>:"/\\|?*]/g, "-").split("").map((character) => character.charCodeAt(0) < 32 ? "-" : character).join("").trim().replace(/[. ]+$/g, "") || "document";
33
+ return `${base}-cover.${extension}`;
34
+ }
35
+ function canvasToBlob(canvas, format, quality) {
36
+ return new Promise((resolve, reject) => {
37
+ canvas.toBlob(
38
+ (blob) => {
39
+ if (blob) resolve(blob);
40
+ else reject(new Error("The browser could not encode the cover image."));
41
+ },
42
+ FORMAT_DETAILS[format].mime,
43
+ format === "png" ? void 0 : quality
44
+ );
45
+ });
46
+ }
47
+ async function chooseSaveTarget(filename, format) {
48
+ const picker = window.showSaveFilePicker;
49
+ if (!picker) return void 0;
50
+ const details = FORMAT_DETAILS[format];
51
+ try {
52
+ return await picker.call(window, {
53
+ suggestedName: filename,
54
+ types: [
55
+ {
56
+ description: `${details.label.split(" \u2014")[0]} image`,
57
+ accept: { [details.mime]: [`.${details.extension}`] }
58
+ }
59
+ ]
60
+ });
61
+ } catch (caught) {
62
+ if (caught instanceof DOMException && caught.name === "AbortError") return null;
63
+ throw caught;
64
+ }
65
+ }
66
+ function downloadBlob(blob, filename) {
67
+ const url = URL.createObjectURL(blob);
68
+ const anchor = document.createElement("a");
69
+ anchor.href = url;
70
+ anchor.download = filename;
71
+ document.body.appendChild(anchor);
72
+ anchor.click();
73
+ anchor.remove();
74
+ window.setTimeout(() => URL.revokeObjectURL(url), 0);
75
+ }
76
+ var overlayStyle = {
77
+ position: "fixed",
78
+ inset: 0,
79
+ zIndex: 1e4,
80
+ display: "flex",
81
+ alignItems: "center",
82
+ justifyContent: "center",
83
+ background: "rgba(0, 0, 0, 0.55)"
84
+ };
85
+ var rowStyle = {
86
+ display: "grid",
87
+ gridTemplateColumns: "1fr 1fr",
88
+ gap: 12
89
+ };
90
+ var labelStyle = {
91
+ display: "grid",
92
+ gap: 5,
93
+ marginBottom: 12,
94
+ fontSize: 13,
95
+ fontWeight: 600
96
+ };
97
+ function CoverImageExportModal({
98
+ doc,
99
+ mediaProvider,
100
+ theme,
101
+ coverSlideTemplate,
102
+ defaultWidth = 1920,
103
+ defaultHeight = 1080,
104
+ defaultFileName,
105
+ colorScheme = "light",
106
+ saveOutput,
107
+ onClose
108
+ }) {
109
+ const overlayRef = useRef(null);
110
+ const dialogRef = useRef(null);
111
+ const titleId = useId();
112
+ const capture = useFrameCapture();
113
+ const [format, setFormat] = useState("png");
114
+ const [width, setWidth] = useState(defaultWidth);
115
+ const [height, setHeight] = useState(defaultHeight);
116
+ const [quality, setQuality] = useState(0.92);
117
+ const [busy, setBusy] = useState(false);
118
+ const [error, setError] = useState(null);
119
+ const dark = colorScheme === "dark";
120
+ const surface = dark ? "#111827" : "#ffffff";
121
+ const control = dark ? "#0f172a" : "#ffffff";
122
+ const text = dark ? "#f8fafc" : "#1f2937";
123
+ const muted = dark ? "#94a3b8" : "#6b7280";
124
+ const border = dark ? "#475569" : "#cbd5e1";
125
+ const filename = coverImageFilename(defaultFileName, format);
126
+ const dimensionError = validateCoverImageDimensions(width, height);
127
+ const handleClose = useCallback(() => {
128
+ if (busy) return;
129
+ capture.destroy();
130
+ onClose();
131
+ }, [busy, capture, onClose]);
132
+ useModalDialog({
133
+ rootRef: overlayRef,
134
+ dialogRef,
135
+ closeOnEscape: !busy,
136
+ onClose: handleClose
137
+ });
138
+ const handleExport = useCallback(async () => {
139
+ if (dimensionError || !doc.startBlock) return;
140
+ setError(null);
141
+ try {
142
+ const saveTarget = saveOutput ? void 0 : await chooseSaveTarget(filename, format);
143
+ if (saveTarget === null) return;
144
+ setBusy(true);
145
+ await capture.init(
146
+ doc,
147
+ {
148
+ width,
149
+ height,
150
+ animationsEnabled: false,
151
+ theme,
152
+ mediaProvider: mediaProvider ?? void 0,
153
+ showCoverSlide: true,
154
+ coverSlideTemplate
155
+ },
156
+ "off"
157
+ );
158
+ await capture.setCoverVisible(true);
159
+ const canvas = await capture.captureCanvasFrame(0);
160
+ const blob = await canvasToBlob(canvas, format, quality);
161
+ if (saveOutput) {
162
+ const saved = await saveOutput(blob, filename);
163
+ if (saved === false) {
164
+ capture.destroy();
165
+ return;
166
+ }
167
+ } else if (saveTarget) {
168
+ const writable = await saveTarget.createWritable();
169
+ await writable.write(blob);
170
+ await writable.close();
171
+ } else {
172
+ downloadBlob(blob, filename);
173
+ }
174
+ capture.destroy();
175
+ onClose();
176
+ } catch (caught) {
177
+ capture.destroy();
178
+ setError(caught instanceof Error ? caught.message : "The cover image could not be exported.");
179
+ } finally {
180
+ setBusy(false);
181
+ }
182
+ }, [
183
+ capture,
184
+ coverSlideTemplate,
185
+ dimensionError,
186
+ doc,
187
+ filename,
188
+ format,
189
+ height,
190
+ mediaProvider,
191
+ onClose,
192
+ quality,
193
+ saveOutput,
194
+ theme,
195
+ width
196
+ ]);
197
+ const fieldStyle = {
198
+ boxSizing: "border-box",
199
+ width: "100%",
200
+ minHeight: 34,
201
+ border: `1px solid ${border}`,
202
+ borderRadius: 4,
203
+ background: control,
204
+ color: text,
205
+ padding: "6px 8px",
206
+ colorScheme
207
+ };
208
+ return /* @__PURE__ */ jsx(
209
+ "div",
210
+ {
211
+ ref: overlayRef,
212
+ style: overlayStyle,
213
+ "data-color-scheme": colorScheme,
214
+ onClick: (event) => event.stopPropagation(),
215
+ children: /* @__PURE__ */ jsxs(
216
+ "div",
217
+ {
218
+ ref: dialogRef,
219
+ role: "dialog",
220
+ "aria-modal": "true",
221
+ "aria-labelledby": titleId,
222
+ tabIndex: -1,
223
+ style: {
224
+ position: "relative",
225
+ boxSizing: "border-box",
226
+ width: "min(440px, calc(100vw - 32px))",
227
+ padding: 24,
228
+ border: `1px solid ${border}`,
229
+ borderRadius: 8,
230
+ background: surface,
231
+ color: text,
232
+ boxShadow: "0 18px 48px rgba(0, 0, 0, 0.28)",
233
+ fontFamily: "system-ui, -apple-system, sans-serif",
234
+ colorScheme
235
+ },
236
+ onClick: (event) => event.stopPropagation(),
237
+ children: [
238
+ /* @__PURE__ */ jsx("h2", { id: titleId, style: { margin: "0 36px 18px 0", fontSize: 19 }, children: "Export cover slide" }),
239
+ /* @__PURE__ */ jsx(
240
+ "button",
241
+ {
242
+ type: "button",
243
+ "aria-label": "Close cover image export",
244
+ disabled: busy,
245
+ onClick: handleClose,
246
+ style: {
247
+ position: "absolute",
248
+ top: 12,
249
+ right: 12,
250
+ width: 32,
251
+ height: 32,
252
+ border: 0,
253
+ background: "transparent",
254
+ color: text,
255
+ fontSize: 24,
256
+ cursor: busy ? "default" : "pointer"
257
+ },
258
+ children: /* @__PURE__ */ jsx("span", { "aria-hidden": "true", children: "\xD7" })
259
+ }
260
+ ),
261
+ /* @__PURE__ */ jsxs("label", { style: labelStyle, children: [
262
+ "Format",
263
+ /* @__PURE__ */ jsx(
264
+ "select",
265
+ {
266
+ "aria-label": "Image format",
267
+ value: format,
268
+ disabled: busy,
269
+ onChange: (event) => setFormat(event.target.value),
270
+ style: fieldStyle,
271
+ children: Object.entries(FORMAT_DETAILS).map(([value, details]) => /* @__PURE__ */ jsx("option", { value, children: details.label }, value))
272
+ }
273
+ )
274
+ ] }),
275
+ /* @__PURE__ */ jsxs("div", { style: rowStyle, children: [
276
+ /* @__PURE__ */ jsxs("label", { style: labelStyle, children: [
277
+ "Width",
278
+ /* @__PURE__ */ jsx(
279
+ "input",
280
+ {
281
+ "aria-label": "Image width",
282
+ type: "number",
283
+ min: MIN_DIMENSION,
284
+ max: MAX_DIMENSION,
285
+ step: 1,
286
+ value: width,
287
+ disabled: busy,
288
+ onChange: (event) => setWidth(Number(event.target.value)),
289
+ style: fieldStyle
290
+ }
291
+ )
292
+ ] }),
293
+ /* @__PURE__ */ jsxs("label", { style: labelStyle, children: [
294
+ "Height",
295
+ /* @__PURE__ */ jsx(
296
+ "input",
297
+ {
298
+ "aria-label": "Image height",
299
+ type: "number",
300
+ min: MIN_DIMENSION,
301
+ max: MAX_DIMENSION,
302
+ step: 1,
303
+ value: height,
304
+ disabled: busy,
305
+ onChange: (event) => setHeight(Number(event.target.value)),
306
+ style: fieldStyle
307
+ }
308
+ )
309
+ ] })
310
+ ] }),
311
+ /* @__PURE__ */ jsxs("div", { style: { display: "flex", flexWrap: "wrap", gap: 8, margin: "-2px 0 14px" }, children: [
312
+ /* @__PURE__ */ jsx(
313
+ "button",
314
+ {
315
+ type: "button",
316
+ disabled: busy,
317
+ onClick: () => {
318
+ setWidth(defaultWidth);
319
+ setHeight(defaultHeight);
320
+ },
321
+ children: "1\xD7"
322
+ }
323
+ ),
324
+ /* @__PURE__ */ jsx(
325
+ "button",
326
+ {
327
+ type: "button",
328
+ disabled: busy,
329
+ onClick: () => {
330
+ setWidth(defaultWidth * 2);
331
+ setHeight(defaultHeight * 2);
332
+ },
333
+ children: "2\xD7"
334
+ }
335
+ ),
336
+ COVER_IMAGE_SIZE_PRESETS.map((preset) => /* @__PURE__ */ jsx(
337
+ "button",
338
+ {
339
+ type: "button",
340
+ disabled: busy,
341
+ title: `${preset.width} \xD7 ${preset.height} pixels`,
342
+ onClick: () => {
343
+ setWidth(preset.width);
344
+ setHeight(preset.height);
345
+ },
346
+ children: preset.label
347
+ },
348
+ preset.label
349
+ )),
350
+ /* @__PURE__ */ jsxs("span", { style: { alignSelf: "center", color: muted, fontSize: 12 }, children: [
351
+ width.toLocaleString(),
352
+ " \xD7 ",
353
+ height.toLocaleString(),
354
+ " pixels"
355
+ ] })
356
+ ] }),
357
+ format !== "png" && /* @__PURE__ */ jsxs("label", { style: labelStyle, children: [
358
+ "Quality: ",
359
+ Math.round(quality * 100),
360
+ "%",
361
+ /* @__PURE__ */ jsx(
362
+ "input",
363
+ {
364
+ "aria-label": "Image quality",
365
+ type: "range",
366
+ min: 0.5,
367
+ max: 1,
368
+ step: 0.05,
369
+ value: quality,
370
+ disabled: busy,
371
+ onChange: (event) => setQuality(Number(event.target.value))
372
+ }
373
+ )
374
+ ] }),
375
+ (dimensionError || error) && /* @__PURE__ */ jsx("p", { role: "alert", style: { margin: "0 0 12px", color: dark ? "#fca5a5" : "#b91c1c" }, children: dimensionError ?? error }),
376
+ !doc.startBlock && /* @__PURE__ */ jsx("p", { role: "alert", style: { margin: "0 0 12px", color: dark ? "#fca5a5" : "#b91c1c" }, children: "This document does not have a cover slide to export." }),
377
+ /* @__PURE__ */ jsxs("div", { style: { display: "flex", justifyContent: "flex-end", gap: 8 }, children: [
378
+ /* @__PURE__ */ jsx("button", { type: "button", disabled: busy, onClick: handleClose, children: "Cancel" }),
379
+ /* @__PURE__ */ jsx(
380
+ "button",
381
+ {
382
+ type: "button",
383
+ disabled: busy || !!dimensionError || !doc.startBlock,
384
+ onClick: () => void handleExport(),
385
+ style: { minWidth: 132 },
386
+ children: busy ? "Rendering\u2026" : "Choose location\u2026"
387
+ }
388
+ )
389
+ ] })
390
+ ]
391
+ }
392
+ )
393
+ }
394
+ );
395
+ }
396
+
397
+ export {
398
+ validateCoverImageDimensions,
399
+ coverImageFilename,
400
+ CoverImageExportModal
401
+ };