react-os-shell 0.2.21 → 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,209 +8,258 @@ 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 [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]);
24
36
  useEffect(() => {
25
37
  const img = new Image();
26
38
  img.crossOrigin = "anonymous";
27
39
  img.onload = () => {
28
- const c = canvasRef.current;
29
- const o = overlayRef.current;
30
- if (!c || !o) return;
31
- c.width = img.naturalWidth;
32
- c.height = img.naturalHeight;
33
- o.width = img.naturalWidth;
34
- o.height = img.naturalHeight;
35
- const ctx = c.getContext("2d");
36
- ctx.drawImage(img, 0, 0);
37
- historyRef.current = [];
38
- setHistoryDepth(0);
39
- setImageReady(true);
40
+ imageRef.current = img;
41
+ setImageSize({ w: img.naturalWidth, h: img.naturalHeight });
42
+ setAnnotations([]);
43
+ setSelectedId(null);
40
44
  };
41
45
  img.onerror = () => toast_default.error("Failed to load image");
42
46
  img.src = src;
43
47
  }, [src]);
44
48
  useEffect(() => {
45
- if (!imageReady) return;
49
+ if (!imageSize) return;
46
50
  const update = () => {
47
- const c = canvasRef.current;
48
- if (!c) return;
49
- const rect = c.getBoundingClientRect();
50
- if (rect.width > 0) setDisplayScale(c.width / rect.width);
51
+ const wrap = wrapRef.current;
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);
56
+ if (availW === 0 || availH === 0) return;
57
+ const ratio = imageSize.w / imageSize.h;
58
+ let w = imageSize.w;
59
+ let h = imageSize.h;
60
+ if (w > availW) {
61
+ w = availW;
62
+ h = w / ratio;
63
+ }
64
+ if (h > availH) {
65
+ h = availH;
66
+ w = h * ratio;
67
+ }
68
+ setFitSize({ w, h });
51
69
  };
52
70
  update();
53
71
  const ro = new ResizeObserver(update);
54
72
  if (wrapRef.current) ro.observe(wrapRef.current);
55
73
  return () => ro.disconnect();
56
- }, [imageReady]);
57
- 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;
58
81
  const c = canvasRef.current;
59
- if (!c) return;
82
+ if (!img || !c || !imageSize) return;
83
+ c.width = imageSize.w;
84
+ c.height = imageSize.h;
60
85
  const ctx = c.getContext("2d");
61
- historyRef.current.push(ctx.getImageData(0, 0, c.width, c.height));
62
- if (historyRef.current.length > 50) historyRef.current.shift();
63
- setHistoryDepth(historyRef.current.length);
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 };
64
99
  };
65
- const undo = () => {
66
- if (historyRef.current.length === 0) return;
67
- const prev = historyRef.current.pop();
68
- setHistoryDepth(historyRef.current.length);
69
- const c = canvasRef.current;
70
- if (!c) return;
71
- c.getContext("2d").putImageData(prev, 0, 0);
72
- setPendingCrop(null);
73
- clearOverlay();
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);
74
107
  };
