@bendyline/squisq-editor-react 2.2.0 → 2.3.1

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,3256 @@
1
+ import {
2
+ Icon
3
+ } from "./chunk-GS7QWYFT.js";
4
+
5
+ // src/ImageViewer.tsx
6
+ import { useCallback, useEffect, useRef, useState } from "react";
7
+ import { Fragment, jsx, jsxs } from "react/jsx-runtime";
8
+ var MIN_ZOOM = 0.1;
9
+ var MAX_ZOOM = 16;
10
+ var ZOOM_STEP = 1.25;
11
+ function ImageViewer({ src, alt = "", className, theme = "light" }) {
12
+ const imgRef = useRef(null);
13
+ const stageRef = useRef(null);
14
+ const [naturalSize, setNaturalSize] = useState(null);
15
+ const [fitZoom, setFitZoom] = useState(1);
16
+ const [state, setState] = useState({ mode: "fit" });
17
+ const [pan, setPan] = useState({ x: 0, y: 0 });
18
+ const [error, setError] = useState(null);
19
+ useEffect(() => {
20
+ setNaturalSize(null);
21
+ setState({ mode: "fit" });
22
+ setPan({ x: 0, y: 0 });
23
+ setError(null);
24
+ }, [src]);
25
+ const recomputeFitZoom = useCallback(() => {
26
+ const stage = stageRef.current;
27
+ if (!stage || !naturalSize) return;
28
+ const { clientWidth, clientHeight } = stage;
29
+ if (clientWidth === 0 || clientHeight === 0) return;
30
+ const fit = Math.min(clientWidth / naturalSize.w, clientHeight / naturalSize.h, 1);
31
+ setFitZoom(fit > 0 ? fit : 1);
32
+ }, [naturalSize]);
33
+ useEffect(() => {
34
+ recomputeFitZoom();
35
+ if (typeof ResizeObserver === "undefined") return;
36
+ const stage = stageRef.current;
37
+ if (!stage) return;
38
+ const ro = new ResizeObserver(() => recomputeFitZoom());
39
+ ro.observe(stage);
40
+ return () => ro.disconnect();
41
+ }, [recomputeFitZoom]);
42
+ const handleLoad = useCallback(() => {
43
+ const img = imgRef.current;
44
+ if (!img) return;
45
+ setNaturalSize({ w: img.naturalWidth, h: img.naturalHeight });
46
+ }, []);
47
+ const handleError = useCallback(() => {
48
+ setError("Failed to load image");
49
+ }, []);
50
+ const effectiveZoom = state.mode === "fit" ? fitZoom : state.zoom;
51
+ const setZoom = useCallback((next) => {
52
+ const clamped = Math.max(MIN_ZOOM, Math.min(MAX_ZOOM, next));
53
+ setState({ mode: "manual", zoom: clamped });
54
+ }, []);
55
+ const onFit = useCallback(() => {
56
+ setState({ mode: "fit" });
57
+ setPan({ x: 0, y: 0 });
58
+ }, []);
59
+ const onActual = useCallback(() => {
60
+ setZoom(1);
61
+ setPan({ x: 0, y: 0 });
62
+ }, [setZoom]);
63
+ const onZoomIn = useCallback(() => setZoom(effectiveZoom * ZOOM_STEP), [effectiveZoom, setZoom]);
64
+ const onZoomOut = useCallback(() => setZoom(effectiveZoom / ZOOM_STEP), [effectiveZoom, setZoom]);
65
+ const dragRef = useRef(
66
+ null
67
+ );
68
+ const onMouseDown = useCallback(
69
+ (e) => {
70
+ if (effectiveZoom <= fitZoom) return;
71
+ dragRef.current = { startX: e.clientX, startY: e.clientY, panX: pan.x, panY: pan.y };
72
+ e.preventDefault();
73
+ },
74
+ [effectiveZoom, fitZoom, pan.x, pan.y]
75
+ );
76
+ useEffect(() => {
77
+ const onMove = (e) => {
78
+ const drag = dragRef.current;
79
+ if (!drag) return;
80
+ setPan({
81
+ x: drag.panX + (e.clientX - drag.startX),
82
+ y: drag.panY + (e.clientY - drag.startY)
83
+ });
84
+ };
85
+ const onUp = () => {
86
+ dragRef.current = null;
87
+ };
88
+ window.addEventListener("mousemove", onMove);
89
+ window.addEventListener("mouseup", onUp);
90
+ return () => {
91
+ window.removeEventListener("mousemove", onMove);
92
+ window.removeEventListener("mouseup", onUp);
93
+ };
94
+ }, []);
95
+ const isPannable = effectiveZoom > fitZoom + 1e-6;
96
+ const imgStyle = naturalSize ? {
97
+ width: `${naturalSize.w * effectiveZoom}px`,
98
+ height: `${naturalSize.h * effectiveZoom}px`,
99
+ transform: `translate(${pan.x}px, ${pan.y}px)`
100
+ } : { maxWidth: "100%", maxHeight: "100%" };
101
+ const containerCls = ["squisq-image-viewer", `squisq-image-viewer--${theme}`, className].filter(Boolean).join(" ");
102
+ return /* @__PURE__ */ jsxs("div", { className: containerCls, "data-testid": "image-viewer", children: [
103
+ /* @__PURE__ */ jsxs(
104
+ "div",
105
+ {
106
+ ref: stageRef,
107
+ className: "squisq-image-viewer-stage",
108
+ onMouseDown,
109
+ style: { cursor: isPannable ? dragRef.current ? "grabbing" : "grab" : "default" },
110
+ children: [
111
+ error ? /* @__PURE__ */ jsx("div", { className: "squisq-image-viewer-error", children: error }) : /* @__PURE__ */ jsx(
112
+ "img",
113
+ {
114
+ ref: imgRef,
115
+ src,
116
+ alt,
117
+ className: "squisq-image-viewer-img",
118
+ style: imgStyle,
119
+ onLoad: handleLoad,
120
+ onError: handleError,
121
+ draggable: false
122
+ }
123
+ ),
124
+ /* @__PURE__ */ jsxs("div", { className: "squisq-image-viewer-toolbar", children: [
125
+ /* @__PURE__ */ jsx(
126
+ "button",
127
+ {
128
+ type: "button",
129
+ className: "squisq-image-viewer-btn",
130
+ onClick: onZoomOut,
131
+ "aria-label": "Zoom out",
132
+ title: "Zoom out",
133
+ children: "\u2212"
134
+ }
135
+ ),
136
+ /* @__PURE__ */ jsx(
137
+ "button",
138
+ {
139
+ type: "button",
140
+ className: "squisq-image-viewer-btn",
141
+ onClick: onFit,
142
+ "aria-pressed": state.mode === "fit",
143
+ title: "Fit to viewport",
144
+ children: "Fit"
145
+ }
146
+ ),
147
+ /* @__PURE__ */ jsx(
148
+ "button",
149
+ {
150
+ type: "button",
151
+ className: "squisq-image-viewer-btn",
152
+ onClick: onActual,
153
+ title: "Actual size (100%)",
154
+ children: "100%"
155
+ }
156
+ ),
157
+ /* @__PURE__ */ jsx(
158
+ "button",
159
+ {
160
+ type: "button",
161
+ className: "squisq-image-viewer-btn",
162
+ onClick: onZoomIn,
163
+ "aria-label": "Zoom in",
164
+ title: "Zoom in",
165
+ children: "+"
166
+ }
167
+ )
168
+ ] })
169
+ ]
170
+ }
171
+ ),
172
+ /* @__PURE__ */ jsx("div", { className: "squisq-image-viewer-status", children: naturalSize ? /* @__PURE__ */ jsxs(Fragment, { children: [
173
+ /* @__PURE__ */ jsxs("span", { children: [
174
+ naturalSize.w,
175
+ " \xD7 ",
176
+ naturalSize.h
177
+ ] }),
178
+ /* @__PURE__ */ jsxs("span", { children: [
179
+ Math.round(effectiveZoom * 100),
180
+ "%"
181
+ ] })
182
+ ] }) : /* @__PURE__ */ jsx("span", { children: "Loading\u2026" }) })
183
+ ] });
184
+ }
185
+
186
+ // src/imageEditor/state.ts
187
+ import {
188
+ addLayer,
189
+ removeLayer,
190
+ reorderLayer,
191
+ setCanvas,
192
+ updateLayer,
193
+ touch
194
+ } from "@bendyline/squisq/imageEdit";
195
+ function initialImageEditorState(doc) {
196
+ return {
197
+ doc,
198
+ selectedLayerId: null,
199
+ tool: "select",
200
+ shapeKind: "rectangle",
201
+ dirty: false
202
+ };
203
+ }
204
+ function imageEditorReducer(state, action) {
205
+ switch (action.type) {
206
+ case "load":
207
+ return {
208
+ doc: action.doc,
209
+ selectedLayerId: null,
210
+ tool: "select",
211
+ shapeKind: state.shapeKind,
212
+ dirty: false
213
+ };
214
+ case "mark-clean":
215
+ return state.dirty ? { ...state, dirty: false } : state;
216
+ case "set-tool":
217
+ return state.tool === action.tool ? state : { ...state, tool: action.tool };
218
+ case "set-shape-kind":
219
+ return state.shapeKind === action.kind ? state : { ...state, shapeKind: action.kind };
220
+ case "select":
221
+ return state.selectedLayerId === action.layerId ? state : { ...state, selectedLayerId: action.layerId };
222
+ case "set-canvas": {
223
+ const next = setCanvas(state.doc, action.canvas);
224
+ return next === state.doc ? state : { ...state, doc: next, dirty: true };
225
+ }
226
+ case "add-layer": {
227
+ const next = addLayer(state.doc, action.layer);
228
+ const newId = next.layers[next.layers.length - 1].id;
229
+ return {
230
+ ...state,
231
+ doc: next,
232
+ dirty: true,
233
+ selectedLayerId: action.select === false ? state.selectedLayerId : newId
234
+ };
235
+ }
236
+ case "remove-layer": {
237
+ const next = removeLayer(state.doc, action.layerId);
238
+ if (next === state.doc) return state;
239
+ return {
240
+ ...state,
241
+ doc: next,
242
+ dirty: true,
243
+ selectedLayerId: state.selectedLayerId === action.layerId ? null : state.selectedLayerId
244
+ };
245
+ }
246
+ case "update-layer": {
247
+ const next = updateLayer(state.doc, action.layerId, action.patch);
248
+ return next === state.doc ? state : { ...state, doc: next, dirty: true };
249
+ }
250
+ case "reorder-layer": {
251
+ const next = reorderLayer(state.doc, action.layerId, action.toIndex);
252
+ return next === state.doc ? state : { ...state, doc: next, dirty: true };
253
+ }
254
+ case "crop": {
255
+ const { rect } = action;
256
+ const newCanvas = {
257
+ ...state.doc.canvas,
258
+ width: Math.max(1, Math.round(rect.width)),
259
+ height: Math.max(1, Math.round(rect.height))
260
+ };
261
+ const translated = state.doc.layers.map((layer) => {
262
+ const { position } = layer;
263
+ const x = typeof position.x === "number" ? position.x - rect.x : position.x;
264
+ const y = typeof position.y === "number" ? position.y - rect.y : position.y;
265
+ return { ...layer, position: { ...position, x, y } };
266
+ });
267
+ const next = touch({ ...state.doc, canvas: newCanvas, layers: translated });
268
+ return { ...state, doc: next, dirty: true };
269
+ }
270
+ }
271
+ }
272
+
273
+ // src/imageEditor/useImageEditor.ts
274
+ import { useCallback as useCallback2, useEffect as useEffect2, useMemo, useReducer, useRef as useRef2, useState as useState2 } from "react";
275
+ import {
276
+ DEFAULT_INTERACTIVE_RESOURCE_POLICY,
277
+ fetchResourceBytes
278
+ } from "@bendyline/squisq/markdown";
279
+ import {
280
+ IMAGE_EDIT_ASSETS_PREFIX,
281
+ IMAGE_EDIT_STATE_FILENAME,
282
+ ImageEditVersionManager,
283
+ createEmptyImageEditDoc,
284
+ readImageEditDoc,
285
+ writeImageEditDoc
286
+ } from "@bendyline/squisq/imageEdit";
287
+ function useImageEditor(options) {
288
+ const {
289
+ container,
290
+ initialSrc,
291
+ resourcePolicy = DEFAULT_INTERACTIVE_RESOURCE_POLICY,
292
+ stateFilename = IMAGE_EDIT_STATE_FILENAME,
293
+ allowVersioning = false,
294
+ versioningAutoSaveIdleMs = 5e3,
295
+ persistDebounceMs = 500
296
+ } = options;
297
+ const [state, dispatch] = useReducer(
298
+ (s, a) => {
299
+ if (s === null) return a.type === "load" ? initialImageEditorState(a.doc) : null;
300
+ return imageEditorReducer(s, a);
301
+ },
302
+ null
303
+ );
304
+ const [ready, setReady] = useState2(false);
305
+ const [error, setError] = useState2(null);
306
+ const seededOnLoadRef = useRef2(false);
307
+ useEffect2(() => {
308
+ let cancelled = false;
309
+ const controller = new AbortController();
310
+ setReady(false);
311
+ setError(null);
312
+ (async () => {
313
+ try {
314
+ const existing = await readImageEditDoc(container, stateFilename);
315
+ if (cancelled) return;
316
+ if (existing) {
317
+ dispatch({ type: "load", doc: existing });
318
+ setReady(true);
319
+ return;
320
+ }
321
+ const seeded = await seedFromSource(
322
+ container,
323
+ initialSrc,
324
+ resourcePolicy,
325
+ controller.signal
326
+ );
327
+ if (cancelled) return;
328
+ await writeImageEditDoc(container, seeded, stateFilename);
329
+ if (cancelled) return;
330
+ dispatch({ type: "load", doc: seeded });
331
+ setReady(true);
332
+ seededOnLoadRef.current = true;
333
+ } catch (err) {
334
+ if (cancelled) return;
335
+ setError(err instanceof Error ? err : new Error(String(err)));
336
+ setReady(true);
337
+ }
338
+ })();
339
+ return () => {
340
+ cancelled = true;
341
+ controller.abort();
342
+ };
343
+ }, [container, stateFilename, initialSrc, resourcePolicy]);
344
+ const persistTimerRef = useRef2(null);
345
+ const docRef = useRef2(null);
346
+ const dirtyRef = useRef2(false);
347
+ const revisionRef = useRef2(0);
348
+ const previousDocRef = useRef2(null);
349
+ const nextDoc = state?.doc ?? null;
350
+ if (nextDoc !== previousDocRef.current) {
351
+ previousDocRef.current = nextDoc;
352
+ revisionRef.current += 1;
353
+ }
354
+ docRef.current = nextDoc;
355
+ dirtyRef.current = state?.dirty ?? false;
356
+ const writeQueueRef = useRef2(Promise.resolve());
357
+ const persistTargetRef = useRef2({ container, stateFilename });
358
+ persistTargetRef.current = { container, stateFilename };
359
+ const enqueueWrite = useCallback2(
360
+ (doc, revision, markClean) => {
361
+ const write = writeQueueRef.current.catch(() => void 0).then(() => writeImageEditDoc(container, doc, stateFilename));
362
+ writeQueueRef.current = write.catch(() => void 0);
363
+ return write.then(() => {
364
+ if (markClean && revision === revisionRef.current && docRef.current === doc) {
365
+ dispatch({ type: "mark-clean" });
366
+ }
367
+ });
368
+ },
369
+ [container, stateFilename]
370
+ );
371
+ useEffect2(() => {
372
+ if (!state?.dirty) return;
373
+ if (persistTimerRef.current) clearTimeout(persistTimerRef.current);
374
+ persistTimerRef.current = setTimeout(() => {
375
+ const doc = state.doc;
376
+ if (!doc) return;
377
+ const revision = revisionRef.current;
378
+ enqueueWrite(doc, revision, true).catch((err) => {
379
+ console.warn(
380
+ "[squisq-editor] image-edit state persist failed:",
381
+ err instanceof Error ? err.message : err
382
+ );
383
+ });
384
+ }, persistDebounceMs);
385
+ return () => {
386
+ if (persistTimerRef.current) clearTimeout(persistTimerRef.current);
387
+ };
388
+ }, [state?.dirty, state?.doc, persistDebounceMs, enqueueWrite]);
389
+ const flush = useCallback2(async () => {
390
+ const doc = docRef.current;
391
+ if (!doc) return;
392
+ if (persistTimerRef.current) {
393
+ clearTimeout(persistTimerRef.current);
394
+ persistTimerRef.current = null;
395
+ }
396
+ await enqueueWrite(doc, revisionRef.current, true);
397
+ }, [enqueueWrite]);
398
+ useEffect2(
399
+ () => () => {
400
+ if (persistTimerRef.current) clearTimeout(persistTimerRef.current);
401
+ const doc = docRef.current;
402
+ if (!dirtyRef.current || !doc) return;
403
+ const target = persistTargetRef.current;
404
+ const write = writeQueueRef.current.catch(() => void 0).then(() => writeImageEditDoc(target.container, doc, target.stateFilename));
405
+ writeQueueRef.current = write.catch((err) => {
406
+ console.warn(
407
+ "[squisq-editor] image-edit final persist failed:",
408
+ err instanceof Error ? err.message : err
409
+ );
410
+ });
411
+ },
412
+ []
413
+ );
414
+ const versioning = useMemo(
415
+ () => allowVersioning ? new ImageEditVersionManager(container, { stateFilename }) : null,
416
+ [allowVersioning, container, stateFilename]
417
+ );
418
+ useEffect2(() => {
419
+ if (!versioning) return;
420
+ if (!ready) return;
421
+ if (!seededOnLoadRef.current) return;
422
+ seededOnLoadRef.current = false;
423
+ versioning.saveVersion({ force: true }).catch((err) => {
424
+ console.warn(
425
+ "[squisq-editor] image-edit initial snapshot failed:",
426
+ err instanceof Error ? err.message : err
427
+ );
428
+ });
429
+ }, [versioning, ready]);
430
+ useEffect2(() => {
431
+ if (!versioning) return;
432
+ if (versioningAutoSaveIdleMs <= 0) return;
433
+ if (!state?.doc) return;
434
+ const timer = setTimeout(() => {
435
+ versioning.saveVersion({ doc: docRef.current ?? void 0 }).catch((err) => {
436
+ console.warn(
437
+ "[squisq-editor] image-edit auto-save version failed:",
438
+ err instanceof Error ? err.message : err
439
+ );
440
+ });
441
+ }, versioningAutoSaveIdleMs);
442
+ return () => clearTimeout(timer);
443
+ }, [versioning, versioningAutoSaveIdleMs, state?.doc]);
444
+ const urlCacheRef = useRef2(/* @__PURE__ */ new Map());
445
+ const urlCacheGenerationRef = useRef2(0);
446
+ const resolveAssetUrl = useCallback2(
447
+ async (path) => {
448
+ const cache = urlCacheRef.current;
449
+ const generation = urlCacheGenerationRef.current;
450
+ const cached = cache.get(path);
451
+ if (cached) return cached;
452
+ const data = await container.readFile(path);
453
+ if (generation !== urlCacheGenerationRef.current) {
454
+ throw new Error("useImageEditor: asset resolution cancelled");
455
+ }
456
+ if (!data) throw new Error(`useImageEditor: missing asset "${path}"`);
457
+ const list = await container.listFiles(path);
458
+ const mime = list.find((e) => e.path === path)?.mimeType ?? "application/octet-stream";
459
+ const url = URL.createObjectURL(new Blob([data], { type: mime }));
460
+ if (generation !== urlCacheGenerationRef.current) {
461
+ URL.revokeObjectURL(url);
462
+ throw new Error("useImageEditor: asset resolution cancelled");
463
+ }
464
+ cache.set(path, url);
465
+ return url;
466
+ },
467
+ [container]
468
+ );
469
+ useEffect2(() => {
470
+ const cache = urlCacheRef.current;
471
+ return () => {
472
+ urlCacheGenerationRef.current += 1;
473
+ for (const url of cache.values()) URL.revokeObjectURL(url);
474
+ cache.clear();
475
+ };
476
+ }, [container]);
477
+ const uploadAsset = useCallback2(
478
+ async (file, suggestedName) => {
479
+ const ext = guessExtensionFromMime(file.type) ?? extensionFromName(suggestedName) ?? "bin";
480
+ const id = randomId();
481
+ const path = `${IMAGE_EDIT_ASSETS_PREFIX}${id}.${ext}`;
482
+ const buf = await file.arrayBuffer();
483
+ await container.writeFile(path, buf, file.type || void 0);
484
+ return path;
485
+ },
486
+ [container]
487
+ );
488
+ return {
489
+ state,
490
+ dispatch,
491
+ flush,
492
+ resolveAssetUrl,
493
+ uploadAsset,
494
+ versioning,
495
+ ready,
496
+ error
497
+ };
498
+ }
499
+ async function seedFromSource(container, initialSrc, resourcePolicy, signal) {
500
+ if (!initialSrc) {
501
+ return createEmptyImageEditDoc(800, 600);
502
+ }
503
+ const resource = await fetchResourceBytes(initialSrc, {
504
+ policy: resourcePolicy,
505
+ signal,
506
+ contentTypePrefixes: ["image/", "application/octet-stream"]
507
+ });
508
+ const mime = resource.contentType || "application/octet-stream";
509
+ const blob = new Blob([resource.bytes.slice().buffer], { type: mime });
510
+ const ext = guessExtensionFromMime(mime) ?? extensionFromName(resource.finalUrl) ?? "png";
511
+ const assetPath = `${IMAGE_EDIT_ASSETS_PREFIX}source.${ext}`;
512
+ await container.writeFile(assetPath, resource.bytes, mime || void 0);
513
+ const objectUrl = URL.createObjectURL(blob);
514
+ const dims = await probeImageDimensions(objectUrl);
515
+ URL.revokeObjectURL(objectUrl);
516
+ const w = dims?.width ?? 800;
517
+ const h = dims?.height ?? 600;
518
+ if (w * h > 1e8) {
519
+ throw new Error("useImageEditor: source image exceeds the 100-megapixel safety limit");
520
+ }
521
+ const layer = {
522
+ id: "base",
523
+ type: "image",
524
+ name: "Background",
525
+ position: { x: 0, y: 0, width: w, height: h },
526
+ content: { src: assetPath, alt: "", fit: "fill" }
527
+ };
528
+ return {
529
+ version: 1,
530
+ canvas: { width: w, height: h, background: "transparent" },
531
+ layers: [layer],
532
+ meta: {
533
+ sourcePath: assetPath,
534
+ createdAt: (/* @__PURE__ */ new Date()).toISOString(),
535
+ updatedAt: (/* @__PURE__ */ new Date()).toISOString()
536
+ }
537
+ };
538
+ }
539
+ function probeImageDimensions(src) {
540
+ return new Promise((resolve) => {
541
+ const img = new Image();
542
+ img.onload = () => resolve({ width: img.naturalWidth, height: img.naturalHeight });
543
+ img.onerror = () => resolve(null);
544
+ img.src = src;
545
+ });
546
+ }
547
+ function guessExtensionFromMime(mime) {
548
+ if (!mime) return null;
549
+ if (mime.includes("png")) return "png";
550
+ if (mime.includes("jpeg") || mime.includes("jpg")) return "jpg";
551
+ if (mime.includes("webp")) return "webp";
552
+ if (mime.includes("gif")) return "gif";
553
+ if (mime.includes("svg")) return "svg";
554
+ return null;
555
+ }
556
+ function extensionFromName(name) {
557
+ if (!name) return null;
558
+ const dot = name.lastIndexOf(".");
559
+ return dot >= 0 ? name.slice(dot + 1).toLowerCase() : null;
560
+ }
561
+ function randomId() {
562
+ return Math.random().toString(36).slice(2, 10);
563
+ }
564
+
565
+ // src/ImageEditor.tsx
566
+ import { useCallback as useCallback5, useRef as useRef8, useState as useState9 } from "react";
567
+ import { exportImageEditDoc as exportImageEditDoc2 } from "@bendyline/squisq/imageEdit";
568
+
569
+ // src/imageEditor/CanvasSurface.tsx
570
+ import { useCallback as useCallback3, useEffect as useEffect4, useId, useLayoutEffect, useRef as useRef3, useState as useState4 } from "react";
571
+ import { PathLayer } from "@bendyline/squisq-react";
572
+
573
+ // src/imageEditor/layers/EditorImageLayer.tsx
574
+ import { useEffect as useEffect3, useState as useState3 } from "react";
575
+ import { jsx as jsx2 } from "react/jsx-runtime";
576
+ function EditorImageLayer({ layer, canvas, resolveAssetUrl }) {
577
+ const [href, setHref] = useState3(null);
578
+ const src = layer.content.src;
579
+ useEffect3(() => {
580
+ let cancelled = false;
581
+ resolveAssetUrl(src).then((url) => {
582
+ if (!cancelled) setHref(url);
583
+ }).catch(() => {
584
+ if (!cancelled) setHref(null);
585
+ });
586
+ return () => {
587
+ cancelled = true;
588
+ };
589
+ }, [src, resolveAssetUrl]);
590
+ if (!href) return null;
591
+ const p = layer.position;
592
+ const x = typeof p.x === "number" ? p.x : 0;
593
+ const y = typeof p.y === "number" ? p.y : 0;
594
+ const width = typeof p.width === "number" ? p.width : canvas.width;
595
+ const height = typeof p.height === "number" ? p.height : canvas.height;
596
+ const fit = layer.content.fit ?? "fill";
597
+ const par = fit === "cover" ? "xMidYMid slice" : fit === "contain" ? "xMidYMid meet" : "none";
598
+ return /* @__PURE__ */ jsx2("image", { href, x, y, width, height, preserveAspectRatio: par });
599
+ }
600
+
601
+ // src/imageEditor/layers/EditorTextLayer.tsx
602
+ import { jsx as jsx3 } from "react/jsx-runtime";
603
+ function EditorTextLayer({ layer, canvas: _canvas }) {
604
+ const p = layer.position;
605
+ const x = typeof p.x === "number" ? p.x : 0;
606
+ const y = typeof p.y === "number" ? p.y : 0;
607
+ const { text, style } = layer.content;
608
+ const lineHeight = style.lineHeight ?? 1.4;
609
+ const lineHeightPx = style.fontSize * lineHeight;
610
+ const lines = (text ?? "").split("\n");
611
+ const textAnchor = style.textAlign === "center" ? "middle" : style.textAlign === "right" ? "end" : "start";
612
+ return /* @__PURE__ */ jsx3(
613
+ "text",
614
+ {
615
+ x,
616
+ y: y + style.fontSize,
617
+ fontFamily: style.fontFamily ?? "sans-serif",
618
+ fontSize: style.fontSize,
619
+ fontWeight: style.fontWeight ?? "normal",
620
+ fill: style.color,
621
+ textAnchor,
622
+ children: lines.map((line, i) => /* @__PURE__ */ jsx3("tspan", { x, dy: i === 0 ? 0 : lineHeightPx, children: line || "\xA0" }, i))
623
+ }
624
+ );
625
+ }
626
+
627
+ // src/imageEditor/layers/EditorShapeLayer.tsx
628
+ import { jsx as jsx4 } from "react/jsx-runtime";
629
+ function EditorShapeLayer({ layer, canvas: _canvas }) {
630
+ const p = layer.position;
631
+ const x = typeof p.x === "number" ? p.x : 0;
632
+ const y = typeof p.y === "number" ? p.y : 0;
633
+ const width = typeof p.width === "number" ? p.width : 100;
634
+ const height = typeof p.height === "number" ? p.height : 100;
635
+ const c = layer.content;
636
+ const fill = c.fill ?? "none";
637
+ const stroke = c.stroke;
638
+ const strokeWidth = c.strokeWidth;
639
+ if (c.shape === "rect") {
640
+ return /* @__PURE__ */ jsx4(
641
+ "rect",
642
+ {
643
+ x,
644
+ y,
645
+ width,
646
+ height,
647
+ rx: c.borderRadius,
648
+ ry: c.borderRadius,
649
+ fill,
650
+ stroke,
651
+ strokeWidth
652
+ }
653
+ );
654
+ }
655
+ if (c.shape === "circle") {
656
+ return /* @__PURE__ */ jsx4(
657
+ "circle",
658
+ {
659
+ cx: x + width / 2,
660
+ cy: y + height / 2,
661
+ r: Math.min(width, height) / 2,
662
+ fill,
663
+ stroke,
664
+ strokeWidth
665
+ }
666
+ );
667
+ }
668
+ return /* @__PURE__ */ jsx4(
669
+ "line",
670
+ {
671
+ x1: x,
672
+ y1: y,
673
+ x2: x + width,
674
+ y2: y + height,
675
+ stroke: stroke ?? "#000",
676
+ strokeWidth: strokeWidth ?? 1
677
+ }
678
+ );
679
+ }
680
+
681
+ // src/imageEditor/layers/SelectionHandles.tsx
682
+ import { jsx as jsx5, jsxs as jsxs2 } from "react/jsx-runtime";
683
+ var HANDLE_SIZE = 10;
684
+ function SelectionHandles({ box, onHandlePointerDown }) {
685
+ const half = HANDLE_SIZE / 2;
686
+ const cx = box.x + box.width / 2;
687
+ const cy = box.y + box.height / 2;
688
+ const handles = [
689
+ { id: "nw", x: box.x, y: box.y, cursor: "nwse-resize" },
690
+ { id: "n", x: cx, y: box.y, cursor: "ns-resize" },
691
+ { id: "ne", x: box.x + box.width, y: box.y, cursor: "nesw-resize" },
692
+ { id: "e", x: box.x + box.width, y: cy, cursor: "ew-resize" },
693
+ { id: "se", x: box.x + box.width, y: box.y + box.height, cursor: "nwse-resize" },
694
+ { id: "s", x: cx, y: box.y + box.height, cursor: "ns-resize" },
695
+ { id: "sw", x: box.x, y: box.y + box.height, cursor: "nesw-resize" },
696
+ { id: "w", x: box.x, y: cy, cursor: "ew-resize" }
697
+ ];
698
+ return /* @__PURE__ */ jsxs2("g", { className: "squisq-image-editor-selection-handles", pointerEvents: "none", children: [
699
+ /* @__PURE__ */ jsx5(
700
+ "rect",
701
+ {
702
+ x: box.x,
703
+ y: box.y,
704
+ width: box.width,
705
+ height: box.height,
706
+ fill: "none",
707
+ stroke: "#ffffff",
708
+ strokeOpacity: 0.9,
709
+ strokeWidth: 4,
710
+ vectorEffect: "non-scaling-stroke"
711
+ }
712
+ ),
713
+ /* @__PURE__ */ jsx5(
714
+ "rect",
715
+ {
716
+ x: box.x,
717
+ y: box.y,
718
+ width: box.width,
719
+ height: box.height,
720
+ fill: "none",
721
+ stroke: "#39f",
722
+ strokeWidth: 2,
723
+ strokeDasharray: "6 4",
724
+ vectorEffect: "non-scaling-stroke"
725
+ }
726
+ ),
727
+ handles.map((h) => /* @__PURE__ */ jsx5(
728
+ "rect",
729
+ {
730
+ x: h.x - half,
731
+ y: h.y - half,
732
+ width: HANDLE_SIZE,
733
+ height: HANDLE_SIZE,
734
+ fill: "#fff",
735
+ stroke: "#39f",
736
+ strokeWidth: 2,
737
+ vectorEffect: "non-scaling-stroke",
738
+ style: { cursor: h.cursor, pointerEvents: "all" },
739
+ onPointerDown: (e) => onHandlePointerDown(e, h.id)
740
+ },
741
+ h.id
742
+ ))
743
+ ] });
744
+ }
745
+
746
+ // src/imageEditor/CanvasSurface.tsx
747
+ import { jsx as jsx6, jsxs as jsxs3 } from "react/jsx-runtime";
748
+ function CanvasSurface({
749
+ doc,
750
+ selectedLayerId,
751
+ tool,
752
+ resolveAssetUrl,
753
+ dispatch,
754
+ onCreateTextAt,
755
+ onCreateShapeAt,
756
+ onCreateShapeFromPoints,
757
+ shapeDragDraw,
758
+ workspaceBackground,
759
+ zoom = 1,
760
+ onSetZoom,
761
+ surfaceRef,
762
+ requestEditLayerId
763
+ }) {
764
+ const checkerId = `squisq-image-editor-checker-${useId().replace(/:/g, "")}`;
765
+ const svgRef = useRef3(null);
766
+ const dragRef = useRef3(null);
767
+ const [, forceRender] = useState4(0);
768
+ const [cropDrag, setCropDrag] = useState4(null);
769
+ const [shapeLineDrag, setShapeLineDrag] = useState4(null);
770
+ const internalWrapRef = useRef3(null);
771
+ const pendingScrollRef = useRef3(null);
772
+ const [zoomDrag, setZoomDrag] = useState4(null);
773
+ const [editingLayerId, setEditingLayerId] = useState4(null);
774
+ const editTextareaRef = useRef3(null);
775
+ const toCanvas = useCallback3(
776
+ (clientX, clientY) => {
777
+ const svg = svgRef.current;
778
+ if (!svg) return { x: 0, y: 0 };
779
+ const rect = svg.getBoundingClientRect();
780
+ const x = (clientX - rect.left) / rect.width * doc.canvas.width;
781
+ const y = (clientY - rect.top) / rect.height * doc.canvas.height;
782
+ return { x, y };
783
+ },
784
+ [doc.canvas.width, doc.canvas.height]
785
+ );
786
+ useLayoutEffect(() => {
787
+ const pending = pendingScrollRef.current;
788
+ if (pending && internalWrapRef.current) {
789
+ pendingScrollRef.current = null;
790
+ internalWrapRef.current.scrollLeft = pending.left;
791
+ internalWrapRef.current.scrollTop = pending.top;
792
+ }
793
+ }, [zoom]);
794
+ const setWrapRefCallback = useCallback3(
795
+ (el) => {
796
+ internalWrapRef.current = el;
797
+ if (surfaceRef) {
798
+ surfaceRef.current = el;
799
+ }
800
+ },
801
+ [surfaceRef]
802
+ );
803
+ useEffect4(() => {
804
+ if (requestEditLayerId) setEditingLayerId(requestEditLayerId);
805
+ }, [requestEditLayerId]);
806
+ useEffect4(() => {
807
+ if (editingLayerId && selectedLayerId !== editingLayerId) setEditingLayerId(null);
808
+ }, [selectedLayerId, editingLayerId]);
809
+ useLayoutEffect(() => {
810
+ if (editingLayerId && editTextareaRef.current) {
811
+ editTextareaRef.current.focus();
812
+ editTextareaRef.current.select();
813
+ }
814
+ }, [editingLayerId]);
815
+ const onPointerDownLayer = useCallback3(
816
+ (e, layer) => {
817
+ if (tool !== "select") return;
818
+ if (layer.locked) return;
819
+ e.stopPropagation();
820
+ const pt = toCanvas(e.clientX, e.clientY);
821
+ dispatch({ type: "select", layerId: layer.id });
822
+ const box = layerBox(layer, doc);
823
+ dragRef.current = {
824
+ layerId: layer.id,
825
+ startCanvasX: pt.x,
826
+ startCanvasY: pt.y,
827
+ startBox: box,
828
+ handle: "move"
829
+ };
830
+ e.target.setPointerCapture?.(e.pointerId);
831
+ },
832
+ [tool, dispatch, toCanvas, doc]
833
+ );
834
+ const onPointerDownHandle = useCallback3(
835
+ (e, handle) => {
836
+ if (!selectedLayerId) return;
837
+ const layer = doc.layers.find((l) => l.id === selectedLayerId);
838
+ if (!layer || layer.locked) return;
839
+ e.stopPropagation();
840
+ const pt = toCanvas(e.clientX, e.clientY);
841
+ const box = layerBox(layer, doc);
842
+ dragRef.current = {
843
+ layerId: layer.id,
844
+ startCanvasX: pt.x,
845
+ startCanvasY: pt.y,
846
+ startBox: box,
847
+ handle
848
+ };
849
+ e.target.setPointerCapture?.(e.pointerId);
850
+ },
851
+ [selectedLayerId, doc, toCanvas]
852
+ );
853
+ const onPointerDownEmpty = useCallback3(
854
+ (e) => {
855
+ const pt = toCanvas(e.clientX, e.clientY);
856
+ if (tool === "select") {
857
+ dispatch({ type: "select", layerId: null });
858
+ setEditingLayerId(null);
859
+ return;
860
+ }
861
+ if (tool === "zoom-rect") {
862
+ e.preventDefault();
863
+ setZoomDrag({ startCanvasX: pt.x, startCanvasY: pt.y, currentX: pt.x, currentY: pt.y });
864
+ e.target.setPointerCapture?.(e.pointerId);
865
+ return;
866
+ }
867
+ if (tool === "crop") {
868
+ e.preventDefault();
869
+ setCropDrag({ startCanvasX: pt.x, startCanvasY: pt.y, currentX: pt.x, currentY: pt.y });
870
+ e.target.setPointerCapture?.(e.pointerId);
871
+ return;
872
+ }
873
+ if (tool === "text") {
874
+ onCreateTextAt?.(pt.x, pt.y);
875
+ return;
876
+ }
877
+ if (tool === "shape") {
878
+ if (shapeDragDraw) {
879
+ e.preventDefault();
880
+ setShapeLineDrag({ startX: pt.x, startY: pt.y, currentX: pt.x, currentY: pt.y });
881
+ e.target.setPointerCapture?.(e.pointerId);
882
+ } else {
883
+ onCreateShapeAt?.(pt.x, pt.y);
884
+ }
885
+ }
886
+ },
887
+ [tool, dispatch, toCanvas, onCreateTextAt, onCreateShapeAt, shapeDragDraw]
888
+ );
889
+ useEffect4(() => {
890
+ function onMove(e) {
891
+ const drag = dragRef.current;
892
+ if (drag) {
893
+ const pt = toCanvas(e.clientX, e.clientY);
894
+ const dx = pt.x - drag.startCanvasX;
895
+ const dy = pt.y - drag.startCanvasY;
896
+ const next = applyHandle(drag.startBox, drag.handle, dx, dy);
897
+ dispatch({
898
+ type: "update-layer",
899
+ layerId: drag.layerId,
900
+ patch: {
901
+ position: {
902
+ x: Math.round(next.x),
903
+ y: Math.round(next.y),
904
+ width: Math.round(next.width),
905
+ height: Math.round(next.height)
906
+ }
907
+ }
908
+ });
909
+ return;
910
+ }
911
+ if (shapeLineDrag) {
912
+ const pt = toCanvas(e.clientX, e.clientY);
913
+ setShapeLineDrag((prev) => prev ? { ...prev, currentX: pt.x, currentY: pt.y } : prev);
914
+ return;
915
+ }
916
+ if (zoomDrag) {
917
+ const pt = toCanvas(e.clientX, e.clientY);
918
+ setZoomDrag((prev) => prev ? { ...prev, currentX: pt.x, currentY: pt.y } : prev);
919
+ return;
920
+ }
921
+ if (cropDrag) {
922
+ const pt = toCanvas(e.clientX, e.clientY);
923
+ setCropDrag((prev) => prev ? { ...prev, currentX: pt.x, currentY: pt.y } : prev);
924
+ }
925
+ }
926
+ function onUp() {
927
+ if (dragRef.current) {
928
+ dragRef.current = null;
929
+ forceRender((n) => n + 1);
930
+ }
931
+ if (shapeLineDrag) {
932
+ const dist = Math.hypot(
933
+ shapeLineDrag.currentX - shapeLineDrag.startX,
934
+ shapeLineDrag.currentY - shapeLineDrag.startY
935
+ );
936
+ if (dist >= 4 && onCreateShapeFromPoints) {
937
+ onCreateShapeFromPoints(
938
+ shapeLineDrag.startX,
939
+ shapeLineDrag.startY,
940
+ shapeLineDrag.currentX,
941
+ shapeLineDrag.currentY
942
+ );
943
+ } else {
944
+ onCreateShapeAt?.(shapeLineDrag.startX, shapeLineDrag.startY);
945
+ }
946
+ setShapeLineDrag(null);
947
+ }
948
+ if (zoomDrag) {
949
+ const rectW = Math.abs(zoomDrag.currentX - zoomDrag.startCanvasX);
950
+ const rectH = Math.abs(zoomDrag.currentY - zoomDrag.startCanvasY);
951
+ if (rectW >= 10 && rectH >= 10 && onSetZoom && internalWrapRef.current) {
952
+ const wrap = internalWrapRef.current;
953
+ const viewportW = wrap.clientWidth - 32;
954
+ const viewportH = wrap.clientHeight - 32;
955
+ const newZoom = Math.max(
956
+ 0.0625,
957
+ Math.min(16, Math.min(viewportW / rectW, viewportH / rectH))
958
+ );
959
+ const cx = Math.min(zoomDrag.startCanvasX, zoomDrag.currentX) + rectW / 2;
960
+ const cy = Math.min(zoomDrag.startCanvasY, zoomDrag.currentY) + rectH / 2;
961
+ pendingScrollRef.current = {
962
+ left: cx * newZoom - wrap.clientWidth / 2 + 16,
963
+ top: cy * newZoom - wrap.clientHeight / 2 + 16
964
+ };
965
+ onSetZoom(newZoom);
966
+ dispatch({ type: "set-tool", tool: "select" });
967
+ }
968
+ setZoomDrag(null);
969
+ }
970
+ if (cropDrag) {
971
+ const rect = normalizeCropRect(cropDrag);
972
+ if (rect.width >= 8 && rect.height >= 8) {
973
+ dispatch({ type: "crop", rect });
974
+ dispatch({ type: "set-tool", tool: "select" });
975
+ }
976
+ setCropDrag(null);
977
+ }
978
+ }
979
+ window.addEventListener("pointermove", onMove);
980
+ window.addEventListener("pointerup", onUp);
981
+ return () => {
982
+ window.removeEventListener("pointermove", onMove);
983
+ window.removeEventListener("pointerup", onUp);
984
+ };
985
+ }, [
986
+ toCanvas,
987
+ dispatch,
988
+ cropDrag,
989
+ shapeLineDrag,
990
+ zoomDrag,
991
+ onSetZoom,
992
+ onCreateShapeFromPoints,
993
+ onCreateShapeAt
994
+ ]);
995
+ const selectedLayer = selectedLayerId ? doc.layers.find((l) => l.id === selectedLayerId) ?? null : null;
996
+ const selectedBox = selectedLayer ? layerBox(selectedLayer, doc) : null;
997
+ const selectionBox = selectedLayer && selectedLayer.type === "text" ? measureTextLayerBox(selectedLayer, selectedBox) : selectedBox;
998
+ const paddedSelectionBox = (() => {
999
+ if (!selectionBox || !selectedLayer) return null;
1000
+ const sw = selectedLayer.type === "shape" || selectedLayer.type === "path" ? selectedLayer.content["strokeWidth"] ?? 0 : 0;
1001
+ const strokeHalf = sw / 2;
1002
+ const proportional = Math.max(doc.canvas.width, doc.canvas.height) * 7e-3;
1003
+ const p = Math.ceil(strokeHalf + proportional);
1004
+ return {
1005
+ x: selectionBox.x - p,
1006
+ y: selectionBox.y - p,
1007
+ width: selectionBox.width + p * 2,
1008
+ height: selectionBox.height + p * 2
1009
+ };
1010
+ })();
1011
+ return /* @__PURE__ */ jsx6(
1012
+ "div",
1013
+ {
1014
+ ref: setWrapRefCallback,
1015
+ className: "squisq-image-editor-surface",
1016
+ style: { background: workspaceBackground ?? "#1f1f24" },
1017
+ children: /* @__PURE__ */ jsx6("div", { className: "squisq-image-editor-canvas-wrap", children: /* @__PURE__ */ jsxs3(
1018
+ "svg",
1019
+ {
1020
+ ref: svgRef,
1021
+ viewBox: `0 0 ${doc.canvas.width} ${doc.canvas.height}`,
1022
+ width: Math.round(doc.canvas.width * zoom),
1023
+ height: Math.round(doc.canvas.height * zoom),
1024
+ className: `squisq-image-editor-canvas squisq-image-editor-canvas--tool-${tool}`,
1025
+ onPointerDown: onPointerDownEmpty,
1026
+ children: [
1027
+ /* @__PURE__ */ jsx6(
1028
+ "rect",
1029
+ {
1030
+ x: 0,
1031
+ y: 0,
1032
+ width: doc.canvas.width,
1033
+ height: doc.canvas.height,
1034
+ fill: doc.canvas.background && doc.canvas.background !== "transparent" ? doc.canvas.background : `url(#${checkerId})`
1035
+ }
1036
+ ),
1037
+ /* @__PURE__ */ jsx6("defs", { children: /* @__PURE__ */ jsxs3("pattern", { id: checkerId, width: "16", height: "16", patternUnits: "userSpaceOnUse", children: [
1038
+ /* @__PURE__ */ jsx6("rect", { width: "16", height: "16", fill: "#f0f0f0" }),
1039
+ /* @__PURE__ */ jsx6("rect", { width: "8", height: "8", fill: "#d0d0d0" }),
1040
+ /* @__PURE__ */ jsx6("rect", { x: "8", y: "8", width: "8", height: "8", fill: "#d0d0d0" })
1041
+ ] }) }),
1042
+ doc.layers.map((layer) => {
1043
+ if (layer.visible === false) return null;
1044
+ const onPointerDown = (e) => onPointerDownLayer(e, layer);
1045
+ const onDoubleClick = layer.type === "text" ? (e) => {
1046
+ e.stopPropagation();
1047
+ dispatch({ type: "select", layerId: layer.id });
1048
+ setEditingLayerId(layer.id);
1049
+ } : void 0;
1050
+ const opacity = layer.opacity ?? 1;
1051
+ const isEditing = editingLayerId === layer.id;
1052
+ return /* @__PURE__ */ jsxs3(
1053
+ "g",
1054
+ {
1055
+ "data-layer-id": layer.id,
1056
+ opacity,
1057
+ visibility: isEditing ? "hidden" : void 0,
1058
+ pointerEvents: isEditing ? "none" : void 0,
1059
+ style: { cursor: tool === "select" && !layer.locked ? "move" : void 0 },
1060
+ onPointerDown: isEditing ? void 0 : onPointerDown,
1061
+ onDoubleClick,
1062
+ children: [
1063
+ layer.type === "image" && /* @__PURE__ */ jsx6(
1064
+ EditorImageLayer,
1065
+ {
1066
+ layer,
1067
+ canvas: doc.canvas,
1068
+ resolveAssetUrl
1069
+ }
1070
+ ),
1071
+ layer.type === "text" && /* @__PURE__ */ jsx6(EditorTextLayer, { layer, canvas: doc.canvas }),
1072
+ layer.type === "shape" && /* @__PURE__ */ jsx6(EditorShapeLayer, { layer, canvas: doc.canvas }),
1073
+ layer.type === "path" && /* @__PURE__ */ jsx6(PathLayer, { layer, viewport: doc.canvas, blockTime: 0 })
1074
+ ]
1075
+ },
1076
+ layer.id
1077
+ );
1078
+ }),
1079
+ selectedLayer && paddedSelectionBox && tool === "select" && !selectedLayer.locked && editingLayerId !== selectedLayer.id && /* @__PURE__ */ jsx6(
1080
+ SelectionHandles,
1081
+ {
1082
+ box: paddedSelectionBox,
1083
+ onHandlePointerDown: onPointerDownHandle
1084
+ }
1085
+ ),
1086
+ editingLayerId && (() => {
1087
+ const editLayer = doc.layers.find((l) => l.id === editingLayerId);
1088
+ if (!editLayer || editLayer.type !== "text") return null;
1089
+ const pos = layerBox(editLayer, doc);
1090
+ const textLayer = editLayer;
1091
+ const s = textLayer.content;
1092
+ const editBox = measureTextLayerBox(textLayer, pos);
1093
+ return /* @__PURE__ */ jsx6(
1094
+ "foreignObject",
1095
+ {
1096
+ x: editBox.x,
1097
+ y: editBox.y,
1098
+ width: editBox.width,
1099
+ height: editBox.height,
1100
+ style: { overflow: "visible" },
1101
+ children: /* @__PURE__ */ jsx6(
1102
+ "textarea",
1103
+ {
1104
+ ref: editTextareaRef,
1105
+ value: s.text,
1106
+ onChange: (e) => {
1107
+ dispatch({
1108
+ type: "update-layer",
1109
+ layerId: editingLayerId,
1110
+ patch: {
1111
+ content: { ...s, text: e.target.value }
1112
+ }
1113
+ });
1114
+ },
1115
+ onKeyDown: (e) => {
1116
+ if (e.key === "Escape") {
1117
+ e.preventDefault();
1118
+ setEditingLayerId(null);
1119
+ }
1120
+ },
1121
+ onPointerDown: (e) => e.stopPropagation(),
1122
+ onClick: (e) => e.stopPropagation(),
1123
+ wrap: "off",
1124
+ style: {
1125
+ display: "block",
1126
+ width: "100%",
1127
+ height: "100%",
1128
+ padding: 0,
1129
+ margin: 0,
1130
+ border: "none",
1131
+ outline: "2px dashed rgba(59,130,246,0.8)",
1132
+ outlineOffset: "2px",
1133
+ resize: "none",
1134
+ background: "transparent",
1135
+ fontFamily: s.style.fontFamily ?? "sans-serif",
1136
+ // The foreignObject participates in the SVG viewBox transform, so its
1137
+ // contents use canvas units and inherit zoom from the SVG exactly once.
1138
+ fontSize: `${s.style.fontSize}px`,
1139
+ fontWeight: s.style.fontWeight ?? "normal",
1140
+ color: s.style.color,
1141
+ textAlign: s.style.textAlign ?? "left",
1142
+ lineHeight: s.style.lineHeight ?? 1.4,
1143
+ caretColor: s.style.color,
1144
+ overflow: "hidden",
1145
+ boxSizing: "border-box"
1146
+ }
1147
+ }
1148
+ )
1149
+ },
1150
+ `inline-edit-${editingLayerId}`
1151
+ );
1152
+ })(),
1153
+ shapeLineDrag && /* @__PURE__ */ jsx6(
1154
+ "line",
1155
+ {
1156
+ x1: shapeLineDrag.startX,
1157
+ y1: shapeLineDrag.startY,
1158
+ x2: shapeLineDrag.currentX,
1159
+ y2: shapeLineDrag.currentY,
1160
+ stroke: "#39f",
1161
+ strokeWidth: 2,
1162
+ strokeDasharray: "6 4",
1163
+ pointerEvents: "none"
1164
+ }
1165
+ ),
1166
+ zoomDrag && (() => {
1167
+ const r = normalizeCropRect(zoomDrag);
1168
+ return /* @__PURE__ */ jsx6("g", { pointerEvents: "none", children: /* @__PURE__ */ jsx6(
1169
+ "rect",
1170
+ {
1171
+ x: r.x,
1172
+ y: r.y,
1173
+ width: r.width,
1174
+ height: r.height,
1175
+ fill: "rgba(255,200,0,0.08)",
1176
+ stroke: "#f90",
1177
+ strokeWidth: 2,
1178
+ strokeDasharray: "6 4",
1179
+ vectorEffect: "non-scaling-stroke"
1180
+ }
1181
+ ) });
1182
+ })(),
1183
+ cropDrag && (() => {
1184
+ const r = normalizeCropRect(cropDrag);
1185
+ return /* @__PURE__ */ jsx6("g", { pointerEvents: "none", children: /* @__PURE__ */ jsx6(
1186
+ "rect",
1187
+ {
1188
+ x: r.x,
1189
+ y: r.y,
1190
+ width: r.width,
1191
+ height: r.height,
1192
+ fill: "rgba(255,255,255,0.05)",
1193
+ stroke: "#39f",
1194
+ strokeWidth: 2,
1195
+ strokeDasharray: "6 4"
1196
+ }
1197
+ ) });
1198
+ })()
1199
+ ]
1200
+ }
1201
+ ) })
1202
+ }
1203
+ );
1204
+ }
1205
+ function layerBox(layer, doc) {
1206
+ const p = layer.position;
1207
+ const x = typeof p.x === "number" ? p.x : 0;
1208
+ const y = typeof p.y === "number" ? p.y : 0;
1209
+ const width = typeof p.width === "number" ? p.width : doc.canvas.width;
1210
+ const height = typeof p.height === "number" ? p.height : doc.canvas.height;
1211
+ return { x, y, width, height };
1212
+ }
1213
+ var MIN_DIM = 4;
1214
+ function applyHandle(box, handle, dx, dy) {
1215
+ if (handle === "move") return { ...box, x: box.x + dx, y: box.y + dy };
1216
+ let { x, y, width, height } = box;
1217
+ if (handle.includes("w")) {
1218
+ const newWidth = Math.max(MIN_DIM, width - dx);
1219
+ x = x + (width - newWidth);
1220
+ width = newWidth;
1221
+ } else if (handle.includes("e")) {
1222
+ width = Math.max(MIN_DIM, width + dx);
1223
+ }
1224
+ if (handle.includes("n")) {
1225
+ const newHeight = Math.max(MIN_DIM, height - dy);
1226
+ y = y + (height - newHeight);
1227
+ height = newHeight;
1228
+ } else if (handle.includes("s")) {
1229
+ height = Math.max(MIN_DIM, height + dy);
1230
+ }
1231
+ return { x, y, width, height };
1232
+ }
1233
+ function normalizeCropRect(d) {
1234
+ const x = Math.min(d.startCanvasX, d.currentX);
1235
+ const y = Math.min(d.startCanvasY, d.currentY);
1236
+ const width = Math.abs(d.currentX - d.startCanvasX);
1237
+ const height = Math.abs(d.currentY - d.startCanvasY);
1238
+ return { x, y, width, height };
1239
+ }
1240
+ var measureCtx;
1241
+ function getMeasureCtx() {
1242
+ if (measureCtx !== void 0) return measureCtx;
1243
+ if (typeof document === "undefined") {
1244
+ measureCtx = null;
1245
+ return null;
1246
+ }
1247
+ try {
1248
+ const c = document.createElement("canvas");
1249
+ measureCtx = c.getContext("2d");
1250
+ } catch {
1251
+ measureCtx = null;
1252
+ }
1253
+ return measureCtx ?? null;
1254
+ }
1255
+ function measureTextLayerBox(layer, fallback) {
1256
+ const ctx = getMeasureCtx();
1257
+ if (!ctx) return fallback;
1258
+ const { text, style } = layer.content;
1259
+ const fontSize = style.fontSize;
1260
+ const fontWeight = style.fontWeight ?? "normal";
1261
+ const fontFamily = style.fontFamily ?? "sans-serif";
1262
+ ctx.font = `${fontWeight} ${fontSize}px ${fontFamily}`;
1263
+ const lines = (text ?? "").split("\n");
1264
+ let maxWidth = 0;
1265
+ for (const line of lines) {
1266
+ const w = ctx.measureText(line || " ").width;
1267
+ if (w > maxWidth) maxWidth = w;
1268
+ }
1269
+ const lineHeight = style.lineHeight ?? 1.4;
1270
+ const lineHeightPx = fontSize * lineHeight;
1271
+ const totalHeight = Math.max(1, lines.length) * lineHeightPx;
1272
+ const anchor = style.textAlign === "center" ? "middle" : style.textAlign === "right" ? "end" : "start";
1273
+ const x = anchor === "middle" ? fallback.x - maxWidth / 2 : anchor === "end" ? fallback.x - maxWidth : fallback.x;
1274
+ return {
1275
+ x,
1276
+ y: fallback.y,
1277
+ width: Math.max(MIN_DIM, Math.ceil(maxWidth)),
1278
+ height: Math.max(MIN_DIM, Math.ceil(totalHeight))
1279
+ };
1280
+ }
1281
+
1282
+ // src/imageEditor/ImageVersionHistoryDropdown.tsx
1283
+ import { useCallback as useCallback4, useEffect as useEffect5, useRef as useRef4, useState as useState5 } from "react";
1284
+ import {
1285
+ exportImageEditDoc
1286
+ } from "@bendyline/squisq/imageEdit";
1287
+ import { jsx as jsx7, jsxs as jsxs4 } from "react/jsx-runtime";
1288
+ var THUMB_MAX_DIM = 96;
1289
+ function ImageVersionHistoryDropdown({
1290
+ versioning,
1291
+ container,
1292
+ onRevert,
1293
+ refreshKey
1294
+ }) {
1295
+ const [open, setOpen] = useState5(false);
1296
+ const [versions, setVersions] = useState5([]);
1297
+ const [loading, setLoading] = useState5(false);
1298
+ const [busyTimestamp, setBusyTimestamp] = useState5(null);
1299
+ const [meta, setMeta] = useState5({});
1300
+ const popoverRef = useRef4(null);
1301
+ const triggerRef = useRef4(null);
1302
+ const urlsRef = useRef4(/* @__PURE__ */ new Set());
1303
+ useEffect5(() => {
1304
+ if (!open) return;
1305
+ let cancelled = false;
1306
+ setLoading(true);
1307
+ versioning.listVersions().then((list) => {
1308
+ if (cancelled) return;
1309
+ const sorted = [...list].sort((a, b) => b.timestamp.getTime() - a.timestamp.getTime());
1310
+ setVersions(sorted);
1311
+ }).catch(() => {
1312
+ if (cancelled) return;
1313
+ setVersions([]);
1314
+ }).finally(() => {
1315
+ if (!cancelled) setLoading(false);
1316
+ });
1317
+ return () => {
1318
+ cancelled = true;
1319
+ };
1320
+ }, [open, versioning, refreshKey]);
1321
+ useEffect5(() => {
1322
+ if (!open) return;
1323
+ if (versions.length === 0) return;
1324
+ let cancelled = false;
1325
+ (async () => {
1326
+ for (const v of versions) {
1327
+ if (cancelled) return;
1328
+ if (meta[v.path]) continue;
1329
+ try {
1330
+ const doc = await versioning.readVersion(v);
1331
+ if (cancelled) return;
1332
+ if (!doc) {
1333
+ setMeta((m) => ({ ...m, [v.path]: { doc: emptyDoc(), thumbUrl: null } }));
1334
+ continue;
1335
+ }
1336
+ let thumbUrl = null;
1337
+ try {
1338
+ const scale = computeThumbScale(doc.canvas.width, doc.canvas.height);
1339
+ const blob = await exportImageEditDoc(doc, container, {
1340
+ format: "png",
1341
+ scale
1342
+ });
1343
+ if (cancelled) return;
1344
+ thumbUrl = URL.createObjectURL(blob);
1345
+ urlsRef.current.add(thumbUrl);
1346
+ } catch {
1347
+ thumbUrl = null;
1348
+ }
1349
+ setMeta((m) => ({ ...m, [v.path]: { doc, thumbUrl } }));
1350
+ } catch {
1351
+ }
1352
+ }
1353
+ })();
1354
+ return () => {
1355
+ cancelled = true;
1356
+ };
1357
+ }, [open, versions, versioning, container, meta]);
1358
+ useEffect5(() => {
1359
+ const urls = urlsRef.current;
1360
+ return () => {
1361
+ for (const url of urls) URL.revokeObjectURL(url);
1362
+ urls.clear();
1363
+ };
1364
+ }, []);
1365
+ useEffect5(() => {
1366
+ for (const url of urlsRef.current) URL.revokeObjectURL(url);
1367
+ urlsRef.current.clear();
1368
+ setMeta({});
1369
+ }, [refreshKey]);
1370
+ useEffect5(() => {
1371
+ if (!open) return;
1372
+ function onDocClick(e) {
1373
+ const t = e.target;
1374
+ if (!t) return;
1375
+ if (popoverRef.current?.contains(t)) return;
1376
+ if (triggerRef.current?.contains(t)) return;
1377
+ setOpen(false);
1378
+ }
1379
+ function onKey(e) {
1380
+ if (e.key === "Escape") setOpen(false);
1381
+ }
1382
+ document.addEventListener("mousedown", onDocClick);
1383
+ document.addEventListener("keydown", onKey);
1384
+ return () => {
1385
+ document.removeEventListener("mousedown", onDocClick);
1386
+ document.removeEventListener("keydown", onKey);
1387
+ };
1388
+ }, [open]);
1389
+ const handleRevert = useCallback4(
1390
+ async (v) => {
1391
+ setBusyTimestamp(v.timestamp.getTime());
1392
+ try {
1393
+ await onRevert(v);
1394
+ setOpen(false);
1395
+ } catch (err) {
1396
+ console.warn(
1397
+ "[squisq-editor] image-edit revert failed:",
1398
+ err instanceof Error ? err.message : err
1399
+ );
1400
+ } finally {
1401
+ setBusyTimestamp(null);
1402
+ }
1403
+ },
1404
+ [onRevert]
1405
+ );
1406
+ return /* @__PURE__ */ jsxs4("div", { className: "squisq-image-editor-version-dropdown", children: [
1407
+ /* @__PURE__ */ jsxs4(
1408
+ "button",
1409
+ {
1410
+ ref: triggerRef,
1411
+ type: "button",
1412
+ className: "squisq-image-editor-tool-button squisq-image-editor-tool-button--with-label",
1413
+ onClick: () => setOpen((o) => !o),
1414
+ "aria-expanded": open,
1415
+ "aria-haspopup": "menu",
1416
+ title: "Version history",
1417
+ "data-testid": "image-editor-history-trigger",
1418
+ children: [
1419
+ /* @__PURE__ */ jsx7("span", { children: "History" }),
1420
+ /* @__PURE__ */ jsx7("span", { "aria-hidden": "true", style: { fontSize: "0.8em" }, children: "\u25BE" })
1421
+ ]
1422
+ }
1423
+ ),
1424
+ open && /* @__PURE__ */ jsxs4(
1425
+ "div",
1426
+ {
1427
+ ref: popoverRef,
1428
+ className: "squisq-image-editor-version-popover",
1429
+ role: "menu",
1430
+ "data-testid": "image-editor-history-popover",
1431
+ children: [
1432
+ /* @__PURE__ */ jsx7("div", { className: "squisq-image-editor-version-popover__title", children: "Version history" }),
1433
+ loading && /* @__PURE__ */ jsx7("div", { className: "squisq-image-editor-version-popover__empty", children: "Loading\u2026" }),
1434
+ !loading && versions.length === 0 && /* @__PURE__ */ jsx7("div", { className: "squisq-image-editor-version-popover__empty", children: "No snapshots yet" }),
1435
+ !loading && versions.length > 0 && /* @__PURE__ */ jsx7("ul", { className: "squisq-image-editor-version-popover__list", children: versions.map((v, i) => {
1436
+ const ts = v.timestamp.getTime();
1437
+ const m = meta[v.path];
1438
+ const isCurrent = i === 0;
1439
+ const isOriginal = i + 1 >= versions.length;
1440
+ const olderMeta = i + 1 < versions.length ? meta[versions[i + 1].path] : void 0;
1441
+ const summary = isOriginal ? "Original" : m && olderMeta ? summarizeDiff(olderMeta.doc, m.doc) : "";
1442
+ return /* @__PURE__ */ jsxs4(
1443
+ "li",
1444
+ {
1445
+ className: "squisq-image-editor-version-popover__row" + (isCurrent ? " squisq-image-editor-version-popover__row--current" : ""),
1446
+ children: [
1447
+ /* @__PURE__ */ jsx7("div", { className: "squisq-image-editor-version-popover__thumb", children: m?.thumbUrl ? /* @__PURE__ */ jsx7(
1448
+ "img",
1449
+ {
1450
+ src: m.thumbUrl,
1451
+ alt: "",
1452
+ className: "squisq-image-editor-version-popover__thumb-img"
1453
+ }
1454
+ ) : /* @__PURE__ */ jsx7(
1455
+ "div",
1456
+ {
1457
+ className: "squisq-image-editor-version-popover__thumb-placeholder",
1458
+ "aria-hidden": "true"
1459
+ }
1460
+ ) }),
1461
+ /* @__PURE__ */ jsxs4("div", { className: "squisq-image-editor-version-popover__info", children: [
1462
+ /* @__PURE__ */ jsxs4("div", { className: "squisq-image-editor-version-popover__when", children: [
1463
+ isCurrent && /* @__PURE__ */ jsx7("span", { className: "squisq-image-editor-version-popover__badge", children: "Current" }),
1464
+ formatTimestamp(v.timestamp)
1465
+ ] }),
1466
+ /* @__PURE__ */ jsx7("div", { className: "squisq-image-editor-version-popover__summary", title: summary, children: summary || (m ? "" : "Loading\u2026") })
1467
+ ] }),
1468
+ /* @__PURE__ */ jsx7(
1469
+ "button",
1470
+ {
1471
+ type: "button",
1472
+ className: "squisq-image-editor-tool-button",
1473
+ onClick: () => handleRevert(v),
1474
+ disabled: isCurrent || busyTimestamp === ts,
1475
+ title: isCurrent ? "This is the current version" : "Revert to this version",
1476
+ children: busyTimestamp === ts ? "Loading\u2026" : "Revert"
1477
+ }
1478
+ )
1479
+ ]
1480
+ },
1481
+ ts
1482
+ );
1483
+ }) })
1484
+ ]
1485
+ }
1486
+ )
1487
+ ] });
1488
+ }
1489
+ function computeThumbScale(width, height) {
1490
+ const longest = Math.max(width, height);
1491
+ if (longest <= THUMB_MAX_DIM) return 1;
1492
+ return THUMB_MAX_DIM / longest;
1493
+ }
1494
+ function formatTimestamp(stamp) {
1495
+ if (Number.isNaN(stamp.getTime())) return String(stamp);
1496
+ return stamp.toLocaleString(void 0, {
1497
+ month: "short",
1498
+ day: "numeric",
1499
+ hour: "numeric",
1500
+ minute: "2-digit",
1501
+ second: "2-digit"
1502
+ });
1503
+ }
1504
+ function summarizeDiff(prev, next) {
1505
+ const parts = [];
1506
+ if (prev.canvas.width !== next.canvas.width || prev.canvas.height !== next.canvas.height) {
1507
+ parts.push(
1508
+ `Resized ${prev.canvas.width}\xD7${prev.canvas.height} \u2192 ${next.canvas.width}\xD7${next.canvas.height}`
1509
+ );
1510
+ } else if (prev.canvas.background !== next.canvas.background) {
1511
+ parts.push("Changed background");
1512
+ }
1513
+ const prevIds = new Set(prev.layers.map((l) => l.id));
1514
+ const nextIds = new Set(next.layers.map((l) => l.id));
1515
+ const added = next.layers.filter((l) => !prevIds.has(l.id));
1516
+ const removed = prev.layers.filter((l) => !nextIds.has(l.id));
1517
+ for (const l of added) parts.push(`Added ${describeLayer(l)}`);
1518
+ for (const l of removed) parts.push(`Removed ${describeLayer(l)}`);
1519
+ for (const n of next.layers) {
1520
+ const p = prev.layers.find((l) => l.id === n.id);
1521
+ if (!p) continue;
1522
+ const change = describeLayerChange(p, n);
1523
+ if (change) parts.push(change);
1524
+ }
1525
+ if (parts.length === 0) return "No changes";
1526
+ return parts.slice(0, 3).join(" \xB7 ") + (parts.length > 3 ? ` (+${parts.length - 3})` : "");
1527
+ }
1528
+ function describeLayer(layer) {
1529
+ const name = layer.name?.trim();
1530
+ return name && name.length > 0 ? `\u201C${name}\u201D` : layer.type;
1531
+ }
1532
+ function describeLayerChange(p, n) {
1533
+ const label = describeLayer(n);
1534
+ const posChanged = p.position.x !== n.position.x || p.position.y !== n.position.y || p.position.width !== n.position.width || p.position.height !== n.position.height;
1535
+ const sizeChanged = p.position.width !== n.position.width || p.position.height !== n.position.height;
1536
+ const contentChanged = JSON.stringify(p.content) !== JSON.stringify(n.content);
1537
+ if (sizeChanged && !contentChanged) return `Resized ${label}`;
1538
+ if (posChanged && !contentChanged) return `Moved ${label}`;
1539
+ if (contentChanged) return `Edited ${label}`;
1540
+ return null;
1541
+ }
1542
+ function emptyDoc() {
1543
+ return {
1544
+ version: 1,
1545
+ canvas: { width: 0, height: 0, background: "transparent" },
1546
+ layers: []
1547
+ };
1548
+ }
1549
+
1550
+ // src/imageEditor/LayersPanel.tsx
1551
+ import { useEffect as useEffect6, useRef as useRef5, useState as useState6 } from "react";
1552
+
1553
+ // src/imageEditor/icons.tsx
1554
+ import { jsx as jsx8 } from "react/jsx-runtime";
1555
+ function EyeIcon(props) {
1556
+ return /* @__PURE__ */ jsx8(Icon, { icon: "fa-solid fa-eye", ...props });
1557
+ }
1558
+ function EyeOffIcon(props) {
1559
+ return /* @__PURE__ */ jsx8(Icon, { icon: "fa-solid fa-eye-slash", ...props });
1560
+ }
1561
+ function LockIcon(props) {
1562
+ return /* @__PURE__ */ jsx8(Icon, { icon: "fa-solid fa-lock", ...props });
1563
+ }
1564
+ function UnlockIcon(props) {
1565
+ return /* @__PURE__ */ jsx8(Icon, { icon: "fa-solid fa-lock-open", ...props });
1566
+ }
1567
+ function ChevronUpIcon(props) {
1568
+ return /* @__PURE__ */ jsx8(Icon, { icon: "fa-solid fa-chevron-up", ...props });
1569
+ }
1570
+ function ChevronDownIcon(props) {
1571
+ return /* @__PURE__ */ jsx8(Icon, { icon: "fa-solid fa-chevron-down", ...props });
1572
+ }
1573
+ function CloseIcon(props) {
1574
+ return /* @__PURE__ */ jsx8(Icon, { icon: "fa-solid fa-xmark", ...props });
1575
+ }
1576
+ function CursorIcon(props) {
1577
+ return /* @__PURE__ */ jsx8(Icon, { icon: "fa-solid fa-arrow-pointer", ...props });
1578
+ }
1579
+ function TextIcon(props) {
1580
+ return /* @__PURE__ */ jsx8(Icon, { icon: "fa-solid fa-font", ...props });
1581
+ }
1582
+ function ShapeIcon(props) {
1583
+ return /* @__PURE__ */ jsx8(Icon, { icon: "fa-solid fa-shapes", ...props });
1584
+ }
1585
+ function CropIcon(props) {
1586
+ return /* @__PURE__ */ jsx8(Icon, { icon: "fa-solid fa-crop-simple", ...props });
1587
+ }
1588
+ function PlusIcon(props) {
1589
+ return /* @__PURE__ */ jsx8(Icon, { icon: "fa-solid fa-plus", ...props });
1590
+ }
1591
+ function ImageIcon(props) {
1592
+ return /* @__PURE__ */ jsx8(Icon, { icon: "fa-solid fa-image", ...props });
1593
+ }
1594
+ function NoneIcon(props) {
1595
+ return /* @__PURE__ */ jsx8(Icon, { icon: "fa-solid fa-ban", ...props });
1596
+ }
1597
+
1598
+ // src/imageEditor/LayersPanel.tsx
1599
+ import { jsx as jsx9, jsxs as jsxs5 } from "react/jsx-runtime";
1600
+ var ADD_LAYER_OPTIONS = [
1601
+ { kind: "text", label: "Text layer", icon: /* @__PURE__ */ jsx9(TextIcon, {}) },
1602
+ { kind: "shape", label: "Shape layer", icon: /* @__PURE__ */ jsx9(ShapeIcon, {}) },
1603
+ { kind: "image", label: "Image layer\u2026", icon: /* @__PURE__ */ jsx9(ImageIcon, {}) }
1604
+ ];
1605
+ function LayersPanel({ doc, selectedLayerId, dispatch, onAddLayer }) {
1606
+ const [addMenuOpen, setAddMenuOpen] = useState6(false);
1607
+ const addMenuRef = useRef5(null);
1608
+ useEffect6(() => {
1609
+ if (!addMenuOpen) return;
1610
+ const closeOnOutsideClick = (event) => {
1611
+ if (!addMenuRef.current?.contains(event.target)) setAddMenuOpen(false);
1612
+ };
1613
+ const closeOnEscape = (event) => {
1614
+ if (event.key === "Escape") setAddMenuOpen(false);
1615
+ };
1616
+ document.addEventListener("mousedown", closeOnOutsideClick);
1617
+ document.addEventListener("keydown", closeOnEscape);
1618
+ return () => {
1619
+ document.removeEventListener("mousedown", closeOnOutsideClick);
1620
+ document.removeEventListener("keydown", closeOnEscape);
1621
+ };
1622
+ }, [addMenuOpen]);
1623
+ const ordered = doc.layers.slice().reverse();
1624
+ return /* @__PURE__ */ jsxs5("div", { className: "squisq-image-editor-layers", "data-testid": "image-editor-layers", children: [
1625
+ /* @__PURE__ */ jsxs5("div", { className: "squisq-image-editor-panel-header squisq-image-editor-layers-header", children: [
1626
+ /* @__PURE__ */ jsx9("span", { children: "Layers" }),
1627
+ /* @__PURE__ */ jsxs5("span", { ref: addMenuRef, className: "squisq-image-editor-layer-add", children: [
1628
+ /* @__PURE__ */ jsx9(
1629
+ "button",
1630
+ {
1631
+ type: "button",
1632
+ className: "squisq-image-editor-layer-add-button",
1633
+ onClick: () => setAddMenuOpen((open) => !open),
1634
+ "aria-label": "Add layer",
1635
+ title: "Add layer",
1636
+ "aria-haspopup": "menu",
1637
+ "aria-expanded": addMenuOpen,
1638
+ children: /* @__PURE__ */ jsx9(PlusIcon, {})
1639
+ }
1640
+ ),
1641
+ addMenuOpen && /* @__PURE__ */ jsx9("div", { className: "squisq-image-editor-layer-add-menu", role: "menu", children: ADD_LAYER_OPTIONS.map(({ kind, label, icon }) => /* @__PURE__ */ jsxs5(
1642
+ "button",
1643
+ {
1644
+ type: "button",
1645
+ className: "squisq-image-editor-layer-add-menu-item",
1646
+ role: "menuitem",
1647
+ onClick: () => {
1648
+ setAddMenuOpen(false);
1649
+ onAddLayer(kind);
1650
+ },
1651
+ children: [
1652
+ icon,
1653
+ /* @__PURE__ */ jsx9("span", { children: label })
1654
+ ]
1655
+ },
1656
+ kind
1657
+ )) })
1658
+ ] })
1659
+ ] }),
1660
+ /* @__PURE__ */ jsxs5("ul", { className: "squisq-image-editor-layer-list", children: [
1661
+ ordered.length === 0 && /* @__PURE__ */ jsx9("li", { className: "squisq-image-editor-layer-empty", children: "No layers yet" }),
1662
+ ordered.map((layer) => {
1663
+ const visible = layer.visible !== false;
1664
+ const locked = !!layer.locked;
1665
+ const stackIndex = doc.layers.findIndex((l) => l.id === layer.id);
1666
+ const canMoveUp = stackIndex < doc.layers.length - 1;
1667
+ const canMoveDown = stackIndex > 0;
1668
+ const isSelected = selectedLayerId === layer.id;
1669
+ const layerName = layer.name ?? defaultLayerName(layer);
1670
+ return /* @__PURE__ */ jsxs5(
1671
+ "li",
1672
+ {
1673
+ className: [
1674
+ "squisq-image-editor-layer-item",
1675
+ isSelected ? "is-selected" : "",
1676
+ visible ? "" : "is-hidden"
1677
+ ].filter(Boolean).join(" "),
1678
+ children: [
1679
+ /* @__PURE__ */ jsx9(
1680
+ "button",
1681
+ {
1682
+ type: "button",
1683
+ className: "squisq-image-editor-layer-toggle",
1684
+ onClick: () => dispatch({
1685
+ type: "update-layer",
1686
+ layerId: layer.id,
1687
+ patch: { visible: !visible }
1688
+ }),
1689
+ "aria-label": visible ? "Hide layer" : "Show layer",
1690
+ title: visible ? "Hide layer" : "Show layer",
1691
+ children: visible ? /* @__PURE__ */ jsx9(EyeIcon, {}) : /* @__PURE__ */ jsx9(EyeOffIcon, {})
1692
+ }
1693
+ ),
1694
+ /* @__PURE__ */ jsx9(
1695
+ "button",
1696
+ {
1697
+ type: "button",
1698
+ className: "squisq-image-editor-layer-toggle",
1699
+ onClick: () => dispatch({
1700
+ type: "update-layer",
1701
+ layerId: layer.id,
1702
+ patch: { locked: !locked }
1703
+ }),
1704
+ "aria-label": locked ? "Unlock layer" : "Lock layer",
1705
+ title: locked ? "Unlock layer" : "Lock layer",
1706
+ children: locked ? /* @__PURE__ */ jsx9(LockIcon, {}) : /* @__PURE__ */ jsx9(UnlockIcon, {})
1707
+ }
1708
+ ),
1709
+ /* @__PURE__ */ jsxs5(
1710
+ "button",
1711
+ {
1712
+ type: "button",
1713
+ className: "squisq-image-editor-layer-name",
1714
+ onClick: () => dispatch({ type: "select", layerId: layer.id }),
1715
+ title: layerName,
1716
+ children: [
1717
+ /* @__PURE__ */ jsx9("span", { className: "squisq-image-editor-layer-name-text", children: layerName }),
1718
+ /* @__PURE__ */ jsx9("span", { className: "squisq-image-editor-layer-kind", children: layer.type })
1719
+ ]
1720
+ }
1721
+ ),
1722
+ /* @__PURE__ */ jsx9(
1723
+ "button",
1724
+ {
1725
+ type: "button",
1726
+ className: "squisq-image-editor-layer-toggle",
1727
+ disabled: !canMoveUp,
1728
+ onClick: () => dispatch({ type: "reorder-layer", layerId: layer.id, toIndex: stackIndex + 1 }),
1729
+ "aria-label": "Move layer up",
1730
+ title: "Move layer up",
1731
+ children: /* @__PURE__ */ jsx9(ChevronUpIcon, {})
1732
+ }
1733
+ ),
1734
+ /* @__PURE__ */ jsx9(
1735
+ "button",
1736
+ {
1737
+ type: "button",
1738
+ className: "squisq-image-editor-layer-toggle",
1739
+ disabled: !canMoveDown,
1740
+ onClick: () => dispatch({ type: "reorder-layer", layerId: layer.id, toIndex: stackIndex - 1 }),
1741
+ "aria-label": "Move layer down",
1742
+ title: "Move layer down",
1743
+ children: /* @__PURE__ */ jsx9(ChevronDownIcon, {})
1744
+ }
1745
+ ),
1746
+ /* @__PURE__ */ jsx9(
1747
+ "button",
1748
+ {
1749
+ type: "button",
1750
+ className: "squisq-image-editor-layer-toggle",
1751
+ onClick: () => dispatch({ type: "remove-layer", layerId: layer.id }),
1752
+ "aria-label": "Delete layer",
1753
+ title: "Delete layer",
1754
+ children: /* @__PURE__ */ jsx9(CloseIcon, {})
1755
+ }
1756
+ )
1757
+ ]
1758
+ },
1759
+ layer.id
1760
+ );
1761
+ })
1762
+ ] })
1763
+ ] });
1764
+ }
1765
+ function defaultLayerName(layer) {
1766
+ if (layer.type === "text") {
1767
+ const c = layer.content;
1768
+ return c?.text?.split("\n")[0]?.slice(0, 24) || "Text";
1769
+ }
1770
+ if (layer.type === "image") return "Image";
1771
+ if (layer.type === "shape") {
1772
+ const c = layer.content;
1773
+ return c?.shape ? c.shape[0].toUpperCase() + c.shape.slice(1) : "Shape";
1774
+ }
1775
+ return layer.type;
1776
+ }
1777
+
1778
+ // src/imageEditor/PropertiesPanel.tsx
1779
+ import { Fragment as Fragment2, jsx as jsx10, jsxs as jsxs6 } from "react/jsx-runtime";
1780
+ function PropertiesPanel({ doc, selectedLayerId, dispatch }) {
1781
+ const selected = selectedLayerId ? doc.layers.find((l) => l.id === selectedLayerId) ?? null : null;
1782
+ return /* @__PURE__ */ jsxs6("div", { className: "squisq-image-editor-properties", "data-testid": "image-editor-properties", children: [
1783
+ /* @__PURE__ */ jsx10("div", { className: "squisq-image-editor-panel-header", children: "Properties" }),
1784
+ /* @__PURE__ */ jsx10(CanvasSection, { doc, dispatch }),
1785
+ selected ? /* @__PURE__ */ jsx10(LayerSection, { layer: selected, dispatch }, selected.id) : /* @__PURE__ */ jsx10("div", { className: "squisq-image-editor-properties-empty", children: "No layer selected" })
1786
+ ] });
1787
+ }
1788
+ function CanvasSection({
1789
+ doc,
1790
+ dispatch
1791
+ }) {
1792
+ const setCanvas2 = (patch) => {
1793
+ dispatch({ type: "set-canvas", canvas: { ...doc.canvas, ...patch } });
1794
+ };
1795
+ return /* @__PURE__ */ jsxs6("fieldset", { className: "squisq-image-editor-fieldset", children: [
1796
+ /* @__PURE__ */ jsx10("legend", { children: "Canvas" }),
1797
+ /* @__PURE__ */ jsx10(
1798
+ NumberField,
1799
+ {
1800
+ label: "Width",
1801
+ value: doc.canvas.width,
1802
+ min: 1,
1803
+ onChange: (v) => setCanvas2({ width: Math.round(v) })
1804
+ }
1805
+ ),
1806
+ /* @__PURE__ */ jsx10(
1807
+ NumberField,
1808
+ {
1809
+ label: "Height",
1810
+ value: doc.canvas.height,
1811
+ min: 1,
1812
+ onChange: (v) => setCanvas2({ height: Math.round(v) })
1813
+ }
1814
+ ),
1815
+ /* @__PURE__ */ jsx10(
1816
+ ColorField,
1817
+ {
1818
+ label: "Background",
1819
+ value: doc.canvas.background ?? "transparent",
1820
+ allowTransparent: true,
1821
+ onChange: (v) => setCanvas2({ background: v })
1822
+ }
1823
+ )
1824
+ ] });
1825
+ }
1826
+ function LayerSection({
1827
+ layer,
1828
+ dispatch
1829
+ }) {
1830
+ const update = (patch) => dispatch({ type: "update-layer", layerId: layer.id, patch });
1831
+ return /* @__PURE__ */ jsxs6(Fragment2, { children: [
1832
+ /* @__PURE__ */ jsxs6("fieldset", { className: "squisq-image-editor-fieldset", children: [
1833
+ /* @__PURE__ */ jsx10("legend", { children: "Layer" }),
1834
+ /* @__PURE__ */ jsx10(TextField, { label: "Name", value: layer.name ?? "", onChange: (name) => update({ name }) }),
1835
+ /* @__PURE__ */ jsx10(
1836
+ NumberField,
1837
+ {
1838
+ label: "Opacity",
1839
+ value: layer.opacity ?? 1,
1840
+ min: 0,
1841
+ max: 1,
1842
+ step: 0.05,
1843
+ onChange: (opacity) => update({ opacity })
1844
+ }
1845
+ )
1846
+ ] }),
1847
+ /* @__PURE__ */ jsx10(PositionFields, { layer, update }),
1848
+ layer.type === "image" && /* @__PURE__ */ jsx10(ImageFields, { layer, update }),
1849
+ layer.type === "text" && /* @__PURE__ */ jsx10(TextFields, { layer, update }),
1850
+ layer.type === "shape" && /* @__PURE__ */ jsx10(ShapeFields, { layer, update }),
1851
+ layer.type === "path" && /* @__PURE__ */ jsx10(PathFields, { layer, update })
1852
+ ] });
1853
+ }
1854
+ function PositionFields({
1855
+ layer,
1856
+ update
1857
+ }) {
1858
+ const p = layer.position;
1859
+ const setPos = (patch) => update({ position: { ...p, ...patch } });
1860
+ return /* @__PURE__ */ jsxs6("fieldset", { className: "squisq-image-editor-fieldset", children: [
1861
+ /* @__PURE__ */ jsx10("legend", { children: "Position" }),
1862
+ /* @__PURE__ */ jsx10(
1863
+ NumberField,
1864
+ {
1865
+ label: "X",
1866
+ value: typeof p.x === "number" ? p.x : 0,
1867
+ onChange: (x) => setPos({ x: Math.round(x) })
1868
+ }
1869
+ ),
1870
+ /* @__PURE__ */ jsx10(
1871
+ NumberField,
1872
+ {
1873
+ label: "Y",
1874
+ value: typeof p.y === "number" ? p.y : 0,
1875
+ onChange: (y) => setPos({ y: Math.round(y) })
1876
+ }
1877
+ ),
1878
+ /* @__PURE__ */ jsx10(
1879
+ NumberField,
1880
+ {
1881
+ label: "Width",
1882
+ value: typeof p.width === "number" ? p.width : 0,
1883
+ onChange: (width) => setPos({ width: Math.round(width) })
1884
+ }
1885
+ ),
1886
+ /* @__PURE__ */ jsx10(
1887
+ NumberField,
1888
+ {
1889
+ label: "Height",
1890
+ value: typeof p.height === "number" ? p.height : 0,
1891
+ onChange: (height) => setPos({ height: Math.round(height) })
1892
+ }
1893
+ )
1894
+ ] });
1895
+ }
1896
+ function ImageFields({
1897
+ layer,
1898
+ update
1899
+ }) {
1900
+ const c = layer.content;
1901
+ const setContent = (patch) => update({ content: { ...c, ...patch } });
1902
+ return /* @__PURE__ */ jsxs6("fieldset", { className: "squisq-image-editor-fieldset", children: [
1903
+ /* @__PURE__ */ jsx10("legend", { children: "Image" }),
1904
+ /* @__PURE__ */ jsx10(TextField, { label: "Alt", value: c.alt ?? "", onChange: (alt) => setContent({ alt }) }),
1905
+ /* @__PURE__ */ jsx10(
1906
+ SelectField,
1907
+ {
1908
+ label: "Fit",
1909
+ value: c.fit ?? "fill",
1910
+ options: [
1911
+ ["fill", "Fill"],
1912
+ ["contain", "Contain"],
1913
+ ["cover", "Cover"]
1914
+ ],
1915
+ onChange: (fit) => setContent({ fit })
1916
+ }
1917
+ )
1918
+ ] });
1919
+ }
1920
+ function TextFields({
1921
+ layer,
1922
+ update
1923
+ }) {
1924
+ const c = layer.content;
1925
+ const setContent = (patch) => update({ content: { ...c, ...patch } });
1926
+ const setStyle = (patch) => setContent({ style: { ...c.style, ...patch } });
1927
+ return /* @__PURE__ */ jsxs6("fieldset", { className: "squisq-image-editor-fieldset", children: [
1928
+ /* @__PURE__ */ jsx10("legend", { children: "Text" }),
1929
+ /* @__PURE__ */ jsx10(TextAreaField, { label: "Text", value: c.text, onChange: (text) => setContent({ text }) }),
1930
+ /* @__PURE__ */ jsx10(
1931
+ NumberField,
1932
+ {
1933
+ label: "Font size",
1934
+ value: c.style.fontSize,
1935
+ min: 1,
1936
+ onChange: (fontSize) => setStyle({ fontSize: Math.round(fontSize) })
1937
+ }
1938
+ ),
1939
+ /* @__PURE__ */ jsx10(ColorField, { label: "Color", value: c.style.color, onChange: (color) => setStyle({ color }) }),
1940
+ /* @__PURE__ */ jsx10(
1941
+ SelectField,
1942
+ {
1943
+ label: "Weight",
1944
+ value: c.style.fontWeight ?? "normal",
1945
+ options: [
1946
+ ["normal", "Normal"],
1947
+ ["bold", "Bold"]
1948
+ ],
1949
+ onChange: (fontWeight) => setStyle({ fontWeight })
1950
+ }
1951
+ ),
1952
+ /* @__PURE__ */ jsx10(
1953
+ SelectField,
1954
+ {
1955
+ label: "Align",
1956
+ value: c.style.textAlign ?? "left",
1957
+ options: [
1958
+ ["left", "Left"],
1959
+ ["center", "Center"],
1960
+ ["right", "Right"]
1961
+ ],
1962
+ onChange: (textAlign) => setStyle({ textAlign })
1963
+ }
1964
+ )
1965
+ ] });
1966
+ }
1967
+ function ShapeFields({
1968
+ layer,
1969
+ update
1970
+ }) {
1971
+ const c = layer.content;
1972
+ const setContent = (patch) => update({ content: { ...c, ...patch } });
1973
+ return /* @__PURE__ */ jsxs6("fieldset", { className: "squisq-image-editor-fieldset", children: [
1974
+ /* @__PURE__ */ jsx10("legend", { children: "Shape" }),
1975
+ /* @__PURE__ */ jsx10(
1976
+ SelectField,
1977
+ {
1978
+ label: "Shape",
1979
+ value: c.shape,
1980
+ options: [
1981
+ ["rect", "Rectangle"],
1982
+ ["circle", "Circle"],
1983
+ ["line", "Line"]
1984
+ ],
1985
+ onChange: (shape) => setContent({ shape })
1986
+ }
1987
+ ),
1988
+ /* @__PURE__ */ jsx10(
1989
+ ColorField,
1990
+ {
1991
+ label: "Fill",
1992
+ value: c.fill ?? "#000000",
1993
+ allowTransparent: true,
1994
+ onChange: (fill) => setContent({ fill })
1995
+ }
1996
+ ),
1997
+ /* @__PURE__ */ jsx10(
1998
+ ColorField,
1999
+ {
2000
+ label: "Stroke",
2001
+ value: c.stroke ?? "#000000",
2002
+ allowTransparent: true,
2003
+ onChange: (stroke) => setContent({ stroke })
2004
+ }
2005
+ ),
2006
+ /* @__PURE__ */ jsx10(
2007
+ NumberField,
2008
+ {
2009
+ label: "Stroke width",
2010
+ value: c.strokeWidth ?? 0,
2011
+ min: 0,
2012
+ onChange: (strokeWidth) => setContent({ strokeWidth })
2013
+ }
2014
+ ),
2015
+ c.shape === "rect" && /* @__PURE__ */ jsx10(
2016
+ NumberField,
2017
+ {
2018
+ label: "Corner radius",
2019
+ value: c.borderRadius ?? 0,
2020
+ min: 0,
2021
+ onChange: (borderRadius) => setContent({ borderRadius: Math.round(borderRadius) })
2022
+ }
2023
+ )
2024
+ ] });
2025
+ }
2026
+ function PathFields({
2027
+ layer,
2028
+ update
2029
+ }) {
2030
+ const c = layer.content;
2031
+ const setContent = (patch) => update({ content: { ...c, ...patch } });
2032
+ const kindLabel = c.shapeKind ? c.shapeKind.replace(/-/g, " ") : c.endMarker || c.startMarker ? "arrow" : "path";
2033
+ return /* @__PURE__ */ jsxs6("fieldset", { className: "squisq-image-editor-fieldset", children: [
2034
+ /* @__PURE__ */ jsx10("legend", { children: "Shape" }),
2035
+ /* @__PURE__ */ jsxs6("div", { className: "squisq-image-editor-field", children: [
2036
+ /* @__PURE__ */ jsx10("span", { children: "Kind" }),
2037
+ /* @__PURE__ */ jsx10("span", { className: "squisq-image-editor-readonly-value", children: kindLabel })
2038
+ ] }),
2039
+ /* @__PURE__ */ jsx10(
2040
+ ColorField,
2041
+ {
2042
+ label: "Fill",
2043
+ value: c.fill ?? "none",
2044
+ allowTransparent: true,
2045
+ onChange: (fill) => setContent({ fill })
2046
+ }
2047
+ ),
2048
+ /* @__PURE__ */ jsx10(
2049
+ ColorField,
2050
+ {
2051
+ label: "Stroke",
2052
+ value: c.stroke ?? "#000000",
2053
+ allowTransparent: true,
2054
+ onChange: (stroke) => setContent({ stroke })
2055
+ }
2056
+ ),
2057
+ /* @__PURE__ */ jsx10(
2058
+ NumberField,
2059
+ {
2060
+ label: "Stroke width",
2061
+ value: c.strokeWidth ?? 0,
2062
+ min: 0,
2063
+ onChange: (strokeWidth) => setContent({ strokeWidth })
2064
+ }
2065
+ )
2066
+ ] });
2067
+ }
2068
+ function NumberField({
2069
+ label,
2070
+ value,
2071
+ min,
2072
+ max,
2073
+ step,
2074
+ onChange
2075
+ }) {
2076
+ return /* @__PURE__ */ jsxs6("label", { className: "squisq-image-editor-field", children: [
2077
+ /* @__PURE__ */ jsx10("span", { children: label }),
2078
+ /* @__PURE__ */ jsx10(
2079
+ "input",
2080
+ {
2081
+ type: "number",
2082
+ value: Number.isFinite(value) ? value : 0,
2083
+ min,
2084
+ max,
2085
+ step: step ?? 1,
2086
+ onChange: (e) => {
2087
+ const n = Number(e.target.value);
2088
+ if (Number.isFinite(n)) onChange(n);
2089
+ }
2090
+ }
2091
+ )
2092
+ ] });
2093
+ }
2094
+ function TextField({
2095
+ label,
2096
+ value,
2097
+ onChange
2098
+ }) {
2099
+ return /* @__PURE__ */ jsxs6("label", { className: "squisq-image-editor-field", children: [
2100
+ /* @__PURE__ */ jsx10("span", { children: label }),
2101
+ /* @__PURE__ */ jsx10("input", { type: "text", value, onChange: (e) => onChange(e.target.value) })
2102
+ ] });
2103
+ }
2104
+ function TextAreaField({
2105
+ label,
2106
+ value,
2107
+ onChange
2108
+ }) {
2109
+ return /* @__PURE__ */ jsxs6("label", { className: "squisq-image-editor-field squisq-image-editor-field--multiline", children: [
2110
+ /* @__PURE__ */ jsx10("span", { children: label }),
2111
+ /* @__PURE__ */ jsx10("textarea", { rows: 3, value, onChange: (e) => onChange(e.target.value) })
2112
+ ] });
2113
+ }
2114
+ function SelectField({
2115
+ label,
2116
+ value,
2117
+ options,
2118
+ onChange
2119
+ }) {
2120
+ return /* @__PURE__ */ jsxs6("label", { className: "squisq-image-editor-field", children: [
2121
+ /* @__PURE__ */ jsx10("span", { children: label }),
2122
+ /* @__PURE__ */ jsx10("select", { value, onChange: (e) => onChange(e.target.value), children: options.map(([v, l]) => /* @__PURE__ */ jsx10("option", { value: v, children: l }, v)) })
2123
+ ] });
2124
+ }
2125
+ function ColorField({
2126
+ label,
2127
+ value,
2128
+ allowTransparent,
2129
+ onChange
2130
+ }) {
2131
+ const isTransparent = value === "transparent" || value === "none";
2132
+ return /* @__PURE__ */ jsxs6("label", { className: "squisq-image-editor-field", children: [
2133
+ /* @__PURE__ */ jsx10("span", { children: label }),
2134
+ /* @__PURE__ */ jsxs6("span", { className: "squisq-image-editor-color-row", children: [
2135
+ /* @__PURE__ */ jsx10(
2136
+ "input",
2137
+ {
2138
+ type: "color",
2139
+ value: isTransparent ? "#000000" : normalizeColor(value),
2140
+ onChange: (e) => onChange(e.target.value)
2141
+ }
2142
+ ),
2143
+ /* @__PURE__ */ jsx10(
2144
+ "input",
2145
+ {
2146
+ type: "text",
2147
+ value,
2148
+ onChange: (e) => onChange(e.target.value),
2149
+ spellCheck: false
2150
+ }
2151
+ ),
2152
+ allowTransparent && /* @__PURE__ */ jsx10(
2153
+ "button",
2154
+ {
2155
+ type: "button",
2156
+ onClick: () => onChange("transparent"),
2157
+ title: "Set transparent",
2158
+ "aria-label": "Set transparent",
2159
+ className: "squisq-image-editor-color-clear",
2160
+ children: /* @__PURE__ */ jsx10(NoneIcon, {})
2161
+ }
2162
+ )
2163
+ ] })
2164
+ ] });
2165
+ }
2166
+ function normalizeColor(v) {
2167
+ if (/^#[0-9a-f]{6}$/i.test(v)) return v;
2168
+ if (/^#[0-9a-f]{3}$/i.test(v)) {
2169
+ return "#" + v.slice(1).split("").map((c) => c + c).join("");
2170
+ }
2171
+ return "#000000";
2172
+ }
2173
+
2174
+ // src/imageEditor/Toolbar.tsx
2175
+ import { useRef as useRef7, useState as useState8, useEffect as useEffect8 } from "react";
2176
+
2177
+ // src/scene/ShapePalette.tsx
2178
+ import { useEffect as useEffect7, useId as useId2, useMemo as useMemo2, useRef as useRef6, useState as useState7 } from "react";
2179
+ import { shapePath } from "@bendyline/squisq/doc";
2180
+ import { jsx as jsx11, jsxs as jsxs7 } from "react/jsx-runtime";
2181
+ var CATALOG = [
2182
+ {
2183
+ category: "Lines",
2184
+ items: [
2185
+ { kind: "line", label: "Line" },
2186
+ { kind: "arrow", label: "Line Arrow" },
2187
+ { kind: "text", label: "Text" }
2188
+ ]
2189
+ },
2190
+ {
2191
+ category: "Basic Shapes",
2192
+ items: [
2193
+ { kind: "rectangle", label: "Rectangle" },
2194
+ { kind: "circle", label: "Ellipse" },
2195
+ { kind: "triangle", label: "Triangle" },
2196
+ { kind: "right-triangle", label: "Right Triangle" },
2197
+ { kind: "diamond", label: "Diamond" },
2198
+ { kind: "pentagon", label: "Pentagon" },
2199
+ { kind: "hexagon", label: "Hexagon" },
2200
+ { kind: "octagon", label: "Octagon" },
2201
+ { kind: "parallelogram", label: "Parallelogram" },
2202
+ { kind: "trapezoid", label: "Trapezoid" },
2203
+ { kind: "plus", label: "Cross" },
2204
+ { kind: "chevron", label: "Chevron" },
2205
+ { kind: "cylinder", label: "Cylinder" },
2206
+ { kind: "callout", label: "Speech Bubble" },
2207
+ { kind: "cloud", label: "Cloud" },
2208
+ { kind: "heart", label: "Heart" },
2209
+ { kind: "lightning", label: "Lightning" }
2210
+ ]
2211
+ },
2212
+ {
2213
+ category: "Stars",
2214
+ items: [
2215
+ { kind: "star", label: "5-Point Star" },
2216
+ { kind: "star4", label: "4-Point Star" },
2217
+ { kind: "star6", label: "6-Point Star" }
2218
+ ]
2219
+ },
2220
+ {
2221
+ category: "Block Arrows",
2222
+ items: [
2223
+ { kind: "arrow-right", label: "Arrow Right" },
2224
+ { kind: "arrow-left", label: "Arrow Left" },
2225
+ { kind: "arrow-up", label: "Arrow Up" },
2226
+ { kind: "arrow-down", label: "Arrow Down" },
2227
+ { kind: "double-arrow", label: "Double Arrow" }
2228
+ ]
2229
+ }
2230
+ ];
2231
+ function ShapePalette({
2232
+ onPick,
2233
+ onClose,
2234
+ ignoreOutsideSelector = ".squisq-scene-block-toolbar"
2235
+ }) {
2236
+ const [query, setQuery] = useState7("");
2237
+ const ref = useRef6(null);
2238
+ useEffect7(() => {
2239
+ const onDocPointer = (e) => {
2240
+ const target = e.target;
2241
+ if (ignoreOutsideSelector && target?.closest?.(ignoreOutsideSelector)) return;
2242
+ if (ref.current && !ref.current.contains(e.target)) onClose();
2243
+ };
2244
+ const onKey = (e) => {
2245
+ if (e.key === "Escape") onClose();
2246
+ };
2247
+ document.addEventListener("pointerdown", onDocPointer);
2248
+ document.addEventListener("keydown", onKey);
2249
+ return () => {
2250
+ document.removeEventListener("pointerdown", onDocPointer);
2251
+ document.removeEventListener("keydown", onKey);
2252
+ };
2253
+ }, [onClose, ignoreOutsideSelector]);
2254
+ const sections = useMemo2(() => {
2255
+ const q = query.trim().toLowerCase();
2256
+ if (!q) return CATALOG;
2257
+ return CATALOG.map((s) => ({
2258
+ category: s.category,
2259
+ items: s.items.filter((i) => i.label.toLowerCase().includes(q) || i.kind.includes(q))
2260
+ })).filter((s) => s.items.length > 0);
2261
+ }, [query]);
2262
+ return /* @__PURE__ */ jsxs7("div", { className: "squisq-shape-palette", ref, role: "dialog", "aria-label": "Shapes", children: [
2263
+ /* @__PURE__ */ jsx11(
2264
+ "input",
2265
+ {
2266
+ className: "squisq-shape-palette-search",
2267
+ type: "text",
2268
+ placeholder: "Search shapes\u2026",
2269
+ value: query,
2270
+ autoFocus: true,
2271
+ onChange: (e) => setQuery(e.target.value)
2272
+ }
2273
+ ),
2274
+ /* @__PURE__ */ jsx11("div", { className: "squisq-shape-palette-scroll", children: sections.map((section) => /* @__PURE__ */ jsxs7("div", { className: "squisq-shape-palette-section", children: [
2275
+ /* @__PURE__ */ jsx11("div", { className: "squisq-shape-palette-heading", children: section.category }),
2276
+ /* @__PURE__ */ jsx11("div", { className: "squisq-shape-palette-grid", children: section.items.map((item) => /* @__PURE__ */ jsx11(
2277
+ "button",
2278
+ {
2279
+ type: "button",
2280
+ className: "squisq-shape-palette-item",
2281
+ title: item.label,
2282
+ "aria-label": item.label,
2283
+ onClick: () => onPick(item.kind),
2284
+ children: /* @__PURE__ */ jsx11(ShapeThumb, { kind: item.kind })
2285
+ },
2286
+ item.kind
2287
+ )) })
2288
+ ] }, section.category)) })
2289
+ ] });
2290
+ }
2291
+ function ShapeThumb({ kind }) {
2292
+ const d = shapePath(kind, 4, 4, 40, 30);
2293
+ const arrowId = `squisq-shape-thumb-arrow-${useId2().replace(/:/g, "")}`;
2294
+ return /* @__PURE__ */ jsxs7("svg", { viewBox: "0 0 48 38", width: 36, height: 28, "aria-hidden": "true", children: [
2295
+ kind === "arrow" ? /* @__PURE__ */ jsx11("defs", { children: /* @__PURE__ */ jsx11(
2296
+ "marker",
2297
+ {
2298
+ id: arrowId,
2299
+ viewBox: "0 0 10 10",
2300
+ refX: 9,
2301
+ refY: 5,
2302
+ markerWidth: 4,
2303
+ markerHeight: 4,
2304
+ children: /* @__PURE__ */ jsx11("path", { d: "M 0 0 L 10 5 L 0 10 z", className: "squisq-shape-thumb-line" })
2305
+ }
2306
+ ) }) : null,
2307
+ d ? /* @__PURE__ */ jsx11("path", { d, className: "squisq-shape-thumb" }) : kind === "circle" ? /* @__PURE__ */ jsx11("ellipse", { cx: 24, cy: 19, rx: 20, ry: 15, className: "squisq-shape-thumb" }) : kind === "line" ? /* @__PURE__ */ jsx11("line", { x1: 4, y1: 34, x2: 44, y2: 4, className: "squisq-shape-thumb-line" }) : kind === "arrow" ? /* @__PURE__ */ jsx11(
2308
+ "line",
2309
+ {
2310
+ x1: 4,
2311
+ y1: 34,
2312
+ x2: 44,
2313
+ y2: 4,
2314
+ className: "squisq-shape-thumb-line",
2315
+ markerEnd: `url(#${arrowId})`
2316
+ }
2317
+ ) : kind === "text" ? /* @__PURE__ */ jsx11("text", { x: 24, y: 26, textAnchor: "middle", className: "squisq-shape-thumb-text", children: "T" }) : /* @__PURE__ */ jsx11("rect", { x: 4, y: 6, width: 40, height: 26, rx: 3, className: "squisq-shape-thumb" })
2318
+ ] });
2319
+ }
2320
+
2321
+ // src/imageEditor/Toolbar.tsx
2322
+ import { jsx as jsx12, jsxs as jsxs8 } from "react/jsx-runtime";
2323
+ function RedlineArrowIcon() {
2324
+ return /* @__PURE__ */ jsxs8("svg", { width: "15", height: "15", viewBox: "0 0 15 15", fill: "none", "aria-hidden": "true", children: [
2325
+ /* @__PURE__ */ jsx12(
2326
+ "line",
2327
+ {
2328
+ x1: "3",
2329
+ y1: "12",
2330
+ x2: "12",
2331
+ y2: "3",
2332
+ stroke: "#cc0000",
2333
+ strokeWidth: "1.8",
2334
+ strokeLinecap: "round"
2335
+ }
2336
+ ),
2337
+ /* @__PURE__ */ jsx12(
2338
+ "polyline",
2339
+ {
2340
+ points: "7,3 12,3 12,8",
2341
+ stroke: "#cc0000",
2342
+ strokeWidth: "1.8",
2343
+ strokeLinejoin: "round",
2344
+ strokeLinecap: "round",
2345
+ fill: "none"
2346
+ }
2347
+ )
2348
+ ] });
2349
+ }
2350
+ function RedlineRectIcon() {
2351
+ return /* @__PURE__ */ jsx12("svg", { width: "15", height: "15", viewBox: "0 0 15 15", fill: "none", "aria-hidden": "true", children: /* @__PURE__ */ jsx12("rect", { x: "1.5", y: "3", width: "12", height: "9", stroke: "#cc0000", strokeWidth: "1.8", rx: "0.5" }) });
2352
+ }
2353
+ function RedlineTextIcon() {
2354
+ return /* @__PURE__ */ jsx12("svg", { width: "15", height: "15", viewBox: "0 0 15 15", "aria-hidden": "true", children: /* @__PURE__ */ jsx12("text", { x: "1.5", y: "13", fill: "#cc0000", fontSize: "13", fontFamily: "sans-serif", fontWeight: "bold", children: "A" }) });
2355
+ }
2356
+ function ZoomRectIcon() {
2357
+ return /* @__PURE__ */ jsxs8("svg", { width: "15", height: "15", viewBox: "0 0 15 15", fill: "none", "aria-hidden": "true", children: [
2358
+ /* @__PURE__ */ jsx12("circle", { cx: "6", cy: "6", r: "4.5", stroke: "currentColor", strokeWidth: "1.5" }),
2359
+ /* @__PURE__ */ jsx12(
2360
+ "line",
2361
+ {
2362
+ x1: "9.5",
2363
+ y1: "9.5",
2364
+ x2: "13.5",
2365
+ y2: "13.5",
2366
+ stroke: "currentColor",
2367
+ strokeWidth: "1.5",
2368
+ strokeLinecap: "round"
2369
+ }
2370
+ ),
2371
+ /* @__PURE__ */ jsx12(
2372
+ "line",
2373
+ {
2374
+ x1: "4",
2375
+ y1: "6",
2376
+ x2: "8",
2377
+ y2: "6",
2378
+ stroke: "currentColor",
2379
+ strokeWidth: "1.2",
2380
+ strokeLinecap: "round"
2381
+ }
2382
+ ),
2383
+ /* @__PURE__ */ jsx12(
2384
+ "line",
2385
+ {
2386
+ x1: "6",
2387
+ y1: "4",
2388
+ x2: "6",
2389
+ y2: "8",
2390
+ stroke: "currentColor",
2391
+ strokeWidth: "1.2",
2392
+ strokeLinecap: "round"
2393
+ }
2394
+ )
2395
+ ] });
2396
+ }
2397
+ var REDLINE_TOOLS = [
2398
+ { kind: "redline-arrow", icon: /* @__PURE__ */ jsx12(RedlineArrowIcon, {}), title: "Redline arrow" },
2399
+ { kind: "redline-rect", icon: /* @__PURE__ */ jsx12(RedlineRectIcon, {}), title: "Redline rectangle" },
2400
+ { kind: "redline-text", icon: /* @__PURE__ */ jsx12(RedlineTextIcon, {}), title: "Redline text" }
2401
+ ];
2402
+ var TOOLS = [
2403
+ { id: "select", icon: /* @__PURE__ */ jsx12(CursorIcon, {}), title: "Select / move (V)" },
2404
+ { id: "text", icon: /* @__PURE__ */ jsx12(TextIcon, {}), title: "Add text (T)" },
2405
+ { id: "shape", icon: /* @__PURE__ */ jsx12(ShapeIcon, {}), title: "Add shape (S)" },
2406
+ { id: "crop", icon: /* @__PURE__ */ jsx12(CropIcon, {}), title: "Crop (C)" },
2407
+ { id: "zoom-rect", icon: /* @__PURE__ */ jsx12(ZoomRectIcon, {}), title: "Zoom to rectangle (Z)" }
2408
+ ];
2409
+ function Toolbar({
2410
+ doc,
2411
+ tool,
2412
+ shapeKind,
2413
+ dispatch,
2414
+ uploadAsset,
2415
+ imageInputRef,
2416
+ onExport,
2417
+ onSave,
2418
+ saveLabel = "Save",
2419
+ saveTitle = "Save state.json",
2420
+ extraTools,
2421
+ zoom,
2422
+ onZoomIn,
2423
+ onZoomOut,
2424
+ onZoomSet,
2425
+ onZoomFit,
2426
+ onZoom1to1
2427
+ }) {
2428
+ const internalImageInputRef = useRef7(null);
2429
+ const fileInputRef = imageInputRef ?? internalImageInputRef;
2430
+ const [shapePaletteOpen, setShapePaletteOpen] = useState8(false);
2431
+ const onFilePicked = async (file) => {
2432
+ try {
2433
+ const path = await uploadAsset(file, file.name);
2434
+ const dims = await probeDims(file);
2435
+ const w = Math.min(dims.width, doc.canvas.width);
2436
+ const h = Math.min(dims.height, doc.canvas.height);
2437
+ dispatch({
2438
+ type: "add-layer",
2439
+ layer: {
2440
+ type: "image",
2441
+ name: file.name,
2442
+ position: {
2443
+ x: Math.round((doc.canvas.width - w) / 2),
2444
+ y: Math.round((doc.canvas.height - h) / 2),
2445
+ width: w,
2446
+ height: h
2447
+ },
2448
+ content: { src: path, alt: file.name, fit: "fill" }
2449
+ }
2450
+ });
2451
+ } catch (err) {
2452
+ console.warn(
2453
+ "[squisq-editor] image upload failed:",
2454
+ err instanceof Error ? err.message : err
2455
+ );
2456
+ }
2457
+ };
2458
+ return /* @__PURE__ */ jsxs8("div", { className: "squisq-image-editor-toolbar", "data-testid": "image-editor-toolbar", children: [
2459
+ /* @__PURE__ */ jsx12("div", { className: "squisq-image-editor-tool-group", role: "radiogroup", "aria-label": "Tools", children: TOOLS.map((t) => {
2460
+ const button = /* @__PURE__ */ jsx12(
2461
+ "button",
2462
+ {
2463
+ type: "button",
2464
+ role: "radio",
2465
+ "aria-checked": tool === t.id,
2466
+ className: ["squisq-image-editor-tool-button", tool === t.id ? "is-active" : ""].filter(Boolean).join(" "),
2467
+ onClick: () => {
2468
+ if (t.id === "shape") {
2469
+ dispatch({ type: "set-tool", tool: "shape" });
2470
+ setShapePaletteOpen((o) => !o);
2471
+ return;
2472
+ }
2473
+ setShapePaletteOpen(false);
2474
+ dispatch({ type: "set-tool", tool: t.id });
2475
+ },
2476
+ title: t.title,
2477
+ "aria-label": t.title,
2478
+ "aria-haspopup": t.id === "shape" ? "dialog" : void 0,
2479
+ "aria-expanded": t.id === "shape" ? shapePaletteOpen : void 0,
2480
+ children: t.icon
2481
+ },
2482
+ t.id
2483
+ );
2484
+ if (t.id !== "shape") return button;
2485
+ return /* @__PURE__ */ jsxs8("span", { className: "squisq-image-editor-shape-trigger", children: [
2486
+ button,
2487
+ shapePaletteOpen && /* @__PURE__ */ jsx12(
2488
+ ShapePalette,
2489
+ {
2490
+ ignoreOutsideSelector: ".squisq-image-editor-toolbar",
2491
+ onPick: (kind) => {
2492
+ dispatch({ type: "set-shape-kind", kind });
2493
+ dispatch({ type: "set-tool", tool: "shape" });
2494
+ setShapePaletteOpen(false);
2495
+ },
2496
+ onClose: () => setShapePaletteOpen(false)
2497
+ }
2498
+ )
2499
+ ] }, t.id);
2500
+ }) }),
2501
+ /* @__PURE__ */ jsx12("div", { className: "squisq-image-editor-tool-group", role: "group", "aria-label": "Redline shortcuts", children: REDLINE_TOOLS.map((rt) => {
2502
+ const active = tool === "shape" && shapeKind === rt.kind;
2503
+ return /* @__PURE__ */ jsx12(
2504
+ "button",
2505
+ {
2506
+ type: "button",
2507
+ className: ["squisq-image-editor-tool-button", active ? "is-active" : ""].filter(Boolean).join(" "),
2508
+ onClick: () => {
2509
+ dispatch({ type: "set-shape-kind", kind: rt.kind });
2510
+ dispatch({ type: "set-tool", tool: "shape" });
2511
+ setShapePaletteOpen(false);
2512
+ },
2513
+ title: rt.title,
2514
+ "aria-label": rt.title,
2515
+ "aria-pressed": active,
2516
+ children: rt.icon
2517
+ },
2518
+ rt.kind
2519
+ );
2520
+ }) }),
2521
+ /* @__PURE__ */ jsxs8("div", { className: "squisq-image-editor-tool-group", children: [
2522
+ /* @__PURE__ */ jsxs8(
2523
+ "button",
2524
+ {
2525
+ type: "button",
2526
+ className: "squisq-image-editor-tool-button squisq-image-editor-tool-button--with-label",
2527
+ onClick: () => fileInputRef.current?.click(),
2528
+ title: "Import image as new layer",
2529
+ "aria-label": "Import image as new layer",
2530
+ children: [
2531
+ /* @__PURE__ */ jsx12(PlusIcon, {}),
2532
+ /* @__PURE__ */ jsx12("span", { children: "Image" })
2533
+ ]
2534
+ }
2535
+ ),
2536
+ /* @__PURE__ */ jsx12(
2537
+ "input",
2538
+ {
2539
+ ref: fileInputRef,
2540
+ type: "file",
2541
+ accept: "image/*",
2542
+ hidden: true,
2543
+ onChange: (e) => {
2544
+ const file = e.target.files?.[0];
2545
+ if (file) void onFilePicked(file);
2546
+ e.target.value = "";
2547
+ }
2548
+ }
2549
+ )
2550
+ ] }),
2551
+ (onZoomIn || onZoomOut) && /* @__PURE__ */ jsxs8("div", { className: "squisq-image-editor-tool-group squisq-image-editor-tool-group--zoom", children: [
2552
+ /* @__PURE__ */ jsx12(
2553
+ "button",
2554
+ {
2555
+ type: "button",
2556
+ className: "squisq-image-editor-tool-button",
2557
+ onClick: onZoomOut,
2558
+ title: "Zoom out",
2559
+ "aria-label": "Zoom out",
2560
+ children: "\u2212"
2561
+ }
2562
+ ),
2563
+ /* @__PURE__ */ jsx12(
2564
+ "input",
2565
+ {
2566
+ type: "number",
2567
+ className: "squisq-image-editor-zoom-input",
2568
+ value: Math.round((zoom ?? 1) * 100),
2569
+ min: 6,
2570
+ max: 1600,
2571
+ step: 1,
2572
+ onChange: (e) => {
2573
+ const v = parseInt(e.target.value, 10);
2574
+ if (!isNaN(v) && v > 0) onZoomSet?.(v / 100);
2575
+ },
2576
+ "aria-label": "Zoom percentage",
2577
+ title: "Zoom %"
2578
+ }
2579
+ ),
2580
+ /* @__PURE__ */ jsx12("span", { className: "squisq-image-editor-zoom-label", children: "%" }),
2581
+ /* @__PURE__ */ jsx12(
2582
+ "button",
2583
+ {
2584
+ type: "button",
2585
+ className: "squisq-image-editor-tool-button",
2586
+ onClick: onZoomIn,
2587
+ title: "Zoom in",
2588
+ "aria-label": "Zoom in",
2589
+ children: "+"
2590
+ }
2591
+ ),
2592
+ /* @__PURE__ */ jsx12(
2593
+ "button",
2594
+ {
2595
+ type: "button",
2596
+ className: "squisq-image-editor-tool-button squisq-image-editor-tool-button--with-label",
2597
+ onClick: onZoom1to1,
2598
+ title: "1:1 pixels",
2599
+ children: "1:1"
2600
+ }
2601
+ ),
2602
+ /* @__PURE__ */ jsx12(
2603
+ "button",
2604
+ {
2605
+ type: "button",
2606
+ className: "squisq-image-editor-tool-button squisq-image-editor-tool-button--with-label",
2607
+ onClick: onZoomFit,
2608
+ title: "Fit to window",
2609
+ children: "Fit"
2610
+ }
2611
+ )
2612
+ ] }),
2613
+ /* @__PURE__ */ jsxs8("div", { className: "squisq-image-editor-tool-group squisq-image-editor-tool-group--right", children: [
2614
+ extraTools,
2615
+ onSave && /* @__PURE__ */ jsx12(
2616
+ "button",
2617
+ {
2618
+ type: "button",
2619
+ className: "squisq-image-editor-tool-button",
2620
+ onClick: onSave,
2621
+ title: saveTitle,
2622
+ children: saveLabel
2623
+ }
2624
+ ),
2625
+ /* @__PURE__ */ jsx12(ExportDropdown, { onExport })
2626
+ ] })
2627
+ ] });
2628
+ }
2629
+ function ExportDropdown({ onExport }) {
2630
+ const [open, setOpen] = useState8(false);
2631
+ const wrapRef = useRef7(null);
2632
+ const triggerRef = useRef7(null);
2633
+ useEffect8(() => {
2634
+ if (!open) return;
2635
+ function onDocClick(e) {
2636
+ const t = e.target;
2637
+ if (!t) return;
2638
+ if (wrapRef.current?.contains(t)) return;
2639
+ setOpen(false);
2640
+ }
2641
+ function onKey(e) {
2642
+ if (e.key === "Escape") setOpen(false);
2643
+ }
2644
+ document.addEventListener("mousedown", onDocClick);
2645
+ document.addEventListener("keydown", onKey);
2646
+ return () => {
2647
+ document.removeEventListener("mousedown", onDocClick);
2648
+ document.removeEventListener("keydown", onKey);
2649
+ };
2650
+ }, [open]);
2651
+ const pick = (f) => {
2652
+ setOpen(false);
2653
+ onExport(f);
2654
+ };
2655
+ return /* @__PURE__ */ jsxs8("span", { ref: wrapRef, className: "squisq-image-editor-version-dropdown", children: [
2656
+ /* @__PURE__ */ jsxs8(
2657
+ "button",
2658
+ {
2659
+ ref: triggerRef,
2660
+ type: "button",
2661
+ className: "squisq-image-editor-tool-button squisq-image-editor-tool-button--with-label",
2662
+ onClick: () => setOpen((o) => !o),
2663
+ "aria-haspopup": "menu",
2664
+ "aria-expanded": open,
2665
+ title: "Export image",
2666
+ children: [
2667
+ /* @__PURE__ */ jsx12("span", { children: "Export" }),
2668
+ /* @__PURE__ */ jsx12("span", { "aria-hidden": "true", style: { fontSize: "0.8em" }, children: "\u25BE" })
2669
+ ]
2670
+ }
2671
+ ),
2672
+ open && /* @__PURE__ */ jsx12("div", { className: "squisq-image-editor-version-popover", role: "menu", style: { minWidth: 160 }, children: /* @__PURE__ */ jsx12("ul", { className: "squisq-image-editor-version-popover__list", style: { maxHeight: "none" }, children: [
2673
+ { f: "png", label: "PNG" },
2674
+ { f: "jpeg", label: "JPEG" },
2675
+ { f: "webp", label: "WebP" }
2676
+ ].map(({ f, label }) => /* @__PURE__ */ jsx12("li", { className: "squisq-image-editor-version-popover__row", children: /* @__PURE__ */ jsxs8(
2677
+ "button",
2678
+ {
2679
+ type: "button",
2680
+ role: "menuitem",
2681
+ className: "squisq-image-editor-tool-button squisq-image-editor-tool-button--menu",
2682
+ onClick: () => pick(f),
2683
+ children: [
2684
+ "Export as ",
2685
+ label
2686
+ ]
2687
+ }
2688
+ ) }, f)) }) })
2689
+ ] });
2690
+ }
2691
+ function probeDims(file) {
2692
+ return new Promise((resolve) => {
2693
+ const url = URL.createObjectURL(file);
2694
+ const img = new Image();
2695
+ img.onload = () => {
2696
+ URL.revokeObjectURL(url);
2697
+ resolve({ width: img.naturalWidth, height: img.naturalHeight });
2698
+ };
2699
+ img.onerror = () => {
2700
+ URL.revokeObjectURL(url);
2701
+ resolve({ width: 200, height: 200 });
2702
+ };
2703
+ img.src = url;
2704
+ });
2705
+ }
2706
+
2707
+ // src/imageEditor/useImageEditorTokens.ts
2708
+ import { useMemo as useMemo3 } from "react";
2709
+ import {
2710
+ applySurface,
2711
+ resolveFontFamily
2712
+ } from "@bendyline/squisq/schemas";
2713
+ import { DEFAULT_THEME } from "@bendyline/squisq/doc";
2714
+ import { useAutoSurface } from "@bendyline/squisq-react";
2715
+ function useImageEditorTokens(theme, surface) {
2716
+ const auto = useAutoSurface(surface === "auto");
2717
+ const effectiveSurface = surface === "auto" ? auto : surface ?? void 0;
2718
+ return useMemo3(() => {
2719
+ const baseTheme = theme ?? DEFAULT_THEME;
2720
+ const finalTheme = effectiveSurface ? applySurface(baseTheme, effectiveSurface) : baseTheme;
2721
+ const bg = finalTheme.colors.background;
2722
+ const text = finalTheme.colors.text;
2723
+ const muted = finalTheme.colors.textMuted;
2724
+ const accent = finalTheme.colors.primary;
2725
+ const panelBg = `color-mix(in srgb, ${bg} 92%, ${text} 8%)`;
2726
+ const panelBorder = `color-mix(in srgb, ${bg} 80%, ${text} 20%)`;
2727
+ const controlBg = `color-mix(in srgb, ${bg} 86%, ${text} 14%)`;
2728
+ const controlBorder = `color-mix(in srgb, ${bg} 72%, ${text} 28%)`;
2729
+ const workspaceBg = `color-mix(in srgb, ${bg} 95%, ${text} 5%)`;
2730
+ const bodyFont = resolveFontFamily(
2731
+ finalTheme.typography.bodyFont,
2732
+ "system-ui, -apple-system, sans-serif"
2733
+ );
2734
+ const style = {
2735
+ ["--squisq-image-editor-bg"]: bg,
2736
+ ["--squisq-image-editor-panel-bg"]: panelBg,
2737
+ ["--squisq-image-editor-panel-border"]: panelBorder,
2738
+ ["--squisq-image-editor-text"]: text,
2739
+ ["--squisq-image-editor-text-muted"]: muted,
2740
+ ["--squisq-image-editor-accent"]: accent,
2741
+ ["--squisq-image-editor-control-bg"]: controlBg,
2742
+ ["--squisq-image-editor-control-border"]: controlBorder,
2743
+ ["--squisq-image-editor-workspace-bg"]: workspaceBg,
2744
+ ["--squisq-image-editor-body-font"]: bodyFont
2745
+ };
2746
+ return { style, theme: finalTheme };
2747
+ }, [theme, effectiveSurface]);
2748
+ }
2749
+
2750
+ // src/imageEditor/createShapeLayer.ts
2751
+ import { shapePath as shapePath2 } from "@bendyline/squisq/doc";
2752
+ var DEFAULT_WIDTH = 120;
2753
+ var DEFAULT_HEIGHT = 80;
2754
+ var DEFAULT_FILL = "#3399ff";
2755
+ var DEFAULT_STROKE = "#1a4d80";
2756
+ var DEFAULT_STROKE_WIDTH = 2;
2757
+ var REDLINE_COLOR = "#cc0000";
2758
+ var REDLINE_STROKE_WIDTH = 15;
2759
+ var LINEAR_KINDS = /* @__PURE__ */ new Set(["line", "arrow", "redline-arrow", "redline-rect"]);
2760
+ function isLinearShapeKind(kind) {
2761
+ return LINEAR_KINDS.has(kind);
2762
+ }
2763
+ var NATIVE = {
2764
+ rectangle: "rect",
2765
+ circle: "circle",
2766
+ line: "line"
2767
+ };
2768
+ function createShapeLayer(kind, x, y) {
2769
+ const width = DEFAULT_WIDTH;
2770
+ const height = DEFAULT_HEIGHT;
2771
+ const px = Math.round(x - width / 2);
2772
+ const py = Math.round(y - height / 2);
2773
+ const position = { x: px, y: py, width, height };
2774
+ const name = prettifyKind(kind);
2775
+ if (kind === "text") {
2776
+ return {
2777
+ type: "text",
2778
+ name: "Text",
2779
+ position: { x: Math.round(x), y: Math.round(y), width: 240, height: 48 },
2780
+ content: {
2781
+ text: "New text",
2782
+ style: { fontSize: 32, color: "#111111", fontFamily: "sans-serif" }
2783
+ }
2784
+ };
2785
+ }
2786
+ if (kind === "redline-arrow") {
2787
+ return {
2788
+ type: "path",
2789
+ name: "Redline Arrow",
2790
+ position,
2791
+ content: {
2792
+ d: `M ${px} ${py} L ${px + width} ${py + height}`,
2793
+ stroke: REDLINE_COLOR,
2794
+ strokeWidth: REDLINE_STROKE_WIDTH,
2795
+ fill: "none",
2796
+ endMarker: "arrow"
2797
+ }
2798
+ };
2799
+ }
2800
+ if (kind === "redline-rect") {
2801
+ return {
2802
+ type: "shape",
2803
+ name: "Redline Rectangle",
2804
+ position,
2805
+ content: {
2806
+ shape: "rect",
2807
+ fill: "none",
2808
+ stroke: REDLINE_COLOR,
2809
+ strokeWidth: REDLINE_STROKE_WIDTH,
2810
+ borderRadius: 0
2811
+ }
2812
+ };
2813
+ }
2814
+ if (kind === "redline-text") {
2815
+ return {
2816
+ type: "text",
2817
+ name: "Redline Text",
2818
+ position: { x: Math.round(x), y: Math.round(y), width: 240, height: 48 },
2819
+ content: {
2820
+ text: "Annotation",
2821
+ style: {
2822
+ fontSize: 24,
2823
+ color: REDLINE_COLOR,
2824
+ fontFamily: "sans-serif",
2825
+ fontWeight: "bold"
2826
+ }
2827
+ }
2828
+ };
2829
+ }
2830
+ const native = NATIVE[kind];
2831
+ if (native === "rect") {
2832
+ return {
2833
+ type: "shape",
2834
+ name,
2835
+ position,
2836
+ content: {
2837
+ shape: "rect",
2838
+ fill: DEFAULT_FILL,
2839
+ stroke: DEFAULT_STROKE,
2840
+ strokeWidth: DEFAULT_STROKE_WIDTH,
2841
+ borderRadius: 8
2842
+ }
2843
+ };
2844
+ }
2845
+ if (native === "circle") {
2846
+ return {
2847
+ type: "shape",
2848
+ name,
2849
+ position,
2850
+ content: {
2851
+ shape: "circle",
2852
+ fill: DEFAULT_FILL,
2853
+ stroke: DEFAULT_STROKE,
2854
+ strokeWidth: DEFAULT_STROKE_WIDTH
2855
+ }
2856
+ };
2857
+ }
2858
+ if (native === "line") {
2859
+ return {
2860
+ type: "shape",
2861
+ name,
2862
+ position,
2863
+ content: { shape: "line", stroke: DEFAULT_STROKE, strokeWidth: DEFAULT_STROKE_WIDTH }
2864
+ };
2865
+ }
2866
+ if (kind === "arrow") {
2867
+ return {
2868
+ type: "path",
2869
+ name: "Arrow",
2870
+ position,
2871
+ content: {
2872
+ d: `M ${px} ${py} L ${px + width} ${py + height}`,
2873
+ stroke: DEFAULT_STROKE,
2874
+ strokeWidth: DEFAULT_STROKE_WIDTH,
2875
+ fill: "none",
2876
+ endMarker: "arrow"
2877
+ }
2878
+ };
2879
+ }
2880
+ const d = shapePath2(kind, px, py, width, height) ?? `M ${px} ${py} L ${px + width} ${py + height}`;
2881
+ return {
2882
+ type: "path",
2883
+ name,
2884
+ position,
2885
+ content: {
2886
+ d,
2887
+ shapeKind: kind,
2888
+ fill: DEFAULT_FILL,
2889
+ stroke: DEFAULT_STROKE,
2890
+ strokeWidth: DEFAULT_STROKE_WIDTH
2891
+ }
2892
+ };
2893
+ }
2894
+ function createLinearShapeLayer(kind, x1, y1, x2, y2) {
2895
+ const rx1 = Math.round(x1);
2896
+ const ry1 = Math.round(y1);
2897
+ const rx2 = Math.round(x2);
2898
+ const ry2 = Math.round(y2);
2899
+ const bx = Math.min(rx1, rx2);
2900
+ const by = Math.min(ry1, ry2);
2901
+ const bw = Math.max(Math.abs(rx2 - rx1), 4);
2902
+ const bh = Math.max(Math.abs(ry2 - ry1), 4);
2903
+ const position = { x: bx, y: by, width: bw, height: bh };
2904
+ if (kind === "line") {
2905
+ return {
2906
+ type: "shape",
2907
+ name: "Line",
2908
+ position: {
2909
+ x: rx1,
2910
+ y: ry1,
2911
+ width: rx2 - rx1,
2912
+ height: ry2 - ry1
2913
+ },
2914
+ content: { shape: "line", stroke: DEFAULT_STROKE, strokeWidth: DEFAULT_STROKE_WIDTH }
2915
+ };
2916
+ }
2917
+ if (kind === "arrow") {
2918
+ return {
2919
+ type: "path",
2920
+ name: "Arrow",
2921
+ position,
2922
+ content: {
2923
+ d: `M ${rx1} ${ry1} L ${rx2} ${ry2}`,
2924
+ stroke: DEFAULT_STROKE,
2925
+ strokeWidth: DEFAULT_STROKE_WIDTH,
2926
+ fill: "none",
2927
+ endMarker: "arrow"
2928
+ }
2929
+ };
2930
+ }
2931
+ if (kind === "redline-arrow") {
2932
+ return {
2933
+ type: "path",
2934
+ name: "Redline Arrow",
2935
+ position,
2936
+ content: {
2937
+ d: `M ${rx1} ${ry1} L ${rx2} ${ry2}`,
2938
+ stroke: REDLINE_COLOR,
2939
+ strokeWidth: REDLINE_STROKE_WIDTH,
2940
+ fill: "none",
2941
+ endMarker: "arrow"
2942
+ }
2943
+ };
2944
+ }
2945
+ if (kind === "redline-rect") {
2946
+ return {
2947
+ type: "shape",
2948
+ name: "Redline Rectangle",
2949
+ position: {
2950
+ x: Math.min(rx1, rx2),
2951
+ y: Math.min(ry1, ry2),
2952
+ width: Math.max(Math.abs(rx2 - rx1), 4),
2953
+ height: Math.max(Math.abs(ry2 - ry1), 4)
2954
+ },
2955
+ content: {
2956
+ shape: "rect",
2957
+ fill: "none",
2958
+ stroke: REDLINE_COLOR,
2959
+ strokeWidth: REDLINE_STROKE_WIDTH,
2960
+ borderRadius: 0
2961
+ }
2962
+ };
2963
+ }
2964
+ return createShapeLayer(kind, (x1 + x2) / 2, (y1 + y2) / 2);
2965
+ }
2966
+ function prettifyKind(kind) {
2967
+ return kind.split("-").map((w) => w.charAt(0).toUpperCase() + w.slice(1)).join(" ");
2968
+ }
2969
+
2970
+ // src/ImageEditor.tsx
2971
+ import { jsx as jsx13, jsxs as jsxs9 } from "react/jsx-runtime";
2972
+ var ZOOM_STEPS = [0.0625, 0.083, 0.125, 0.167, 0.25, 0.333, 0.5, 0.667, 1, 1.5, 2, 3, 4, 8, 16];
2973
+ function ImageEditor(props) {
2974
+ const {
2975
+ filesContainer,
2976
+ initialSrc,
2977
+ stateFilename,
2978
+ allowVersioning,
2979
+ versioningAutoSaveIdleMs,
2980
+ onExport,
2981
+ saveBehavior = "flush",
2982
+ saveFormat = "png",
2983
+ saveLabel,
2984
+ saveTitle,
2985
+ theme,
2986
+ surface,
2987
+ className
2988
+ } = props;
2989
+ const tokens = useImageEditorTokens(theme, surface);
2990
+ const { state, dispatch, flush, resolveAssetUrl, uploadAsset, versioning, ready, error } = useImageEditor({
2991
+ container: filesContainer,
2992
+ initialSrc,
2993
+ stateFilename,
2994
+ allowVersioning,
2995
+ versioningAutoSaveIdleMs
2996
+ });
2997
+ const [historyRefreshKey, setHistoryRefreshKey] = useState9(0);
2998
+ const surfaceRef = useRef8(null);
2999
+ const imageInputRef = useRef8(null);
3000
+ const [zoom, setZoom] = useState9(1);
3001
+ const handleZoomIn = useCallback5(() => {
3002
+ setZoom((z) => ZOOM_STEPS.find((s) => s > z + 1e-3) ?? 16);
3003
+ }, []);
3004
+ const handleZoomOut = useCallback5(() => {
3005
+ setZoom((z) => [...ZOOM_STEPS].reverse().find((s) => s < z - 1e-3) ?? 0.0625);
3006
+ }, []);
3007
+ const handleZoomSet = useCallback5((z) => {
3008
+ setZoom(Math.max(0.0625, Math.min(16, z)));
3009
+ }, []);
3010
+ const handleZoom1to1 = useCallback5(() => setZoom(1), []);
3011
+ const handleZoomFit = useCallback5(() => {
3012
+ if (!state || !surfaceRef.current) return;
3013
+ const { clientWidth, clientHeight } = surfaceRef.current;
3014
+ const PADDING = 32;
3015
+ const fitZ = Math.min(
3016
+ (clientWidth - PADDING) / state.doc.canvas.width,
3017
+ (clientHeight - PADDING) / state.doc.canvas.height
3018
+ );
3019
+ setZoom(Math.max(0.0625, Math.min(16, fitZ)));
3020
+ }, [state]);
3021
+ const [requestEditLayerId, setRequestEditLayerId] = useState9(null);
3022
+ const handleExport = useCallback5(
3023
+ async (format) => {
3024
+ if (!state) return;
3025
+ try {
3026
+ const blob = await exportImageEditDoc2(state.doc, filesContainer, { format });
3027
+ if (onExport) {
3028
+ onExport(blob, format);
3029
+ } else {
3030
+ const url = URL.createObjectURL(blob);
3031
+ const a = document.createElement("a");
3032
+ a.href = url;
3033
+ a.download = `image.${format === "jpeg" ? "jpg" : format}`;
3034
+ document.body.appendChild(a);
3035
+ a.click();
3036
+ document.body.removeChild(a);
3037
+ URL.revokeObjectURL(url);
3038
+ }
3039
+ } catch (err) {
3040
+ console.warn(
3041
+ "[squisq-editor] image export failed:",
3042
+ err instanceof Error ? err.message : err
3043
+ );
3044
+ }
3045
+ },
3046
+ [state, filesContainer, onExport]
3047
+ );
3048
+ const handleSaveAndClose = useCallback5(async () => {
3049
+ try {
3050
+ await flush();
3051
+ if (versioning) {
3052
+ try {
3053
+ await versioning.saveVersion();
3054
+ setHistoryRefreshKey((k) => k + 1);
3055
+ } catch (err) {
3056
+ console.warn(
3057
+ "[squisq-editor] image-edit save-version failed:",
3058
+ err instanceof Error ? err.message : err
3059
+ );
3060
+ }
3061
+ }
3062
+ await handleExport(saveFormat);
3063
+ } catch (err) {
3064
+ console.warn(
3065
+ "[squisq-editor] image-edit save-and-close failed:",
3066
+ err instanceof Error ? err.message : err
3067
+ );
3068
+ }
3069
+ }, [flush, versioning, handleExport, saveFormat]);
3070
+ const handleRevertToVersion = useCallback5(
3071
+ async (version) => {
3072
+ if (!versioning) return;
3073
+ try {
3074
+ await flush();
3075
+ } catch {
3076
+ }
3077
+ const result = await versioning.revertToVersion(version);
3078
+ if (!result.reverted) return;
3079
+ const doc = await versioning.readVersion(version);
3080
+ if (doc) {
3081
+ dispatch({ type: "load", doc });
3082
+ setHistoryRefreshKey((k) => k + 1);
3083
+ }
3084
+ },
3085
+ [dispatch, flush, versioning]
3086
+ );
3087
+ const handleCreateTextAt = useCallback5(
3088
+ (x, y) => {
3089
+ const id = `layer-${Math.random().toString(36).slice(2, 10)}`;
3090
+ dispatch({
3091
+ type: "add-layer",
3092
+ layer: {
3093
+ id,
3094
+ type: "text",
3095
+ name: "Text",
3096
+ position: { x: Math.round(x), y: Math.round(y), width: 240, height: 48 },
3097
+ content: {
3098
+ text: "New text",
3099
+ style: { fontSize: 32, color: "#111111", fontFamily: "sans-serif" }
3100
+ }
3101
+ }
3102
+ });
3103
+ dispatch({ type: "set-tool", tool: "select" });
3104
+ setRequestEditLayerId(id);
3105
+ },
3106
+ [dispatch]
3107
+ );
3108
+ const shapeKind = state?.shapeKind ?? "rectangle";
3109
+ const shapeDragDraw = isLinearShapeKind(shapeKind);
3110
+ const handleCreateShapeAt = useCallback5(
3111
+ (x, y) => {
3112
+ const layer = createShapeLayer(shapeKind, x, y);
3113
+ const id = `layer-${Math.random().toString(36).slice(2, 10)}`;
3114
+ dispatch({ type: "add-layer", layer: { ...layer, id } });
3115
+ dispatch({ type: "set-tool", tool: "select" });
3116
+ if (layer.type === "text") {
3117
+ setRequestEditLayerId(id);
3118
+ }
3119
+ },
3120
+ [dispatch, shapeKind]
3121
+ );
3122
+ const handleCreateShapeFromPoints = useCallback5(
3123
+ (x1, y1, x2, y2) => {
3124
+ dispatch({ type: "add-layer", layer: createLinearShapeLayer(shapeKind, x1, y1, x2, y2) });
3125
+ dispatch({ type: "set-tool", tool: "select" });
3126
+ },
3127
+ [dispatch, shapeKind]
3128
+ );
3129
+ const handleAddLayer = useCallback5(
3130
+ (kind) => {
3131
+ if (!state) return;
3132
+ if (kind === "image") {
3133
+ imageInputRef.current?.click();
3134
+ return;
3135
+ }
3136
+ const { width, height } = state.doc.canvas;
3137
+ if (kind === "text") {
3138
+ handleCreateTextAt(Math.max(0, (width - 240) / 2), Math.max(0, (height - 48) / 2));
3139
+ return;
3140
+ }
3141
+ handleCreateShapeAt(width / 2, height / 2);
3142
+ },
3143
+ [handleCreateShapeAt, handleCreateTextAt, state]
3144
+ );
3145
+ if (error) {
3146
+ return /* @__PURE__ */ jsx13(
3147
+ "div",
3148
+ {
3149
+ className: ["squisq-image-editor", className].filter(Boolean).join(" "),
3150
+ style: tokens.style,
3151
+ children: /* @__PURE__ */ jsxs9("div", { className: "squisq-image-editor-error", children: [
3152
+ "Failed to load image editor: ",
3153
+ error.message
3154
+ ] })
3155
+ }
3156
+ );
3157
+ }
3158
+ if (!ready || !state) {
3159
+ return /* @__PURE__ */ jsx13(
3160
+ "div",
3161
+ {
3162
+ className: ["squisq-image-editor", className].filter(Boolean).join(" "),
3163
+ style: tokens.style,
3164
+ children: /* @__PURE__ */ jsx13("div", { className: "squisq-image-editor-loading", children: "Loading image editor\u2026" })
3165
+ }
3166
+ );
3167
+ }
3168
+ return /* @__PURE__ */ jsxs9(
3169
+ "div",
3170
+ {
3171
+ className: ["squisq-image-editor", className].filter(Boolean).join(" "),
3172
+ style: tokens.style,
3173
+ "data-testid": "image-editor",
3174
+ children: [
3175
+ /* @__PURE__ */ jsx13(
3176
+ Toolbar,
3177
+ {
3178
+ doc: state.doc,
3179
+ tool: state.tool,
3180
+ shapeKind: state.shapeKind,
3181
+ dispatch,
3182
+ uploadAsset,
3183
+ imageInputRef,
3184
+ onExport: handleExport,
3185
+ onSave: saveBehavior === "export" ? handleSaveAndClose : flush,
3186
+ saveLabel: saveLabel ?? (saveBehavior === "export" ? "Save and close" : "Save"),
3187
+ saveTitle: saveTitle ?? (saveBehavior === "export" ? `Rasterize and save as ${saveFormat.toUpperCase()}` : "Save state.json"),
3188
+ extraTools: versioning ? /* @__PURE__ */ jsx13(
3189
+ ImageVersionHistoryDropdown,
3190
+ {
3191
+ versioning,
3192
+ container: filesContainer,
3193
+ onRevert: handleRevertToVersion,
3194
+ refreshKey: historyRefreshKey
3195
+ }
3196
+ ) : null,
3197
+ zoom,
3198
+ onZoomIn: handleZoomIn,
3199
+ onZoomOut: handleZoomOut,
3200
+ onZoomSet: handleZoomSet,
3201
+ onZoomFit: handleZoomFit,
3202
+ onZoom1to1: handleZoom1to1
3203
+ }
3204
+ ),
3205
+ /* @__PURE__ */ jsxs9("div", { className: "squisq-image-editor-body", children: [
3206
+ /* @__PURE__ */ jsx13("div", { className: "squisq-image-editor-center", children: /* @__PURE__ */ jsx13(
3207
+ CanvasSurface,
3208
+ {
3209
+ doc: state.doc,
3210
+ selectedLayerId: state.selectedLayerId,
3211
+ tool: state.tool,
3212
+ resolveAssetUrl,
3213
+ dispatch,
3214
+ onCreateTextAt: handleCreateTextAt,
3215
+ onCreateShapeAt: handleCreateShapeAt,
3216
+ onCreateShapeFromPoints: handleCreateShapeFromPoints,
3217
+ shapeDragDraw,
3218
+ zoom,
3219
+ onSetZoom: handleZoomSet,
3220
+ surfaceRef,
3221
+ requestEditLayerId
3222
+ }
3223
+ ) }),
3224
+ /* @__PURE__ */ jsxs9("div", { className: "squisq-image-editor-side", children: [
3225
+ /* @__PURE__ */ jsx13(
3226
+ LayersPanel,
3227
+ {
3228
+ doc: state.doc,
3229
+ selectedLayerId: state.selectedLayerId,
3230
+ dispatch,
3231
+ onAddLayer: handleAddLayer
3232
+ }
3233
+ ),
3234
+ /* @__PURE__ */ jsx13(
3235
+ PropertiesPanel,
3236
+ {
3237
+ doc: state.doc,
3238
+ selectedLayerId: state.selectedLayerId,
3239
+ dispatch
3240
+ }
3241
+ )
3242
+ ] })
3243
+ ] })
3244
+ ]
3245
+ }
3246
+ );
3247
+ }
3248
+
3249
+ export {
3250
+ ShapePalette,
3251
+ ImageViewer,
3252
+ initialImageEditorState,
3253
+ imageEditorReducer,
3254
+ useImageEditor,
3255
+ ImageEditor
3256
+ };