react-os-shell 0.2.22 → 0.2.23

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.
@@ -8,53 +8,55 @@ import { jsxs, jsx, Fragment } from 'react/jsx-runtime';
8
8
  var COLORS = ["#ef4444", "#f97316", "#eab308", "#22c55e", "#3b82f6", "#8b5cf6", "#000000", "#ffffff"];
9
9
  var STROKE_DEFAULT = 4;
10
10
  var MOSAIC_BLOCK = 12;
11
+ var ZOOM_MIN = 0.25;
12
+ var ZOOM_MAX = 4;
13
+ var ZOOM_STEP = 0.25;
14
+ var newId = () => Math.random().toString(36).slice(2, 9);
11
15
  function ImageAnnotator({ src, filename, onClose }) {
12
- const canvasRef = useRef(null);
13
- const overlayRef = useRef(null);
14
16
  const wrapRef = useRef(null);
15
- const [tool, setTool] = useState("rect");
17
+ const canvasRef = useRef(null);
18
+ const svgRef = useRef(null);
19
+ const imageRef = useRef(null);
20
+ const [tool, setTool] = useState("select");
16
21
  const [color, setColor] = useState(COLORS[0]);
17
22
  const [stroke, setStroke] = useState(STROKE_DEFAULT);
18
- const historyRef = useRef([]);
19
- const [historyDepth, setHistoryDepth] = useState(0);
20
- const [imageReady, setImageReady] = useState(false);
23
+ const [annotations, setAnnotations] = useState([]);
24
+ const [selectedId, setSelectedId] = useState(null);
25
+ const [imageSize, setImageSize] = useState(null);
26
+ const [fitSize, setFitSize] = useState(null);
27
+ const [zoom, setZoom] = useState(1);
28
+ const [preview, setPreview] = useState(null);
21
29
  const [pendingText, setPendingText] = useState(null);
22
30
  const [pendingCrop, setPendingCrop] = useState(null);
23
- const [displaySize, setDisplaySize] = useState(null);
24
- const [displayScale, setDisplayScale] = useState(1);
31
+ const dragRef = useRef(null);
32
+ const displaySize = useMemo(() => {
33
+ if (!fitSize) return null;
34
+ return { w: fitSize.w * zoom, h: fitSize.h * zoom };
35
+ }, [fitSize, zoom]);
25
36
  useEffect(() => {
26
37
  const img = new Image();
27
38
  img.crossOrigin = "anonymous";
28
39
  img.onload = () => {
29
- const c = canvasRef.current;
30
- const o = overlayRef.current;
31
- if (!c || !o) return;
32
- c.width = img.naturalWidth;
33
- c.height = img.naturalHeight;
34
- o.width = img.naturalWidth;
35
- o.height = img.naturalHeight;
36
- const ctx = c.getContext("2d");
37
- ctx.drawImage(img, 0, 0);
38
- historyRef.current = [];
39
- setHistoryDepth(0);
40
- setImageReady(true);
40
+ imageRef.current = img;
41
+ setImageSize({ w: img.naturalWidth, h: img.naturalHeight });
42
+ setAnnotations([]);
43
+ setSelectedId(null);
41
44
  };
42
45
  img.onerror = () => toast_default.error("Failed to load image");
43
46
  img.src = src;
44
47
  }, [src]);
45
48
  useEffect(() => {
46
- if (!imageReady) return;
49
+ if (!imageSize) return;
47
50
  const update = () => {
48
- const c = canvasRef.current;
49
51
  const wrap = wrapRef.current;
50
- if (!c || !wrap) return;
51
- const wrapRect = wrap.getBoundingClientRect();
52
- const availW = Math.max(0, wrapRect.width - 32);
53
- const availH = Math.max(0, wrapRect.height - 32);
52
+ if (!wrap) return;
53
+ const r = wrap.getBoundingClientRect();
54
+ const availW = Math.max(0, r.width - 32);
55
+ const availH = Math.max(0, r.height - 32);
54
56
  if (availW === 0 || availH === 0) return;
55
- const ratio = c.width / c.height;
56
- let w = c.width;
57
- let h = c.height;
57
+ const ratio = imageSize.w / imageSize.h;
58
+ let w = imageSize.w;
59
+ let h = imageSize.h;
58
60
  if (w > availW) {
59
61
  w = availW;
60
62
  h = w / ratio;
@@ -63,171 +65,201 @@ function ImageAnnotator({ src, filename, onClose }) {
63
65
  h = availH;
64
66
  w = h * ratio;
65
67
  }
66
- setDisplaySize({ w, h });
67
- setDisplayScale(c.width / w);
68
+ setFitSize({ w, h });
68
69
  };
69
70
  update();
70
71
  const ro = new ResizeObserver(update);
71
72
  if (wrapRef.current) ro.observe(wrapRef.current);
72
73
  return () => ro.disconnect();
73
- }, [imageReady]);
74
- const pushHistory = () => {
74
+ }, [imageSize]);
75
+ const mosaicAnnos = useMemo(
76
+ () => annotations.filter((a) => a.type === "mosaic"),
77
+ [annotations]
78
+ );
79
+ useEffect(() => {
80
+ const img = imageRef.current;
75
81
  const c = canvasRef.current;
76
- if (!c) return;
82
+ if (!img || !c || !imageSize) return;
83
+ c.width = imageSize.w;
84
+ c.height = imageSize.h;
77
85
  const ctx = c.getContext("2d");
78
- historyRef.current.push(ctx.getImageData(0, 0, c.width, c.height));
79
- if (historyRef.current.length > 50) historyRef.current.shift();
80
- setHistoryDepth(historyRef.current.length);
81
- };
82
- const undo = () => {
83
- if (historyRef.current.length === 0) return;
84
- const prev = historyRef.current.pop();
85
- setHistoryDepth(historyRef.current.length);
86
- const c = canvasRef.current;
87
- if (!c) return;
88
- c.getContext("2d").putImageData(prev, 0, 0);
89
- setPendingCrop(null);
90
- clearOverlay();
91
- };
92
- const clearOverlay = () => {
93
- const o = overlayRef.current;
94
- if (!o) return;
95
- o.getContext("2d").clearRect(0, 0, o.width, o.height);
86
+ ctx.drawImage(img, 0, 0);
87
+ for (const m of mosaicAnnos) applyMosaic(ctx, m);
88
+ }, [imageSize, mosaicAnnos]);
89
+ const evToImage = (e) => {
90
+ const svg = svgRef.current;
91
+ if (!svg) return { x: 0, y: 0 };
92
+ const pt = svg.createSVGPoint();
93
+ pt.x = e.clientX;
94
+ pt.y = e.clientY;
95
+ const ctm = svg.getScreenCTM();
96
+ if (!ctm) return { x: 0, y: 0 };
97
+ const t = pt.matrixTransform(ctm.inverse());
98
+ return { x: t.x, y: t.y };
96
99
  };
97
- const evToCanvas = (e) => {
98
- const rect = e.currentTarget.getBoundingClientRect();
99
- const x = (e.clientX - rect.left) * displayScale;
100
- const y = (e.clientY - rect.top) * displayScale;
101
- return { x, y };
100
+ const handleAnnoPointerDown = (e, anno) => {
101
+ if (tool !== "select") return;
102
+ e.stopPropagation();
103
+ setSelectedId(anno.id);
104
+ const start = evToImage(e);
105
+ dragRef.current = { kind: "move", id: anno.id, start, original: anno };
106
+ e.currentTarget.setPointerCapture?.(e.pointerId);
102
107
  };
103
- const dragRef = useRef(null);
104
- const drawShapePreview = (start, end) => {
105
- const o = overlayRef.current;
106
- if (!o) return;
107
- const ctx = o.getContext("2d");
108
- ctx.clearRect(0, 0, o.width, o.height);
109
- ctx.strokeStyle = color;
110
- ctx.fillStyle = color;
111
- ctx.lineWidth = stroke;
112
- ctx.lineCap = "round";
113
- ctx.lineJoin = "round";
114
- if (tool === "rect") {
115
- drawRoundedRect(ctx, start, end, stroke);
116
- } else if (tool === "circle") {
117
- drawEllipse(ctx, start, end);
118
- } else if (tool === "arrow") {
119
- drawArrow(ctx, start, end, stroke);
120
- } else if (tool === "mosaic") {
121
- ctx.fillStyle = `${color}33`;
122
- ctx.strokeStyle = color;
123
- ctx.lineWidth = 1.5;
124
- const r = normalizeRect(start, end);
125
- ctx.fillRect(r.x, r.y, r.w, r.h);
126
- ctx.strokeRect(r.x, r.y, r.w, r.h);
127
- } else if (tool === "crop") {
128
- ctx.strokeStyle = "#ffffff";
129
- ctx.lineWidth = 2;
130
- ctx.setLineDash([8, 6]);
131
- const r = normalizeRect(start, end);
132
- ctx.fillStyle = "rgba(0,0,0,0.45)";
133
- ctx.fillRect(0, 0, o.width, r.y);
134
- ctx.fillRect(0, r.y + r.h, o.width, o.height - (r.y + r.h));
135
- ctx.fillRect(0, r.y, r.x, r.h);
136
- ctx.fillRect(r.x + r.w, r.y, o.width - (r.x + r.w), r.h);
137
- ctx.strokeRect(r.x, r.y, r.w, r.h);
138
- ctx.setLineDash([]);
108
+ const handleSvgPointerDown = (e) => {
109
+ if (tool === "select") {
110
+ setSelectedId(null);
111
+ return;
139
112
  }
140
- };
141
- const onPointerDown = (e) => {
142
- if (!imageReady) return;
143
- const pos = evToCanvas(e);
144
113
  if (tool === "text") {
145
- setPendingText({ x: pos.x, y: pos.y, value: "" });
114
+ const p = evToImage(e);
115
+ setPendingText({ x: p.x, y: p.y, value: "" });
116
+ return;
117
+ }
118
+ const start = evToImage(e);
119
+ if (tool === "crop") {
120
+ dragRef.current = { kind: "crop", start };
121
+ e.currentTarget.setPointerCapture(e.pointerId);
146
122
  return;
147
123
  }
124
+ dragRef.current = { kind: "draw", start };
148
125
  e.currentTarget.setPointerCapture(e.pointerId);
149
- dragRef.current = { start: pos, current: pos };
150
- drawShapePreview(pos, pos);
126
+ setPreview(makeShape(tool, start, start, color, stroke));
151
127
  };
152
- const onPointerMove = (e) => {
153
- if (!dragRef.current) return;
154
- const pos = evToCanvas(e);
155
- dragRef.current.current = pos;
156
- drawShapePreview(dragRef.current.start, pos);
128
+ const handleSvgPointerMove = (e) => {
129
+ const drag = dragRef.current;
130
+ if (!drag) return;
131
+ const p = evToImage(e);
132
+ if (drag.kind === "draw") {
133
+ setPreview(makeShape(tool, drag.start, p, color, stroke));
134
+ } else if (drag.kind === "crop") {
135
+ setPendingCrop(normalizeRect(drag.start, p));
136
+ } else if (drag.kind === "move") {
137
+ const dx = p.x - drag.start.x;
138
+ const dy = p.y - drag.start.y;
139
+ setAnnotations((prev) => prev.map((a) => a.id === drag.id ? translate(drag.original, dx, dy) : a));
140
+ }
157
141
  };
158
- const onPointerUp = (e) => {
159
- if (!dragRef.current) return;
160
- e.currentTarget.releasePointerCapture(e.pointerId);
161
- const { start, current } = dragRef.current;
142
+ const handleSvgPointerUp = (e) => {
143
+ const drag = dragRef.current;
144
+ if (!drag) return;
162
145
  dragRef.current = null;
163
- if (Math.abs(current.x - start.x) < 2 && Math.abs(current.y - start.y) < 2) {
164
- clearOverlay();
165
- return;
166
- }
167
- if (tool === "crop") {
168
- const r = normalizeRect(start, current);
169
- setPendingCrop(r);
170
- return;
171
- }
172
- pushHistory();
173
- const c = canvasRef.current;
174
- const ctx = c.getContext("2d");
175
- ctx.strokeStyle = color;
176
- ctx.fillStyle = color;
177
- ctx.lineWidth = stroke;
178
- ctx.lineCap = "round";
179
- ctx.lineJoin = "round";
180
- if (tool === "rect") drawRoundedRect(ctx, start, current, stroke);
181
- else if (tool === "circle") drawEllipse(ctx, start, current);
182
- else if (tool === "arrow") drawArrow(ctx, start, current, stroke);
183
- else if (tool === "mosaic") applyMosaic(ctx, normalizeRect(start, current));
184
- clearOverlay();
146
+ e.currentTarget.releasePointerCapture?.(e.pointerId);
147
+ if (drag.kind === "draw") {
148
+ const p = preview;
149
+ if (!p) return;
150
+ const tooSmall = isTrivial(p);
151
+ setPreview(null);
152
+ if (tooSmall) return;
153
+ const anno = { ...p, id: newId() };
154
+ setAnnotations((prev) => [...prev, anno]);
155
+ setSelectedId(anno.id);
156
+ setTool("select");
157
+ } else if (drag.kind === "crop") ; else if (drag.kind === "move") ;
185
158
  };
159
+ const handleAnnoDoubleClick = (anno) => {
160
+ if (anno.type !== "text") return;
161
+ setPendingText({ x: anno.x, y: anno.y, value: anno.text, editingId: anno.id });
162
+ };
163
+ useEffect(() => {
164
+ const onKey = (ev) => {
165
+ if (!selectedId) return;
166
+ if (document.activeElement && /^(INPUT|TEXTAREA)$/.test(document.activeElement.tagName)) return;
167
+ if (ev.key === "Delete" || ev.key === "Backspace") {
168
+ ev.preventDefault();
169
+ setAnnotations((prev) => prev.filter((a) => a.id !== selectedId));
170
+ setSelectedId(null);
171
+ } else if (ev.key === "Escape") {
172
+ setSelectedId(null);
173
+ }
174
+ };
175
+ window.addEventListener("keydown", onKey);
176
+ return () => window.removeEventListener("keydown", onKey);
177
+ }, [selectedId]);
186
178
  const commitText = () => {
187
179
  if (!pendingText) return;
188
- if (!pendingText.value.trim()) {
180
+ const value = pendingText.value.trim();
181
+ if (!value) {
182
+ if (pendingText.editingId) {
183
+ setAnnotations((prev) => prev.filter((a) => a.id !== pendingText.editingId));
184
+ }
189
185
  setPendingText(null);
190
186
  return;
191
187
  }
192
- pushHistory();
193
- const c = canvasRef.current;
194
- const ctx = c.getContext("2d");
195
188
  const fontSize = Math.max(16, stroke * 6);
196
- ctx.fillStyle = color;
197
- ctx.font = `600 ${fontSize}px -apple-system, system-ui, sans-serif`;
198
- ctx.textBaseline = "top";
199
- const lines = pendingText.value.split("\n");
200
- lines.forEach((line, i) => {
201
- ctx.fillText(line, pendingText.x, pendingText.y + i * fontSize * 1.2);
202
- });
189
+ if (pendingText.editingId) {
190
+ setAnnotations((prev) => prev.map(
191
+ (a) => a.id === pendingText.editingId && a.type === "text" ? { ...a, text: value } : a
192
+ ));
193
+ } else {
194
+ const anno = {
195
+ id: newId(),
196
+ type: "text",
197
+ x: pendingText.x,
198
+ y: pendingText.y,
199
+ text: value,
200
+ color,
201
+ size: fontSize
202
+ };
203
+ setAnnotations((prev) => [...prev, anno]);
204
+ setSelectedId(anno.id);
205
+ setTool("select");
206
+ }
203
207
  setPendingText(null);
204
208
  };
205
209
  const applyCrop = () => {
206
- if (!pendingCrop) return;
207
- const c = canvasRef.current;
208
- pushHistory();
210
+ if (!pendingCrop || !imageRef.current || !imageSize) return;
209
211
  const r = pendingCrop;
210
- const data = c.getContext("2d").getImageData(r.x, r.y, r.w, r.h);
211
- c.width = Math.round(r.w);
212
- c.height = Math.round(r.h);
213
- overlayRef.current.width = c.width;
214
- overlayRef.current.height = c.height;
215
- c.getContext("2d").putImageData(data, 0, 0);
216
- setPendingCrop(null);
217
- clearOverlay();
218
- requestAnimationFrame(() => {
219
- const rect = c.getBoundingClientRect();
220
- if (rect.width > 0) setDisplayScale(c.width / rect.width);
221
- });
212
+ const tmp = document.createElement("canvas");
213
+ tmp.width = Math.round(r.w);
214
+ tmp.height = Math.round(r.h);
215
+ const tctx = tmp.getContext("2d");
216
+ const sourceCanvas = document.createElement("canvas");
217
+ sourceCanvas.width = imageSize.w;
218
+ sourceCanvas.height = imageSize.h;
219
+ const sctx = sourceCanvas.getContext("2d");
220
+ sctx.drawImage(imageRef.current, 0, 0);
221
+ for (const m of mosaicAnnos) applyMosaic(sctx, m);
222
+ tctx.drawImage(sourceCanvas, r.x, r.y, r.w, r.h, 0, 0, r.w, r.h);
223
+ const newImg = new Image();
224
+ newImg.onload = () => {
225
+ imageRef.current = newImg;
226
+ setImageSize({ w: newImg.naturalWidth, h: newImg.naturalHeight });
227
+ setAnnotations(
228
+ (prev) => prev.filter((a) => a.type !== "mosaic").map((a) => translate(a, -r.x, -r.y)).filter((a) => withinBounds(a, newImg.naturalWidth, newImg.naturalHeight))
229
+ );
230
+ setPendingCrop(null);
231
+ };
232
+ newImg.src = tmp.toDataURL("image/png");
222
233
  };
223
- const cancelCrop = () => {
224
- setPendingCrop(null);
225
- clearOverlay();
234
+ const cancelCrop = () => setPendingCrop(null);
235
+ const undoLast = () => {
236
+ setAnnotations((prev) => prev.slice(0, -1));
237
+ setSelectedId(null);
226
238
  };
227
- const downloadAnnotated = () => {
239
+ const downloadAnnotated = async () => {
228
240
  const c = canvasRef.current;
229
- if (!c) return;
230
- c.toBlob((blob) => {
241
+ const svg = svgRef.current;
242
+ if (!c || !svg || !imageSize) return;
243
+ const out = document.createElement("canvas");
244
+ out.width = imageSize.w;
245
+ out.height = imageSize.h;
246
+ const octx = out.getContext("2d");
247
+ octx.drawImage(c, 0, 0);
248
+ const clone = svg.cloneNode(true);
249
+ clone.querySelectorAll("[data-chrome]").forEach((n) => n.remove());
250
+ const xml = new XMLSerializer().serializeToString(clone);
251
+ const svgBlob = new Blob([xml], { type: "image/svg+xml;charset=utf-8" });
252
+ const svgUrl = URL.createObjectURL(svgBlob);
253
+ const svgImg = new Image();
254
+ await new Promise((resolve, reject) => {
255
+ svgImg.onload = () => resolve();
256
+ svgImg.onerror = reject;
257
+ svgImg.src = svgUrl;
258
+ }).catch(() => {
259
+ });
260
+ octx.drawImage(svgImg, 0, 0, imageSize.w, imageSize.h);
261
+ URL.revokeObjectURL(svgUrl);
262
+ out.toBlob((blob) => {
231
263
  if (!blob) {
232
264
  toast_default.error("Failed to export");
233
265
  return;
@@ -242,6 +274,7 @@ function ImageAnnotator({ src, filename, onClose }) {
242
274
  }, "image/png");
243
275
  };
244
276
  const tools = useMemo(() => [
277
+ { id: "select", label: "Select / Move", icon: /* @__PURE__ */ jsx("svg", { className: "h-4 w-4", fill: "none", viewBox: "0 0 24 24", stroke: "currentColor", strokeWidth: 1.8, children: /* @__PURE__ */ jsx("path", { strokeLinecap: "round", strokeLinejoin: "round", d: "M5 3l7 17 2-7 7-2L5 3z" }) }) },
245
278
  { id: "rect", label: "Rectangle", icon: /* @__PURE__ */ jsx("svg", { className: "h-4 w-4", fill: "none", viewBox: "0 0 24 24", stroke: "currentColor", strokeWidth: 1.8, children: /* @__PURE__ */ jsx("rect", { x: "4", y: "6", width: "16", height: "12", rx: "3" }) }) },
246
279
  { id: "circle", label: "Ellipse", icon: /* @__PURE__ */ jsx("svg", { className: "h-4 w-4", fill: "none", viewBox: "0 0 24 24", stroke: "currentColor", strokeWidth: 1.8, children: /* @__PURE__ */ jsx("ellipse", { cx: "12", cy: "12", rx: "8", ry: "6" }) }) },
247
280
  { id: "arrow", label: "Arrow", icon: /* @__PURE__ */ jsx("svg", { className: "h-4 w-4", fill: "none", viewBox: "0 0 24 24", stroke: "currentColor", strokeWidth: 1.8, children: /* @__PURE__ */ jsx("path", { strokeLinecap: "round", strokeLinejoin: "round", d: "M5 19L19 5m0 0h-7m7 0v7" }) }) },
@@ -250,12 +283,16 @@ function ImageAnnotator({ src, filename, onClose }) {
250
283
  { id: "crop", label: "Crop", icon: /* @__PURE__ */ jsx("svg", { className: "h-4 w-4", fill: "none", viewBox: "0 0 24 24", stroke: "currentColor", strokeWidth: 1.8, children: /* @__PURE__ */ jsx("path", { strokeLinecap: "round", strokeLinejoin: "round", d: "M6 2v15a1 1 0 001 1h15M2 6h15a1 1 0 011 1v15" }) }) }
251
284
  ], []);
252
285
  const btnClass = (active) => `p-1.5 rounded transition-colors ${active ? "bg-blue-500 text-white" : "text-gray-700 hover:bg-gray-200"}`;
286
+ const scale = displaySize && imageSize ? displaySize.w / imageSize.w : 1;
253
287
  return /* @__PURE__ */ jsxs("div", { className: "flex flex-col h-full bg-gray-100", children: [
254
288
  /* @__PURE__ */ jsxs("div", { className: "flex items-center gap-2 px-3 py-2 border-b border-gray-200 bg-white shrink-0 flex-wrap", children: [
255
289
  tools.map((t) => /* @__PURE__ */ jsx(
256
290
  "button",
257
291
  {
258
- onClick: () => setTool(t.id),
292
+ onClick: () => {
293
+ setTool(t.id);
294
+ setSelectedId(null);
295
+ },
259
296
  title: t.label,
260
297
  className: btnClass(tool === t.id),
261
298
  children: t.icon
@@ -266,7 +303,12 @@ function ImageAnnotator({ src, filename, onClose }) {
266
303
  /* @__PURE__ */ jsx("div", { className: "flex items-center gap-1", children: COLORS.map((c) => /* @__PURE__ */ jsx(
267
304
  "button",
268
305
  {
269
- onClick: () => setColor(c),
306
+ onClick: () => {
307
+ setColor(c);
308
+ if (selectedId) {
309
+ setAnnotations((prev) => prev.map((a) => a.id === selectedId && a.type !== "mosaic" ? { ...a, color: c } : a));
310
+ }
311
+ },
270
312
  title: c,
271
313
  className: `h-5 w-5 rounded-full border ${color === c ? "ring-2 ring-blue-500 ring-offset-1" : "border-gray-300"}`,
272
314
  style: { background: c }
@@ -291,7 +333,15 @@ function ImageAnnotator({ src, filename, onClose }) {
291
333
  /* @__PURE__ */ jsx("span", { className: "tabular-nums w-4 text-right", children: stroke })
292
334
  ] }),
293
335
  /* @__PURE__ */ jsx("div", { className: "h-5 w-px bg-gray-300 mx-1" }),
294
- /* @__PURE__ */ jsx("button", { onClick: undo, disabled: historyDepth === 0, className: "px-2 py-1 text-xs rounded hover:bg-gray-200 disabled:opacity-30 text-gray-700", children: "Undo" }),
336
+ /* @__PURE__ */ jsx("button", { onClick: () => setZoom((z) => Math.max(ZOOM_MIN, Math.round((z - ZOOM_STEP) * 100) / 100)), className: "px-2 py-1 text-xs rounded hover:bg-gray-200 text-gray-700", title: "Zoom out", children: "\u2212" }),
337
+ /* @__PURE__ */ jsxs("span", { className: "text-[11px] text-gray-600 tabular-nums w-10 text-center", children: [
338
+ Math.round(zoom * 100),
339
+ "%"
340
+ ] }),
341
+ /* @__PURE__ */ jsx("button", { onClick: () => setZoom((z) => Math.min(ZOOM_MAX, Math.round((z + ZOOM_STEP) * 100) / 100)), className: "px-2 py-1 text-xs rounded hover:bg-gray-200 text-gray-700", title: "Zoom in", children: "+" }),
342
+ /* @__PURE__ */ jsx("button", { onClick: () => setZoom(1), className: "px-2 py-1 text-xs rounded hover:bg-gray-200 text-gray-700", title: "Fit to area", children: "Fit" }),
343
+ /* @__PURE__ */ jsx("div", { className: "h-5 w-px bg-gray-300 mx-1" }),
344
+ /* @__PURE__ */ jsx("button", { onClick: undoLast, disabled: annotations.length === 0, className: "px-2 py-1 text-xs rounded hover:bg-gray-200 disabled:opacity-30 text-gray-700", children: "Undo" }),
295
345
  /* @__PURE__ */ jsx("button", { onClick: downloadAnnotated, className: "px-2 py-1 text-xs rounded hover:bg-gray-200 text-gray-700", children: "Save" }),
296
346
  /* @__PURE__ */ jsxs("div", { className: "ml-auto flex items-center gap-2", children: [
297
347
  pendingCrop && /* @__PURE__ */ jsxs(Fragment, { children: [
@@ -301,39 +351,54 @@ function ImageAnnotator({ src, filename, onClose }) {
301
351
  /* @__PURE__ */ jsx("button", { onClick: onClose, className: "px-2 py-1 text-xs rounded hover:bg-gray-200 text-gray-700", children: "Exit" })
302
352
  ] })
303
353
  ] }),
304
- /* @__PURE__ */ jsx("div", { ref: wrapRef, className: "flex-1 overflow-hidden bg-gray-200 flex items-center justify-center p-4 relative", children: /* @__PURE__ */ jsxs(
354
+ /* @__PURE__ */ jsx("div", { ref: wrapRef, className: "flex-1 overflow-auto bg-gray-200 flex items-center justify-center p-4 relative", children: displaySize && imageSize && /* @__PURE__ */ jsxs(
305
355
  "div",
306
356
  {
307
- className: "relative shadow-lg rounded overflow-hidden",
308
- style: displaySize ? { width: displaySize.w, height: displaySize.h } : void 0,
357
+ className: "relative shadow-lg rounded overflow-hidden bg-white shrink-0",
358
+ style: { width: displaySize.w, height: displaySize.h },
309
359
  children: [
310
360
  /* @__PURE__ */ jsx(
311
361
  "canvas",
312
362
  {
313
363
  ref: canvasRef,
314
- onPointerDown,
315
- onPointerMove,
316
- onPointerUp,
317
- onPointerCancel: () => {
318
- dragRef.current = null;
319
- clearOverlay();
320
- },
321
- style: { touchAction: "none", display: "block", width: "100%", height: "100%", cursor: tool === "text" ? "text" : "crosshair", background: "#fff" }
364
+ style: { position: "absolute", inset: 0, width: "100%", height: "100%", display: "block" }
322
365
  }
323
366
  ),
324
- /* @__PURE__ */ jsx(
325
- "canvas",
367
+ /* @__PURE__ */ jsxs(
368
+ "svg",
326
369
  {
327
- ref: overlayRef,
370
+ ref: svgRef,
371
+ viewBox: `0 0 ${imageSize.w} ${imageSize.h}`,
372
+ preserveAspectRatio: "none",
328
373
  style: {
329
374
  position: "absolute",
330
- top: 0,
331
- left: 0,
375
+ inset: 0,
332
376
  width: "100%",
333
377
  height: "100%",
334
- pointerEvents: "none",
335
- display: "block"
336
- }
378
+ touchAction: "none",
379
+ cursor: tool === "select" ? "default" : tool === "text" ? "text" : "crosshair"
380
+ },
381
+ onPointerDown: handleSvgPointerDown,
382
+ onPointerMove: handleSvgPointerMove,
383
+ onPointerUp: handleSvgPointerUp,
384
+ onPointerCancel: () => {
385
+ dragRef.current = null;
386
+ setPreview(null);
387
+ },
388
+ children: [
389
+ annotations.map((a) => /* @__PURE__ */ jsx(
390
+ AnnotationView,
391
+ {
392
+ anno: a,
393
+ selected: selectedId === a.id,
394
+ onPointerDown: (e) => handleAnnoPointerDown(e, a),
395
+ onDoubleClick: () => handleAnnoDoubleClick(a)
396
+ },
397
+ a.id
398
+ )),
399
+ preview && /* @__PURE__ */ jsx(AnnotationView, { anno: { ...preview, id: "__preview" }, preview: true }),
400
+ pendingCrop && /* @__PURE__ */ jsx(CropOverlay, { rect: pendingCrop, imageSize })
401
+ ]
337
402
  }
338
403
  ),
339
404
  pendingText && /* @__PURE__ */ jsx(
@@ -341,11 +406,11 @@ function ImageAnnotator({ src, filename, onClose }) {
341
406
  {
342
407
  style: {
343
408
  position: "absolute",
344
- left: `${pendingText.x / displayScale}px`,
345
- top: `${pendingText.y / displayScale}px`,
346
- transform: "translateY(-2px)"
409
+ left: `${pendingText.x * scale}px`,
410
+ top: `${pendingText.y * scale}px`,
411
+ transform: "translateY(-2px)",
412
+ zIndex: 5
347
413
  },
348
- className: "z-10",
349
414
  children: /* @__PURE__ */ jsx(
350
415
  "textarea",
351
416
  {
@@ -371,9 +436,193 @@ function ImageAnnotator({ src, filename, onClose }) {
371
436
  )
372
437
  ]
373
438
  }
374
- ) })
439
+ ) }),
440
+ /* @__PURE__ */ jsx("div", { className: "px-3 py-1.5 border-t border-gray-200 bg-white text-[11px] text-gray-500 shrink-0", children: tool === "select" ? selectedId ? "Drag to move. Delete / Backspace removes. Click outside to deselect." : "Tap a shape to select it. Double-click text to edit." : tool === "text" ? "Click to drop a text label." : tool === "crop" ? "Drag a rectangle to set the crop region." : "Drag on the image to draw." })
441
+ ] });
442
+ }
443
+ function AnnotationView({ anno, selected, preview, onPointerDown, onDoubleClick }) {
444
+ const dim = boundingBox(anno);
445
+ const interactive = onPointerDown ? { onPointerDown, style: { cursor: "move" } } : {};
446
+ const dblc = onDoubleClick ? { onDoubleClick } : {};
447
+ let body;
448
+ if (anno.type === "rect") {
449
+ const radius = Math.min(anno.w, anno.h, 16) * 0.4;
450
+ body = /* @__PURE__ */ jsx(
451
+ "rect",
452
+ {
453
+ x: anno.x,
454
+ y: anno.y,
455
+ width: anno.w,
456
+ height: anno.h,
457
+ rx: radius,
458
+ ry: radius,
459
+ fill: "none",
460
+ stroke: anno.color,
461
+ strokeWidth: anno.stroke,
462
+ strokeLinecap: "round",
463
+ strokeLinejoin: "round",
464
+ ...interactive
465
+ }
466
+ );
467
+ } else if (anno.type === "circle") {
468
+ body = /* @__PURE__ */ jsx(
469
+ "ellipse",
470
+ {
471
+ cx: anno.x + anno.w / 2,
472
+ cy: anno.y + anno.h / 2,
473
+ rx: anno.w / 2,
474
+ ry: anno.h / 2,
475
+ fill: "none",
476
+ stroke: anno.color,
477
+ strokeWidth: anno.stroke,
478
+ ...interactive
479
+ }
480
+ );
481
+ } else if (anno.type === "arrow") {
482
+ body = /* @__PURE__ */ jsx(ArrowShape, { anno, interactive });
483
+ } else if (anno.type === "mosaic") {
484
+ body = /* @__PURE__ */ jsx(
485
+ "rect",
486
+ {
487
+ x: anno.x,
488
+ y: anno.y,
489
+ width: anno.w,
490
+ height: anno.h,
491
+ fill: "rgba(0,0,0,0.001)",
492
+ stroke: selected ? "#3b82f6" : "none",
493
+ strokeWidth: selected ? 2 : 0,
494
+ strokeDasharray: selected ? "6,4" : void 0,
495
+ ...interactive
496
+ }
497
+ );
498
+ } else {
499
+ body = /* @__PURE__ */ jsx(
500
+ "text",
501
+ {
502
+ x: anno.x,
503
+ y: anno.y + anno.size,
504
+ fill: anno.color,
505
+ fontSize: anno.size,
506
+ fontWeight: 600,
507
+ fontFamily: "-apple-system, system-ui, sans-serif",
508
+ style: { userSelect: "none" },
509
+ ...interactive,
510
+ ...dblc,
511
+ children: anno.text.split("\n").map((line, i) => /* @__PURE__ */ jsx("tspan", { x: anno.x, dy: i === 0 ? 0 : anno.size * 1.2, children: line }, i))
512
+ }
513
+ );
514
+ }
515
+ return /* @__PURE__ */ jsxs("g", { children: [
516
+ body,
517
+ selected && !preview && /* @__PURE__ */ jsx(
518
+ "rect",
519
+ {
520
+ "data-chrome": "selection",
521
+ x: dim.x - 4,
522
+ y: dim.y - 4,
523
+ width: dim.w + 8,
524
+ height: dim.h + 8,
525
+ fill: "none",
526
+ stroke: "#3b82f6",
527
+ strokeWidth: 2,
528
+ strokeDasharray: "6,4",
529
+ pointerEvents: "none"
530
+ }
531
+ )
532
+ ] });
533
+ }
534
+ function ArrowShape({ anno, interactive }) {
535
+ const headLen = Math.max(12, anno.stroke * 4);
536
+ const angle = Math.atan2(anno.y2 - anno.y1, anno.x2 - anno.x1);
537
+ const a1 = angle + Math.PI - Math.PI / 7;
538
+ const a2 = angle + Math.PI + Math.PI / 7;
539
+ const head = `M${anno.x2},${anno.y2} L${anno.x2 + headLen * Math.cos(a1)},${anno.y2 + headLen * Math.sin(a1)} L${anno.x2 + headLen * Math.cos(a2)},${anno.y2 + headLen * Math.sin(a2)} Z`;
540
+ return /* @__PURE__ */ jsxs("g", { ...interactive, children: [
541
+ /* @__PURE__ */ jsx(
542
+ "line",
543
+ {
544
+ x1: anno.x1,
545
+ y1: anno.y1,
546
+ x2: anno.x2,
547
+ y2: anno.y2,
548
+ stroke: anno.color,
549
+ strokeWidth: anno.stroke,
550
+ strokeLinecap: "round"
551
+ }
552
+ ),
553
+ /* @__PURE__ */ jsx("path", { d: head, fill: anno.color })
375
554
  ] });
376
555
  }
556
+ function CropOverlay({ rect, imageSize }) {
557
+ return /* @__PURE__ */ jsxs("g", { pointerEvents: "none", children: [
558
+ /* @__PURE__ */ jsx(
559
+ "path",
560
+ {
561
+ d: `M0,0 H${imageSize.w} V${imageSize.h} H0 Z M${rect.x},${rect.y} V${rect.y + rect.h} H${rect.x + rect.w} V${rect.y} Z`,
562
+ fill: "rgba(0,0,0,0.45)",
563
+ fillRule: "evenodd"
564
+ }
565
+ ),
566
+ /* @__PURE__ */ jsx(
567
+ "rect",
568
+ {
569
+ x: rect.x,
570
+ y: rect.y,
571
+ width: rect.w,
572
+ height: rect.h,
573
+ fill: "none",
574
+ stroke: "#fff",
575
+ strokeWidth: 2,
576
+ strokeDasharray: "8,6"
577
+ }
578
+ )
579
+ ] });
580
+ }
581
+ function makeShape(tool, start, end, color, stroke) {
582
+ if (tool === "rect" || tool === "circle") {
583
+ const r = normalizeRect(start, end);
584
+ return { id: "", type: tool, x: r.x, y: r.y, w: r.w, h: r.h, color, stroke };
585
+ }
586
+ if (tool === "arrow") {
587
+ return { id: "", type: "arrow", x1: start.x, y1: start.y, x2: end.x, y2: end.y, color, stroke };
588
+ }
589
+ if (tool === "mosaic") {
590
+ const r = normalizeRect(start, end);
591
+ return { id: "", type: "mosaic", x: r.x, y: r.y, w: r.w, h: r.h };
592
+ }
593
+ return null;
594
+ }
595
+ function isTrivial(a) {
596
+ if (a.type === "arrow") {
597
+ return Math.abs(a.x2 - a.x1) < 4 && Math.abs(a.y2 - a.y1) < 4;
598
+ }
599
+ if ("w" in a && "h" in a) {
600
+ return a.w < 4 || a.h < 4;
601
+ }
602
+ return false;
603
+ }
604
+ function translate(a, dx, dy) {
605
+ if (a.type === "arrow") {
606
+ return { ...a, x1: a.x1 + dx, y1: a.y1 + dy, x2: a.x2 + dx, y2: a.y2 + dy };
607
+ }
608
+ if (a.type === "text") {
609
+ return { ...a, x: a.x + dx, y: a.y + dy };
610
+ }
611
+ return { ...a, x: a.x + dx, y: a.y + dy };
612
+ }
613
+ function boundingBox(a) {
614
+ if (a.type === "arrow") {
615
+ const x = Math.min(a.x1, a.x2);
616
+ const y = Math.min(a.y1, a.y2);
617
+ return { x, y, w: Math.abs(a.x2 - a.x1), h: Math.abs(a.y2 - a.y1) };
618
+ }
619
+ if (a.type === "text") {
620
+ const lines = a.text.split("\n");
621
+ const longest = lines.reduce((m, l) => Math.max(m, l.length), 0);
622
+ return { x: a.x, y: a.y, w: longest * a.size * 0.55, h: lines.length * a.size * 1.2 };
623
+ }
624
+ return { x: a.x, y: a.y, w: a.w, h: a.h };
625
+ }
377
626
  function normalizeRect(a, b) {
378
627
  const x = Math.min(a.x, b.x);
379
628
  const y = Math.min(a.y, b.y);
@@ -381,44 +630,9 @@ function normalizeRect(a, b) {
381
630
  const h = Math.abs(a.y - b.y);
382
631
  return { x, y, w, h };
383
632
  }
384
- function drawRoundedRect(ctx, start, end, stroke) {
385
- const r = normalizeRect(start, end);
386
- const radius = Math.min(r.w, r.h, 16) * 0.4;
387
- ctx.beginPath();
388
- if (ctx.roundRect) {
389
- ctx.roundRect(r.x, r.y, r.w, r.h, radius);
390
- } else {
391
- ctx.moveTo(r.x + radius, r.y);
392
- ctx.arcTo(r.x + r.w, r.y, r.x + r.w, r.y + r.h, radius);
393
- ctx.arcTo(r.x + r.w, r.y + r.h, r.x, r.y + r.h, radius);
394
- ctx.arcTo(r.x, r.y + r.h, r.x, r.y, radius);
395
- ctx.arcTo(r.x, r.y, r.x + r.w, r.y, radius);
396
- ctx.closePath();
397
- }
398
- ctx.lineWidth = stroke;
399
- ctx.stroke();
400
- }
401
- function drawEllipse(ctx, start, end) {
402
- const r = normalizeRect(start, end);
403
- ctx.beginPath();
404
- ctx.ellipse(r.x + r.w / 2, r.y + r.h / 2, r.w / 2, r.h / 2, 0, 0, Math.PI * 2);
405
- ctx.stroke();
406
- }
407
- function drawArrow(ctx, start, end, stroke) {
408
- ctx.beginPath();
409
- ctx.moveTo(start.x, start.y);
410
- ctx.lineTo(end.x, end.y);
411
- ctx.stroke();
412
- const headLen = Math.max(12, stroke * 4);
413
- const angle = Math.atan2(end.y - start.y, end.x - start.x);
414
- const a1 = angle + Math.PI - Math.PI / 7;
415
- const a2 = angle + Math.PI + Math.PI / 7;
416
- ctx.beginPath();
417
- ctx.moveTo(end.x, end.y);
418
- ctx.lineTo(end.x + headLen * Math.cos(a1), end.y + headLen * Math.sin(a1));
419
- ctx.lineTo(end.x + headLen * Math.cos(a2), end.y + headLen * Math.sin(a2));
420
- ctx.closePath();
421
- ctx.fill();
633
+ function withinBounds(a, w, h) {
634
+ const bb = boundingBox(a);
635
+ return bb.x + bb.w > 0 && bb.y + bb.h > 0 && bb.x < w && bb.y < h;
422
636
  }
423
637
  function applyMosaic(ctx, rect) {
424
638
  const x = Math.round(Math.max(0, rect.x));
@@ -1886,5 +2100,5 @@ function ImagePanel({ url, filename, onDownload, onEmail }) {
1886
2100
  }
1887
2101
 
1888
2102
  export { Preview, setPdfPreview };
1889
- //# sourceMappingURL=chunk-4DW5YQ7Y.js.map
1890
- //# sourceMappingURL=chunk-4DW5YQ7Y.js.map
2103
+ //# sourceMappingURL=chunk-HQJRNCTU.js.map
2104
+ //# sourceMappingURL=chunk-HQJRNCTU.js.map