75
- const clearOverlay = () => {
76
- const o = overlayRef.current;
77
- if (!o) return;
78
- o.getContext("2d").clearRect(0, 0, o.width, o.height);
79
- };
80
- const evToCanvas = (e) => {
81
- const rect = e.currentTarget.getBoundingClientRect();
82
- const x = (e.clientX - rect.left) * displayScale;
83
- const y = (e.clientY - rect.top) * displayScale;
84
- return { x, y };
85
- };
86
- const dragRef = useRef(null);
87
- const drawShapePreview = (start, end) => {
88
- const o = overlayRef.current;
89
- if (!o) return;
90
- const ctx = o.getContext("2d");
91
- ctx.clearRect(0, 0, o.width, o.height);
92
- ctx.strokeStyle = color;
93
- ctx.fillStyle = color;
94
- ctx.lineWidth = stroke;
95
- ctx.lineCap = "round";
96
- ctx.lineJoin = "round";
97
- if (tool === "rect") {
98
- drawRoundedRect(ctx, start, end, stroke);
99
- } else if (tool === "circle") {
100
- drawEllipse(ctx, start, end);
101
- } else if (tool === "arrow") {
102
- drawArrow(ctx, start, end, stroke);
103
- } else if (tool === "mosaic") {
104
- ctx.fillStyle = `${color}33`;
105
- ctx.strokeStyle = color;
106
- ctx.lineWidth = 1.5;
107
- const r = normalizeRect(start, end);
108
- ctx.fillRect(r.x, r.y, r.w, r.h);
109
- ctx.strokeRect(r.x, r.y, r.w, r.h);
110
- } else if (tool === "crop") {
111
- ctx.strokeStyle = "#ffffff";
112
- ctx.lineWidth = 2;
113
- ctx.setLineDash([8, 6]);
114
- const r = normalizeRect(start, end);
115
- ctx.fillStyle = "rgba(0,0,0,0.45)";
116
- ctx.fillRect(0, 0, o.width, r.y);
117
- ctx.fillRect(0, r.y + r.h, o.width, o.height - (r.y + r.h));
118
- ctx.fillRect(0, r.y, r.x, r.h);
119
- ctx.fillRect(r.x + r.w, r.y, o.width - (r.x + r.w), r.h);
120
- ctx.strokeRect(r.x, r.y, r.w, r.h);
121
- ctx.setLineDash([]);
108
+ const handleSvgPointerDown = (e) => {
109
+ if (tool === "select") {
110
+ setSelectedId(null);
111
+ return;
122
112
  }
123
- };
124
- const onPointerDown = (e) => {
125
- if (!imageReady) return;
126
- const pos = evToCanvas(e);
127
113
  if (tool === "text") {
128
- 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);
129
122
  return;
130
123
  }
124
+ dragRef.current = { kind: "draw", start };
131
125
  e.currentTarget.setPointerCapture(e.pointerId);
132
- dragRef.current = { start: pos, current: pos };
133
- drawShapePreview(pos, pos);
126
+ setPreview(makeShape(tool, start, start, color, stroke));
134
127
  };
135
- const onPointerMove = (e) => {
136
- if (!dragRef.current) return;
137
- const pos = evToCanvas(e);
138
- dragRef.current.current = pos;
139
- 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
+ }
140
141
  };
141
- const onPointerUp = (e) => {
142
- if (!dragRef.current) return;
143
- e.currentTarget.releasePointerCapture(e.pointerId);
144
- const { start, current } = dragRef.current;
142
+ const handleSvgPointerUp = (e) => {
143
+ const drag = dragRef.current;
144
+ if (!drag) return;
145
145
  dragRef.current = null;
146
- if (Math.abs(current.x - start.x) < 2 && Math.abs(current.y - start.y) < 2) {
147
- clearOverlay();
148
- return;
149
- }
150
- if (tool === "crop") {
151
- const r = normalizeRect(start, current);
152
- setPendingCrop(r);
153
- return;
154
- }
155
- pushHistory();
156
- const c = canvasRef.current;
157
- const ctx = c.getContext("2d");
158
- ctx.strokeStyle = color;
159
- ctx.fillStyle = color;
160
- ctx.lineWidth = stroke;
161
- ctx.lineCap = "round";
162
- ctx.lineJoin = "round";
163
- if (tool === "rect") drawRoundedRect(ctx, start, current, stroke);
164
- else if (tool === "circle") drawEllipse(ctx, start, current);
165
- else if (tool === "arrow") drawArrow(ctx, start, current, stroke);
166
- else if (tool === "mosaic") applyMosaic(ctx, normalizeRect(start, current));
167
- 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") ;
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 });
168
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]);
169
178
  const commitText = () => {
170
179
  if (!pendingText) return;
171
- 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
+ }
172
185
  setPendingText(null);
173
186
  return;
174
187
  }
175
- pushHistory();
176
- const c = canvasRef.current;
177
- const ctx = c.getContext("2d");
178
188
  const fontSize = Math.max(16, stroke * 6);
