hazo_images 1.7.0 → 1.8.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.
package/CHANGE_LOG.md CHANGED
@@ -1,5 +1,30 @@
1
1
  # hazo_images Change Log
2
2
 
3
+ ## 1.8.0 — 2026-07-11
4
+
5
+ ### Added
6
+
7
+ - **`ImageAnnotator`** — new `hazo_images/ui` component: a mobile-first image markup tool built on a Konva `Stage`/`Layer` (via `react-konva`). Loads a source image (`File | Blob | string`), scales it to fit within a `maxWidth`/`maxHeight` viewport, and renders it as the bottom Konva `Image` layer beneath drawn annotations.
8
+ - Tools: **pen** (freehand `Line`, points collected on pointer move), **arrow** (drag-stamped `Arrow`), **circle** (drag-stamped `Ellipse`, sized by drag distance) — switched via a large-tap-target toolbar.
9
+ - **Undo** (pops the last shape) and **Clear** (empties the shape list) buttons.
10
+ - Drawing is driven by pointer events (not mouse-only assumptions), so it works with touch/stylus input as well as a mouse.
11
+ - `onSave(blob: Blob) => void` — flattens the stage (source image + markup) via `Konva.Stage#toBlob({ mimeType: 'image/png' })` and calls `onSave` with the resulting PNG `Blob`.
12
+ - `labels` override object (tool names, undo/clear/save, loading text), matching the `ImageEditorDialog` customization pattern.
13
+ - New export: `ImageAnnotatorProps`.
14
+ - **`image-annotator-shapes.ts`** — pure, framework-free shape-list helpers (`addShape`, `undoShape`, `clearShapes`) that back `ImageAnnotator`'s state transitions; unit-tested directly (no DOM/Konva required). New exports: `AnnotatorTool`, `AnnotatorShape`, `PenShape`, `ArrowShape`, `CircleShape`.
15
+ - **`konva ^10.3.0`** and **`react-konva ^19.2.5`** added as optional peer dependencies (mirroring the existing `hazo_ui`/`react`/`react-dom` optional-peer pattern) — only required when importing `ImageAnnotator` from `hazo_images/ui`; the rest of the package is unaffected.
16
+ - Demo: `test-app /annotator` page and a `hazo_images_annotator` autotest scenario exercising the pen/arrow/circle → flatten-to-blob path.
17
+
18
+ ### Fixed
19
+
20
+ - **`createImageProcessHandler` save now overwrites.** The handler's `save` step calls `uploadFile(..., { overwrite: true })`, so re-saving an edited image to the same path replaces it instead of failing with `File already exists`. (Previously a second save of the same filename returned `saved: null` + a `saveError`.)
21
+
22
+ ### Test-app
23
+
24
+ - `test-app/next.config.js` externalises `dropbox` + `googleapis` (pulled transitively by `hazo_files`) so Turbopack no longer tries to bundle their `node-fetch` requires and fail the build.
25
+
26
+ ---
27
+
3
28
  ## 1.7.0 — 2026-06-22
4
29
  ### Added
5
30
  - `deriveVariant(sourceFileId, deps, opts)` — fileId-in/fileId-out convenience over `processImage` + `uploadProcessedImage`
