@pixerate/editor-react 0.74.0 → 0.75.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -72,8 +72,9 @@ interface PaintToolbarProps {
72
72
  }
73
73
  /**
74
74
  * Tools (brush, eyedropper, pan, rotate view), undo / redo, view rotation, mirroring and reset,
75
- * the symmetry and perspective guides, the tracing image and reference images, the drawing hand,
76
- * and file actions: save / open JSON, export PNG, JPEG and PSD.
75
+ * the symmetry and perspective guides, stencils (ruler, circle, mask), the tracing image and reference images, the drawing hand,
76
+ * and file actions: save / open JSON, export PNG, JPEG and PSD, a time-lapse (zipped frames, video
77
+ * project and ffmpeg command) and a PNG repainted from the strokes at twice the size.
77
78
  */
78
79
  declare const PaintToolbar: React__default.FC<PaintToolbarProps>;
79
80
 
@@ -100,8 +101,9 @@ interface PaperPanelProps {
100
101
  }
101
102
  /**
102
103
  * The paper library by family (each a generated grain preview) and the current paper's colour,
103
- * grain depth, scale, shading and contrast, plus the wet-media properties stored for later.
104
- * Each change is one undo step (sliders commit on release).
104
+ * grain depth, scale, shading and contrast, the wet-media properties and metallic sheen (each
105
+ * change one undo step; sliders commit on release); then the light for thick paint and metallic
106
+ * paper (not an undo step) and Flatten relief for the active layer.
105
107
  */
106
108
  declare const PaperPanel: React__default.FC<PaperPanelProps>;
107
109
 