179
- ctx.fillStyle = color;
180
- ctx.font = `600 ${fontSize}px -apple-system, system-ui, sans-serif`;
181
- ctx.textBaseline = "top";
182
- const lines = pendingText.value.split("\n");
183
- lines.forEach((line, i) => {
184
- ctx.fillText(line, pendingText.x, pendingText.y + i * fontSize * 1.2);
185
- });
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
+ }
186
207
  setPendingText(null);
187
208
  };
188
209
  const applyCrop = () => {
189
- if (!pendingCrop) return;
190
- const c = canvasRef.current;
191
- pushHistory();
210
+ if (!pendingCrop || !imageRef.current || !imageSize) return;
192
211
  const r = pendingCrop;
193
- const data = c.getContext("2d").getImageData(r.x, r.y, r.w, r.h);
194
- c.width = Math.round(r.w);
195
- c.height = Math.round(r.h);
196
- overlayRef.current.width = c.width;
197
- overlayRef.current.height = c.height;
198
- c.getContext("2d").putImageData(data, 0, 0);
199
- setPendingCrop(null);
200
- clearOverlay();
201
- requestAnimationFrame(() => {
202
- const rect = c.getBoundingClientRect();
203
- if (rect.width > 0) setDisplayScale(c.width / rect.width);
204
- });
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");
205
233
  };