package/README.md CHANGED
@@ -138,7 +138,7 @@ try {
138
138
  |---|---|
139
139
  | `hazo_images` | Shared types + error classes (isomorphic, no Node deps) |
140
140
  | `hazo_images/server` | `processImage`, `uploadProcessedImage`, `createImageProcessHandler` (Node.js only) |
141
- | `hazo_images/ui` | `ImageUploader`, `ImageViewer`, `ImageEditorDialog` + control-schema helpers (React, client-only) |
141
+ | `hazo_images/ui` | `ImageUploader`, `ImageViewer`, `ImageEditorDialog`, `ImageAnnotator` + control-schema helpers (React, client-only) |
142
142
  | `hazo_images/runware` | `createRunwareClient`, `assemblePrompts`, 4 error classes (Node.js only) |
143
143
 
144
144
  ## Supported formats
@@ -346,6 +346,29 @@ import type { ImageEditControl } from 'hazo_images/ui';
346
346
 
347
347
  ---
348
348
 
349
+ ## `<ImageAnnotator>` (`hazo_images/ui`)
350
+
351
+ Mobile-first image markup tool built on a Konva `Stage`/`Layer` (via `react-konva`). Loads a source image (`File | Blob | string`), scales it to fit a `maxWidth`/`maxHeight` viewport, and lets the user draw over it with **pen** (freehand), **arrow**, and **circle** tools. **Undo** pops the last shape; **Clear** empties them. `onSave` flattens the source image + markup to a PNG `Blob`.
352
+
353
+ > **`"use client"` required** and **optional peer deps** `konva ^10.3.0` + `react-konva ^19.2.5` must be installed to use it (the rest of the package works without them).
354
+
355
+ ```tsx
356
+ 'use client';
357
+ import { ImageAnnotator } from 'hazo_images/ui';
358
+
359
+ <ImageAnnotator
360
+ source={file} // File | Blob | string (URL)
361
+ maxWidth={800}
362
+ maxHeight={600}
363
+ onSave={(blob) => { /* PNG Blob of image + markup */ }}
364
+ // labels={{ pen: 'Draw', arrow: 'Arrow', circle: 'Circle', undo: 'Undo', clear: 'Clear', save: 'Save', loading: 'Loading…' }}
365
+ />
366
+ ```
367
+
368
+ Pure, framework-free shape-list helpers back its state and are exported for reuse: `addShape`, `undoShape`, `clearShapes` (+ types `AnnotatorTool`, `AnnotatorShape`, `PenShape`, `ArrowShape`, `CircleShape`).
369
+
370
+ ---
371
+
349
372
  ## Runware (AI image generation)
350
373
 
351
374
  `hazo_images/runware` wraps the Runware REST API behind a factory client.
@@ -400,6 +423,8 @@ const { positive, negative } = assemblePrompts({
400
423
  | `@1.3` | `blur` Gaussian-blur option on `processImage` |
401
424
  | `@1.4` | Extended pipeline options (crop / rotate / flip / flop / grayscale / sharpen / brightness / saturation / hue / tint / quality); `createImageProcessHandler` factory; `hazo_images/ui` (`ImageEditorDialog` + helpers) |
402
425
  | `@1.5` | `ImageEditorDialog`: instant CSS live preview for geometry + filters (realtime while dragging); interactive crop (drag + 8 handles) with rect / square / circle shapes; rotate 0/90/180/270 presets; tint color picker; Reset button. Server: crop runs before rotate; `crop.shape: 'circle'` masks to a transparent PNG |
426
+ | `@1.7` | `deriveVariant` — fileId-in / fileId-out convenience over `processImage` + `uploadProcessedImage` |
427
+ | `@1.8` | `ImageAnnotator` — Konva-based image markup (pen / arrow / circle → flatten to PNG); `createImageProcessHandler` save now overwrites an existing file at the same path |
403
428
 
404
429
  ## License
405
430
 
@@ -121,13 +121,13 @@ async function processImage(buffer, opts = {}) {
121
121
  let forcePng = false;
122
122
  let cropBaked = false;
123
123
  if (autoRotate) {
124
- pipeline = pipeline.autoOrient();
124
+ pipeline = pipeline.rotate();
125
125
  }
126
126
  if (crop) {
127
127
  let imgW = inputMetadata.width;
128
128
  let imgH = inputMetadata.height;
129
129
  if (autoRotate) {
130
- const oriented = await sharpFn(buffer).autoOrient().metadata();
130
+ const oriented = await sharpFn(buffer).rotate().metadata();
131
131
  imgW = oriented.width ?? imgW;
132
132
  imgH = oriented.height ?? imgH;
133
133
  }
@@ -165,6 +165,11 @@ async function processImage(buffer, opts = {}) {
165
165
  cropBaked = true;
166
166
  }
167
167
  if (typeof rotate === "number" && rotate !== 0) {
168
+ if (autoRotate && !cropBaked) {
169
+ const oriented = await pipeline.png().toBuffer();
170
+ pipeline = sharpFn(oriented);
171
+ cropBaked = true;
172
+ }
168
173
  pipeline = pipeline.rotate(rotate);
169
174
  }
170
175
  if (flip) {
@@ -450,7 +455,7 @@ function createImageProcessHandler(opts) {
450
455
  const fm = await getFileManager();
451
456
  const safeName = sanitizeFilename(desiredFilename, result.metadata.format, !!parsedOptions.webp);
452
457
  const savePath = `${savePathPrefix}/${safeName}`;
453
- const up = await fm.uploadFile(result.buffer, savePath);
458
+ const up = await fm.uploadFile(result.buffer, savePath, { overwrite: true });
454
459
  if (!up.success) {
455
460
  saveError = up.error ?? "Upload failed";
456
461
  } else {
@@ -199,6 +199,100 @@ interface ImageEditorDialogProps {
199
199
  }
200
200
  declare function ImageEditorDialog({ open, onOpenChange, trigger, file: fileProp, controls, defaultOptions, endpoint, onProcess, onSave, labels, className, headerSlot, footerSlot, debounceMs, }: ImageEditorDialogProps): react_jsx_runtime.JSX.Element;
201
201
 
202
+ interface ImageAnnotatorProps {
203
+ /** Source image — a File/Blob (an object URL is created and revoked internally) or a plain URL string. */
204
+ src: File | Blob | string;
205
+ /** Called with the flattened annotation (source image + markup) as a PNG Blob. */
206
+ onSave?: (blob: Blob) => void;
207
+ /** Initial stroke color for new shapes. Defaults to a visible red. */
208
+ strokeColor?: string;
209
+ /** Initial stroke width for new shapes, in stage pixels. */
210
+ strokeWidth?: number;
211
+ /** Maximum viewport width the stage is scaled to fit within. */
212
+ maxWidth?: number;
213
+ /** Maximum viewport height the stage is scaled to fit within. */
214
+ maxHeight?: number;
215
+ className?: string;
216
+ labels?: {
217
+ save?: string;
218
+ loading?: string;
219
+ };
220
+ }
221
+ /**
222
+ * Mobile-first image markup tool: draws freehand (pen), arrow, circle, speech
223
+ * bubble, and text annotations over a source image on a Konva stage, with
224
+ * undo/redo/clear, a color + stroke-width picker, and a `Save` action that
225
+ * flattens the stage (image + markup) to a PNG Blob.
226
+ *
227
+ * Drawing is driven entirely by pointer events (not mouse-only assumptions)
228
+ * so it works with touch and stylus input as well as a mouse. Text and speech
229
+ * bubbles are typed through an inline editor overlaid on the stage.
230
+ */
231
+ declare function ImageAnnotator({ src, onSave, strokeColor, strokeWidth, maxWidth, maxHeight, className, labels, }: ImageAnnotatorProps): react_jsx_runtime.JSX.Element;
232
+
233
+ /**
234
+ * Pure, framework-free shape-list helpers for `ImageAnnotator`.
235
+ * No DOM / Konva / React dependency — this is what the Jest unit tests cover
236
+ * directly (see `__tests__/image-annotator-shapes.test.ts`); the component
237
+ * imports these functions for all of its shape-list state transitions.
238
+ */
239
+ type AnnotatorTool = 'pen' | 'arrow' | 'circle' | 'bubble' | 'text';
240
+ interface BaseAnnotatorShape {
241
+ id: string;
242
+ color: string;
243
+ }
244
+ interface PenShape extends BaseAnnotatorShape {
245
+ tool: 'pen';
246
+ strokeWidth: number;
247
+ /** Flat point list `[x0, y0, x1, y1, ...]` in stage-pixel coordinates. */
248
+ points: number[];
249
+ }
250
+ interface ArrowShape extends BaseAnnotatorShape {
251
+ tool: 'arrow';
252
+ strokeWidth: number;
253
+ /** `[x1, y1, x2, y2]` — drag-start point to drag-end point. */
254
+ points: [number, number, number, number];
255
+ }
256
+ interface CircleShape extends BaseAnnotatorShape {
257
+ tool: 'circle';
258
+ strokeWidth: number;
259
+ /** Center point (drag-start). */
260
+ x: number;
261
+ y: number;
262
+ radiusX: number;
263
+ radiusY: number;
264
+ }
265
+ interface BubbleShape extends BaseAnnotatorShape {
266
+ tool: 'bubble';
267
+ strokeWidth: number;
268
+ /** Top-left corner (in stage-pixel coordinates). */
269
+ x: number;
270
+ y: number;
271
+ width: number;
272
+ height: number;
273
+ /** Text rendered inside the bubble (may be empty while first drawn). */
274
+ text: string;
275
+ fontSize: number;
276
+ }
277
+ interface TextShape extends BaseAnnotatorShape {
278
+ tool: 'text';
279
+ /** Anchor point (top-left of the text block) in stage-pixel coordinates. */
280
+ x: number;
281
+ y: number;
282
+ text: string;
283
+ fontSize: number;
284
+ }
285
+ type AnnotatorShape = PenShape | ArrowShape | CircleShape | BubbleShape | TextShape;
286
+ /** Appends a shape to the end of the list. Does not mutate `shapes`. */
287
+ declare function addShape(shapes: AnnotatorShape[], shape: AnnotatorShape): AnnotatorShape[];
288
+ /**
289
+ * Removes the most recently added shape (stack-style undo).
290
+ * No-ops (returns the same array reference) on an empty list.
291
+ */
292
+ declare function undoShape(shapes: AnnotatorShape[]): AnnotatorShape[];
293
+ /** Returns a fresh, empty shape list. */
294
+ declare function clearShapes(): AnnotatorShape[];
295
+
202
296
  /**
203
297
  * Pure geometry helpers for the resize-handle overlay.
204
298
  * Extracted so they can be unit-tested without a DOM/React environment.
@@ -299,4 +393,4 @@ declare function ResizeHandleOverlay({ box, onResize, onResizeEnd, containerWidt
299
393
 
300
394
  declare function cn(...parts: Array<string | false | null | undefined>): string;
301
395
 
302
- export { DEFAULT_IMAGE_EDIT_CONTROLS, type ImageEditControls, type ImageEditTab, ImageEditorDialog, type ImageEditorDialogProps, type ImageProcessOptions, type ImageProcessResult, type ImageProcessThumbnail, ImageUploader, type ImageUploaderProps, ImageViewer, type ImageViewerProps, type InputOption, type ProcessFn, type ResizeBox, type ResizeHandle, ResizeHandleOverlay, type ResizeHandleOverlayProps, type TabElement, type ValidationError, type ValidationResult, cn, controlValuesToOptions, defaultControlValues, validate_image_edit_controls };
396
+ export { type AnnotatorShape, type AnnotatorTool, type ArrowShape, type CircleShape, DEFAULT_IMAGE_EDIT_CONTROLS, ImageAnnotator, type ImageAnnotatorProps, type ImageEditControls, type ImageEditTab, ImageEditorDialog, type ImageEditorDialogProps, type ImageProcessOptions, type ImageProcessResult, type ImageProcessThumbnail, ImageUploader, type ImageUploaderProps, ImageViewer, type ImageViewerProps, type InputOption, type PenShape, type ProcessFn, type ResizeBox, type ResizeHandle, ResizeHandleOverlay, type ResizeHandleOverlayProps, type TabElement, type ValidationError, type ValidationResult, addShape, clearShapes, cn, controlValuesToOptions, defaultControlValues, undoShape, validate_image_edit_controls };
package/dist/ui/index.js CHANGED
@@ -1271,14 +1271,641 @@ function ImageEditorDialog({
1271
1271
  /* @__PURE__ */ jsx4(DialogContent, { className: cn("max-w-5xl", className), children: dialogBody })
1272
1272
  ] });
1273
1273
  }
1274
+
1275
+ // src/ui/image-annotator.tsx
1276
+ import * as React5 from "react";
1277
+ import { Stage, Layer, Image as KonvaImage, Line, Arrow, Ellipse, Rect, Text, Group } from "react-konva";
1278
+ import {
1279
+ Pencil,
1280
+ MoveUpRight,
1281
+ Circle as CircleIcon,
1282
+ MessageSquare,
1283
+ Type as TypeIcon,
1284
+ Undo2,
1285
+ Redo2,
1286
+ Trash2,
1287
+ Check,
1288
+ Palette,
1289
+ SlidersHorizontal
1290
+ } from "lucide-react";
1291
+ import {
1292
+ Button as Button3,
1293
+ Slider as Slider2,
1294
+ Popover,
1295
+ PopoverTrigger,
1296
+ PopoverContent,
1297
+ Tooltip,
1298
+ TooltipTrigger,
1299
+ TooltipContent,
1300
+ TooltipProvider
1301
+ } from "hazo_ui";
1302
+
1303
+ // src/ui/image-annotator-shapes.ts
1304
+ function addShape(shapes, shape) {
1305
+ return [...shapes, shape];
1306
+ }
1307
+ function undoShape(shapes) {
1308
+ if (shapes.length === 0) return shapes;
1309
+ return shapes.slice(0, -1);
1310
+ }
1311
+ function clearShapes() {
1312
+ return [];
1313
+ }
1314
+ function updateShape(shapes, id, patch) {
1315
+ let changed = false;
1316
+ const next = shapes.map((s) => {
1317
+ if (s.id !== id) return s;
1318
+ changed = true;
1319
+ return { ...s, ...patch };
1320
+ });
1321
+ return changed ? next : shapes;
1322
+ }
1323
+ function removeShape(shapes, id) {
1324
+ const next = shapes.filter((s) => s.id !== id);
1325
+ return next.length === shapes.length ? shapes : next;
1326
+ }
1327
+ function undo(shapes, redo2) {
1328
+ if (shapes.length === 0) return { shapes, redo: redo2 };
1329
+ const last = shapes[shapes.length - 1];
1330
+ return { shapes: shapes.slice(0, -1), redo: [...redo2, last] };
1331
+ }
1332
+ function redo(shapes, redoStack) {
1333
+ if (redoStack.length === 0) return { shapes, redo: redoStack };
1334
+ const last = redoStack[redoStack.length - 1];
1335
+ return { shapes: [...shapes, last], redo: redoStack.slice(0, -1) };
1336
+ }
1337
+
1338
+ // src/ui/image-annotator.tsx
1339
+ import { jsx as jsx5, jsxs as jsxs4 } from "react/jsx-runtime";
1340
+ var TOOL_IDS = ["pen", "arrow", "circle", "bubble", "text"];
1341
+ var DEFAULT_STROKE_COLOR = "#ef4444";
1342
+ var DEFAULT_STROKE_WIDTH = 4;
1343
+ var DEFAULT_MAX_WIDTH = 720;
1344
+ var DEFAULT_MAX_HEIGHT = 640;
1345
+ var PRESET_COLORS = [
1346
+ "#ef4444",
1347
+ "#f59e0b",
1348
+ "#eab308",
1349
+ "#22c55e",
1350
+ "#3b82f6",
1351
+ "#a855f7",
1352
+ "#ffffff",
1353
+ "#000000"
1354
+ ];
1355
+ var BUBBLE_PADDING = 10;
1356
+ var fontSizeFor = (strokeWidth) => Math.max(12, Math.round(strokeWidth * 4));
1357
+ var TOOL_META = {
1358
+ pen: { Icon: Pencil, label: "Pen", hint: "Freehand pen \u2014 draw by dragging" },
1359
+ arrow: { Icon: MoveUpRight, label: "Arrow", hint: "Arrow \u2014 drag from tail to head" },
1360
+ circle: { Icon: CircleIcon, label: "Circle", hint: "Circle \u2014 drag to size an ellipse" },
1361
+ bubble: { Icon: MessageSquare, label: "Speech bubble", hint: "Speech bubble \u2014 drag to size, then type" },
1362
+ text: { Icon: TypeIcon, label: "Text", hint: "Text \u2014 click to place, then type" }
1363
+ };
1364
+ function bubbleTailPoints(width, height) {
1365
+ const baseX = Math.min(28, width * 0.5);
1366
+ const tipX = Math.min(14, width * 0.25);
1367
+ const tipY = height + Math.min(22, height * 0.4);
1368
+ return [16, height, baseX, height, tipX, tipY];
1369
+ }
1370
+ function ShapeNode({
1371
+ shape,
1372
+ onDragEnd
1373
+ }) {
1374
+ if (shape.tool === "pen") {
1375
+ return /* @__PURE__ */ jsx5(
1376
+ Line,
1377
+ {
1378
+ points: shape.points,
1379
+ stroke: shape.color,
1380
+ strokeWidth: shape.strokeWidth,
1381
+ lineCap: "round",
1382
+ lineJoin: "round",
1383
+ listening: false
1384
+ }
1385
+ );
1386
+ }
1387
+ if (shape.tool === "arrow") {
1388
+ return /* @__PURE__ */ jsx5(
1389
+ Arrow,
1390
+ {
1391
+ points: shape.points,
1392
+ stroke: shape.color,
1393
+ fill: shape.color,
1394
+ strokeWidth: shape.strokeWidth,
1395
+ pointerLength: Math.max(8, shape.strokeWidth * 3),
1396
+ pointerWidth: Math.max(8, shape.strokeWidth * 3),
1397
+ listening: false
1398
+ }
1399
+ );
1400
+ }
1401
+ if (shape.tool === "circle") {
1402
+ return /* @__PURE__ */ jsx5(
1403
+ Ellipse,
1404
+ {
1405
+ x: shape.x,
1406
+ y: shape.y,
1407
+ radiusX: Math.max(shape.radiusX, 1),
1408
+ radiusY: Math.max(shape.radiusY, 1),
1409
+ stroke: shape.color,
1410
+ strokeWidth: shape.strokeWidth,
1411
+ listening: false
1412
+ }
1413
+ );
1414
+ }
1415
+ if (shape.tool === "text") {
1416
+ return /* @__PURE__ */ jsx5(
1417
+ Text,
1418
+ {
1419
+ x: shape.x,
1420
+ y: shape.y,
1421
+ text: shape.text,
1422
+ fontSize: shape.fontSize,
1423
+ fill: shape.color,
1424
+ draggable: !!onDragEnd,
1425
+ onDragEnd: (e) => onDragEnd?.(shape.id, e.target.x(), e.target.y())
1426
+ }
1427
+ );
1428
+ }
1429
+ return /* @__PURE__ */ jsxs4(
1430
+ Group,
1431
+ {
1432
+ x: shape.x,
1433
+ y: shape.y,
1434
+ draggable: !!onDragEnd,
1435
+ onDragEnd: (e) => onDragEnd?.(shape.id, e.target.x(), e.target.y()),
1436
+ children: [
1437
+ /* @__PURE__ */ jsx5(
1438
+ Line,
1439
+ {
1440
+ points: bubbleTailPoints(shape.width, shape.height),
1441
+ closed: true,
1442
+ fill: "#ffffff",
1443
+ stroke: shape.color,
1444
+ strokeWidth: shape.strokeWidth
1445
+ }
1446
+ ),
1447
+ /* @__PURE__ */ jsx5(
1448
+ Rect,
1449
+ {
1450
+ width: shape.width,
1451
+ height: shape.height,
1452
+ cornerRadius: Math.min(16, shape.height / 2),
1453
+ fill: "#ffffff",
1454
+ stroke: shape.color,
1455
+ strokeWidth: shape.strokeWidth
1456
+ }
1457
+ ),
1458
+ /* @__PURE__ */ jsx5(
1459
+ Text,
1460
+ {
1461
+ x: BUBBLE_PADDING,
1462
+ y: BUBBLE_PADDING,
1463
+ width: Math.max(1, shape.width - BUBBLE_PADDING * 2),
1464
+ height: Math.max(1, shape.height - BUBBLE_PADDING * 2),
1465
+ text: shape.text,
1466
+ fontSize: shape.fontSize,
1467
+ fill: shape.color,
1468
+ wrap: "word",
1469
+ listening: false
1470
+ }
1471
+ )
1472
+ ]
1473
+ }
1474
+ );
1475
+ }
1476
+ function ImageAnnotator({
1477
+ src,
1478
+ onSave,
1479
+ strokeColor = DEFAULT_STROKE_COLOR,
1480
+ strokeWidth = DEFAULT_STROKE_WIDTH,
1481
+ maxWidth = DEFAULT_MAX_WIDTH,
1482
+ maxHeight = DEFAULT_MAX_HEIGHT,
1483
+ className,
1484
+ labels
1485
+ }) {
1486
+ const [imageEl, setImageEl] = React5.useState(null);
1487
+ const [naturalSize, setNaturalSize] = React5.useState(null);
1488
+ const [tool, setTool] = React5.useState("pen");
1489
+ const [shapes, setShapes] = React5.useState([]);
1490
+ const [redoStack, setRedoStack] = React5.useState([]);
1491
+ const [drawingShape, setDrawingShape] = React5.useState(null);
1492
+ const [color, setColor] = React5.useState(strokeColor);
1493
+ const [width, setWidth] = React5.useState(strokeWidth);
1494
+ const [editing, setEditing] = React5.useState(null);
1495
+ const [draft, setDraft] = React5.useState("");
1496
+ const drawingRef = React5.useRef(null);
1497
+ const stageRef = React5.useRef(null);
1498
+ const editorRef = React5.useRef(null);
1499
+ const idRef = React5.useRef(0);
1500
+ React5.useEffect(() => {
1501
+ let objectUrl = null;
1502
+ let cancelled = false;
1503
+ const url = typeof src === "string" ? src : objectUrl = URL.createObjectURL(src);
1504
+ const img = new window.Image();
1505
+ img.onload = () => {
1506
+ if (cancelled) return;
1507
+ setImageEl(img);
1508
+ setNaturalSize({ w: img.naturalWidth, h: img.naturalHeight });
1509
+ };
1510
+ img.src = url;
1511
+ return () => {
1512
+ cancelled = true;
1513
+ if (objectUrl) URL.revokeObjectURL(objectUrl);
1514
+ };
1515
+ }, [src]);
1516
+ React5.useEffect(() => {
1517
+ if (editing) editorRef.current?.focus();
1518
+ }, [editing]);
1519
+ const scale = React5.useMemo(() => {
1520
+ if (!naturalSize) return 1;
1521
+ return Math.min(1, maxWidth / naturalSize.w, maxHeight / naturalSize.h);
1522
+ }, [naturalSize, maxWidth, maxHeight]);
1523
+ const stageWidth = naturalSize ? Math.round(naturalSize.w * scale) : 0;
1524
+ const stageHeight = naturalSize ? Math.round(naturalSize.h * scale) : 0;
1525
+ function nextId() {
1526
+ idRef.current += 1;
1527
+ return `shape-${idRef.current}`;
1528
+ }
1529
+ function commitShape(shape) {
1530
+ setShapes((prev) => addShape(prev, shape));
1531
+ setRedoStack([]);
1532
+ }
1533
+ function openEditor(shape, state) {
1534
+ commitShape(shape);
1535
+ setDraft("");
1536
+ setEditing(state);
1537
+ }
1538
+ function finishEditing() {
1539
+ if (!editing) return;
1540
+ const value = draft.trim();
1541
+ if (value === "") {
1542
+ setShapes((prev) => removeShape(prev, editing.id));
1543
+ setRedoStack([]);
1544
+ } else {
1545
+ setShapes((prev) => updateShape(prev, editing.id, { text: value }));
1546
+ }
1547
+ setEditing(null);
1548
+ setDraft("");
1549
+ }
1550
+ function cancelEditing() {
1551
+ if (!editing) return;
1552
+ setShapes((prev) => removeShape(prev, editing.id));
1553
+ setRedoStack([]);
1554
+ setEditing(null);
1555
+ setDraft("");
1556
+ }
1557
+ function handlePointerDown(e) {
1558
+ if (editing) {
1559
+ finishEditing();
1560
+ return;
1561
+ }
1562
+ if (e.target !== e.target.getStage()) return;
1563
+ e.evt?.preventDefault?.();
1564
+ const pos = stageRef.current?.getPointerPosition();
1565
+ if (!pos) return;
1566
+ if (tool === "text") {
1567
+ const shape2 = {
1568
+ id: nextId(),
1569
+ tool: "text",
1570
+ x: pos.x,
1571
+ y: pos.y,
1572
+ text: "",
1573
+ color,
1574
+ fontSize: fontSizeFor(width)
1575
+ };
1576
+ openEditor(shape2, { id: shape2.id, kind: "text", x: pos.x, y: pos.y, fontSize: shape2.fontSize });
1577
+ return;
1578
+ }
1579
+ let shape;
1580
+ if (tool === "pen") {
1581
+ shape = { id: nextId(), tool: "pen", points: [pos.x, pos.y], color, strokeWidth: width };
1582
+ } else if (tool === "arrow") {
1583
+ shape = { id: nextId(), tool: "arrow", points: [pos.x, pos.y, pos.x, pos.y], color, strokeWidth: width };
1584
+ } else if (tool === "circle") {
1585
+ shape = { id: nextId(), tool: "circle", x: pos.x, y: pos.y, radiusX: 0, radiusY: 0, color, strokeWidth: width };
1586
+ } else {
1587
+ shape = { id: nextId(), tool: "bubble", x: pos.x, y: pos.y, width: 0, height: 0, text: "", color, strokeWidth: width, fontSize: fontSizeFor(width) };
1588
+ }
1589
+ drawingRef.current = shape;
1590
+ setDrawingShape(shape);
1591
+ }
1592
+ function handlePointerMove(e) {
1593
+ const current = drawingRef.current;
1594
+ if (!current) return;
1595
+ e.evt?.preventDefault?.();
1596
+ const pos = stageRef.current?.getPointerPosition();
1597
+ if (!pos) return;
1598
+ let next;
1599
+ if (current.tool === "pen") {
1600
+ next = { ...current, points: [...current.points, pos.x, pos.y] };
1601
+ } else if (current.tool === "arrow") {
1602
+ next = { ...current, points: [current.points[0], current.points[1], pos.x, pos.y] };
1603
+ } else if (current.tool === "circle") {
1604
+ next = { ...current, radiusX: Math.abs(pos.x - current.x), radiusY: Math.abs(pos.y - current.y) };
1605
+ } else if (current.tool === "bubble") {
1606
+ const x = Math.min(current.x, pos.x);
1607
+ const y = Math.min(current.y, pos.y);
1608
+ next = { ...current, x, y, width: Math.abs(pos.x - current.x), height: Math.abs(pos.y - current.y) };
1609
+ } else {
1610
+ return;
1611
+ }
1612
+ drawingRef.current = next;
1613
+ setDrawingShape(next);
1614
+ }
1615
+ function handlePointerUp() {
1616
+ const current = drawingRef.current;
1617
+ drawingRef.current = null;
1618
+ setDrawingShape(null);
1619
+ if (!current) return;
1620
+ if (current.tool === "pen" && current.points.length < 4) return;
1621
+ if (current.tool === "arrow" && current.points[0] === current.points[2] && current.points[1] === current.points[3]) return;
1622
+ if (current.tool === "circle" && current.radiusX < 2 && current.radiusY < 2) return;
1623
+ if (current.tool === "bubble") {
1624
+ if (current.width < 24 || current.height < 24) return;
1625
+ openEditor(current, {
1626
+ id: current.id,
1627
+ kind: "bubble",
1628
+ x: current.x,
1629
+ y: current.y,
1630
+ width: current.width,
1631
+ height: current.height,
1632
+ fontSize: current.fontSize
1633
+ });
1634
+ return;
1635
+ }
1636
+ commitShape(current);
1637
+ }
1638
+ function handleShapeDragEnd(id, x, y) {
1639
+ setShapes((prev) => updateShape(prev, id, { x, y }));
1640
+ }
1641
+ function handleUndo() {
1642
+ if (editing) cancelEditing();
1643
+ setShapes((prevShapes) => {
1644
+ const { shapes: nextShapes, redo: redo2 } = undo(prevShapes, redoStack);
1645
+ setRedoStack(redo2);
1646
+ return nextShapes;
1647
+ });
1648
+ }
1649
+ function handleRedo() {
1650
+ setShapes((prevShapes) => {
1651
+ const { shapes: nextShapes, redo: redo2 } = redo(prevShapes, redoStack);
1652
+ setRedoStack(redo2);
1653
+ return nextShapes;
1654
+ });
1655
+ }
1656
+ function handleClear() {
1657
+ if (editing) cancelEditing();
1658
+ setShapes(clearShapes());
1659
+ setRedoStack([]);
1660
+ }
1661
+ function handleSave() {
1662
+ const stage = stageRef.current;
1663
+ if (!stage || !onSave) return;
1664
+ if (editing) finishEditing();
1665
+ window.requestAnimationFrame(() => {
1666
+ void stage.toBlob({
1667
+ mimeType: "image/png",
1668
+ callback: (blob) => {
1669
+ if (blob) onSave(blob);
1670
+ }
1671
+ });
1672
+ });
1673
+ }
1674
+ const resolvedLabels = {
1675
+ save: labels?.save ?? "Save",
1676
+ loading: labels?.loading ?? "Loading image\u2026"
1677
+ };
1678
+ const iconBtn = "h-10 w-10";
1679
+ return /* @__PURE__ */ jsx5(TooltipProvider, { delayDuration: 200, children: /* @__PURE__ */ jsxs4("div", { className: cn("flex flex-col gap-3", className), children: [
1680
+ /* @__PURE__ */ jsxs4("div", { className: "flex flex-wrap items-center gap-1.5", role: "toolbar", "aria-label": "Annotation tools", children: [
1681
+ TOOL_IDS.map((id) => {
1682
+ const { Icon, label, hint } = TOOL_META[id];
1683
+ return /* @__PURE__ */ jsxs4(Tooltip, { children: [
1684
+ /* @__PURE__ */ jsx5(TooltipTrigger, { asChild: true, children: /* @__PURE__ */ jsx5(
1685
+ Button3,
1686
+ {
1687
+ type: "button",
1688
+ size: "icon",
1689
+ variant: tool === id ? "default" : "outline",
1690
+ className: iconBtn,
1691
+ "aria-label": label,
1692
+ "aria-pressed": tool === id,
1693
+ onClick: () => setTool(id),
1694
+ children: /* @__PURE__ */ jsx5(Icon, { size: 18 })
1695
+ }
1696
+ ) }),
1697
+ /* @__PURE__ */ jsx5(TooltipContent, { children: hint })
1698
+ ] }, id);
1699
+ }),
1700
+ /* @__PURE__ */ jsx5("span", { className: "mx-1 h-6 w-px self-center bg-gray-300", "aria-hidden": "true" }),
1701
+ /* @__PURE__ */ jsxs4(Tooltip, { children: [
1702
+ /* @__PURE__ */ jsx5(TooltipTrigger, { asChild: true, children: /* @__PURE__ */ jsxs4(Popover, { children: [
1703
+ /* @__PURE__ */ jsx5(PopoverTrigger, { asChild: true, children: /* @__PURE__ */ jsx5(Button3, { type: "button", size: "icon", variant: "outline", className: iconBtn, "aria-label": "Color", children: /* @__PURE__ */ jsx5(Palette, { size: 18, style: { color } }) }) }),
1704
+ /* @__PURE__ */ jsx5(PopoverContent, { className: "w-auto p-3", children: /* @__PURE__ */ jsxs4("div", { className: "flex flex-col gap-3", children: [
1705
+ /* @__PURE__ */ jsx5("div", { className: "grid grid-cols-4 gap-2", children: PRESET_COLORS.map((c) => /* @__PURE__ */ jsx5(
1706
+ "button",
1707
+ {
1708
+ type: "button",
1709
+ "aria-label": `Color ${c}`,
1710
+ onClick: () => setColor(c),
1711
+ className: cn(
1712
+ "h-7 w-7 rounded-full border border-gray-300",
1713
+ color.toLowerCase() === c.toLowerCase() && "ring-2 ring-offset-2 ring-gray-800"
1714
+ ),
1715
+ style: { backgroundColor: c }
1716
+ },
1717
+ c
1718
+ )) }),
1719
+ /* @__PURE__ */ jsxs4("label", { className: "flex items-center gap-2 text-sm", children: [
1720
+ /* @__PURE__ */ jsx5("span", { className: "text-gray-600", children: "Custom" }),
1721
+ /* @__PURE__ */ jsx5(
1722
+ "input",
1723
+ {
1724
+ type: "color",
1725
+ value: color,
1726
+ onChange: (e) => setColor(e.target.value),
1727
+ className: "h-7 w-10 cursor-pointer rounded border border-gray-300 bg-transparent p-0"
1728
+ }
1729
+ )
1730
+ ] })
1731
+ ] }) })
1732
+ ] }) }),
1733
+ /* @__PURE__ */ jsx5(TooltipContent, { children: "Color" })
1734
+ ] }),
1735
+ /* @__PURE__ */ jsxs4(Tooltip, { children: [
1736
+ /* @__PURE__ */ jsx5(TooltipTrigger, { asChild: true, children: /* @__PURE__ */ jsxs4(Popover, { children: [
1737
+ /* @__PURE__ */ jsx5(PopoverTrigger, { asChild: true, children: /* @__PURE__ */ jsx5(Button3, { type: "button", size: "icon", variant: "outline", className: iconBtn, "aria-label": "Stroke width", children: /* @__PURE__ */ jsx5(SlidersHorizontal, { size: 18 }) }) }),
1738
+ /* @__PURE__ */ jsx5(PopoverContent, { className: "w-56 p-4", children: /* @__PURE__ */ jsxs4("div", { className: "flex flex-col gap-3", children: [
1739
+ /* @__PURE__ */ jsxs4("div", { className: "flex items-center justify-between text-sm text-gray-600", children: [
1740
+ /* @__PURE__ */ jsx5("span", { children: "Size" }),
1741
+ /* @__PURE__ */ jsxs4("span", { className: "font-medium text-gray-900", children: [
1742
+ width,
1743
+ "px"
1744
+ ] })
1745
+ ] }),
1746
+ /* @__PURE__ */ jsx5(
1747
+ Slider2,
1748
+ {
1749
+ min: 1,
1750
+ max: 30,
1751
+ step: 1,
1752
+ value: [width],
1753
+ onValueChange: ([v]) => setWidth(v),
1754
+ "aria-label": "Stroke width"
1755
+ }
1756
+ ),
1757
+ /* @__PURE__ */ jsx5("div", { className: "flex items-center justify-center", children: /* @__PURE__ */ jsx5(
1758
+ "span",
1759
+ {
1760
+ className: "rounded-full",
1761
+ style: { width, height: width, backgroundColor: color, minWidth: 4, minHeight: 4 }
1762
+ }
1763
+ ) })
1764
+ ] }) })
1765
+ ] }) }),
1766
+ /* @__PURE__ */ jsx5(TooltipContent, { children: "Stroke width" })
1767
+ ] }),
1768
+ /* @__PURE__ */ jsx5("span", { className: "mx-1 h-6 w-px self-center bg-gray-300", "aria-hidden": "true" }),
1769
+ /* @__PURE__ */ jsxs4(Tooltip, { children: [
1770
+ /* @__PURE__ */ jsx5(TooltipTrigger, { asChild: true, children: /* @__PURE__ */ jsx5(
1771
+ Button3,
1772
+ {
1773
+ type: "button",
1774
+ size: "icon",
1775
+ variant: "outline",
1776
+ className: iconBtn,
1777
+ "aria-label": "Undo",
1778
+ disabled: shapes.length === 0 && !editing,
1779
+ onClick: handleUndo,
1780
+ children: /* @__PURE__ */ jsx5(Undo2, { size: 18 })
1781
+ }
1782
+ ) }),
1783
+ /* @__PURE__ */ jsx5(TooltipContent, { children: "Undo" })
1784
+ ] }),
1785
+ /* @__PURE__ */ jsxs4(Tooltip, { children: [
1786
+ /* @__PURE__ */ jsx5(TooltipTrigger, { asChild: true, children: /* @__PURE__ */ jsx5(
1787
+ Button3,
1788
+ {
1789
+ type: "button",
1790
+ size: "icon",
1791
+ variant: "outline",
1792
+ className: iconBtn,
1793
+ "aria-label": "Redo",
1794
+ disabled: redoStack.length === 0,
1795
+ onClick: handleRedo,
1796
+ children: /* @__PURE__ */ jsx5(Redo2, { size: 18 })
1797
+ }
1798
+ ) }),
1799
+ /* @__PURE__ */ jsx5(TooltipContent, { children: "Redo" })
1800
+ ] }),
1801
+ /* @__PURE__ */ jsxs4(Tooltip, { children: [
1802
+ /* @__PURE__ */ jsx5(TooltipTrigger, { asChild: true, children: /* @__PURE__ */ jsx5(
1803
+ Button3,
1804
+ {
1805
+ type: "button",
1806
+ size: "icon",
1807
+ variant: "outline",
1808
+ className: iconBtn,
1809
+ "aria-label": "Clear",
1810
+ disabled: shapes.length === 0 && !editing,
1811
+ onClick: handleClear,
1812
+ children: /* @__PURE__ */ jsx5(Trash2, { size: 18 })
1813
+ }
1814
+ ) }),
1815
+ /* @__PURE__ */ jsx5(TooltipContent, { children: "Clear all" })
1816
+ ] }),
1817
+ onSave && /* @__PURE__ */ jsxs4(Tooltip, { children: [
1818
+ /* @__PURE__ */ jsx5(TooltipTrigger, { asChild: true, children: /* @__PURE__ */ jsxs4(
1819
+ Button3,
1820
+ {
1821
+ type: "button",
1822
+ className: "ml-auto h-10 gap-1.5 px-4",
1823
+ disabled: !imageEl,
1824
+ onClick: handleSave,
1825
+ children: [
1826
+ /* @__PURE__ */ jsx5(Check, { size: 18 }),
1827
+ resolvedLabels.save
1828
+ ]
1829
+ }
1830
+ ) }),
1831
+ /* @__PURE__ */ jsx5(TooltipContent, { children: "Flatten image + markup to PNG" })
1832
+ ] })
1833
+ ] }),
1834
+ /* @__PURE__ */ jsx5(
1835
+ "div",
1836
+ {
1837
+ className: "relative flex items-center justify-center overflow-hidden rounded-lg bg-gray-900",
1838
+ style: { minHeight: stageHeight || void 0, touchAction: "none" },
1839
+ children: imageEl ? /* @__PURE__ */ jsxs4("div", { className: "relative", style: { width: stageWidth, height: stageHeight }, children: [
1840
+ /* @__PURE__ */ jsxs4(
1841
+ Stage,
1842
+ {
1843
+ ref: stageRef,
1844
+ width: stageWidth,
1845
+ height: stageHeight,
1846
+ onPointerDown: handlePointerDown,
1847
+ onPointerMove: handlePointerMove,
1848
+ onPointerUp: handlePointerUp,
1849
+ style: { touchAction: "none" },
1850
+ children: [
1851
+ /* @__PURE__ */ jsx5(Layer, { children: /* @__PURE__ */ jsx5(KonvaImage, { image: imageEl, width: stageWidth, height: stageHeight, listening: false }) }),
1852
+ /* @__PURE__ */ jsxs4(Layer, { children: [
1853
+ shapes.map(
1854
+ (shape) => (
1855
+ // Hide the shape currently being typed — the overlay shows its text instead.
1856
+ editing?.id === shape.id ? null : /* @__PURE__ */ jsx5(ShapeNode, { shape, onDragEnd: handleShapeDragEnd }, shape.id)
1857
+ )
1858
+ ),
1859
+ drawingShape && /* @__PURE__ */ jsx5(ShapeNode, { shape: drawingShape })
1860
+ ] })
1861
+ ]
1862
+ }
1863
+ ),
1864
+ editing && /* @__PURE__ */ jsx5(
1865
+ "textarea",
1866
+ {
1867
+ ref: editorRef,
1868
+ value: draft,
1869
+ onChange: (e) => setDraft(e.target.value),
1870
+ onBlur: finishEditing,
1871
+ onKeyDown: (e) => {
1872
+ if (e.key === "Enter" && !e.shiftKey) {
1873
+ e.preventDefault();
1874
+ finishEditing();
1875
+ } else if (e.key === "Escape") {
1876
+ e.preventDefault();
1877
+ cancelEditing();
1878
+ }
1879
+ },
1880
+ placeholder: "Type\u2026",
1881
+ className: "absolute resize-none rounded border border-dashed border-gray-400 bg-white/90 p-1 leading-tight shadow-sm outline-none",
1882
+ style: {
1883
+ left: editing.x + (editing.kind === "bubble" ? BUBBLE_PADDING : 0),
1884
+ top: editing.y + (editing.kind === "bubble" ? BUBBLE_PADDING : 0),
1885
+ width: editing.kind === "bubble" ? Math.max(40, (editing.width ?? 0) - BUBBLE_PADDING * 2) : Math.max(120, stageWidth - editing.x - 8),
1886
+ height: editing.kind === "bubble" ? Math.max(24, (editing.height ?? 0) - BUBBLE_PADDING * 2) : void 0,
1887
+ color,
1888
+ fontSize: editing.fontSize
1889
+ }
1890
+ }
1891
+ )
1892
+ ] }) : /* @__PURE__ */ jsx5("span", { className: "p-8 text-sm text-gray-400", children: resolvedLabels.loading })
1893
+ }
1894
+ )
1895
+ ] }) });
1896
+ }
1274
1897
  export {
1275
1898
  DEFAULT_IMAGE_EDIT_CONTROLS,
1899
+ ImageAnnotator,
1276
1900
  ImageEditorDialog,
1277
1901
  ImageUploader,
1278
1902
  ImageViewer,
1279
1903
  ResizeHandleOverlay,
1904
+ addShape,
1905
+ clearShapes,
1280
1906
  cn,
1281
1907
  controlValuesToOptions,
1282
1908
  defaultControlValues,
1909
+ undoShape,
1283
1910
  validate_image_edit_controls
1284
1911
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "hazo_images",
3
- "version": "1.7.0",
3
+ "version": "1.8.1",
4
4
  "description": "Image processing pipeline for the hazo ecosystem — Sharp wrapper, thumbnail generation, EXIF handling, and hazo_files integration helper",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -51,28 +51,44 @@
51
51
  "zod": "^4.1.12"
52
52
  },
53
53
  "peerDependencies": {
54
- "hazo_core": "^1.2.0",
55
- "hazo_files": "^3.0.0",
56
- "hazo_ui": "^4.4.0",
54
+ "hazo_core": "^1.2.1",
55
+ "hazo_files": "^3.1.1",
56
+ "hazo_theme": "^1.0.0",
57
+ "hazo_ui": "^6.0.0",
58
+ "konva": "^10.3.0",
59
+ "lucide-react": "^0.553.0",
57
60
  "react": "^18.0.0 || ^19.0.0",
58
61
  "react-dom": "^18.0.0 || ^19.0.0",
62
+ "react-konva": "^18.2.16 || ^19.2.5",
59
63
  "sharp": "^0.33.0"
60
64
  },
61
65
  "peerDependenciesMeta": {
62
66
  "hazo_files": {
63
67
  "optional": true
64
68
  },
69
+ "lucide-react": {
70
+ "optional": true
71
+ },
65
72
  "hazo_ui": {
66
73
  "optional": true
67
74
  },
75
+ "konva": {
76
+ "optional": true
77
+ },
68
78
  "react": {
69
79
  "optional": true
70
80
  },
71
81
  "react-dom": {
72
82
  "optional": true
73
83
  },
84
+ "react-konva": {
85
+ "optional": true
86
+ },
74
87
  "sharp": {
75
88
  "optional": true
89
+ },
90
+ "hazo_theme": {
91
+ "optional": true
76
92
  }
77
93
  },
78
94
  "devDependencies": {
@@ -82,6 +98,9 @@
82
98
  "hazo_core": "^1.2.1",
83
99
  "jest": "^30.2.0",
84
100
  "jest-environment-node": "^30.2.0",
101
+ "konva": "^10.3.0",
102
+ "lucide-react": "^0.553.0",
103
+ "react-konva": "^18.2.16",
85
104
  "ts-jest": "^29.4.5",
86
105
  "tsup": "^8.0.0",
87
106
  "typescript": "^5.7.2"