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 +25 -0
- package/README.md +26 -1
- package/dist/server/index.js +8 -3
- package/dist/ui/index.d.ts +95 -1
- package/dist/ui/index.js +627 -0
- package/package.json +23 -4
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
|
|
package/dist/server/index.js
CHANGED
|
@@ -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.
|
|
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).
|
|
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 {
|
package/dist/ui/index.d.ts
CHANGED
|
@@ -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.
|
|
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.
|
|
55
|
-
"hazo_files": "^3.
|
|
56
|
-
"
|
|
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"
|