206
- const cancelCrop = () => {
207
- setPendingCrop(null);
208
- clearOverlay();
234
+ const cancelCrop = () => setPendingCrop(null);
235
+ const undoLast = () => {
236
+ setAnnotations((prev) => prev.slice(0, -1));
237
+ setSelectedId(null);
209
238
  };
210
- const downloadAnnotated = () => {
239
+ const downloadAnnotated = async () => {
211
240
  const c = canvasRef.current;
212
- if (!c) return;
213
- 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) => {
214
263
  if (!blob) {
215
264
  toast_default.error("Failed to export");
216
265
  return;
@@ -225,6 +274,7 @@ function ImageAnnotator({ src, filename, onClose }) {
225
274
  }, "image/png");
226
275
  };
227
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" }) }) },
228
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" }) }) },
229
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" }) }) },
230
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" }) }) },
@@ -233,12 +283,16 @@ function ImageAnnotator({ src, filename, onClose }) {
233
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" }) }) }
234
284
  ], []);
235
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;
236
287
  return /* @__PURE__ */ jsxs("div", { className: "flex flex-col h-full bg-gray-100", children: [
237
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: [
238
289
  tools.map((t) => /* @__PURE__ */ jsx(
239
290
  "button",
240
291
  {
241
- onClick: () => setTool(t.id),
292
+ onClick: () => {
293
+ setTool(t.id);
294
+ setSelectedId(null);
295
+ },
242
296
  title: t.label,
243
297
  className: btnClass(tool === t.id),
244
298
  children: t.icon
@@ -249,7 +303,12 @@ function ImageAnnotator({ src, filename, onClose }) {
249
303
  /* @__PURE__ */ jsx("div", { className: "flex items-center gap-1", children: COLORS.map((c) => /* @__PURE__ */ jsx(
250
304
  "button",
251
305
  {
252
- 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
+ },
253
312
  title: c,
254
313
  className: `h-5 w-5 rounded-full border ${color === c ? "ring-2 ring-blue-500 ring-offset-1" : "border-gray-300"}`,
255
314
  style: { background: c }
@@ -274,7 +333,15 @@ function ImageAnnotator({ src, filename, onClose }) {
274
333
  /* @__PURE__ */ jsx("span", { className: "tabular-nums w-4 text-right", children: stroke })
275
334
  ] }),
276
335
  /* @__PURE__ */ jsx("div", { className: "h-5 w-px bg-gray-300 mx-1" }),
277
- /* @__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" }),
278
345
  /* @__PURE__ */ jsx("button", { onClick: downloadAnnotated, className: "px-2 py-1 text-xs rounded hover:bg-gray-200 text-gray-700", children: "Save" }),
279
346
  /* @__PURE__ */ jsxs("div", { className: "ml-auto flex items-center gap-2", children: [
280
347
  pendingCrop && /* @__PURE__ */ jsxs(Fragment, { children: [
@@ -284,73 +351,278 @@ function ImageAnnotator({ src, filename, onClose }) {
284
351
  /* @__PURE__ */ jsx("button", { onClick: onClose, className: "px-2 py-1 text-xs rounded hover:bg-gray-200 text-gray-700", children: "Exit" })
285
352
  ] })
286
353
  ] }),
287
- /* @__PURE__ */ jsx("div", { ref: wrapRef, className: "flex-1 overflow-auto bg-gray-200 flex items-center justify-center p-4 relative", children: /* @__PURE__ */ jsxs("div", { className: "relative inline-block max-w-full max-h-full", children: [
288
- /* @__PURE__ */ jsx(
289
- "canvas",
290
- {
291
- ref: canvasRef,
292
- onPointerDown,
293
- onPointerMove,
294
- onPointerUp,
295
- onPointerCancel: () => {
296
- dragRef.current = null;
297
- clearOverlay();
298
- },
299
- style: { touchAction: "none", maxWidth: "100%", maxHeight: "100%", display: "block", cursor: tool === "text" ? "text" : "crosshair" },
300
- className: "shadow-lg rounded bg-white"
301
- }
302
- ),
303
- /* @__PURE__ */ jsx(
304
- "canvas",
305
- {
306
- ref: overlayRef,
307
- style: {
308
- position: "absolute",
309
- inset: 0,
310
- pointerEvents: "none",
311
- maxWidth: "100%",
312
- maxHeight: "100%",
313
- width: "100%",
314
- height: "100%"
315
- }
316
- }
317
- ),
318
- pendingText && /* @__PURE__ */ jsx(
319
- "div",
320
- {
321
- style: {
322
- position: "absolute",
323
- left: `${pendingText.x / displayScale}px`,
324
- top: `${pendingText.y / displayScale}px`,
325
- transform: "translateY(-2px)"
326
- },
327
- className: "z-10",
328
- children: /* @__PURE__ */ jsx(
329
- "textarea",
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(
355
+ "div",
356
+ {
357
+ className: "relative shadow-lg rounded overflow-hidden bg-white shrink-0",
358
+ style: { width: displaySize.w, height: displaySize.h },
359
+ children: [
360
+ /* @__PURE__ */ jsx(
361
+ "canvas",
330
362
  {
331
- autoFocus: true,
332
- value: pendingText.value,
333
- onChange: (e) => setPendingText({ ...pendingText, value: e.target.value }),
334
- onBlur: commitText,
335
- onKeyDown: (e) => {
336
- if (e.key === "Escape") {
337
- setPendingText(null);
338
- } else if (e.key === "Enter" && !e.shiftKey) {
339
- e.preventDefault();
340
- commitText();
341
- }
363
+ ref: canvasRef,
364
+ style: { position: "absolute", inset: 0, width: "100%", height: "100%", display: "block" }
365
+ }
366
+ ),
367
+ /* @__PURE__ */ jsxs(
368
+ "svg",
369
+ {
370
+ ref: svgRef,
371
+ viewBox: `0 0 ${imageSize.w} ${imageSize.h}`,
372
+ preserveAspectRatio: "none",
373
+ style: {
374
+ position: "absolute",
375
+ inset: 0,
376
+ width: "100%",
377
+ height: "100%",
378
+ touchAction: "none",
379
+ cursor: tool === "select" ? "default" : tool === "text" ? "text" : "crosshair"
342
380
  },
343
- placeholder: "Type then Enter\u2026",
344
- rows: 1,
345
- className: "bg-white/95 border border-blue-400 rounded px-1 py-0.5 text-sm outline-none resize-none shadow-md",
346
- style: { color, fontWeight: 600, minWidth: 80 }
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
+ ]
402
+ }
403
+ ),
404
+ pendingText && /* @__PURE__ */ jsx(
405
+ "div",
406
+ {
407
+ style: {
408
+ position: "absolute",
409
+ left: `${pendingText.x * scale}px`,
410
+ top: `${pendingText.y * scale}px`,
411
+ transform: "translateY(-2px)",
412
+ zIndex: 5
413
+ },
414
+ children: /* @__PURE__ */ jsx(
415
+ "textarea",
416
+ {
417
+ autoFocus: true,
418
+ value: pendingText.value,
419
+ onChange: (e) => setPendingText({ ...pendingText, value: e.target.value }),
420
+ onBlur: commitText,
421
+ onKeyDown: (e) => {
422
+ if (e.key === "Escape") {
423
+ setPendingText(null);
424
+ } else if (e.key === "Enter" && !e.shiftKey) {
425
+ e.preventDefault();
426
+ commitText();
427
+ }
428
+ },
429
+ placeholder: "Type then Enter\u2026",
430
+ rows: 1,
431
+ className: "bg-white/95 border border-blue-400 rounded px-1 py-0.5 text-sm outline-none resize-none shadow-md",
432
+ style: { color, fontWeight: 600, minWidth: 80 }
433
+ }
434
+ )
347
435
  }
348
436
  )
349
- }
350
- )
351
- ] }) })
437
+ ]
438
+ }
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
+ )
352
532
  ] });
353
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 })
554
+ ] });
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
+ }
354
626
  function normalizeRect(a, b) {
355
627
  const x = Math.min(a.x, b.x);
356
628
  const y = Math.min(a.y, b.y);
@@ -358,44 +630,9 @@ function normalizeRect(a, b) {
358
630
  const h = Math.abs(a.y - b.y);
359
631
  return { x, y, w, h };
360
632
  }
361
- function drawRoundedRect(ctx, start, end, stroke) {
362
- const r = normalizeRect(start, end);
363
- const radius = Math.min(r.w, r.h, 16) * 0.4;
364
- ctx.beginPath();
365
- if (ctx.roundRect) {
366
- ctx.roundRect(r.x, r.y, r.w, r.h, radius);
367
- } else {
368
- ctx.moveTo(r.x + radius, r.y);
369
- ctx.arcTo(r.x + r.w, r.y, r.x + r.w, r.y + r.h, radius);
370
- ctx.arcTo(r.x + r.w, r.y + r.h, r.x, r.y + r.h, radius);
371
- ctx.arcTo(r.x, r.y + r.h, r.x, r.y, radius);
372
- ctx.arcTo(r.x, r.y, r.x + r.w, r.y, radius);
373
- ctx.closePath();
374
- }
375
- ctx.lineWidth = stroke;
376
- ctx.stroke();
377
- }
378
- function drawEllipse(ctx, start, end) {
379
- const r = normalizeRect(start, end);
380
- ctx.beginPath();
381
- ctx.ellipse(r.x + r.w / 2, r.y + r.h / 2, r.w / 2, r.h / 2, 0, 0, Math.PI * 2);
382
- ctx.stroke();
383
- }
384
- function drawArrow(ctx, start, end, stroke) {
385
- ctx.beginPath();
386
- ctx.moveTo(start.x, start.y);
387
- ctx.lineTo(end.x, end.y);
388
- ctx.stroke();
389
- const headLen = Math.max(12, stroke * 4);
390
- const angle = Math.atan2(end.y - start.y, end.x - start.x);
391
- const a1 = angle + Math.PI - Math.PI / 7;
392
- const a2 = angle + Math.PI + Math.PI / 7;
393
- ctx.beginPath();
394
- ctx.moveTo(end.x, end.y);
395
- ctx.lineTo(end.x + headLen * Math.cos(a1), end.y + headLen * Math.sin(a1));
396
- ctx.lineTo(end.x + headLen * Math.cos(a2), end.y + headLen * Math.sin(a2));
397
- ctx.closePath();
398
- 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;
399
636
  }
400
637
  function applyMosaic(ctx, rect) {
401
638
  const x = Math.round(Math.max(0, rect.x));
@@ -1863,5 +2100,5 @@ function ImagePanel({ url, filename, onDownload, onEmail }) {
1863
2100
  }
1864
2101
 
1865
2102
  export { Preview, setPdfPreview };
1866
- //# sourceMappingURL=chunk-YLXY6AZK.js.map
1867
- //# sourceMappingURL=chunk-YLXY6AZK.js.map
2103
+ //# sourceMappingURL=chunk-HQJRNCTU.js.map
2104
+ //# sourceMappingURL=chunk-HQJRNCTU.js.map