@@ -132,6 +134,29 @@ interface PaintLayersPanelProps {
132
134
  */
133
135
  declare const PaintLayersPanel: React__default.FC<PaintLayersPanelProps>;
134
136
 
137
+ interface WetPanelProps {
138
+ controller: PaintEditorController;
139
+ className?: string;
140
+ }
141
+ /**
142
+ * Wet media: whether paint is wet, Dry now, Pause / Resume, the water overlay and speed; Wet paper
143
+ * on the active layer; canvas tilt; the active layer's wet settings (viscosity, evaporation, edge
144
+ * darkening, granulation, blooms, bleed, pigment weight, lifting, staining) and flow map; and the
145
+ * kernel in use, with a note when it runs slower than it could (no cross-origin isolation).
146
+ */
147
+ declare const WetPanel: React__default.FC<WetPanelProps>;
148
+
149
+ interface StickerPanelProps {
150
+ controller: PaintEditorController;
151
+ className?: string;
152
+ }
153
+ /**
154
+ * The active layer's stickers: pick one to change its stamp, size, rotation, opacity and colour,
155
+ * bring it to the front or back, or delete it (each change one undo step). Shows nothing for a
156
+ * layer without stickers.
157
+ */
158
+ declare const StickerPanel: React__default.FC<StickerPanelProps>;
159
+
135
160
  interface PaintEditorProps {
136
161
  controller: PaintEditorController;
137
162
  /** Base name of saved and exported files (default `painting`). */
@@ -144,7 +169,7 @@ interface PaintEditorProps {
144
169
  }
145
170
  /**
146
171
  * The paint editor: toolbar, brush library on the left, the canvas, and colour (wheel,
147
- * harmonies, swatches, mixing pad), paper and layers on the right.
172
+ * harmonies, swatches, mixing pad), wet media, paper and layers on the right.
148
173
  */
149
174
  declare const PaintEditor: React__default.FC<PaintEditorProps>;
150
175
 
@@ -159,4 +184,4 @@ declare const PaintSlider: React__default.FC<{
159
184
  onCommit?: (v: number) => void;
160
185
  }>;
161
186
 
162
- export { BrushPanel, type BrushPanelProps, ColorPanel, type ColorPanelProps, PaintCanvas, type PaintCanvasProps, PaintEditor, type PaintEditorProps, PaintLayersPanel, type PaintLayersPanelProps, PaintPresenceLayer, PaintSlider, PaintToolbar, type PaintToolbarProps, PaperPanel, type PaperPanelProps, type UsePaintEditorReturn, usePaintDirector, usePaintEditor, usePaintEditorState };
187
+ export { BrushPanel, type BrushPanelProps, ColorPanel, type ColorPanelProps, PaintCanvas, type PaintCanvasProps, PaintEditor, type PaintEditorProps, PaintLayersPanel, type PaintLayersPanelProps, PaintPresenceLayer, PaintSlider, PaintToolbar, type PaintToolbarProps, PaperPanel, type PaperPanelProps, StickerPanel, type StickerPanelProps, type UsePaintEditorReturn, WetPanel, type WetPanelProps, usePaintDirector, usePaintEditor, usePaintEditorState };
@@ -72,8 +72,9 @@ interface PaintToolbarProps {
72
72
  }
73
73
  /**
74
74
  * Tools (brush, eyedropper, pan, rotate view), undo / redo, view rotation, mirroring and reset,
75
- * the symmetry and perspective guides, the tracing image and reference images, the drawing hand,
76
- * and file actions: save / open JSON, export PNG, JPEG and PSD.
75
+ * the symmetry and perspective guides, stencils (ruler, circle, mask), the tracing image and reference images, the drawing hand,
76
+ * and file actions: save / open JSON, export PNG, JPEG and PSD, a time-lapse (zipped frames, video
77
+ * project and ffmpeg command) and a PNG repainted from the strokes at twice the size.
77
78
  */
78
79
  declare const PaintToolbar: React__default.FC<PaintToolbarProps>;
79
80
 
@@ -100,8 +101,9 @@ interface PaperPanelProps {
100
101
  }
101
102
  /**
102
103
  * The paper library by family (each a generated grain preview) and the current paper's colour,
103
- * grain depth, scale, shading and contrast, plus the wet-media properties stored for later.
104
- * Each change is one undo step (sliders commit on release).
104
+ * grain depth, scale, shading and contrast, the wet-media properties and metallic sheen (each
105
+ * change one undo step; sliders commit on release); then the light for thick paint and metallic
106
+ * paper (not an undo step) and Flatten relief for the active layer.
105
107
  */
106
108
  declare const PaperPanel: React__default.FC<PaperPanelProps>;
107
109
 
@@ -132,6 +134,29 @@ interface PaintLayersPanelProps {
132
134
  */
133
135
  declare const PaintLayersPanel: React__default.FC<PaintLayersPanelProps>;
134
136
 
137
+ interface WetPanelProps {
138
+ controller: PaintEditorController;
139
+ className?: string;
140
+ }
141
+ /**
142
+ * Wet media: whether paint is wet, Dry now, Pause / Resume, the water overlay and speed; Wet paper
143
+ * on the active layer; canvas tilt; the active layer's wet settings (viscosity, evaporation, edge
144
+ * darkening, granulation, blooms, bleed, pigment weight, lifting, staining) and flow map; and the
145
+ * kernel in use, with a note when it runs slower than it could (no cross-origin isolation).
146
+ */
147
+ declare const WetPanel: React__default.FC<WetPanelProps>;
148
+
149
+ interface StickerPanelProps {
150
+ controller: PaintEditorController;
151
+ className?: string;
152
+ }
153
+ /**
154
+ * The active layer's stickers: pick one to change its stamp, size, rotation, opacity and colour,
155
+ * bring it to the front or back, or delete it (each change one undo step). Shows nothing for a
156
+ * layer without stickers.
157
+ */
158
+ declare const StickerPanel: React__default.FC<StickerPanelProps>;
159
+
135
160
  interface PaintEditorProps {
136
161
  controller: PaintEditorController;
137
162
  /** Base name of saved and exported files (default `painting`). */
@@ -144,7 +169,7 @@ interface PaintEditorProps {
144
169
  }
145
170
  /**
146
171
  * The paint editor: toolbar, brush library on the left, the canvas, and colour (wheel,
147
- * harmonies, swatches, mixing pad), paper and layers on the right.
172
+ * harmonies, swatches, mixing pad), wet media, paper and layers on the right.
148
173
  */
149
174
  declare const PaintEditor: React__default.FC<PaintEditorProps>;
150
175
 
@@ -159,4 +184,4 @@ declare const PaintSlider: React__default.FC<{
159
184
  onCommit?: (v: number) => void;
160
185
  }>;
161
186
 
162
- export { BrushPanel, type BrushPanelProps, ColorPanel, type ColorPanelProps, PaintCanvas, type PaintCanvasProps, PaintEditor, type PaintEditorProps, PaintLayersPanel, type PaintLayersPanelProps, PaintPresenceLayer, PaintSlider, PaintToolbar, type PaintToolbarProps, PaperPanel, type PaperPanelProps, type UsePaintEditorReturn, usePaintDirector, usePaintEditor, usePaintEditorState };
187
+ export { BrushPanel, type BrushPanelProps, ColorPanel, type ColorPanelProps, PaintCanvas, type PaintCanvasProps, PaintEditor, type PaintEditorProps, PaintLayersPanel, type PaintLayersPanelProps, PaintPresenceLayer, PaintSlider, PaintToolbar, type PaintToolbarProps, PaperPanel, type PaperPanelProps, StickerPanel, type StickerPanelProps, type UsePaintEditorReturn, WetPanel, type WetPanelProps, usePaintDirector, usePaintEditor, usePaintEditorState };
@@ -27,6 +27,8 @@ import {
27
27
  PaintCanvasPresenter,
28
28
  PaintGesture,
29
29
  documentToScreen,
30
+ drawWaterOverlay,
31
+ startWetClock,
30
32
  fitZoom,
31
33
  guideOverlay,
32
34
  handlePaintShortcut,
@@ -37,6 +39,7 @@ var PaintCanvas = ({ controller, tool = "brush", showGuides = true, fitOnMount =
37
39
  const state = usePaintEditorState(controller);
38
40
  const viewport = useRef(null);
39
41
  const canvas = useRef(null);
42
+ const water = useRef(null);
40
43
  const [size, setSize] = useState2({ width: 800, height: 600 });
41
44
  const measured = useRef(false);
42
45
  const [predicted, setPredicted] = useState2([]);
@@ -76,6 +79,11 @@ var PaintCanvas = ({ controller, tool = "brush", showGuides = true, fitOnMount =
76
79
  if (ctx && typeof ctx.createImageData === "function") presenter.render(ctx);
77
80
  else controller.takeDirty();
78
81
  }, [state, presenter, controller]);
82
+ useEffect(() => startWetClock(controller), [controller]);
83
+ useEffect(() => {
84
+ const ctx = water.current?.getContext("2d");
85
+ if (ctx && typeof ctx.createImageData === "function") drawWaterOverlay(controller, ctx);
86
+ }, [state, controller]);
79
87
  useEffect(() => {
80
88
  const el = viewport.current;
81
89
  if (!el) return;
@@ -130,6 +138,17 @@ var PaintCanvas = ({ controller, tool = "brush", showGuides = true, fitOnMount =
130
138
  style: { transform, transformOrigin: "0 0", imageRendering: state.view.zoom > 2 ? "pixelated" : "auto" }
131
139
  }
132
140
  ),
141
+ state.wet.overlay && /* @__PURE__ */ jsx(
142
+ "canvas",
143
+ {
144
+ ref: water,
145
+ width: doc.width,
146
+ height: doc.height,
147
+ "data-testid": "paint-water-overlay",
148
+ className: "absolute left-0 top-0 pointer-events-none opacity-60",
149
+ style: { transform, transformOrigin: "0 0" }
150
+ }
151
+ ),
133
152
  /* @__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: [
134
153
  lines.map((l, i) => /* @__PURE__ */ jsx(
135
154
  "line",
@@ -138,8 +157,8 @@ var PaintCanvas = ({ controller, tool = "brush", showGuides = true, fitOnMount =
138
157
  y1: l.from.y,
139
158
  x2: l.to.x,
140
159
  y2: l.to.y,
141
- stroke: l.kind === "symmetry" ? "#22d3ee" : "#f472b6",
142
- strokeOpacity: l.kind === "symmetry" ? 0.8 : 0.35,
160
+ stroke: l.kind === "symmetry" ? "#22d3ee" : l.kind === "stencil" ? "#fbbf24" : "#f472b6",
161
+ strokeOpacity: l.kind === "symmetry" || l.kind === "stencil" ? 0.8 : 0.35,
143
162
  strokeWidth: 1 / state.view.zoom,
144
163
  strokeDasharray: l.kind === "symmetry" ? `${6 / state.view.zoom} ${4 / state.view.zoom}` : void 0
145
164
  },
@@ -225,8 +244,8 @@ var ReferencePanel = ({ controller, id }) => {
225
244
  };
226
245
 
227
246
  // src/paint-editor/PaintToolbar.tsx
228
- import { useRef as useRef3 } from "react";
229
- import { PAINT_TOOLS, SYMMETRY_MODES, decodeImageBlob, downloadBytes } from "@pixerate/editor/paint-editor";
247
+ import { useRef as useRef3, useState as useState4 } from "react";
248
+ import { PAINT_TOOLS, SYMMETRY_MODES, decodeImageBlob, downloadBytes, quickStencil, timeLapseZip } from "@pixerate/editor/paint-editor";
230
249
 
231
250
  // src/paint-editor/shared.tsx
232
251
  import { useEffect as useEffect2, useRef as useRef2, useState as useState3 } from "react";
@@ -323,6 +342,8 @@ import { Fragment, jsx as jsx3, jsxs as jsxs3 } from "react/jsx-runtime";
323
342
  var PaintToolbar = ({ controller, tool, onToolChange, fileName = "painting", className }) => {
324
343
  const state = usePaintEditorState(controller);
325
344
  const [error, attempt] = useAttempt();
345
+ const [busy, setBusy] = useState4(null);
346
+ const [note, setNote] = useState4(null);
326
347
  const openRef = useRef3(null);
327
348
  const tracingRef = useRef3(null);
328
349
  const refRef = useRef3(null);
@@ -419,6 +440,12 @@ var PaintToolbar = ({ controller, tool, onToolChange, fileName = "painting", cla
419
440
  /* @__PURE__ */ jsx3("button", { type: "button", className: button, onClick: () => controller.clearTracingImage(), children: "Remove tracing" })
420
441
  ] }),
421
442
  /* @__PURE__ */ jsx3("button", { type: "button", className: button, onClick: () => refRef.current?.click(), children: "Reference\u2026" }),
443
+ /* @__PURE__ */ jsxs3("span", { className: "flex items-center gap-1", role: "group", "aria-label": "Stencils", children: [
444
+ /* @__PURE__ */ jsx3("button", { type: "button", className: button, title: "A ruler: strokes that start near it follow it", onClick: () => attempt(() => controller.addStencil(quickStencil("ruler", canvas))), children: "Ruler" }),
445
+ /* @__PURE__ */ jsx3("button", { type: "button", className: button, title: "A circle guide: strokes that start near it follow it", onClick: () => attempt(() => controller.addStencil(quickStencil("circle", canvas))), children: "Circle" }),
446
+ /* @__PURE__ */ jsx3("button", { type: "button", className: button, title: "An ellipse mask: paint lands only inside it", onClick: () => attempt(() => controller.addStencil(quickStencil("ellipse-mask", canvas))), children: "Mask" }),
447
+ state.guides.stencils.length > 0 && /* @__PURE__ */ jsx3("button", { type: "button", className: button, onClick: () => attempt(() => controller.removeStencils()), children: "Clear stencils" })
448
+ ] }),
422
449
  /* @__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" }),
423
450
  /* @__PURE__ */ jsx3("span", { className: "mx-1 h-4 w-px bg-slate-700" }),
424
451
  /* @__PURE__ */ jsx3("button", { type: "button", className: button, onClick: () => attempt(() => downloadBytes(JSON.stringify(controller.toJSON()), `${fileName}.paint.json`, "application/json")), children: "Save" }),
@@ -426,6 +453,45 @@ var PaintToolbar = ({ controller, tool, onToolChange, fileName = "painting", cla
426
453
  /* @__PURE__ */ jsx3("button", { type: "button", className: button, onClick: () => exportImage("image/png"), children: "PNG" }),
427
454
  /* @__PURE__ */ jsx3("button", { type: "button", className: button, onClick: () => exportImage("image/jpeg"), children: "JPEG" }),
428
455
  /* @__PURE__ */ jsx3("button", { type: "button", className: button, onClick: () => attempt(() => downloadBytes(controller.exportPsd().bytes, `${fileName}.psd`, "image/vnd.adobe.photoshop")), children: "PSD" }),
456
+ /* @__PURE__ */ jsx3(
457
+ "button",
458
+ {
459
+ type: "button",
460
+ className: button,
461
+ disabled: busy !== null,
462
+ title: "Repaint the painting from its strokes as a time-lapse: frames, a video project and the ffmpeg command, zipped",
463
+ onClick: () => attempt(async () => {
464
+ setBusy("Repainting\u2026");
465
+ try {
466
+ const tl = await controller.timeLapse({ onProgress: (p) => setBusy(`Repainting\u2026 ${Math.round(p * 100)}%`) });
467
+ downloadBytes(await timeLapseZip(tl), `${fileName}-timelapse.zip`, "application/zip");
468
+ setNote(`Time-lapse: ${tl.frames.length} frames, ${(tl.durationMs / 1e3).toFixed(1)} s (run render.sh in the zip for the MP4).`);
469
+ } finally {
470
+ setBusy(null);
471
+ }
472
+ }),
473
+ children: "Time-lapse"
474
+ }
475
+ ),
476
+ /* @__PURE__ */ jsx3(
477
+ "button",
478
+ {
479
+ type: "button",
480
+ className: button,
481
+ disabled: busy !== null,
482
+ title: "Repaint the painting from its strokes at twice the size (more detail, not upscaled)",
483
+ onClick: () => attempt(async () => {
484
+ setBusy("Repainting at 2\xD7\u2026");
485
+ try {
486
+ downloadBytes(await controller.exportHighResPng({ scale: 2 }), `${fileName}@2x.png`, "image/png");
487
+ } finally {
488
+ setBusy(null);
489
+ }
490
+ }),
491
+ children: "PNG 2\xD7"
492
+ }
493
+ ),
494
+ (busy ?? note) && /* @__PURE__ */ jsx3("span", { className: "text-[11px] text-slate-400", "data-testid": "paint-replay-status", children: busy ?? note }),
429
495
  /* @__PURE__ */ jsx3(
430
496
  "input",
431
497
  {
@@ -476,7 +542,7 @@ var PaintToolbar = ({ controller, tool, onToolChange, fileName = "painting", cla
476
542
  };
477
543
 
478
544
  // src/paint-editor/BrushPanel.tsx
479
- import { useMemo as useMemo3, useRef as useRef4, useState as useState4 } from "react";
545
+ import { useMemo as useMemo3, useRef as useRef4, useState as useState5 } from "react";
480
546
  import {
481
547
  BLEND_MENU_GROUPS,
482
548
  BRUSH_ROTATIONS,
@@ -487,13 +553,26 @@ import {
487
553
  multiColorSliders,
488
554
  patchBrushValue,
489
555
  patchPath,
490
- readPath
556
+ readPath,
557
+ WET_TOOLS,
558
+ WET_TOOL_LABELS,
559
+ WET_MEDIA,
560
+ WET_MEDIUM_LABELS,
561
+ IMPASTO_TOOLS,
562
+ IMPASTO_TOOL_LABELS,
563
+ PARTICLE_KINDS,
564
+ PARTICLE_KIND_LABELS,
565
+ PARTICLE_RENDERS,
566
+ PARTICLE_RENDER_LABELS,
567
+ STAMP_KINDS,
568
+ STAMP_LABELS,
569
+ toggleStamp
491
570
  } from "@pixerate/editor/paint-editor";
492
571
  import { Fragment as Fragment2, jsx as jsx4, jsxs as jsxs4 } from "react/jsx-runtime";
493
572
  var BrushPanel = ({ controller, thumbnailWidth = 112, thumbnailHeight = 32, className }) => {
494
573
  const state = usePaintEditorState(controller);
495
574
  const [error, attempt] = useAttempt();
496
- const [saveName, setSaveName] = useState4("");
575
+ const [saveName, setSaveName] = useState5("");
497
576
  const fileRef = useRef4(null);
498
577
  const { brush, brushes } = state;
499
578
  const paper = state.document.paper;
@@ -546,6 +625,39 @@ var BrushPanel = ({ controller, thumbnailWidth = 112, thumbnailHeight = 32, clas
546
625
  ] }),
547
626
  engine && /* @__PURE__ */ jsxs4("div", { className: "flex flex-col gap-1.5", "data-testid": "brush-engine", children: [
548
627
  /* @__PURE__ */ jsx4("span", { className: "font-medium text-slate-300", children: engine.title }),
628
+ engine.wetTool && /* @__PURE__ */ jsxs4("label", { className: "flex items-center justify-between gap-2", children: [
629
+ /* @__PURE__ */ jsx4("span", { className: "text-slate-400", children: "Wet tool" }),
630
+ /* @__PURE__ */ jsx4("select", { "aria-label": "Wet tool", value: brush.wet.tool, onChange: (e) => controller.setBrush({ wet: { tool: e.target.value } }), className: "rounded bg-slate-800 px-1 py-0.5", children: WET_TOOLS.map((t) => /* @__PURE__ */ jsx4("option", { value: t, children: WET_TOOL_LABELS[t] }, t)) })
631
+ ] }),
632
+ engine.wetTool && /* @__PURE__ */ jsxs4("label", { className: "flex items-center justify-between gap-2", children: [
633
+ /* @__PURE__ */ jsx4("span", { className: "text-slate-400", children: "Medium" }),
634
+ /* @__PURE__ */ jsx4("select", { "aria-label": "Wet medium", value: brush.wet.medium, onChange: (e) => controller.setBrush({ wet: { medium: e.target.value } }), className: "rounded bg-slate-800 px-1 py-0.5", children: WET_MEDIA.map((m) => /* @__PURE__ */ jsx4("option", { value: m, children: WET_MEDIUM_LABELS[m] }, m)) })
635
+ ] }),
636
+ engine.particleMenus && /* @__PURE__ */ jsxs4(Fragment2, { children: [
637
+ /* @__PURE__ */ jsxs4("label", { className: "flex items-center justify-between gap-2", children: [
638
+ /* @__PURE__ */ jsx4("span", { className: "text-slate-400", children: "Particles" }),
639
+ /* @__PURE__ */ jsx4("select", { "aria-label": "Particle kind", value: brush.particle.kind, onChange: (e) => controller.setBrush({ particle: { kind: e.target.value } }), className: "rounded bg-slate-800 px-1 py-0.5", children: PARTICLE_KINDS.map((k) => /* @__PURE__ */ jsx4("option", { value: k, children: PARTICLE_KIND_LABELS[k] }, k)) })
640
+ ] }),
641
+ /* @__PURE__ */ jsxs4("label", { className: "flex items-center justify-between gap-2", children: [
642
+ /* @__PURE__ */ jsx4("span", { className: "text-slate-400", children: "Drawn as" }),
643
+ /* @__PURE__ */ jsx4("select", { "aria-label": "Particle render", value: brush.particle.render, onChange: (e) => controller.setBrush({ particle: { render: e.target.value } }), className: "rounded bg-slate-800 px-1 py-0.5", children: PARTICLE_RENDERS.map((k) => /* @__PURE__ */ jsx4("option", { value: k, children: PARTICLE_RENDER_LABELS[k] }, k)) })
644
+ ] })
645
+ ] }),
646
+ engine.stickerStamps && /* @__PURE__ */ jsx4("div", { className: "flex flex-wrap gap-1", role: "group", "aria-label": "Sticker stamps", children: STAMP_KINDS.map((k) => /* @__PURE__ */ jsx4(
647
+ "button",
648
+ {
649
+ type: "button",
650
+ "aria-pressed": brush.sticker.stamps.includes(k),
651
+ className: `px-1.5 py-0.5 rounded text-[10px] ${brush.sticker.stamps.includes(k) ? "bg-indigo-600 text-white" : "bg-slate-800 text-slate-300"}`,
652
+ onClick: () => controller.setBrush({ sticker: { stamps: toggleStamp(brush.sticker.stamps, k) } }),
653
+ children: STAMP_LABELS[k]
654
+ },
655
+ k
656
+ )) }),
657
+ engine.impastoTool && /* @__PURE__ */ jsxs4("label", { className: "flex items-center justify-between gap-2", children: [
658
+ /* @__PURE__ */ jsx4("span", { className: "text-slate-400", children: "Tool" }),
659
+ /* @__PURE__ */ jsx4("select", { "aria-label": "Impasto tool", value: brush.impasto.tool, onChange: (e) => controller.setBrush({ impasto: { tool: e.target.value } }), className: "rounded bg-slate-800 px-1 py-0.5", children: IMPASTO_TOOLS.map((t) => /* @__PURE__ */ jsx4("option", { value: t, children: IMPASTO_TOOL_LABELS[t] }, t)) })
660
+ ] }),
549
661
  engine.sliders.map((c) => /* @__PURE__ */ jsx4(PaintSlider, { control: c, value: readPath(brush, c.path), onChange: (v) => controller.setBrush(patchPath(brush, c.path, v)) }, c.path)),
550
662
  engine.mixingPath && /* @__PURE__ */ jsxs4("label", { className: "flex items-center justify-between gap-2", children: [
551
663
  /* @__PURE__ */ jsx4("span", { className: "text-slate-400", children: "Mixing" }),
@@ -629,7 +741,7 @@ var BrushPanel = ({ controller, thumbnailWidth = 112, thumbnailHeight = 32, clas
629
741
 
630
742
  // src/paint-editor/PaperPanel.tsx
631
743
  import { useMemo as useMemo4 } from "react";
632
- import { PAPER_FAMILIES, PAPER_LIBRARY, PAPER_SLIDERS, patchPath as patchPath2, readPath as readPath2, renderPaper } from "@pixerate/editor/paint-editor";
744
+ import { LIGHT_SLIDERS, PAPER_FAMILIES, PAPER_LIBRARY, PAPER_SLIDERS, patchPath as patchPath2, readPath as readPath2, renderPaper } from "@pixerate/editor/paint-editor";
633
745
  import { jsx as jsx5, jsxs as jsxs5 } from "react/jsx-runtime";
634
746
  var SWATCH = { x: 0, y: 0, width: 64, height: 40 };
635
747
  var PaperPanel = ({ controller, className }) => {
@@ -665,12 +777,17 @@ var PaperPanel = ({ controller, className }) => {
665
777
  " Mirror tiling"
666
778
  ] }),
667
779
  /* @__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" }),
780
+ /* @__PURE__ */ jsxs5("div", { className: "flex flex-col gap-1.5", "data-testid": "light-panel", children: [
781
+ /* @__PURE__ */ jsx5("span", { className: "font-medium text-slate-300", children: "Light (thick paint, metallic paper)" }),
782
+ LIGHT_SLIDERS.map((c) => /* @__PURE__ */ jsx5(PaintSlider, { control: c, value: readPath2(controller.getLighting(), c.path), onChange: (v) => attempt(() => controller.setLighting({ [c.path]: v })) }, c.path)),
783
+ /* @__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.flattenRelief()), children: "Flatten relief" })
784
+ ] }),
668
785
  /* @__PURE__ */ jsx5(ErrorLine, { error })
669
786
  ] });
670
787
  };
671
788
 
672
789
  // src/paint-editor/ColorPanel.tsx
673
- import { useMemo as useMemo5, useRef as useRef5, useState as useState5 } from "react";
790
+ import { useMemo as useMemo5, useRef as useRef5, useState as useState6 } from "react";
674
791
  import {
675
792
  HARMONY_KINDS,
676
793
  MIXING_PAD_HEIGHT,
@@ -690,12 +807,12 @@ import { jsx as jsx6, jsxs as jsxs6 } from "react/jsx-runtime";
690
807
  var ColorPanel = ({ controller, wheelSize = 168, mixingPad = true, className }) => {
691
808
  const state = usePaintEditorState(controller);
692
809
  const [error, attempt] = useAttempt();
693
- const [wheel, setWheel] = useState5("ryb");
694
- const [kind, setKind] = useState5("complementary");
695
- const [padTool, setPadTool] = useState5("paint");
696
- const [sample, setSample] = useState5(3);
697
- const [parts, setParts] = useState5([1, 1]);
698
- const [padName, setPadName] = useState5("");
810
+ const [wheel, setWheel] = useState6("ryb");
811
+ const [kind, setKind] = useState6("complementary");
812
+ const [padTool, setPadTool] = useState6("paint");
813
+ const [sample, setSample] = useState6(3);
814
+ const [parts, setParts] = useState6([1, 1]);
815
+ const [padName, setPadName] = useState6("");
699
816
  const padPoints = useRef5(null);
700
817
  const ring = useMemo5(() => renderColorWheel(wheelSize, wheel), [wheelSize, wheel]);
701
818
  const color = state.color;
@@ -865,7 +982,7 @@ var ColorPanel = ({ controller, wheelSize = 168, mixingPad = true, className })
865
982
  };
866
983
 
867
984
  // src/paint-editor/PaintLayersPanel.tsx
868
- import { useState as useState6 } from "react";
985
+ import { useState as useState7 } from "react";
869
986
  import { BLEND_MENU_GROUPS as BLEND_MENU_GROUPS2 } from "@pixerate/editor/paint-editor";
870
987
  import { jsx as jsx7, jsxs as jsxs7 } from "react/jsx-runtime";
871
988
  function siblingIndex(layers, l) {
@@ -875,7 +992,7 @@ function siblingIndex(layers, l) {
875
992
  var PaintLayersPanel = ({ controller, className }) => {
876
993
  usePaintEditorState(controller);
877
994
  const [error, attempt] = useAttempt();
878
- const [renaming, setRenaming] = useState6(null);
995
+ const [renaming, setRenaming] = useState7(null);
879
996
  const layers = controller.listLayers();
880
997
  const active = layers.find((l) => l.active) ?? null;
881
998
  const move = (delta) => {
@@ -975,26 +1092,117 @@ var PaintLayersPanel = ({ controller, className }) => {
975
1092
  ] });
976
1093
  };
977
1094
 
978
- // src/paint-editor/PaintEditor.tsx
979
- import { useState as useState7 } from "react";
1095
+ // src/paint-editor/WetPanel.tsx
1096
+ import { WET_FLOW_KINDS, WET_PARAM_SLIDERS, WET_SPEED_SLIDER, WET_TILT_SLIDERS, readPath as readPath3 } from "@pixerate/editor/paint-editor";
980
1097
  import { jsx as jsx8, jsxs as jsxs8 } from "react/jsx-runtime";
1098
+ var FLOW_LABELS = { none: "None", linear: "One way", swirl: "Swirl", radial: "Outwards", turbulent: "Eddies" };
1099
+ var WetPanel = ({ controller, className }) => {
1100
+ const state = usePaintEditorState(controller);
1101
+ const [error, attempt] = useAttempt();
1102
+ const wet = state.wet;
1103
+ const layerId = state.document.activeLayerId;
1104
+ const info = layerId ? controller.wetLayerInfo(layerId) : null;
1105
+ const backend = controller.wetState();
1106
+ return /* @__PURE__ */ jsxs8("div", { className: `${panel} ${className ?? ""}`, "data-testid": "wet-panel", children: [
1107
+ /* @__PURE__ */ jsxs8("div", { className: "flex items-center justify-between", children: [
1108
+ /* @__PURE__ */ jsx8("span", { className: wet.wet ? "text-sky-300" : "text-slate-400", "data-testid": "wet-status", children: wet.wet ? wet.paused ? "Wet (paused)" : "Wet" : "Dry" }),
1109
+ /* @__PURE__ */ jsxs8("span", { className: "text-[10px] text-slate-500", children: [
1110
+ "step ",
1111
+ wet.clock
1112
+ ] })
1113
+ ] }),
1114
+ /* @__PURE__ */ jsxs8("div", { className: "flex flex-wrap gap-1", children: [
1115
+ /* @__PURE__ */ jsx8("button", { type: "button", className: button, disabled: !wet.layerIds.length, onClick: () => attempt(async () => (await controller.wetIdle(), controller.dryPaint())), children: "Dry now" }),
1116
+ /* @__PURE__ */ jsx8("button", { type: "button", className: chip(wet.paused), "aria-pressed": wet.paused, onClick: () => controller.setWetPaused(!wet.paused), children: wet.paused ? "Resume" : "Pause" }),
1117
+ /* @__PURE__ */ jsx8("button", { type: "button", className: chip(wet.overlay), "aria-pressed": wet.overlay, onClick: () => controller.setWaterOverlay(!wet.overlay), children: "Water" }),
1118
+ /* @__PURE__ */ jsx8("button", { type: "button", className: chip(wet.instantDry), "aria-pressed": wet.instantDry, title: "Each wet stroke dries as soon as it ends", onClick: () => controller.setInstantDry(!wet.instantDry), children: "Instant dry" }),
1119
+ /* @__PURE__ */ jsx8("button", { type: "button", className: button, disabled: !layerId, onClick: () => attempt(async () => (await controller.wetIdle(), controller.wetPaper({}))), children: "Wet paper" })
1120
+ ] }),
1121
+ /* @__PURE__ */ jsx8(PaintSlider, { control: WET_SPEED_SLIDER, value: wet.speed, onChange: (v) => controller.setWetSpeed(v) }),
1122
+ WET_TILT_SLIDERS.map((c) => /* @__PURE__ */ jsx8(PaintSlider, { control: c, value: readPath3(wet.tilt, c.path), onCommit: (v) => attempt(async () => (await controller.wetIdle(), controller.setCanvasTilt({ [c.path]: v }))) }, c.path)),
1123
+ info && /* @__PURE__ */ jsxs8("div", { className: "flex flex-col gap-1.5", "data-testid": "wet-layer", children: [
1124
+ /* @__PURE__ */ jsx8("span", { className: "font-medium text-slate-300", children: "This layer" }),
1125
+ WET_PARAM_SLIDERS.map((c) => /* @__PURE__ */ jsx8(PaintSlider, { control: c, value: readPath3(info.params, c.path), onCommit: (v) => attempt(async () => (await controller.wetIdle(), controller.setWetParams(layerId, { [c.path]: v }))) }, c.path)),
1126
+ /* @__PURE__ */ jsxs8("label", { className: "flex items-center justify-between gap-2", children: [
1127
+ /* @__PURE__ */ jsx8("span", { className: "text-slate-400", children: "Flow map" }),
1128
+ /* @__PURE__ */ jsx8("select", { "aria-label": "Flow map", value: info.flow.kind, onChange: (e) => attempt(async () => (await controller.wetIdle(), controller.setFlowMap(layerId, { kind: e.target.value }))), className: "rounded bg-slate-800 px-1 py-0.5", children: WET_FLOW_KINDS.map((k) => /* @__PURE__ */ jsx8("option", { value: k, children: FLOW_LABELS[k] }, k)) })
1129
+ ] }),
1130
+ info.flow.kind !== "none" && /* @__PURE__ */ jsx8(
1131
+ PaintSlider,
1132
+ {
1133
+ control: { path: "strength", label: "Flow strength", min: 0, max: 1, step: 0.01 },
1134
+ value: info.flow.strength,
1135
+ onCommit: (v) => attempt(async () => (await controller.wetIdle(), controller.setFlowMap(layerId, { strength: v })))
1136
+ }
1137
+ )
1138
+ ] }),
1139
+ backend.message && /* @__PURE__ */ jsx8("p", { className: "text-[10px] text-amber-300", "data-testid": "wet-message", children: backend.message }),
1140
+ backend.backend && /* @__PURE__ */ jsx8("p", { className: "text-[10px] text-slate-500", children: backend.backend.kind === "wasm-threads" ? `WebAssembly, ${backend.backend.threads} threads` : backend.backend.kind === "wasm" ? "WebAssembly, one thread" : "JavaScript" }),
1141
+ /* @__PURE__ */ jsx8(ErrorLine, { error })
1142
+ ] });
1143
+ };
1144
+
1145
+ // src/paint-editor/StickerPanel.tsx
1146
+ import { useState as useState8 } from "react";
1147
+ import { STAMP_KINDS as STAMP_KINDS2, STAMP_LABELS as STAMP_LABELS2, STICKER_SLIDERS, readPath as readPath4 } from "@pixerate/editor/paint-editor";
1148
+ import { jsx as jsx9, jsxs as jsxs9 } from "react/jsx-runtime";
1149
+ var StickerPanel = ({ controller, className }) => {
1150
+ const state = usePaintEditorState(controller);
1151
+ const [error, attempt] = useAttempt();
1152
+ const [picked, setPicked] = useState8(null);
1153
+ const layerId = state.document.activeLayerId;
1154
+ const stickers = layerId ? state.document.stickers?.[layerId] ?? [] : [];
1155
+ if (!stickers.length) return null;
1156
+ const sel = stickers.find((s) => s.id === picked) ?? null;
1157
+ return /* @__PURE__ */ jsxs9("div", { className: `${panel} ${className ?? ""}`, "data-testid": "sticker-panel", children: [
1158
+ /* @__PURE__ */ jsxs9("span", { className: "font-medium text-slate-300", children: [
1159
+ "Stickers (",
1160
+ stickers.length,
1161
+ ")"
1162
+ ] }),
1163
+ /* @__PURE__ */ jsx9("div", { className: "flex max-h-24 flex-wrap gap-1 overflow-y-auto", role: "listbox", "aria-label": "Stickers", children: stickers.map((s) => /* @__PURE__ */ jsx9("button", { type: "button", role: "option", "aria-selected": s.id === picked, title: `${STAMP_LABELS2[s.stamp]} ${s.id}`, className: `h-5 w-5 rounded ring-1 ${s.id === picked ? "ring-indigo-400" : "ring-slate-700"}`, style: { background: s.color }, onClick: () => setPicked(s.id) }, s.id)) }),
1164
+ sel && /* @__PURE__ */ jsxs9("div", { className: "flex flex-col gap-1.5", "data-testid": "sticker-edit", children: [
1165
+ /* @__PURE__ */ jsxs9("label", { className: "flex items-center justify-between gap-2", children: [
1166
+ /* @__PURE__ */ jsx9("span", { className: "text-slate-400", children: "Stamp" }),
1167
+ /* @__PURE__ */ jsx9("select", { "aria-label": "Sticker stamp", value: sel.stamp, onChange: (e) => attempt(() => controller.updateSticker(layerId, sel.id, { stamp: e.target.value })), className: "rounded bg-slate-800 px-1 py-0.5", children: STAMP_KINDS2.map((k) => /* @__PURE__ */ jsx9("option", { value: k, children: STAMP_LABELS2[k] }, k)) })
1168
+ ] }),
1169
+ STICKER_SLIDERS.map((c) => /* @__PURE__ */ jsx9(PaintSlider, { control: c, value: readPath4(sel, c.path), onCommit: (v) => attempt(() => controller.updateSticker(layerId, sel.id, { [c.path]: v })) }, c.path)),
1170
+ /* @__PURE__ */ jsxs9("label", { className: "flex items-center justify-between", children: [
1171
+ /* @__PURE__ */ jsx9("span", { className: "text-slate-400", children: "Colour" }),
1172
+ /* @__PURE__ */ jsx9(CommitColorInput, { label: "Sticker colour", value: sel.color, onCommit: (v) => attempt(() => controller.updateSticker(layerId, sel.id, { color: v })), className: "h-6 w-10 rounded bg-transparent" })
1173
+ ] }),
1174
+ /* @__PURE__ */ jsxs9("div", { className: "flex flex-wrap gap-1", children: [
1175
+ /* @__PURE__ */ jsx9("button", { type: "button", className: button, onClick: () => attempt(() => controller.arrangeStickers(layerId, [sel.id], "front")), children: "To front" }),
1176
+ /* @__PURE__ */ jsx9("button", { type: "button", className: button, onClick: () => attempt(() => controller.arrangeStickers(layerId, [sel.id], "back")), children: "To back" }),
1177
+ /* @__PURE__ */ jsx9("button", { type: "button", className: button, onClick: () => attempt(() => (controller.removeStickers(layerId, [sel.id]), setPicked(null))), children: "Delete" })
1178
+ ] })
1179
+ ] }),
1180
+ /* @__PURE__ */ jsx9(ErrorLine, { error })
1181
+ ] });
1182
+ };
1183
+
1184
+ // src/paint-editor/PaintEditor.tsx
1185
+ import { useState as useState9 } from "react";
1186
+ import { jsx as jsx10, jsxs as jsxs10 } from "react/jsx-runtime";
981
1187
  var PaintEditor = ({ controller, fileName, height = 720, presence, className }) => {
982
- const [tool, setTool] = useState7("brush");
983
- const [tab, setTab] = useState7("color");
984
- const [error, setError] = useState7(null);
985
- 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: [
986
- /* @__PURE__ */ jsx8(PaintToolbar, { controller, tool, onToolChange: setTool, fileName }),
987
- /* @__PURE__ */ jsxs8("div", { className: "flex min-h-0 flex-1 gap-2", children: [
988
- /* @__PURE__ */ jsx8(BrushPanel, { controller, className: "w-60 shrink-0 overflow-y-auto pr-1" }),
989
- /* @__PURE__ */ jsxs8("div", { className: "flex min-w-0 flex-1 flex-col", children: [
990
- /* @__PURE__ */ jsx8(PaintCanvas, { controller, tool, onError: setError, presence, className: "flex-1 rounded-lg" }),
991
- error && /* @__PURE__ */ jsx8("p", { role: "alert", className: "mt-1 text-[11px] text-rose-400", children: error })
1188
+ const [tool, setTool] = useState9("brush");
1189
+ const [tab, setTab] = useState9("color");
1190
+ const [error, setError] = useState9(null);
1191
+ return /* @__PURE__ */ jsxs10("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: [
1192
+ /* @__PURE__ */ jsx10(PaintToolbar, { controller, tool, onToolChange: setTool, fileName }),
1193
+ /* @__PURE__ */ jsxs10("div", { className: "flex min-h-0 flex-1 gap-2", children: [
1194
+ /* @__PURE__ */ jsx10(BrushPanel, { controller, className: "w-60 shrink-0 overflow-y-auto pr-1" }),
1195
+ /* @__PURE__ */ jsxs10("div", { className: "flex min-w-0 flex-1 flex-col", children: [
1196
+ /* @__PURE__ */ jsx10(PaintCanvas, { controller, tool, onError: setError, presence, className: "flex-1 rounded-lg" }),
1197
+ error && /* @__PURE__ */ jsx10("p", { role: "alert", className: "mt-1 text-[11px] text-rose-400", children: error })
992
1198
  ] }),
993
- /* @__PURE__ */ jsxs8("div", { className: "flex w-64 shrink-0 flex-col gap-2 overflow-y-auto pr-1", children: [
994
- /* @__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)) }),
995
- tab === "color" && /* @__PURE__ */ jsx8(ColorPanel, { controller }),
996
- tab === "paper" && /* @__PURE__ */ jsx8(PaperPanel, { controller }),
997
- tab === "layers" && /* @__PURE__ */ jsx8(PaintLayersPanel, { controller })
1199
+ /* @__PURE__ */ jsxs10("div", { className: "flex w-64 shrink-0 flex-col gap-2 overflow-y-auto pr-1", children: [
1200
+ /* @__PURE__ */ jsx10("div", { className: "flex gap-1", role: "tablist", "aria-label": "Paint panels", children: ["color", "wet", "paper", "layers"].map((t) => /* @__PURE__ */ jsx10("button", { type: "button", role: "tab", "aria-selected": tab === t, className: chip(tab === t), onClick: () => setTab(t), children: t === "color" ? "Colour" : t === "wet" ? "Wet" : t === "paper" ? "Paper" : "Layers" }, t)) }),
1201
+ tab === "color" && /* @__PURE__ */ jsx10(ColorPanel, { controller }),
1202
+ tab === "wet" && /* @__PURE__ */ jsx10(WetPanel, { controller }),
1203
+ tab === "paper" && /* @__PURE__ */ jsx10(PaperPanel, { controller }),
1204
+ tab === "layers" && /* @__PURE__ */ jsx10(PaintLayersPanel, { controller }),
1205
+ tab === "layers" && /* @__PURE__ */ jsx10(StickerPanel, { controller })
998
1206
  ] })
999
1207
  ] })
1000
1208
  ] });
@@ -1009,6 +1217,8 @@ export {
1009
1217
  PaintSlider,
1010
1218
  PaintToolbar,
1011
1219
  PaperPanel,
1220
+ StickerPanel,
1221
+ WetPanel,
1012
1222
  usePaintDirector,
1013
1223
  usePaintEditor,
1014
1224
  usePaintEditorState