annotate-image 2.0.0-beta.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/dist/vue.js ADDED
@@ -0,0 +1,810 @@
1
+ // src/vue.ts
2
+ import { defineComponent, ref, shallowRef, computed, onMounted, onBeforeUnmount, watch, h } from "vue";
3
+
4
+ // src/annotate-edit.ts
5
+ var DEFAULT_NOTE_TOP = 30;
6
+ var DEFAULT_NOTE_LEFT = 30;
7
+ var DEFAULT_NOTE_WIDTH = 30;
8
+ var DEFAULT_NOTE_HEIGHT = 30;
9
+ var AnnotateEdit = class {
10
+ /**
11
+ * @param image - The parent AnnotateImage controller.
12
+ * @param note - Existing note to edit, or omit to create a new annotation.
13
+ * @param existingView - The view being edited (for updates); omit for new annotations.
14
+ */
15
+ constructor(image, note, existingView) {
16
+ this.busy = false;
17
+ this.image = image;
18
+ this.handlers = image.handlers;
19
+ if (note) {
20
+ this.note = note;
21
+ } else {
22
+ this.note = {
23
+ id: "new",
24
+ top: DEFAULT_NOTE_TOP,
25
+ left: DEFAULT_NOTE_LEFT,
26
+ width: DEFAULT_NOTE_WIDTH,
27
+ height: DEFAULT_NOTE_HEIGHT,
28
+ text: "",
29
+ editable: true
30
+ };
31
+ }
32
+ this.area = image.editOverlay.querySelector(".image-annotate-edit-area");
33
+ this.area.style.height = this.note.height + "px";
34
+ this.area.style.width = this.note.width + "px";
35
+ this.area.style.left = this.note.left + "px";
36
+ this.area.style.top = this.note.top + "px";
37
+ this.form = document.createElement("div");
38
+ this.form.className = "image-annotate-edit-form";
39
+ const formEl = document.createElement("form");
40
+ this.textarea = document.createElement("textarea");
41
+ this.textarea.name = "text";
42
+ this.textarea.rows = 3;
43
+ this.textarea.cols = 30;
44
+ this.textarea.value = this.note.text;
45
+ const placeholder = this.image.options.labels?.placeholder ?? "";
46
+ if (placeholder) {
47
+ this.textarea.placeholder = placeholder;
48
+ }
49
+ formEl.appendChild(this.textarea);
50
+ this.form.appendChild(formEl);
51
+ this.area.appendChild(this.form);
52
+ this.form.addEventListener("pointerdown", (e) => e.stopPropagation());
53
+ const area = this.area;
54
+ const applyRect = (rect) => {
55
+ area.style.left = rect.left + "px";
56
+ area.style.top = rect.top + "px";
57
+ area.style.width = rect.width + "px";
58
+ area.style.height = rect.height + "px";
59
+ };
60
+ this.handlers.makeResizable(area, {
61
+ containment: image.canvas,
62
+ onResize: applyRect,
63
+ onStop: applyRect
64
+ });
65
+ this.handlers.makeDraggable(area, {
66
+ containment: image.canvas,
67
+ onDrag: (pos) => {
68
+ area.style.left = pos.left + "px";
69
+ area.style.top = pos.top + "px";
70
+ },
71
+ onStop: (pos) => {
72
+ area.style.left = pos.left + "px";
73
+ area.style.top = pos.top + "px";
74
+ }
75
+ });
76
+ this.textarea.focus();
77
+ this.form.addEventListener("keydown", (e) => {
78
+ if (e.key === "Escape") {
79
+ const cancelBtn = this.form.querySelector(".image-annotate-edit-close");
80
+ if (cancelBtn) cancelBtn.click();
81
+ }
82
+ });
83
+ const buttonRow = document.createElement("div");
84
+ buttonRow.className = "image-annotate-edit-buttons";
85
+ this.form.appendChild(buttonRow);
86
+ this.addSaveButton(buttonRow, existingView);
87
+ if (existingView) {
88
+ this.addDeleteButton(buttonRow, existingView);
89
+ }
90
+ this.addCancelButton(buttonRow);
91
+ }
92
+ /** Tear down the edit form and interaction handlers. */
93
+ destroy() {
94
+ this.image.activeEdit = null;
95
+ this.handlers.destroyResizable(this.area);
96
+ this.handlers.destroyDraggable(this.area);
97
+ this.area.style.height = "";
98
+ this.area.style.width = "";
99
+ this.area.style.left = "";
100
+ this.area.style.top = "";
101
+ this.form.remove();
102
+ }
103
+ addSaveButton(container, existingView) {
104
+ const ok = document.createElement("button");
105
+ ok.className = "image-annotate-edit-ok";
106
+ ok.textContent = this.image.options.labels?.save ?? "OK";
107
+ ok.type = "button";
108
+ ok.addEventListener("click", () => {
109
+ if (this.busy) return;
110
+ const text = this.textarea.value;
111
+ const commitSave = () => {
112
+ this.image.setMode("view");
113
+ if (existingView) {
114
+ existingView.resetPosition(this, text);
115
+ } else {
116
+ this.note.editable = true;
117
+ const view = new AnnotateView(this.image, this.note);
118
+ view.resetPosition(this, text);
119
+ this.image.notes.push(this.note);
120
+ }
121
+ this.image.notifySave(stripInternals(this.note));
122
+ this.destroy();
123
+ };
124
+ const pos = readInlinePosition(this.area);
125
+ const size = readInlineSize(this.area);
126
+ this.note.top = pos.top;
127
+ this.note.left = pos.left;
128
+ this.note.width = size.width;
129
+ this.note.height = size.height;
130
+ this.note.text = text;
131
+ if (this.image.api.save) {
132
+ this.busy = true;
133
+ this.image.api.save(stripInternals(this.note)).then((result) => {
134
+ if (result.annotation_id != null) {
135
+ this.note.id = result.annotation_id;
136
+ }
137
+ commitSave();
138
+ }).catch((err) => {
139
+ this.busy = false;
140
+ const error = err instanceof Error ? err : new Error(String(err));
141
+ this.image.reportError({ type: "save", error, note: this.note });
142
+ });
143
+ } else {
144
+ commitSave();
145
+ }
146
+ });
147
+ container.appendChild(ok);
148
+ }
149
+ addDeleteButton(container, view) {
150
+ const del = document.createElement("button");
151
+ del.className = "image-annotate-edit-delete";
152
+ del.textContent = this.image.options.labels?.delete ?? "Delete";
153
+ del.type = "button";
154
+ del.addEventListener("click", () => {
155
+ if (this.busy) return;
156
+ const removeNote = () => {
157
+ this.image.setMode("view");
158
+ this.destroy();
159
+ view.destroy();
160
+ const idx = this.image.notes.indexOf(this.note);
161
+ if (idx !== -1) this.image.notes.splice(idx, 1);
162
+ this.image.notifyDelete(stripInternals(this.note));
163
+ };
164
+ if (this.image.api.delete) {
165
+ this.busy = true;
166
+ this.image.api.delete(stripInternals(this.note)).then(() => {
167
+ removeNote();
168
+ }).catch((err) => {
169
+ this.busy = false;
170
+ const error = err instanceof Error ? err : new Error(String(err));
171
+ this.image.reportError({ type: "delete", error, note: this.note });
172
+ });
173
+ } else {
174
+ removeNote();
175
+ }
176
+ });
177
+ container.appendChild(del);
178
+ }
179
+ addCancelButton(container) {
180
+ const cancel = document.createElement("button");
181
+ cancel.className = "image-annotate-edit-close";
182
+ cancel.textContent = this.image.options.labels?.cancel ?? "Cancel";
183
+ cancel.type = "button";
184
+ cancel.addEventListener("click", () => {
185
+ this.image.cancelEdit();
186
+ });
187
+ container.appendChild(cancel);
188
+ }
189
+ };
190
+
191
+ // src/annotate-view.ts
192
+ function readInlinePosition(el) {
193
+ return {
194
+ left: parseInt(el.style.left) || 0,
195
+ top: parseInt(el.style.top) || 0
196
+ };
197
+ }
198
+ function readInlineSize(el) {
199
+ return {
200
+ width: parseInt(el.style.width) || el.offsetWidth,
201
+ height: parseInt(el.style.height) || el.offsetHeight
202
+ };
203
+ }
204
+ var AnnotateView = class {
205
+ /**
206
+ * @param image - The parent AnnotateImage controller.
207
+ * @param note - Annotation data to display.
208
+ */
209
+ constructor(image, note) {
210
+ this.image = image;
211
+ this.note = note;
212
+ this.editable = !!(note.editable && image.options.editable);
213
+ this.area = document.createElement("div");
214
+ this.area.className = "image-annotate-area" + (this.editable ? " image-annotate-area-editable" : "");
215
+ const innerDiv = document.createElement("div");
216
+ this.area.appendChild(innerDiv);
217
+ image.viewOverlay.insertBefore(this.area, image.viewOverlay.firstChild);
218
+ this.tooltip = document.createElement("div");
219
+ this.tooltip.className = "image-annotate-note";
220
+ this.tooltip.textContent = note.text;
221
+ this.tooltip.style.display = "none";
222
+ this.area.appendChild(this.tooltip);
223
+ this.setPosition();
224
+ this.area.addEventListener("mouseenter", () => this.show());
225
+ this.area.addEventListener("mouseleave", () => this.hide());
226
+ if (this.editable) {
227
+ this.area.setAttribute("tabindex", "0");
228
+ this.area.setAttribute("role", "button");
229
+ this.area.addEventListener("click", () => this.edit());
230
+ this.area.addEventListener("keydown", (e) => {
231
+ if (e.key === "Enter" || e.key === " ") {
232
+ e.preventDefault();
233
+ this.edit();
234
+ }
235
+ });
236
+ }
237
+ }
238
+ /** Apply the note's position and dimensions to the area element. */
239
+ setPosition() {
240
+ const innerDiv = this.area.firstElementChild;
241
+ innerDiv.style.height = this.note.height + "px";
242
+ innerDiv.style.width = this.note.width + "px";
243
+ this.area.style.left = this.note.left + "px";
244
+ this.area.style.top = this.note.top + "px";
245
+ }
246
+ /** Update the view's position, size, and text from the edit area after a save. */
247
+ resetPosition(editable, text) {
248
+ this.tooltip.textContent = text;
249
+ this.tooltip.style.display = "none";
250
+ const areaPos = readInlinePosition(editable.area);
251
+ const areaSize = readInlineSize(editable.area);
252
+ const innerDiv = this.area.firstElementChild;
253
+ innerDiv.style.height = areaSize.height + "px";
254
+ innerDiv.style.width = areaSize.width + "px";
255
+ this.area.style.left = areaPos.left + "px";
256
+ this.area.style.top = areaPos.top + "px";
257
+ this.note.top = areaPos.top;
258
+ this.note.left = areaPos.left;
259
+ this.note.height = areaSize.height;
260
+ this.note.width = areaSize.width;
261
+ this.note.text = text;
262
+ this.note.id = editable.note.id;
263
+ this.editable = true;
264
+ }
265
+ /** Show the tooltip and apply hover styling. */
266
+ show() {
267
+ this.tooltip.style.display = "block";
268
+ if (!this.editable) {
269
+ this.area.classList.add("image-annotate-area-hover");
270
+ } else {
271
+ this.area.classList.add("image-annotate-area-editable-hover");
272
+ }
273
+ }
274
+ /** Hide the tooltip and remove hover styling. */
275
+ hide() {
276
+ this.tooltip.style.display = "none";
277
+ this.area.classList.remove("image-annotate-area-hover");
278
+ this.area.classList.remove("image-annotate-area-editable-hover");
279
+ }
280
+ /** Remove the annotation's DOM elements. */
281
+ destroy() {
282
+ this.area.remove();
283
+ this.tooltip.remove();
284
+ }
285
+ /** Open the edit form for this annotation (only if in view mode). */
286
+ edit() {
287
+ if (this.image.mode === "view") {
288
+ this.image.setMode("edit");
289
+ this.image.activeEdit = new AnnotateEdit(this.image, this.note, this);
290
+ }
291
+ }
292
+ };
293
+
294
+ // src/interactions.ts
295
+ var dragCleanups = /* @__PURE__ */ new WeakMap();
296
+ var resizeCleanups = /* @__PURE__ */ new WeakMap();
297
+ function makeDraggable(el, opts) {
298
+ destroyDraggable(el);
299
+ function onPointerDown(e) {
300
+ if (e.button !== 0) return;
301
+ e.preventDefault();
302
+ if (el.setPointerCapture) el.setPointerCapture(e.pointerId);
303
+ const startX = e.clientX;
304
+ const startY = e.clientY;
305
+ const startLeft = parseFloat(el.style.left) || 0;
306
+ const startTop = parseFloat(el.style.top) || 0;
307
+ const elWidth = parseFloat(el.style.width) || el.offsetWidth;
308
+ const elHeight = parseFloat(el.style.height) || el.offsetHeight;
309
+ let minLeft = -Infinity;
310
+ let minTop = -Infinity;
311
+ let maxLeft = Infinity;
312
+ let maxTop = Infinity;
313
+ if (opts.containment) {
314
+ const containerRect = opts.containment.getBoundingClientRect();
315
+ const elRect = el.getBoundingClientRect();
316
+ const offsetX = startLeft - (elRect.left - containerRect.left);
317
+ const offsetY = startTop - (elRect.top - containerRect.top);
318
+ minLeft = offsetX;
319
+ minTop = offsetY;
320
+ maxLeft = containerRect.width - elWidth + offsetX;
321
+ maxTop = containerRect.height - elHeight + offsetY;
322
+ }
323
+ function clamp(value, min, max) {
324
+ return Math.max(min, Math.min(max, value));
325
+ }
326
+ function onPointerMove(e2) {
327
+ const dx = e2.clientX - startX;
328
+ const dy = e2.clientY - startY;
329
+ const newLeft = clamp(startLeft + dx, minLeft, maxLeft);
330
+ const newTop = clamp(startTop + dy, minTop, maxTop);
331
+ if (opts.onDrag) {
332
+ opts.onDrag({ left: newLeft, top: newTop });
333
+ }
334
+ }
335
+ function onPointerUp(e2) {
336
+ if (el.releasePointerCapture) el.releasePointerCapture(e2.pointerId);
337
+ el.removeEventListener("pointermove", onPointerMove);
338
+ el.removeEventListener("pointerup", onPointerUp);
339
+ const dx = e2.clientX - startX;
340
+ const dy = e2.clientY - startY;
341
+ const finalLeft = clamp(startLeft + dx, minLeft, maxLeft);
342
+ const finalTop = clamp(startTop + dy, minTop, maxTop);
343
+ if (opts.onStop) {
344
+ opts.onStop({ left: finalLeft, top: finalTop });
345
+ }
346
+ }
347
+ el.addEventListener("pointermove", onPointerMove);
348
+ el.addEventListener("pointerup", onPointerUp);
349
+ }
350
+ el.addEventListener("pointerdown", onPointerDown);
351
+ dragCleanups.set(el, () => {
352
+ el.removeEventListener("pointerdown", onPointerDown);
353
+ });
354
+ }
355
+ function destroyDraggable(el) {
356
+ const cleanup = dragCleanups.get(el);
357
+ if (cleanup) {
358
+ cleanup();
359
+ dragCleanups.delete(el);
360
+ }
361
+ }
362
+ var MIN_SIZE = 10;
363
+ var CORNERS = ["nw", "ne", "sw", "se"];
364
+ function computeResize(corner, startLeft, startTop, startWidth, startHeight, dx, dy) {
365
+ let left = startLeft, top = startTop, width = startWidth, height = startHeight;
366
+ if (corner === "nw" || corner === "sw") {
367
+ left = startLeft + dx;
368
+ width = startWidth - dx;
369
+ } else {
370
+ width = startWidth + dx;
371
+ }
372
+ if (corner === "nw" || corner === "ne") {
373
+ top = startTop + dy;
374
+ height = startHeight - dy;
375
+ } else {
376
+ height = startHeight + dy;
377
+ }
378
+ if (width < MIN_SIZE) {
379
+ if (corner === "nw" || corner === "sw") left = startLeft + startWidth - MIN_SIZE;
380
+ width = MIN_SIZE;
381
+ }
382
+ if (height < MIN_SIZE) {
383
+ if (corner === "nw" || corner === "ne") top = startTop + startHeight - MIN_SIZE;
384
+ height = MIN_SIZE;
385
+ }
386
+ return { left, top, width, height };
387
+ }
388
+ function makeResizable(el, opts) {
389
+ destroyResizable(el);
390
+ const handles = [];
391
+ for (const corner of CORNERS) {
392
+ const handle = document.createElement("div");
393
+ handle.className = `image-annotate-resize-handle image-annotate-resize-handle-${corner}`;
394
+ el.appendChild(handle);
395
+ handles.push(handle);
396
+ handle.addEventListener("pointerdown", function onPointerDown(e) {
397
+ if (e.button !== 0) return;
398
+ e.preventDefault();
399
+ e.stopPropagation();
400
+ if (handle.setPointerCapture) handle.setPointerCapture(e.pointerId);
401
+ const startX = e.clientX;
402
+ const startY = e.clientY;
403
+ const startLeft = parseFloat(el.style.left) || 0;
404
+ const startTop = parseFloat(el.style.top) || 0;
405
+ const startWidth = parseFloat(el.style.width) || 0;
406
+ const startHeight = parseFloat(el.style.height) || 0;
407
+ let maxRight = Infinity;
408
+ let maxBottom = Infinity;
409
+ let minLeft = -Infinity;
410
+ let minTop = -Infinity;
411
+ if (opts.containment) {
412
+ const containerRect = opts.containment.getBoundingClientRect();
413
+ const elRect = el.getBoundingClientRect();
414
+ const offsetX = startLeft - (elRect.left - containerRect.left);
415
+ const offsetY = startTop - (elRect.top - containerRect.top);
416
+ minLeft = offsetX;
417
+ minTop = offsetY;
418
+ maxRight = containerRect.width + offsetX;
419
+ maxBottom = containerRect.height + offsetY;
420
+ }
421
+ function clampRect(rect) {
422
+ let { left, top, width, height } = rect;
423
+ if (left < minLeft) {
424
+ width -= minLeft - left;
425
+ left = minLeft;
426
+ }
427
+ if (top < minTop) {
428
+ height -= minTop - top;
429
+ top = minTop;
430
+ }
431
+ if (left + width > maxRight) {
432
+ width = maxRight - left;
433
+ }
434
+ if (top + height > maxBottom) {
435
+ height = maxBottom - top;
436
+ }
437
+ if (width < MIN_SIZE) width = MIN_SIZE;
438
+ if (height < MIN_SIZE) height = MIN_SIZE;
439
+ return { left, top, width, height };
440
+ }
441
+ function onPointerMove(e2) {
442
+ const dx = e2.clientX - startX;
443
+ const dy = e2.clientY - startY;
444
+ const rect = clampRect(computeResize(corner, startLeft, startTop, startWidth, startHeight, dx, dy));
445
+ opts.onResize?.(rect);
446
+ }
447
+ function onPointerUp(e2) {
448
+ if (handle.releasePointerCapture) handle.releasePointerCapture(e2.pointerId);
449
+ handle.removeEventListener("pointermove", onPointerMove);
450
+ handle.removeEventListener("pointerup", onPointerUp);
451
+ const dx = e2.clientX - startX;
452
+ const dy = e2.clientY - startY;
453
+ const rect = clampRect(computeResize(corner, startLeft, startTop, startWidth, startHeight, dx, dy));
454
+ if (opts.onStop) {
455
+ opts.onStop(rect);
456
+ }
457
+ }
458
+ handle.addEventListener("pointermove", onPointerMove);
459
+ handle.addEventListener("pointerup", onPointerUp);
460
+ });
461
+ }
462
+ resizeCleanups.set(el, () => {
463
+ for (const handle of handles) {
464
+ handle.remove();
465
+ }
466
+ });
467
+ }
468
+ function destroyResizable(el) {
469
+ const cleanup = resizeCleanups.get(el);
470
+ if (cleanup) {
471
+ cleanup();
472
+ resizeCleanups.delete(el);
473
+ }
474
+ }
475
+ function createDefaultHandlers() {
476
+ return {
477
+ makeDraggable,
478
+ makeResizable,
479
+ destroyDraggable,
480
+ destroyResizable
481
+ };
482
+ }
483
+
484
+ // src/annotate-image.ts
485
+ function stripInternals(note) {
486
+ const { view: _view, editable: _editable, ...data } = note;
487
+ return data;
488
+ }
489
+ function normalizeApi(api) {
490
+ return {
491
+ load: typeof api.load === "string" ? defaultLoader(api.load) : api.load,
492
+ save: typeof api.save === "string" ? defaultSaver(api.save) : api.save,
493
+ delete: typeof api.delete === "string" ? defaultDeleter(api.delete) : api.delete
494
+ };
495
+ }
496
+ function defaultLoader(url) {
497
+ return () => fetch(url).then((r) => {
498
+ if (!r.ok) throw new Error(`Load failed (HTTP ${r.status})`);
499
+ return r.json();
500
+ });
501
+ }
502
+ function defaultSaver(url) {
503
+ return (note) => fetch(url, {
504
+ method: "POST",
505
+ headers: { "Content-Type": "application/json" },
506
+ body: JSON.stringify(note)
507
+ }).then((r) => {
508
+ if (!r.ok) throw new Error(`Save failed (HTTP ${r.status})`);
509
+ return r.json();
510
+ });
511
+ }
512
+ function defaultDeleter(url) {
513
+ return (note) => fetch(url, {
514
+ method: "POST",
515
+ headers: { "Content-Type": "application/json" },
516
+ body: JSON.stringify(note)
517
+ }).then((r) => {
518
+ if (!r.ok) throw new Error(`Delete failed (HTTP ${r.status})`);
519
+ });
520
+ }
521
+ var AnnotateImage = class {
522
+ /**
523
+ * @param img - Image element to annotate. Must be in the DOM with non-zero dimensions.
524
+ * @param options - Plugin configuration.
525
+ */
526
+ constructor(img, options) {
527
+ this._mode = "view";
528
+ this.activeEdit = null;
529
+ this.destroyed = false;
530
+ this.options = options;
531
+ this.handlers = createDefaultHandlers();
532
+ this.img = img;
533
+ const width = img.width;
534
+ const height = img.height;
535
+ if (width === 0 || height === 0) {
536
+ throw new Error("image-annotate: image must have non-zero dimensions (is the image loaded?)");
537
+ }
538
+ this.notes = options.notes.map((n) => ({ ...n }));
539
+ this.canvas = document.createElement("div");
540
+ this.canvas.className = "image-annotate-canvas";
541
+ this.viewOverlay = document.createElement("div");
542
+ this.viewOverlay.className = "image-annotate-view";
543
+ this.editOverlay = document.createElement("div");
544
+ this.editOverlay.className = "image-annotate-edit";
545
+ this.editOverlay.style.display = "none";
546
+ const editArea = document.createElement("div");
547
+ editArea.className = "image-annotate-edit-area";
548
+ this.editOverlay.appendChild(editArea);
549
+ this.canvas.appendChild(this.viewOverlay);
550
+ this.canvas.appendChild(this.editOverlay);
551
+ if (!img.parentNode) {
552
+ throw new Error("image-annotate: image must be in the DOM before initialization");
553
+ }
554
+ img.parentNode.insertBefore(this.canvas, img.nextSibling);
555
+ this.canvas.style.height = height + "px";
556
+ this.canvas.style.width = width + "px";
557
+ this.canvas.style.backgroundImage = 'url("' + img.src + '")';
558
+ this.viewOverlay.style.height = height + "px";
559
+ this.viewOverlay.style.width = width + "px";
560
+ this.editOverlay.style.height = height + "px";
561
+ this.editOverlay.style.width = width + "px";
562
+ this.api = this.options.api ? normalizeApi(this.options.api) : {};
563
+ if (this.api.load) {
564
+ this.loadFromApi();
565
+ } else {
566
+ this.load();
567
+ }
568
+ if (this.options.editable) {
569
+ this.createButton();
570
+ }
571
+ img.style.display = "none";
572
+ }
573
+ /** Current interaction mode — 'view' for browsing, 'edit' when an annotation is being created or modified. */
574
+ get mode() {
575
+ return this._mode;
576
+ }
577
+ /** Switch between view and edit mode, toggling overlay visibility. */
578
+ setMode(newMode) {
579
+ this._mode = newMode;
580
+ if (newMode === "edit") {
581
+ this.canvas.classList.add("image-annotate-editing");
582
+ this.editOverlay.style.display = "block";
583
+ } else {
584
+ this.canvas.classList.remove("image-annotate-editing");
585
+ this.editOverlay.style.display = "none";
586
+ }
587
+ }
588
+ /** Return current notes with internal fields stripped. */
589
+ getNotes() {
590
+ return this.notes.map(stripInternals);
591
+ }
592
+ /** @internal Notify that the notes collection changed. */
593
+ notifyChange() {
594
+ this.options.onChange?.(this.getNotes());
595
+ }
596
+ /** @internal Notify that a note was saved, then fire onChange. */
597
+ notifySave(note) {
598
+ this.options.onSave?.(note);
599
+ this.notifyChange();
600
+ }
601
+ /** @internal Notify that a note was deleted, then fire onChange. */
602
+ notifyDelete(note) {
603
+ this.options.onDelete?.(note);
604
+ this.notifyChange();
605
+ }
606
+ /** @internal Notify that notes were loaded, then fire onChange. */
607
+ notifyLoad() {
608
+ this.options.onLoad?.(this.getNotes());
609
+ this.notifyChange();
610
+ }
611
+ destroyViews() {
612
+ this.cancelEdit();
613
+ for (const note of this.notes) {
614
+ note.view?.destroy();
615
+ }
616
+ }
617
+ createViews() {
618
+ for (const note of this.notes) {
619
+ note.view = new AnnotateView(this, note);
620
+ }
621
+ }
622
+ /** Rebuild annotation views from the current notes array. */
623
+ load() {
624
+ this.destroyViews();
625
+ this.createViews();
626
+ this.notifyLoad();
627
+ }
628
+ /** Remove all annotations and their views. */
629
+ clear() {
630
+ this.destroyViews();
631
+ this.notes = [];
632
+ this.notifyChange();
633
+ }
634
+ /** Tear down the plugin: remove canvas, restore the original image. Idempotent. */
635
+ destroy() {
636
+ if (this.destroyed) return;
637
+ this.destroyed = true;
638
+ this.destroyViews();
639
+ this.notes = [];
640
+ if (this.button) {
641
+ this.button.remove();
642
+ }
643
+ this.canvas.remove();
644
+ this.img.style.display = "";
645
+ }
646
+ /** Cancel the active edit (if any) and return to view mode. */
647
+ cancelEdit() {
648
+ if (this.activeEdit) {
649
+ this.activeEdit.destroy();
650
+ this.setMode("view");
651
+ }
652
+ }
653
+ /** Replace all annotations with new data. Does not fire lifecycle callbacks. */
654
+ setNotes(notes) {
655
+ if (this.destroyed) return;
656
+ this.destroyViews();
657
+ this.notes = notes.map((n) => ({ ...n }));
658
+ this.createViews();
659
+ }
660
+ /** Toggle editing mode. Creates or removes Add Note button and rebuilds views. Does not fire lifecycle callbacks. */
661
+ setEditable(editable) {
662
+ if (this.destroyed) return;
663
+ if (this.options.editable === editable) return;
664
+ this.options.editable = editable;
665
+ if (editable && !this.button) {
666
+ this.createButton();
667
+ } else if (!editable && this.button) {
668
+ this.button.remove();
669
+ this.button = void 0;
670
+ }
671
+ this.destroyViews();
672
+ this.createViews();
673
+ }
674
+ createButton() {
675
+ this.button = document.createElement("button");
676
+ this.button.className = "image-annotate-add";
677
+ this.button.title = this.options.labels?.addNote ?? "Add Note";
678
+ this.button.type = "button";
679
+ this.button.addEventListener("click", () => {
680
+ this.add();
681
+ });
682
+ this.canvas.appendChild(this.button);
683
+ }
684
+ /** Report an API error via the onError callback, or log to console if none configured. */
685
+ reportError(context) {
686
+ if (this.options.onError) {
687
+ this.options.onError(context);
688
+ } else {
689
+ console.error(`image-annotate: ${context.type} failed`, context.error);
690
+ }
691
+ }
692
+ /** Load annotations from the server via api.load. */
693
+ loadFromApi() {
694
+ if (!this.api.load) return;
695
+ this.api.load().then((notes) => {
696
+ this.destroyViews();
697
+ this.notes = notes;
698
+ this.createViews();
699
+ this.notifyLoad();
700
+ }).catch((err) => {
701
+ const error = err instanceof Error ? err : new Error(String(err));
702
+ this.reportError({ type: "load", error });
703
+ });
704
+ }
705
+ /**
706
+ * Enter edit mode to create a new annotation.
707
+ * @returns true if edit mode was entered, false if already editing.
708
+ */
709
+ add() {
710
+ if (this.mode === "view") {
711
+ this.setMode("edit");
712
+ this.activeEdit = new AnnotateEdit(this);
713
+ return true;
714
+ }
715
+ return false;
716
+ }
717
+ };
718
+
719
+ // src/vue.ts
720
+ var AnnotateImage2 = defineComponent({
721
+ name: "AnnotateImage",
722
+ props: {
723
+ /** Image source URL. */
724
+ src: { type: String, required: true },
725
+ /** Image width in pixels. Required if image may not be loaded yet. */
726
+ width: { type: Number },
727
+ /** Image height in pixels. Required if image may not be loaded yet. */
728
+ height: { type: Number },
729
+ /** Image alt text for accessibility. */
730
+ alt: { type: String },
731
+ /** Annotations to render. */
732
+ notes: { type: Array },
733
+ /** Enable annotation editing. Default: true. */
734
+ editable: { type: Boolean, default: true }
735
+ },
736
+ emits: {
737
+ save: (_note) => true,
738
+ delete: (_note) => true,
739
+ load: (_notes) => true,
740
+ change: (_notes) => true,
741
+ error: (_context) => true
742
+ },
743
+ setup(props, { emit, expose }) {
744
+ const imgRef = shallowRef(null);
745
+ const instanceRef = shallowRef(null);
746
+ const currentNotes = ref([]);
747
+ function createInstance() {
748
+ if (!imgRef.value) return;
749
+ try {
750
+ const instance = new AnnotateImage(imgRef.value, {
751
+ editable: props.editable,
752
+ notes: props.notes ? props.notes.slice() : [],
753
+ onChange: (notes) => {
754
+ currentNotes.value = notes;
755
+ emit("change", notes);
756
+ },
757
+ onSave: (note) => emit("save", note),
758
+ onDelete: (note) => emit("delete", note),
759
+ onLoad: (notes) => emit("load", notes),
760
+ onError: (ctx) => emit("error", ctx)
761
+ });
762
+ instanceRef.value = instance;
763
+ } catch (err) {
764
+ const error = err instanceof Error ? err : new Error(String(err));
765
+ emit("error", { type: "load", error });
766
+ }
767
+ }
768
+ function destroyInstance() {
769
+ instanceRef.value?.destroy();
770
+ instanceRef.value = null;
771
+ }
772
+ onMounted(() => {
773
+ createInstance();
774
+ });
775
+ onBeforeUnmount(() => {
776
+ destroyInstance();
777
+ });
778
+ watch(
779
+ () => props.src,
780
+ () => {
781
+ destroyInstance();
782
+ createInstance();
783
+ }
784
+ );
785
+ const serializedNotes = computed(() => JSON.stringify(props.notes ?? []));
786
+ watch(serializedNotes, () => {
787
+ instanceRef.value?.setNotes(props.notes ? props.notes.slice() : []);
788
+ });
789
+ watch(
790
+ () => props.editable,
791
+ (newEditable) => {
792
+ instanceRef.value?.setEditable(newEditable);
793
+ }
794
+ );
795
+ const add = () => instanceRef.value?.add();
796
+ const clear = () => instanceRef.value?.clear();
797
+ const getNotes = () => instanceRef.value?.getNotes() ?? [];
798
+ expose({ add, clear, getNotes, notes: currentNotes });
799
+ return () => h("img", {
800
+ ref: imgRef,
801
+ src: props.src,
802
+ width: props.width,
803
+ height: props.height,
804
+ alt: props.alt
805
+ });
806
+ }
807
+ });
808
+ export {
809
+ AnnotateImage2 as AnnotateImage
810
+ };