@pixerate/editor-react 0.67.0 → 0.68.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,876 @@
1
+ // src/paint-editor/usePaintEditor.ts
2
+ import { useMemo, useState, useSyncExternalStore } from "react";
3
+ import { PaintEditorController } from "@pixerate/editor/paint-editor";
4
+ function usePaintEditor(options = {}) {
5
+ const [controller] = useState(() => options instanceof PaintEditorController ? options : new PaintEditorController(options));
6
+ const state = usePaintEditorState(controller);
7
+ return useMemo(() => ({ controller, state }), [controller, state]);
8
+ }
9
+ function usePaintEditorState(controller) {
10
+ return useSyncExternalStore(
11
+ (notify) => controller.subscribe(notify),
12
+ () => controller.getState(),
13
+ () => controller.getState()
14
+ );
15
+ }
16
+
17
+ // src/paint-editor/PaintCanvas.tsx
18
+ import { useEffect, useMemo as useMemo2, useRef, useState as useState2 } from "react";
19
+ import {
20
+ PaintCanvasPresenter,
21
+ PaintGesture,
22
+ fitZoom,
23
+ guideOverlay,
24
+ handlePaintShortcut,
25
+ viewCssTransform
26
+ } from "@pixerate/editor/paint-editor";
27
+ import { jsx, jsxs } from "react/jsx-runtime";
28
+ var PaintCanvas = ({ controller, tool = "brush", showGuides = true, fitOnMount = true, onError, className }) => {
29
+ const state = usePaintEditorState(controller);
30
+ const viewport = useRef(null);
31
+ const canvas = useRef(null);
32
+ const [size, setSize] = useState2({ width: 800, height: 600 });
33
+ const measured = useRef(false);
34
+ const [predicted, setPredicted] = useState2([]);
35
+ const presenter = useMemo2(() => new PaintCanvasPresenter(controller), [controller]);
36
+ const gesture = useMemo2(
37
+ () => new PaintGesture(controller, () => {
38
+ const r = viewport.current?.getBoundingClientRect();
39
+ return { left: r?.left ?? 0, top: r?.top ?? 0, width: r?.width || size.width, height: r?.height || size.height };
40
+ }),
41
+ // The box reads the element when called; `size` is only the jsdom fallback.
42
+ // eslint-disable-next-line react-hooks/exhaustive-deps
43
+ [controller]
44
+ );
45
+ gesture.tool = tool;
46
+ const { canvas: doc } = state.document;
47
+ useEffect(() => {
48
+ const el = viewport.current;
49
+ if (!el || typeof ResizeObserver === "undefined") return;
50
+ const ro = new ResizeObserver(() => {
51
+ if (!el.clientWidth || !el.clientHeight) return;
52
+ measured.current = true;
53
+ setSize({ width: el.clientWidth, height: el.clientHeight });
54
+ });
55
+ ro.observe(el);
56
+ return () => ro.disconnect();
57
+ }, []);
58
+ const fitted = useRef(null);
59
+ useEffect(() => {
60
+ const key = `${doc.width}x${doc.height}`;
61
+ if (!fitOnMount || fitted.current === key) return;
62
+ if (typeof ResizeObserver !== "undefined" && !measured.current) return;
63
+ fitted.current = key;
64
+ controller.setView({ zoom: fitZoom(doc, size), pan: { x: 0, y: 0 } });
65
+ }, [controller, doc, size, fitOnMount]);
66
+ useEffect(() => {
67
+ const ctx = canvas.current?.getContext("2d");
68
+ if (ctx && typeof ctx.createImageData === "function") presenter.render(ctx);
69
+ else controller.takeDirty();
70
+ }, [state, presenter, controller]);
71
+ useEffect(() => {
72
+ const el = viewport.current;
73
+ if (!el) return;
74
+ const onWheel = (e) => {
75
+ e.preventDefault();
76
+ gesture.wheel(e);
77
+ };
78
+ el.addEventListener("wheel", onWheel, { passive: false });
79
+ return () => el.removeEventListener("wheel", onWheel);
80
+ }, [gesture]);
81
+ const report = () => onError?.(gesture.error);
82
+ const transform = viewCssTransform(state.view, doc, size);
83
+ const lines = showGuides ? guideOverlay(controller) : [];
84
+ return /* @__PURE__ */ jsxs(
85
+ "div",
86
+ {
87
+ ref: viewport,
88
+ tabIndex: 0,
89
+ role: "application",
90
+ "aria-label": "Paint canvas",
91
+ "data-testid": "paint-canvas",
92
+ className: `relative overflow-hidden bg-slate-950 outline-none touch-none select-none ${className ?? ""}`,
93
+ style: { cursor: tool === "pan" ? "grab" : tool === "eyedropper" ? "copy" : "crosshair" },
94
+ onPointerDown: (e) => {
95
+ viewport.current?.focus();
96
+ if (gesture.down(e.nativeEvent)) e.target.setPointerCapture?.(e.pointerId);
97
+ report();
98
+ },
99
+ onPointerMove: (e) => {
100
+ gesture.move(e.nativeEvent);
101
+ if (gesture.predicted.length || predicted.length) setPredicted(gesture.predicted.map((p) => ({ x: p.x, y: p.y })));
102
+ },
103
+ onPointerUp: (e) => {
104
+ gesture.up(e.nativeEvent);
105
+ setPredicted([]);
106
+ },
107
+ onPointerCancel: () => {
108
+ gesture.cancel();
109
+ setPredicted([]);
110
+ },
111
+ onKeyDown: (e) => {
112
+ if (handlePaintShortcut(controller, gesture, e)) e.preventDefault();
113
+ },
114
+ children: [
115
+ /* @__PURE__ */ jsx(
116
+ "canvas",
117
+ {
118
+ ref: canvas,
119
+ width: doc.width,
120
+ height: doc.height,
121
+ className: "absolute left-0 top-0 shadow-2xl",
122
+ style: { transform, transformOrigin: "0 0", imageRendering: state.view.zoom > 2 ? "pixelated" : "auto" }
123
+ }
124
+ ),
125
+ /* @__PURE__ */ jsxs("svg", { className: "absolute left-0 top-0 pointer-events-none overflow-visible", width: doc.width, height: doc.height, style: { transform, transformOrigin: "0 0" }, "aria-hidden": "true", children: [
126
+ lines.map((l, i) => /* @__PURE__ */ jsx(
127
+ "line",
128
+ {
129
+ x1: l.from.x,
130
+ y1: l.from.y,
131
+ x2: l.to.x,
132
+ y2: l.to.y,
133
+ stroke: l.kind === "symmetry" ? "#22d3ee" : "#f472b6",
134
+ strokeOpacity: l.kind === "symmetry" ? 0.8 : 0.35,
135
+ strokeWidth: 1 / state.view.zoom,
136
+ strokeDasharray: l.kind === "symmetry" ? `${6 / state.view.zoom} ${4 / state.view.zoom}` : void 0
137
+ },
138
+ i
139
+ )),
140
+ predicted.length > 0 && /* @__PURE__ */ jsx("polyline", { points: predicted.map((p) => `${p.x},${p.y}`).join(" "), fill: "none", stroke: state.color, strokeOpacity: 0.5, strokeWidth: Math.max(1, state.brush.tip.size / 2), strokeLinecap: "round" })
141
+ ] }),
142
+ state.guides.references.filter((r) => r.visible).map((r) => /* @__PURE__ */ jsx(ReferencePanel, { controller, id: r.id }, r.id)),
143
+ /* @__PURE__ */ jsxs("div", { className: "absolute bottom-1 right-2 text-[10px] text-slate-500 pointer-events-none", "data-testid": "paint-view-status", children: [
144
+ Math.round(state.view.zoom * 100),
145
+ "% \xB7 ",
146
+ Math.round(state.view.rotation),
147
+ "\xB0",
148
+ state.view.mirror ? " \xB7 mirrored" : ""
149
+ ] })
150
+ ]
151
+ }
152
+ );
153
+ };
154
+ var ReferencePanel = ({ controller, id }) => {
155
+ const state = usePaintEditorState(controller);
156
+ const ref = state.guides.references.find((r) => r.id === id);
157
+ const drag = useRef(null);
158
+ if (!ref) return null;
159
+ const h = ref.height / ref.width * ref.displayWidth;
160
+ return /* @__PURE__ */ jsxs(
161
+ "div",
162
+ {
163
+ className: "absolute rounded border border-slate-700 bg-slate-900/95 shadow-xl",
164
+ style: { left: ref.x, top: ref.y, width: ref.displayWidth },
165
+ "data-testid": "paint-reference",
166
+ onPointerDown: (e) => e.stopPropagation(),
167
+ children: [
168
+ /* @__PURE__ */ jsxs(
169
+ "div",
170
+ {
171
+ className: "flex items-center justify-between px-1.5 py-0.5 text-[10px] text-slate-300 cursor-move",
172
+ onPointerDown: (e) => {
173
+ drag.current = { x: e.clientX, y: e.clientY, ox: ref.x, oy: ref.y };
174
+ e.target.setPointerCapture?.(e.pointerId);
175
+ },
176
+ onPointerMove: (e) => {
177
+ const d = drag.current;
178
+ if (d) controller.updateReferenceImage(id, { x: d.ox + e.clientX - d.x, y: d.oy + e.clientY - d.y });
179
+ },
180
+ onPointerUp: () => drag.current = null,
181
+ children: [
182
+ /* @__PURE__ */ jsx("span", { className: "truncate", children: ref.name }),
183
+ /* @__PURE__ */ jsx("button", { type: "button", "aria-label": `Hide ${ref.name}`, className: "px-1 text-slate-400 hover:text-white", onClick: () => controller.updateReferenceImage(id, { visible: false }), children: "\xD7" })
184
+ ]
185
+ }
186
+ ),
187
+ /* @__PURE__ */ jsx(
188
+ "img",
189
+ {
190
+ src: ref.src,
191
+ alt: ref.name,
192
+ draggable: false,
193
+ width: ref.displayWidth,
194
+ height: h,
195
+ className: "block cursor-copy",
196
+ onClick: (e) => {
197
+ const rect = e.target.getBoundingClientRect();
198
+ const k = ref.width / (rect.width || ref.displayWidth);
199
+ controller.setColor(controller.pickReferenceColor(id, Math.min(ref.width - 1, (e.clientX - rect.left) * k), Math.min(ref.height - 1, (e.clientY - rect.top) * k)));
200
+ }
201
+ }
202
+ )
203
+ ]
204
+ }
205
+ );
206
+ };
207
+
208
+ // src/paint-editor/PaintToolbar.tsx
209
+ import { useRef as useRef3 } from "react";
210
+ import { PAINT_TOOLS, SYMMETRY_MODES, decodeImageBlob, downloadBytes } from "@pixerate/editor/paint-editor";
211
+
212
+ // src/paint-editor/shared.tsx
213
+ import { useEffect as useEffect2, useRef as useRef2, useState as useState3 } from "react";
214
+ import { jsx as jsx2, jsxs as jsxs2 } from "react/jsx-runtime";
215
+ var chip = (on) => `px-2 py-1 rounded text-[11px] ${on ? "bg-indigo-600 text-white" : "bg-slate-800 text-slate-300 hover:text-white"}`;
216
+ var button = "px-2 py-1 rounded text-[11px] bg-slate-800 text-slate-200 hover:bg-slate-700 disabled:opacity-40";
217
+ var panel = "flex flex-col gap-2 text-xs text-slate-200";
218
+ var PixelCanvas = ({ pixels, label, className, style }) => {
219
+ const ref = useRef2(null);
220
+ useEffect2(() => {
221
+ const canvas = ref.current;
222
+ const ctx = canvas?.getContext("2d");
223
+ if (!canvas || !ctx || !pixels || typeof ctx.createImageData !== "function") return;
224
+ canvas.width = pixels.width;
225
+ canvas.height = pixels.height;
226
+ const img = ctx.createImageData(pixels.width, pixels.height);
227
+ img.data.set(pixels.data);
228
+ ctx.putImageData(img, 0, 0);
229
+ }, [pixels]);
230
+ return /* @__PURE__ */ jsx2("canvas", { ref, role: "img", "aria-label": label, className, style, width: pixels?.width, height: pixels?.height });
231
+ };
232
+ var fmt = (c, v) => c.step >= 1 ? String(Math.round(v)) : v.toFixed(2);
233
+ var PaintSlider = ({ control, value, onChange, onCommit }) => {
234
+ const [draft, setDraft] = useState3(null);
235
+ const ref = useRef2(null);
236
+ const commit = useRef2(onCommit);
237
+ commit.current = onCommit;
238
+ useEffect2(() => {
239
+ const el = ref.current;
240
+ if (!el) return;
241
+ const done = () => {
242
+ commit.current?.(Number(el.value));
243
+ setDraft(null);
244
+ };
245
+ el.addEventListener("change", done);
246
+ return () => el.removeEventListener("change", done);
247
+ }, []);
248
+ const shown = draft ?? value;
249
+ return /* @__PURE__ */ jsxs2("label", { className: "flex flex-col gap-0.5", "data-path": control.path, children: [
250
+ /* @__PURE__ */ jsxs2("span", { className: "flex justify-between text-slate-400", children: [
251
+ /* @__PURE__ */ jsx2("span", { children: control.label }),
252
+ /* @__PURE__ */ jsx2("span", { className: "font-mono", children: fmt(control, shown) })
253
+ ] }),
254
+ /* @__PURE__ */ jsx2(
255
+ "input",
256
+ {
257
+ ref,
258
+ type: "range",
259
+ "aria-label": control.label,
260
+ min: control.min,
261
+ max: control.max,
262
+ step: control.step,
263
+ value: shown,
264
+ onChange: (e) => {
265
+ const v = Number(e.target.value);
266
+ if (onCommit) setDraft(v);
267
+ onChange?.(v);
268
+ },
269
+ className: "w-full accent-indigo-500"
270
+ }
271
+ )
272
+ ] });
273
+ };
274
+ function useAttempt() {
275
+ const [error, setError] = useState3(null);
276
+ const attempt = (fn) => {
277
+ try {
278
+ setError(null);
279
+ const r = fn();
280
+ if (r && typeof r.catch === "function") r.catch((e) => setError(e instanceof Error ? e.message : String(e)));
281
+ } catch (e) {
282
+ setError(e instanceof Error ? e.message : String(e));
283
+ }
284
+ };
285
+ return [error, attempt, setError];
286
+ }
287
+ var ErrorLine = ({ error }) => error ? /* @__PURE__ */ jsx2("p", { role: "alert", className: "text-[11px] text-rose-400", children: error }) : null;
288
+ var CommitColorInput = ({ value, label, onCommit, className }) => {
289
+ const ref = useRef2(null);
290
+ const commit = useRef2(onCommit);
291
+ commit.current = onCommit;
292
+ useEffect2(() => {
293
+ const el = ref.current;
294
+ if (!el) return;
295
+ const done = () => commit.current(el.value);
296
+ el.addEventListener("change", done);
297
+ return () => el.removeEventListener("change", done);
298
+ }, []);
299
+ return /* @__PURE__ */ jsx2("input", { ref, type: "color", "aria-label": label, defaultValue: value, className }, value);
300
+ };
301
+
302
+ // src/paint-editor/PaintToolbar.tsx
303
+ import { Fragment, jsx as jsx3, jsxs as jsxs3 } from "react/jsx-runtime";
304
+ var PaintToolbar = ({ controller, tool, onToolChange, fileName = "painting", className }) => {
305
+ const state = usePaintEditorState(controller);
306
+ const [error, attempt] = useAttempt();
307
+ const openRef = useRef3(null);
308
+ const tracingRef = useRef3(null);
309
+ const refRef = useRef3(null);
310
+ const { symmetry, perspective, tracing } = state.guides;
311
+ const { canvas } = state.document;
312
+ const exportImage = (format) => attempt(async () => downloadBytes(await controller.exportImage({ format }), `${fileName}.${format === "image/png" ? "png" : "jpg"}`, format));
313
+ return /* @__PURE__ */ jsxs3("div", { className: `flex flex-wrap items-center gap-1.5 text-xs text-slate-200 ${className ?? ""}`, role: "toolbar", "aria-label": "Paint toolbar", "data-testid": "paint-toolbar", children: [
314
+ PAINT_TOOLS.map((t) => /* @__PURE__ */ jsx3("button", { type: "button", "aria-pressed": tool === t.id, title: `${t.label} (${t.key.toUpperCase()})`, className: chip(tool === t.id), onClick: () => onToolChange(t.id), children: t.label }, t.id)),
315
+ /* @__PURE__ */ jsx3("span", { className: "mx-1 h-4 w-px bg-slate-700" }),
316
+ /* @__PURE__ */ jsx3("button", { type: "button", className: button, disabled: !state.canUndo, title: state.undoLabel ? `Undo ${state.undoLabel}` : "Undo", onClick: () => controller.undo(), children: "Undo" }),
317
+ /* @__PURE__ */ jsx3("button", { type: "button", className: button, disabled: !state.canRedo, title: state.redoLabel ? `Redo ${state.redoLabel}` : "Redo", onClick: () => controller.redo(), children: "Redo" }),
318
+ /* @__PURE__ */ jsx3("span", { className: "mx-1 h-4 w-px bg-slate-700" }),
319
+ /* @__PURE__ */ jsx3("button", { type: "button", className: button, "aria-label": "Rotate view left", onClick: () => controller.setView({ rotation: state.view.rotation - 15 }), children: "\u27F2" }),
320
+ /* @__PURE__ */ jsx3("button", { type: "button", className: button, "aria-label": "Rotate view right", onClick: () => controller.setView({ rotation: state.view.rotation + 15 }), children: "\u27F3" }),
321
+ /* @__PURE__ */ jsx3("button", { type: "button", className: chip(state.view.mirror), "aria-pressed": state.view.mirror, onClick: () => controller.setView({ mirror: !state.view.mirror }), children: "Mirror view" }),
322
+ /* @__PURE__ */ jsx3("button", { type: "button", className: button, onClick: () => controller.resetView(), children: "Reset view" }),
323
+ /* @__PURE__ */ jsx3("span", { className: "mx-1 h-4 w-px bg-slate-700" }),
324
+ /* @__PURE__ */ jsxs3("label", { className: "flex items-center gap-1", children: [
325
+ "Symmetry",
326
+ /* @__PURE__ */ jsx3(
327
+ "select",
328
+ {
329
+ "aria-label": "Symmetry",
330
+ value: symmetry.mode,
331
+ onChange: (e) => controller.setSymmetry({ mode: e.target.value, center: symmetry.mode === "off" ? { x: canvas.width / 2, y: canvas.height / 2 } : symmetry.center }),
332
+ className: "rounded bg-slate-800 px-1 py-0.5",
333
+ children: SYMMETRY_MODES.map((m) => /* @__PURE__ */ jsx3("option", { value: m, children: m }, m))
334
+ }
335
+ )
336
+ ] }),
337
+ symmetry.mode === "kaleidoscope" && /* @__PURE__ */ jsx3(
338
+ "input",
339
+ {
340
+ type: "number",
341
+ "aria-label": "Kaleidoscope segments",
342
+ min: 2,
343
+ max: 32,
344
+ value: symmetry.segments,
345
+ onChange: (e) => controller.setSymmetry({ segments: Number(e.target.value) }),
346
+ className: "w-12 rounded bg-slate-800 px-1 py-0.5"
347
+ }
348
+ ),
349
+ /* @__PURE__ */ jsx3(
350
+ "button",
351
+ {
352
+ type: "button",
353
+ className: chip(perspective.enabled),
354
+ "aria-pressed": perspective.enabled,
355
+ onClick: () => attempt(
356
+ () => controller.setPerspective(
357
+ perspective.enabled ? { enabled: false } : { enabled: true, points: perspective.points.length ? perspective.points : [{ x: canvas.width / 2, y: canvas.height * 0.4 }] }
358
+ )
359
+ ),
360
+ children: "Perspective"
361
+ }
362
+ ),
363
+ perspective.enabled && /* @__PURE__ */ jsxs3(
364
+ "select",
365
+ {
366
+ "aria-label": "Vanishing points",
367
+ value: perspective.points.length,
368
+ onChange: (e) => {
369
+ const n = Number(e.target.value);
370
+ const y = canvas.height * 0.4;
371
+ const pts = n === 1 ? [{ x: canvas.width / 2, y }] : n === 2 ? [{ x: -canvas.width * 0.5, y }, { x: canvas.width * 1.5, y }] : [{ x: -canvas.width * 0.5, y }, { x: canvas.width * 1.5, y }, { x: canvas.width / 2, y: canvas.height * 3 }];
372
+ attempt(() => controller.setPerspective({ points: pts }));
373
+ },
374
+ className: "rounded bg-slate-800 px-1 py-0.5",
375
+ children: [
376
+ /* @__PURE__ */ jsx3("option", { value: 1, children: "1-point" }),
377
+ /* @__PURE__ */ jsx3("option", { value: 2, children: "2-point" }),
378
+ /* @__PURE__ */ jsx3("option", { value: 3, children: "3-point" })
379
+ ]
380
+ }
381
+ ),
382
+ /* @__PURE__ */ jsx3("span", { className: "mx-1 h-4 w-px bg-slate-700" }),
383
+ /* @__PURE__ */ jsx3("button", { type: "button", className: button, onClick: () => tracingRef.current?.click(), children: "Tracing image\u2026" }),
384
+ tracing && /* @__PURE__ */ jsxs3(Fragment, { children: [
385
+ /* @__PURE__ */ jsx3(
386
+ "input",
387
+ {
388
+ type: "range",
389
+ "aria-label": "Tracing opacity",
390
+ min: 0,
391
+ max: 1,
392
+ step: 0.05,
393
+ value: tracing.opacity,
394
+ onChange: (e) => controller.updateTracing({ opacity: Number(e.target.value) }),
395
+ className: "w-16 accent-indigo-500"
396
+ }
397
+ ),
398
+ /* @__PURE__ */ jsx3("button", { type: "button", className: chip(tracing.sampleColors), "aria-pressed": tracing.sampleColors, onClick: () => controller.updateTracing({ sampleColors: !tracing.sampleColors }), children: "Trace colours" }),
399
+ /* @__PURE__ */ jsx3("button", { type: "button", className: chip(tracing.visible), "aria-pressed": tracing.visible, onClick: () => controller.updateTracing({ visible: !tracing.visible }), children: "Show tracing" }),
400
+ /* @__PURE__ */ jsx3("button", { type: "button", className: button, onClick: () => controller.clearTracingImage(), children: "Remove tracing" })
401
+ ] }),
402
+ /* @__PURE__ */ jsx3("button", { type: "button", className: button, onClick: () => refRef.current?.click(), children: "Reference\u2026" }),
403
+ /* @__PURE__ */ jsx3("button", { type: "button", className: button, title: "Which hand holds the pen (palm rejection)", onClick: () => controller.setHand(state.hand === "right" ? "left" : "right"), children: state.hand === "right" ? "Right-handed" : "Left-handed" }),
404
+ /* @__PURE__ */ jsx3("span", { className: "mx-1 h-4 w-px bg-slate-700" }),
405
+ /* @__PURE__ */ jsx3("button", { type: "button", className: button, onClick: () => attempt(() => downloadBytes(JSON.stringify(controller.toJSON()), `${fileName}.paint.json`, "application/json")), children: "Save" }),
406
+ /* @__PURE__ */ jsx3("button", { type: "button", className: button, onClick: () => openRef.current?.click(), children: "Open\u2026" }),
407
+ /* @__PURE__ */ jsx3("button", { type: "button", className: button, onClick: () => exportImage("image/png"), children: "PNG" }),
408
+ /* @__PURE__ */ jsx3("button", { type: "button", className: button, onClick: () => exportImage("image/jpeg"), children: "JPEG" }),
409
+ /* @__PURE__ */ jsx3("button", { type: "button", className: button, onClick: () => attempt(() => downloadBytes(controller.exportPsd().bytes, `${fileName}.psd`, "image/vnd.adobe.photoshop")), children: "PSD" }),
410
+ /* @__PURE__ */ jsx3(
411
+ "input",
412
+ {
413
+ ref: openRef,
414
+ type: "file",
415
+ accept: ".json,application/json",
416
+ "aria-label": "Open painting",
417
+ className: "hidden",
418
+ onChange: (e) => {
419
+ const f = e.target.files?.[0];
420
+ if (f) attempt(async () => controller.loadJSON(JSON.parse(await f.text())));
421
+ e.target.value = "";
422
+ }
423
+ }
424
+ ),
425
+ /* @__PURE__ */ jsx3(
426
+ "input",
427
+ {
428
+ ref: tracingRef,
429
+ type: "file",
430
+ accept: "image/*",
431
+ "aria-label": "Load tracing image",
432
+ className: "hidden",
433
+ onChange: (e) => {
434
+ const f = e.target.files?.[0];
435
+ if (f) attempt(async () => controller.setTracingImage({ pixels: await decodeImageBlob(f) }));
436
+ e.target.value = "";
437
+ }
438
+ }
439
+ ),
440
+ /* @__PURE__ */ jsx3(
441
+ "input",
442
+ {
443
+ ref: refRef,
444
+ type: "file",
445
+ accept: "image/*",
446
+ "aria-label": "Add reference image",
447
+ className: "hidden",
448
+ onChange: (e) => {
449
+ const f = e.target.files?.[0];
450
+ if (f) attempt(async () => controller.addReferenceImage({ pixels: await decodeImageBlob(f), name: f.name }));
451
+ e.target.value = "";
452
+ }
453
+ }
454
+ ),
455
+ /* @__PURE__ */ jsx3(ErrorLine, { error })
456
+ ] });
457
+ };
458
+
459
+ // src/paint-editor/BrushPanel.tsx
460
+ import { useMemo as useMemo3, useRef as useRef4, useState as useState4 } from "react";
461
+ import { BRUSH_ROTATIONS, BRUSH_SLIDERS, downloadBytes as downloadBytes2, patchPath, readPath } from "@pixerate/editor/paint-editor";
462
+ import { jsx as jsx4, jsxs as jsxs4 } from "react/jsx-runtime";
463
+ var BrushPanel = ({ controller, thumbnailWidth = 112, thumbnailHeight = 32, className }) => {
464
+ const state = usePaintEditorState(controller);
465
+ const [error, attempt] = useAttempt();
466
+ const [saveName, setSaveName] = useState4("");
467
+ const fileRef = useRef4(null);
468
+ const { brush, brushes } = state;
469
+ const paper = state.document.paper;
470
+ const thumbs = useMemo3(
471
+ () => new Map(brushes.map((b) => [b.id, controller.brushThumbnail(b.id, { width: thumbnailWidth, height: thumbnailHeight, color: "#26324a" })])),
472
+ // Thumbnails follow the library and the paper.
473
+ // eslint-disable-next-line react-hooks/exhaustive-deps
474
+ [controller, brushes, paper, thumbnailWidth, thumbnailHeight]
475
+ );
476
+ const slug = (name) => name.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "").slice(0, 60) || "brush";
477
+ return /* @__PURE__ */ jsxs4("div", { className: `${panel} ${className ?? ""}`, "data-testid": "brush-panel", children: [
478
+ /* @__PURE__ */ jsx4("div", { className: "grid grid-cols-2 gap-1", role: "listbox", "aria-label": "Brushes", children: brushes.map((b) => /* @__PURE__ */ jsxs4(
479
+ "button",
480
+ {
481
+ type: "button",
482
+ role: "option",
483
+ "aria-selected": brush.id === b.id,
484
+ title: `${b.name} (${b.category})`,
485
+ className: `flex flex-col items-start gap-0.5 rounded p-1 text-left ${brush.id === b.id ? "bg-indigo-600/30 ring-1 ring-indigo-500" : "bg-slate-900 hover:bg-slate-800"}`,
486
+ onClick: () => controller.setBrush({ id: b.id }),
487
+ children: [
488
+ /* @__PURE__ */ jsx4(PixelCanvas, { pixels: thumbs.get(b.id) ?? null, label: `${b.name} thumbnail`, className: "rounded", style: { width: "100%", aspectRatio: `${thumbnailWidth} / ${thumbnailHeight}` } }),
489
+ /* @__PURE__ */ jsx4("span", { className: "truncate text-[10px] text-slate-300", children: b.name })
490
+ ]
491
+ },
492
+ b.id
493
+ )) }),
494
+ /* @__PURE__ */ jsxs4("div", { className: "flex flex-col gap-1.5", children: [
495
+ BRUSH_SLIDERS.map((c) => /* @__PURE__ */ jsx4(PaintSlider, { control: c, value: readPath(brush, c.path), onChange: (v) => controller.setBrush(patchPath(brush, c.path, v)) }, c.path)),
496
+ /* @__PURE__ */ jsxs4("label", { className: "flex items-center justify-between gap-2", children: [
497
+ /* @__PURE__ */ jsx4("span", { className: "text-slate-400", children: "Rotation" }),
498
+ /* @__PURE__ */ jsx4("select", { "aria-label": "Brush rotation", value: brush.rotation, onChange: (e) => controller.setBrush({ rotation: e.target.value }), className: "rounded bg-slate-800 px-1 py-0.5", children: BRUSH_ROTATIONS.map((r) => /* @__PURE__ */ jsx4("option", { value: r, children: r === "stroke" ? "Follow stroke" : r === "barrel" ? "Pen barrel" : "Fixed" }, r)) })
499
+ ] }),
500
+ /* @__PURE__ */ jsxs4("label", { className: "flex items-center gap-2", children: [
501
+ /* @__PURE__ */ jsx4("input", { type: "checkbox", checked: brush.tip.pressureSize, onChange: (e) => controller.setBrush({ tip: { pressureSize: e.target.checked } }) }),
502
+ " Pressure \u2192 size"
503
+ ] }),
504
+ /* @__PURE__ */ jsxs4("label", { className: "flex items-center gap-2", children: [
505
+ /* @__PURE__ */ jsx4("input", { type: "checkbox", checked: brush.tip.pressureOpacity, onChange: (e) => controller.setBrush({ tip: { pressureOpacity: e.target.checked } }) }),
506
+ " Pressure \u2192 opacity"
507
+ ] })
508
+ ] }),
509
+ /* @__PURE__ */ jsxs4("div", { className: "flex flex-wrap items-center gap-1", children: [
510
+ /* @__PURE__ */ jsx4("input", { value: saveName, placeholder: "Brush name", "aria-label": "Brush name", onChange: (e) => setSaveName(e.target.value), className: "w-28 rounded bg-slate-800 px-1.5 py-0.5" }),
511
+ /* @__PURE__ */ jsx4("button", { type: "button", className: button, disabled: !saveName.trim(), onClick: () => attempt(() => (controller.saveBrush({ id: slug(saveName), name: saveName.trim() }), setSaveName(""))), children: "Save brush" }),
512
+ /* @__PURE__ */ jsx4("button", { type: "button", className: button, onClick: () => attempt(() => downloadBytes2(JSON.stringify(controller.exportBrushes(), null, 2), "brushes.json", "application/json")), children: "Export" }),
513
+ /* @__PURE__ */ jsx4("button", { type: "button", className: button, onClick: () => fileRef.current?.click(), children: "Import\u2026" }),
514
+ /* @__PURE__ */ jsx4(
515
+ "input",
516
+ {
517
+ ref: fileRef,
518
+ type: "file",
519
+ accept: ".json,application/json",
520
+ "aria-label": "Import brushes",
521
+ className: "hidden",
522
+ onChange: (e) => {
523
+ const f = e.target.files?.[0];
524
+ if (f) attempt(async () => controller.importBrushes(JSON.parse(await f.text())));
525
+ e.target.value = "";
526
+ }
527
+ }
528
+ )
529
+ ] }),
530
+ /* @__PURE__ */ jsx4(ErrorLine, { error })
531
+ ] });
532
+ };
533
+
534
+ // src/paint-editor/PaperPanel.tsx
535
+ import { useMemo as useMemo4 } from "react";
536
+ import { PAPER_FAMILIES, PAPER_LIBRARY, PAPER_SLIDERS, patchPath as patchPath2, readPath as readPath2, renderPaper } from "@pixerate/editor/paint-editor";
537
+ import { jsx as jsx5, jsxs as jsxs5 } from "react/jsx-runtime";
538
+ var SWATCH = { x: 0, y: 0, width: 64, height: 40 };
539
+ var PaperPanel = ({ controller, className }) => {
540
+ const state = usePaintEditorState(controller);
541
+ const [error, attempt] = useAttempt();
542
+ const paper = state.document.paper;
543
+ const previews = useMemo4(() => new Map(PAPER_LIBRARY.map((p) => [p.preset, { ...SWATCH, data: renderPaper({ ...p, shading: Math.max(0.8, p.shading) }, SWATCH) }])), []);
544
+ return /* @__PURE__ */ jsxs5("div", { className: `${panel} ${className ?? ""}`, "data-testid": "paper-panel", children: [
545
+ PAPER_FAMILIES.map((family) => /* @__PURE__ */ jsxs5("div", { children: [
546
+ /* @__PURE__ */ jsx5("div", { className: "mb-0.5 text-[10px] uppercase tracking-wide text-slate-500", children: family }),
547
+ /* @__PURE__ */ jsx5("div", { className: "flex flex-wrap gap-1", role: "listbox", "aria-label": `${family} papers`, children: PAPER_LIBRARY.filter((p) => p.family === family).map((p) => /* @__PURE__ */ jsx5(
548
+ "button",
549
+ {
550
+ type: "button",
551
+ role: "option",
552
+ "aria-selected": paper.preset === p.preset,
553
+ title: p.name,
554
+ className: `rounded p-0.5 ${paper.preset === p.preset ? "ring-2 ring-indigo-500" : "ring-1 ring-slate-700 hover:ring-slate-500"}`,
555
+ onClick: () => attempt(() => controller.setPaper({ preset: p.preset })),
556
+ children: /* @__PURE__ */ jsx5(PixelCanvas, { pixels: previews.get(p.preset) ?? null, label: p.name, style: { width: 48, height: 30 } })
557
+ },
558
+ p.preset
559
+ )) })
560
+ ] }, family)),
561
+ /* @__PURE__ */ jsx5("div", { className: "text-slate-300", children: paper.name }),
562
+ /* @__PURE__ */ jsxs5("label", { className: "flex items-center justify-between", children: [
563
+ /* @__PURE__ */ jsx5("span", { className: "text-slate-400", children: "Colour" }),
564
+ /* @__PURE__ */ jsx5(CommitColorInput, { label: "Paper colour", value: paper.color, onCommit: (v) => attempt(() => controller.setPaper({ color: v })), className: "h-6 w-10 rounded bg-transparent" })
565
+ ] }),
566
+ PAPER_SLIDERS.map((c) => /* @__PURE__ */ jsx5(PaintSlider, { control: c, value: readPath2(paper, c.path), onCommit: (v) => attempt(() => controller.setPaper(patchPath2(paper, c.path, v))) }, c.path)),
567
+ /* @__PURE__ */ jsxs5("label", { className: "flex items-center gap-2", children: [
568
+ /* @__PURE__ */ jsx5("input", { type: "checkbox", checked: paper.tiling === "mirror", onChange: (e) => attempt(() => controller.setPaper({ tiling: e.target.checked ? "mirror" : "repeat" })) }),
569
+ " Mirror tiling"
570
+ ] }),
571
+ /* @__PURE__ */ jsx5("button", { type: "button", className: "self-start px-2 py-1 rounded text-[11px] bg-slate-800 text-slate-200 hover:bg-slate-700", onClick: () => attempt(() => controller.setPaper({ seed: paper.seed + 1 })), children: "New sheet" }),
572
+ /* @__PURE__ */ jsx5(ErrorLine, { error })
573
+ ] });
574
+ };
575
+
576
+ // src/paint-editor/ColorPanel.tsx
577
+ import { useMemo as useMemo5, useRef as useRef5, useState as useState5 } from "react";
578
+ import {
579
+ HARMONY_KINDS,
580
+ MIXING_PAD_HEIGHT,
581
+ MIXING_PAD_WIDTH,
582
+ hsvToRgb,
583
+ hueToWheel,
584
+ parseHexColor,
585
+ renderColorWheel,
586
+ rgbToHexColor,
587
+ rgbToHsv,
588
+ wheelPick,
589
+ wheelPoint
590
+ } from "@pixerate/editor/paint-editor";
591
+ import { jsx as jsx6, jsxs as jsxs6 } from "react/jsx-runtime";
592
+ var ColorPanel = ({ controller, wheelSize = 168, mixingPad = true, className }) => {
593
+ const state = usePaintEditorState(controller);
594
+ const [error, attempt] = useAttempt();
595
+ const [wheel, setWheel] = useState5("ryb");
596
+ const [kind, setKind] = useState5("complementary");
597
+ const [padTool, setPadTool] = useState5("paint");
598
+ const padPoints = useRef5(null);
599
+ const ring = useMemo5(() => renderColorWheel(wheelSize, wheel), [wheelSize, wheel]);
600
+ const color = state.color;
601
+ const harmony = useMemo5(() => controller.harmony(kind, { color, wheel }), [controller, kind, color, wheel]);
602
+ const [r, g, b] = parseHexColor(color);
603
+ const hsv = rgbToHsv(r, g, b);
604
+ const pad = useMemo5(() => ({ width: MIXING_PAD_WIDTH, height: MIXING_PAD_HEIGHT, data: controller.mixingPad.pixels() }), [controller, state.mixingPadVersion]);
605
+ const R = wheelSize / 2;
606
+ const mid = R * 0.82;
607
+ const setHsv = (s, v) => controller.setColor(rgbToHexColor(...hsvToRgb(hsv.h, s, v)));
608
+ const padPoint = (e) => {
609
+ const rect = e.currentTarget.getBoundingClientRect();
610
+ const kx = MIXING_PAD_WIDTH / (rect.width || MIXING_PAD_WIDTH);
611
+ const ky = MIXING_PAD_HEIGHT / (rect.height || MIXING_PAD_HEIGHT);
612
+ const x = Math.min(MIXING_PAD_WIDTH, Math.max(0, (e.clientX - rect.left) * kx));
613
+ const y = Math.min(MIXING_PAD_HEIGHT, Math.max(0, (e.clientY - rect.top) * ky));
614
+ return e.pointerType === "pen" && e.pressure > 0 ? { x, y, pressure: e.pressure } : { x, y };
615
+ };
616
+ return /* @__PURE__ */ jsxs6("div", { className: `${panel} ${className ?? ""}`, "data-testid": "color-panel", children: [
617
+ /* @__PURE__ */ jsxs6("div", { className: "relative self-center", style: { width: wheelSize, height: wheelSize }, children: [
618
+ /* @__PURE__ */ jsx6(
619
+ "div",
620
+ {
621
+ role: "slider",
622
+ "aria-label": "Hue wheel",
623
+ "aria-valuemin": 0,
624
+ "aria-valuemax": 360,
625
+ "aria-valuenow": Math.round(hueToWheel(hsv.h, wheel)),
626
+ tabIndex: 0,
627
+ className: "absolute inset-0 cursor-crosshair",
628
+ onPointerDown: (e) => {
629
+ const rect = e.currentTarget.getBoundingClientRect();
630
+ controller.setColor(wheelPick(e.clientX - rect.left - R, e.clientY - rect.top - R, wheel, color));
631
+ },
632
+ children: /* @__PURE__ */ jsx6(PixelCanvas, { pixels: ring, label: "Colour wheel", style: { width: wheelSize, height: wheelSize } })
633
+ }
634
+ ),
635
+ /* @__PURE__ */ jsx6("svg", { className: "pointer-events-none absolute inset-0", width: wheelSize, height: wheelSize, "aria-hidden": "true", children: harmony.map((h, i) => {
636
+ const p = wheelPoint(h.angle, mid);
637
+ return /* @__PURE__ */ jsx6("circle", { cx: R + p.x, cy: R + p.y, r: i === 0 ? 7 : 5, fill: h.color, stroke: "#fff", strokeWidth: i === 0 ? 2 : 1 }, i);
638
+ }) }),
639
+ /* @__PURE__ */ jsx6("div", { className: "absolute rounded-full border border-slate-700", style: { left: R - R * 0.45, top: R - R * 0.45, width: R * 0.9, height: R * 0.9, background: color } })
640
+ ] }),
641
+ /* @__PURE__ */ jsxs6("div", { className: "flex items-center gap-2", children: [
642
+ /* @__PURE__ */ jsx6("input", { type: "color", "aria-label": "Paint colour", value: color, onChange: (e) => controller.setColor(e.target.value), className: "h-7 w-9 rounded bg-transparent" }),
643
+ /* @__PURE__ */ jsx6("input", { type: "color", "aria-label": "Second colour", value: state.secondaryColor, onChange: (e) => controller.setSecondaryColor(e.target.value), className: "h-5 w-7 rounded bg-transparent" }),
644
+ /* @__PURE__ */ jsx6("button", { type: "button", className: button, title: "Swap colours (X)", onClick: () => controller.swapColors(), children: "Swap" }),
645
+ /* @__PURE__ */ jsx6(
646
+ "input",
647
+ {
648
+ "aria-label": "Hex colour",
649
+ defaultValue: color,
650
+ onBlur: (e) => attempt(() => controller.setColor(e.target.value.startsWith("#") ? e.target.value : `#${e.target.value}`)),
651
+ className: "w-20 rounded bg-slate-800 px-1.5 py-0.5 font-mono"
652
+ },
653
+ color
654
+ )
655
+ ] }),
656
+ /* @__PURE__ */ jsxs6("label", { className: "flex items-center gap-2", children: [
657
+ /* @__PURE__ */ jsx6("span", { className: "w-16 text-slate-400", children: "Saturation" }),
658
+ /* @__PURE__ */ jsx6("input", { type: "range", "aria-label": "Saturation", min: 0, max: 1, step: 0.01, value: hsv.s, onChange: (e) => setHsv(Number(e.target.value), hsv.v), className: "flex-1 accent-indigo-500" })
659
+ ] }),
660
+ /* @__PURE__ */ jsxs6("label", { className: "flex items-center gap-2", children: [
661
+ /* @__PURE__ */ jsx6("span", { className: "w-16 text-slate-400", children: "Value" }),
662
+ /* @__PURE__ */ jsx6("input", { type: "range", "aria-label": "Value", min: 0, max: 1, step: 0.01, value: hsv.v, onChange: (e) => setHsv(hsv.s, Number(e.target.value)), className: "flex-1 accent-indigo-500" })
663
+ ] }),
664
+ /* @__PURE__ */ jsxs6("div", { className: "flex items-center gap-1", children: [
665
+ /* @__PURE__ */ jsx6("select", { "aria-label": "Harmony", value: kind, onChange: (e) => setKind(e.target.value), className: "rounded bg-slate-800 px-1 py-0.5", children: HARMONY_KINDS.map((k) => /* @__PURE__ */ jsx6("option", { value: k, children: k }, k)) }),
666
+ /* @__PURE__ */ jsx6("button", { type: "button", className: chip(wheel === "ryb"), "aria-pressed": wheel === "ryb", onClick: () => setWheel("ryb"), children: "RYB" }),
667
+ /* @__PURE__ */ jsx6("button", { type: "button", className: chip(wheel === "rgb"), "aria-pressed": wheel === "rgb", onClick: () => setWheel("rgb"), children: "RGB" })
668
+ ] }),
669
+ /* @__PURE__ */ jsxs6("div", { className: "flex flex-wrap gap-1", "aria-label": "Harmony colours", children: [
670
+ harmony.map((h, i) => /* @__PURE__ */ jsx6("button", { type: "button", "aria-label": `Use ${h.color}`, title: h.color, className: "h-6 w-6 rounded ring-1 ring-slate-700", style: { background: h.color }, onClick: () => controller.setColor(h.color) }, i)),
671
+ /* @__PURE__ */ jsx6("button", { type: "button", className: button, onClick: () => harmony.forEach((h) => controller.addSwatch(h.color)), children: "+ swatches" })
672
+ ] }),
673
+ /* @__PURE__ */ jsxs6("div", { className: "flex flex-wrap gap-1", role: "listbox", "aria-label": "Swatches", children: [
674
+ state.swatches.map((s, i) => /* @__PURE__ */ jsx6(
675
+ "button",
676
+ {
677
+ type: "button",
678
+ role: "option",
679
+ "aria-selected": s.color === color,
680
+ "aria-label": s.name ?? s.color,
681
+ title: `${s.name ?? s.color} (right-click to remove)`,
682
+ className: "h-5 w-5 rounded ring-1 ring-slate-700",
683
+ style: { background: s.color },
684
+ onClick: () => controller.setColor(s.color),
685
+ onContextMenu: (e) => {
686
+ e.preventDefault();
687
+ controller.removeSwatch(i);
688
+ }
689
+ },
690
+ i
691
+ )),
692
+ /* @__PURE__ */ jsx6("button", { type: "button", className: button, "aria-label": "Add swatch", onClick: () => controller.addSwatch(color), children: "+" })
693
+ ] }),
694
+ mixingPad && /* @__PURE__ */ jsxs6("div", { className: "flex flex-col gap-1", "data-testid": "mixing-pad", children: [
695
+ /* @__PURE__ */ jsxs6("div", { className: "flex items-center gap-1", children: [
696
+ /* @__PURE__ */ jsx6("span", { className: "text-slate-400", children: "Mixing pad" }),
697
+ ["paint", "knife", "pick"].map((t) => /* @__PURE__ */ jsx6("button", { type: "button", "aria-pressed": padTool === t, className: chip(padTool === t), onClick: () => setPadTool(t), children: t === "paint" ? "Paint" : t === "knife" ? "Knife" : "Pick" }, t)),
698
+ /* @__PURE__ */ jsx6("button", { type: "button", className: button, onClick: () => controller.clearMixingPad(), children: "Clear" })
699
+ ] }),
700
+ /* @__PURE__ */ jsx6(
701
+ "div",
702
+ {
703
+ role: "application",
704
+ "aria-label": "Mixing pad",
705
+ className: "touch-none cursor-crosshair",
706
+ onPointerDown: (e) => {
707
+ const p = padPoint(e);
708
+ if (padTool === "pick") {
709
+ attempt(() => controller.mixingPadPick(p.x, p.y, { setColor: true }));
710
+ return;
711
+ }
712
+ padPoints.current = [p];
713
+ e.target.setPointerCapture?.(e.pointerId);
714
+ },
715
+ onPointerMove: (e) => padPoints.current?.push(padPoint(e)),
716
+ onPointerUp: () => {
717
+ const pts = padPoints.current;
718
+ padPoints.current = null;
719
+ if (pts?.length) attempt(() => controller.mixingPadStroke(pts, { tool: padTool === "knife" ? "knife" : "paint" }));
720
+ },
721
+ children: /* @__PURE__ */ jsx6(PixelCanvas, { pixels: pad, label: "Mixing pad pixels", className: "w-full rounded", style: { aspectRatio: `${MIXING_PAD_WIDTH} / ${MIXING_PAD_HEIGHT}` } })
722
+ }
723
+ )
724
+ ] }),
725
+ /* @__PURE__ */ jsx6(ErrorLine, { error })
726
+ ] });
727
+ };
728
+
729
+ // src/paint-editor/PaintLayersPanel.tsx
730
+ import { useState as useState6 } from "react";
731
+ import { BLEND_MENU_GROUPS } from "@pixerate/editor/paint-editor";
732
+ import { jsx as jsx7, jsxs as jsxs7 } from "react/jsx-runtime";
733
+ function siblingIndex(layers, l) {
734
+ const sibs = layers.filter((x) => x.parentId === l.parentId).reverse();
735
+ return { index: sibs.findIndex((x) => x.id === l.id), count: sibs.length };
736
+ }
737
+ var PaintLayersPanel = ({ controller, className }) => {
738
+ usePaintEditorState(controller);
739
+ const [error, attempt] = useAttempt();
740
+ const [renaming, setRenaming] = useState6(null);
741
+ const layers = controller.listLayers();
742
+ const active = layers.find((l) => l.active) ?? null;
743
+ const move = (delta) => {
744
+ if (!active) return;
745
+ const { index, count } = siblingIndex(layers, active);
746
+ const to = Math.min(count - 1, Math.max(0, index + delta));
747
+ if (to !== index) attempt(() => controller.moveLayer(active.id, to));
748
+ };
749
+ return /* @__PURE__ */ jsxs7("div", { className: `${panel} ${className ?? ""}`, "data-testid": "paint-layers-panel", children: [
750
+ /* @__PURE__ */ jsxs7("div", { className: "flex flex-wrap gap-1", children: [
751
+ /* @__PURE__ */ jsx7("button", { type: "button", className: button, onClick: () => attempt(() => controller.addLayer()), children: "New layer" }),
752
+ /* @__PURE__ */ jsx7("button", { type: "button", className: button, onClick: () => attempt(() => controller.addLayer({ type: "group" })), children: "New group" }),
753
+ /* @__PURE__ */ jsx7("button", { type: "button", className: button, disabled: !active, onClick: () => active && attempt(() => controller.duplicateLayer(active.id)), children: "Duplicate" }),
754
+ /* @__PURE__ */ jsx7("button", { type: "button", className: button, disabled: !active, "aria-label": "Move layer up", onClick: () => move(1), children: "\u2191" }),
755
+ /* @__PURE__ */ jsx7("button", { type: "button", className: button, disabled: !active, "aria-label": "Move layer down", onClick: () => move(-1), children: "\u2193" }),
756
+ /* @__PURE__ */ jsx7("button", { type: "button", className: button, disabled: !active, onClick: () => active && attempt(() => controller.mergeDown(active.id)), children: "Merge down" }),
757
+ /* @__PURE__ */ jsx7("button", { type: "button", className: button, disabled: !active || layers.length < 2, onClick: () => active && attempt(() => controller.removeLayer(active.id)), children: "Delete" })
758
+ ] }),
759
+ /* @__PURE__ */ jsx7("ul", { role: "tree", "aria-label": "Layers", className: "flex flex-col gap-0.5", children: layers.map((l) => /* @__PURE__ */ jsxs7(
760
+ "li",
761
+ {
762
+ role: "treeitem",
763
+ "aria-selected": l.active,
764
+ className: `flex items-center gap-1.5 rounded px-1.5 py-1 cursor-pointer ${l.active ? "bg-indigo-600/30 ring-1 ring-indigo-500" : "hover:bg-slate-800"}`,
765
+ style: { paddingLeft: 6 + l.depth * 12 },
766
+ onClick: () => controller.setActiveLayer(l.id),
767
+ children: [
768
+ /* @__PURE__ */ jsx7(
769
+ "button",
770
+ {
771
+ type: "button",
772
+ "aria-label": l.visible ? `Hide ${l.name}` : `Show ${l.name}`,
773
+ className: `w-5 text-[11px] ${l.visible ? "text-slate-200" : "text-slate-600"}`,
774
+ onClick: (e) => {
775
+ e.stopPropagation();
776
+ attempt(() => controller.updateLayer(l.id, { visible: !l.visible }));
777
+ },
778
+ children: l.visible ? "\u25CF" : "\u25CB"
779
+ }
780
+ ),
781
+ renaming === l.id ? /* @__PURE__ */ jsx7(
782
+ "input",
783
+ {
784
+ autoFocus: true,
785
+ "aria-label": "Layer name",
786
+ defaultValue: l.name,
787
+ className: "flex-1 rounded bg-slate-800 px-1",
788
+ onClick: (e) => e.stopPropagation(),
789
+ onBlur: (e) => {
790
+ setRenaming(null);
791
+ if (e.target.value.trim() && e.target.value !== l.name) attempt(() => controller.updateLayer(l.id, { name: e.target.value }));
792
+ },
793
+ onKeyDown: (e) => {
794
+ if (e.key === "Enter") e.target.blur();
795
+ if (e.key === "Escape") setRenaming(null);
796
+ }
797
+ }
798
+ ) : /* @__PURE__ */ jsxs7("span", { className: "flex-1 truncate", onDoubleClick: () => setRenaming(l.id), children: [
799
+ l.type === "group" ? "\u25B8 " : "",
800
+ l.name
801
+ ] }),
802
+ l.locks.pixels || l.locks.all ? /* @__PURE__ */ jsx7("span", { title: "Locked", children: "\u{1F512}" }) : null
803
+ ]
804
+ },
805
+ l.id
806
+ )) }),
807
+ active && /* @__PURE__ */ jsxs7("div", { className: "flex flex-col gap-1.5", children: [
808
+ /* @__PURE__ */ jsxs7("label", { className: "flex items-center gap-2", children: [
809
+ /* @__PURE__ */ jsx7("span", { className: "w-14 text-slate-400", children: "Opacity" }),
810
+ /* @__PURE__ */ jsx7(
811
+ "input",
812
+ {
813
+ type: "range",
814
+ "aria-label": "Layer opacity",
815
+ min: 0,
816
+ max: 1,
817
+ step: 0.01,
818
+ value: active.opacity,
819
+ onChange: (e) => attempt(() => controller.updateLayer(active.id, { opacity: Number(e.target.value) })),
820
+ className: "flex-1 accent-indigo-500"
821
+ }
822
+ )
823
+ ] }),
824
+ /* @__PURE__ */ jsxs7("label", { className: "flex items-center gap-2", children: [
825
+ /* @__PURE__ */ jsx7("span", { className: "w-14 text-slate-400", children: "Blend" }),
826
+ /* @__PURE__ */ jsxs7("select", { "aria-label": "Layer blend", value: active.blend, onChange: (e) => attempt(() => controller.updateLayer(active.id, { blend: e.target.value })), className: "flex-1 rounded bg-slate-800 px-1 py-0.5", children: [
827
+ active.type === "group" && /* @__PURE__ */ jsx7("option", { value: "pass-through", children: "Pass through" }),
828
+ BLEND_MENU_GROUPS.map((g) => /* @__PURE__ */ jsx7("optgroup", { label: g.label, children: g.modes.map((m) => /* @__PURE__ */ jsx7("option", { value: m.id, title: m.description, children: m.label }, m.id)) }, g.label))
829
+ ] })
830
+ ] }),
831
+ /* @__PURE__ */ jsxs7("div", { className: "flex gap-1", children: [
832
+ ["pixels", "transparency"].map((k) => /* @__PURE__ */ jsx7("button", { type: "button", "aria-pressed": Boolean(active.locks[k]), className: chip(Boolean(active.locks[k])), onClick: () => attempt(() => controller.updateLayer(active.id, { locks: { [k]: !active.locks[k] } })), children: k === "pixels" ? "Lock pixels" : "Lock transparency" }, k)),
833
+ /* @__PURE__ */ jsx7("button", { type: "button", "aria-pressed": active.clipped, className: chip(active.clipped), onClick: () => attempt(() => controller.updateLayer(active.id, { clipped: !active.clipped })), children: "Clip" })
834
+ ] })
835
+ ] }),
836
+ /* @__PURE__ */ jsx7(ErrorLine, { error })
837
+ ] });
838
+ };
839
+
840
+ // src/paint-editor/PaintEditor.tsx
841
+ import { useState as useState7 } from "react";
842
+ import { jsx as jsx8, jsxs as jsxs8 } from "react/jsx-runtime";
843
+ var PaintEditor = ({ controller, fileName, height = 720, className }) => {
844
+ const [tool, setTool] = useState7("brush");
845
+ const [tab, setTab] = useState7("color");
846
+ const [error, setError] = useState7(null);
847
+ return /* @__PURE__ */ jsxs8("div", { className: `flex flex-col gap-2 rounded-xl border border-slate-800 bg-slate-900 p-2 ${className ?? ""}`, style: { height }, "data-testid": "paint-editor", children: [
848
+ /* @__PURE__ */ jsx8(PaintToolbar, { controller, tool, onToolChange: setTool, fileName }),
849
+ /* @__PURE__ */ jsxs8("div", { className: "flex min-h-0 flex-1 gap-2", children: [
850
+ /* @__PURE__ */ jsx8(BrushPanel, { controller, className: "w-60 shrink-0 overflow-y-auto pr-1" }),
851
+ /* @__PURE__ */ jsxs8("div", { className: "flex min-w-0 flex-1 flex-col", children: [
852
+ /* @__PURE__ */ jsx8(PaintCanvas, { controller, tool, onError: setError, className: "flex-1 rounded-lg" }),
853
+ error && /* @__PURE__ */ jsx8("p", { role: "alert", className: "mt-1 text-[11px] text-rose-400", children: error })
854
+ ] }),
855
+ /* @__PURE__ */ jsxs8("div", { className: "flex w-64 shrink-0 flex-col gap-2 overflow-y-auto pr-1", children: [
856
+ /* @__PURE__ */ jsx8("div", { className: "flex gap-1", role: "tablist", "aria-label": "Paint panels", children: ["color", "paper", "layers"].map((t) => /* @__PURE__ */ jsx8("button", { type: "button", role: "tab", "aria-selected": tab === t, className: chip(tab === t), onClick: () => setTab(t), children: t === "color" ? "Colour" : t === "paper" ? "Paper" : "Layers" }, t)) }),
857
+ tab === "color" && /* @__PURE__ */ jsx8(ColorPanel, { controller }),
858
+ tab === "paper" && /* @__PURE__ */ jsx8(PaperPanel, { controller }),
859
+ tab === "layers" && /* @__PURE__ */ jsx8(PaintLayersPanel, { controller })
860
+ ] })
861
+ ] })
862
+ ] });
863
+ };
864
+ export {
865
+ BrushPanel,
866
+ ColorPanel,
867
+ PaintCanvas,
868
+ PaintEditor,
869
+ PaintLayersPanel,
870
+ PaintSlider,
871
+ PaintToolbar,
872
+ PaperPanel,
873
+ usePaintEditor,
874
+ usePaintEditorState
875
+ };
876
+ //# sourceMappingURL=index.js.map