This commit is contained in:
@@ -0,0 +1,502 @@
|
||||
'use strict';
|
||||
|
||||
/**
|
||||
* AnnotationCanvas: renders a step's working image plus its normalized
|
||||
* annotation scene graph, and provides editing interactions (create, select,
|
||||
* move, resize, nudge, crop). Geometry rules mirror core/raster.js so the
|
||||
* editor shows what exports produce.
|
||||
*/
|
||||
|
||||
const DRAW_ORDER = { blur: 0, highlight: 1, magnify: 2, rect: 3, oval: 3, line: 3, arrow: 3, cursor: 4, number: 5, text: 6, tooltip: 7 };
|
||||
const POINT_TOOLS = new Set(['line', 'arrow']);
|
||||
const HANDLE_SIZE = 8;
|
||||
|
||||
class AnnotationCanvas {
|
||||
constructor(canvasEl, callbacks = {}) {
|
||||
this.canvas = canvasEl;
|
||||
this.ctx = canvasEl.getContext('2d');
|
||||
this.cb = callbacks; // { onChange, onSelect, onCrop, onRequestText }
|
||||
this.image = null; // HTMLImageElement
|
||||
this.imgW = 0;
|
||||
this.imgH = 0;
|
||||
this.annotations = [];
|
||||
this.tool = 'select';
|
||||
this.zoomMode = 'fit';
|
||||
this.scale = 1;
|
||||
this.selectedId = null;
|
||||
this.drag = null;
|
||||
this.cropRect = null;
|
||||
|
||||
canvasEl.addEventListener('pointerdown', (e) => this.onDown(e));
|
||||
canvasEl.addEventListener('pointermove', (e) => this.onMove(e));
|
||||
canvasEl.addEventListener('pointerup', (e) => this.onUp(e));
|
||||
canvasEl.addEventListener('dblclick', (e) => this.onDblClick(e));
|
||||
}
|
||||
|
||||
setImage(image, w, h) {
|
||||
this.image = image;
|
||||
this.imgW = w || 0;
|
||||
this.imgH = h || 0;
|
||||
this.cropRect = null;
|
||||
if (!image || !this.imgW || !this.imgH) {
|
||||
this.canvas.width = 1;
|
||||
this.canvas.height = 1;
|
||||
this.render();
|
||||
return;
|
||||
}
|
||||
this.applyZoom();
|
||||
}
|
||||
|
||||
setAnnotations(annotations) {
|
||||
this.annotations = annotations || [];
|
||||
if (!this.annotations.some((a) => a.id === this.selectedId)) this.selectedId = null;
|
||||
this.render();
|
||||
}
|
||||
|
||||
setTool(tool) {
|
||||
this.tool = tool;
|
||||
this.cropRect = null;
|
||||
if (tool !== 'select') this.select(null);
|
||||
this.render();
|
||||
}
|
||||
|
||||
setZoom(mode) {
|
||||
this.zoomMode = mode;
|
||||
this.applyZoom();
|
||||
}
|
||||
|
||||
applyZoom() {
|
||||
if (!this.image) return;
|
||||
const wrap = this.canvas.parentElement;
|
||||
if (this.zoomMode === 'fit') {
|
||||
const availW = Math.max(100, wrap.clientWidth - 40);
|
||||
const availH = Math.max(100, wrap.clientHeight - 40);
|
||||
this.scale = Math.min(availW / this.imgW, availH / this.imgH, 1);
|
||||
} else {
|
||||
this.scale = Number(this.zoomMode) || 1;
|
||||
}
|
||||
this.canvas.width = Math.round(this.imgW * this.scale);
|
||||
this.canvas.height = Math.round(this.imgH * this.scale);
|
||||
this.render();
|
||||
}
|
||||
|
||||
select(id) {
|
||||
this.selectedId = id;
|
||||
if (this.cb.onSelect) this.cb.onSelect(this.annotations.find((a) => a.id === id) || null);
|
||||
this.render();
|
||||
}
|
||||
|
||||
selected() {
|
||||
return this.annotations.find((a) => a.id === this.selectedId) || null;
|
||||
}
|
||||
|
||||
changed() {
|
||||
if (this.cb.onChange) this.cb.onChange(this.annotations);
|
||||
this.render();
|
||||
}
|
||||
|
||||
// ---- coordinate helpers ----
|
||||
toNorm(e) {
|
||||
const rect = this.canvas.getBoundingClientRect();
|
||||
return {
|
||||
x: (e.clientX - rect.left) / rect.width,
|
||||
y: (e.clientY - rect.top) / rect.height,
|
||||
};
|
||||
}
|
||||
|
||||
px(ann) {
|
||||
return {
|
||||
x: ann.x * this.canvas.width,
|
||||
y: ann.y * this.canvas.height,
|
||||
w: ann.w * this.canvas.width,
|
||||
h: ann.h * this.canvas.height,
|
||||
};
|
||||
}
|
||||
|
||||
// ---- rendering ----
|
||||
render() {
|
||||
const { ctx, canvas } = this;
|
||||
ctx.clearRect(0, 0, canvas.width, canvas.height);
|
||||
if (!this.image) return;
|
||||
ctx.imageSmoothingEnabled = true;
|
||||
ctx.drawImage(this.image, 0, 0, canvas.width, canvas.height);
|
||||
|
||||
const ordered = [...this.annotations].sort((a, b) => (DRAW_ORDER[a.type] ?? 3) - (DRAW_ORDER[b.type] ?? 3));
|
||||
for (const ann of ordered) this.drawAnnotation(ann);
|
||||
|
||||
const sel = this.selected();
|
||||
if (sel) this.drawSelection(sel);
|
||||
if (this.cropRect) this.drawCropOverlay();
|
||||
}
|
||||
|
||||
strokePx(ann) {
|
||||
return Math.max(1, ((ann.style && ann.style.strokeWidth) || 3) * this.canvas.width / 1000);
|
||||
}
|
||||
|
||||
fontPx(ann) {
|
||||
return Math.max(9, ((ann.style && ann.style.fontSize) || 0.022) * this.canvas.height);
|
||||
}
|
||||
|
||||
drawAnnotation(ann) {
|
||||
const { ctx } = this;
|
||||
const { x, y, w, h } = this.px(ann);
|
||||
const style = ann.style || {};
|
||||
const stroke = style.stroke || '#E5484D';
|
||||
const fill = style.fill && style.fill !== 'transparent' ? style.fill : null;
|
||||
ctx.save();
|
||||
ctx.lineWidth = this.strokePx(ann);
|
||||
ctx.strokeStyle = stroke;
|
||||
|
||||
switch (ann.type) {
|
||||
case 'rect':
|
||||
if (fill) { ctx.fillStyle = fill; ctx.fillRect(x, y, w, h); }
|
||||
ctx.strokeRect(x, y, w, h);
|
||||
break;
|
||||
case 'oval':
|
||||
ctx.beginPath();
|
||||
ctx.ellipse(x + w / 2, y + h / 2, Math.abs(w / 2), Math.abs(h / 2), 0, 0, Math.PI * 2);
|
||||
if (fill) { ctx.fillStyle = fill; ctx.fill(); }
|
||||
ctx.stroke();
|
||||
break;
|
||||
case 'line':
|
||||
ctx.beginPath(); ctx.moveTo(x, y); ctx.lineTo(x + w, y + h); ctx.stroke();
|
||||
break;
|
||||
case 'arrow': {
|
||||
const len = Math.hypot(w, h) || 1;
|
||||
const head = Math.min(len * 0.4, Math.max(10, ctx.lineWidth * 4));
|
||||
const ux = w / len, uy = h / len;
|
||||
const bx = x + w - ux * head, by = y + h - uy * head;
|
||||
ctx.beginPath(); ctx.moveTo(x, y); ctx.lineTo(bx, by); ctx.stroke();
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(x + w, y + h);
|
||||
ctx.lineTo(bx - uy * head * 0.5, by + ux * head * 0.5);
|
||||
ctx.lineTo(bx + uy * head * 0.5, by - ux * head * 0.5);
|
||||
ctx.closePath();
|
||||
ctx.fillStyle = stroke; ctx.fill();
|
||||
break;
|
||||
}
|
||||
case 'blur': {
|
||||
// preview: pixelate the region by down/up-scaling
|
||||
const f = Math.max(6, (ann.radius || 8));
|
||||
try {
|
||||
ctx.imageSmoothingEnabled = true;
|
||||
const tw = Math.max(1, Math.round(w / f)), th = Math.max(1, Math.round(h / f));
|
||||
const off = document.createElement('canvas');
|
||||
off.width = tw; off.height = th;
|
||||
off.getContext('2d').drawImage(this.canvas, x, y, w, h, 0, 0, tw, th);
|
||||
ctx.imageSmoothingEnabled = true;
|
||||
ctx.drawImage(off, 0, 0, tw, th, x, y, w, h);
|
||||
} catch { /* region may be degenerate while dragging */ }
|
||||
break;
|
||||
}
|
||||
case 'highlight':
|
||||
ctx.fillStyle = 'rgba(255, 235, 59, 0.41)';
|
||||
ctx.fillRect(x, y, w, h);
|
||||
break;
|
||||
case 'magnify': {
|
||||
const zoom = ann.zoom || 2;
|
||||
ctx.save();
|
||||
ctx.beginPath();
|
||||
ctx.ellipse(x + w / 2, y + h / 2, Math.abs(w / 2), Math.abs(h / 2), 0, 0, Math.PI * 2);
|
||||
ctx.clip();
|
||||
const sw = w / zoom, sh = h / zoom;
|
||||
ctx.drawImage(
|
||||
this.image,
|
||||
(x + w / 2 - sw / 2) / this.scale, (y + h / 2 - sh / 2) / this.scale,
|
||||
sw / this.scale, sh / this.scale,
|
||||
x, y, w, h
|
||||
);
|
||||
ctx.restore();
|
||||
ctx.beginPath();
|
||||
ctx.ellipse(x + w / 2, y + h / 2, Math.abs(w / 2), Math.abs(h / 2), 0, 0, Math.PI * 2);
|
||||
ctx.stroke();
|
||||
break;
|
||||
}
|
||||
case 'text': {
|
||||
ctx.font = `600 ${this.fontPx(ann)}px system-ui, sans-serif`;
|
||||
ctx.fillStyle = stroke;
|
||||
ctx.textBaseline = 'top';
|
||||
let ty = y;
|
||||
for (const line of String(ann.text || 'Text').split('\n')) {
|
||||
ctx.fillText(line, x, ty);
|
||||
ty += this.fontPx(ann) * 1.25;
|
||||
}
|
||||
break;
|
||||
}
|
||||
case 'tooltip': {
|
||||
const bg = fill || '#1F2937';
|
||||
const ts = Math.max(6, Math.min(Math.abs(w), Math.abs(h)) * 0.25);
|
||||
ctx.fillStyle = bg;
|
||||
ctx.beginPath();
|
||||
const r = 6;
|
||||
ctx.roundRect(x, y, w, h, r);
|
||||
ctx.fill();
|
||||
const tail = style.tail || 'bottom';
|
||||
ctx.beginPath();
|
||||
if (tail === 'bottom') { ctx.moveTo(x + w / 2 - ts, y + h); ctx.lineTo(x + w / 2 + ts, y + h); ctx.lineTo(x + w / 2, y + h + ts * 1.4); }
|
||||
if (tail === 'top') { ctx.moveTo(x + w / 2 - ts, y); ctx.lineTo(x + w / 2 + ts, y); ctx.lineTo(x + w / 2, y - ts * 1.4); }
|
||||
if (tail === 'left') { ctx.moveTo(x, y + h / 2 - ts); ctx.lineTo(x, y + h / 2 + ts); ctx.lineTo(x - ts * 1.4, y + h / 2); }
|
||||
if (tail === 'right') { ctx.moveTo(x + w, y + h / 2 - ts); ctx.lineTo(x + w, y + h / 2 + ts); ctx.lineTo(x + w + ts * 1.4, y + h / 2); }
|
||||
ctx.closePath(); ctx.fill();
|
||||
ctx.fillStyle = style.textColor || '#fff';
|
||||
ctx.font = `600 ${this.fontPx(ann)}px system-ui, sans-serif`;
|
||||
ctx.textAlign = 'center'; ctx.textBaseline = 'middle';
|
||||
ctx.fillText(String(ann.text || '…'), x + w / 2, y + h / 2, Math.abs(w) - 8);
|
||||
break;
|
||||
}
|
||||
case 'number': {
|
||||
const rr = Math.max(8, Math.min(Math.abs(w), Math.abs(h)) / 2);
|
||||
ctx.fillStyle = stroke;
|
||||
ctx.beginPath();
|
||||
ctx.arc(x + w / 2, y + h / 2, rr, 0, Math.PI * 2);
|
||||
ctx.fill();
|
||||
ctx.fillStyle = style.textColor || '#fff';
|
||||
ctx.font = `700 ${rr}px system-ui, sans-serif`;
|
||||
ctx.textAlign = 'center'; ctx.textBaseline = 'middle';
|
||||
ctx.fillText(String(ann.value ?? '?'), x + w / 2, y + h / 2 + 1);
|
||||
break;
|
||||
}
|
||||
case 'cursor': {
|
||||
const s = Math.max(12, Math.min(Math.abs(w), Math.abs(h)));
|
||||
ctx.fillStyle = '#fff';
|
||||
ctx.strokeStyle = '#111827';
|
||||
ctx.lineWidth = Math.max(1, s / 12);
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(x, y); ctx.lineTo(x, y + s); ctx.lineTo(x + s * 0.28, y + s * 0.75);
|
||||
ctx.lineTo(x + s * 0.45, y + s * 1.05); ctx.lineTo(x + s * 0.58, y + s * 0.98);
|
||||
ctx.lineTo(x + s * 0.42, y + s * 0.68); ctx.lineTo(x + s * 0.72, y + s * 0.68);
|
||||
ctx.closePath();
|
||||
ctx.fill(); ctx.stroke();
|
||||
break;
|
||||
}
|
||||
default: break;
|
||||
}
|
||||
ctx.restore();
|
||||
}
|
||||
|
||||
drawSelection(ann) {
|
||||
const { ctx } = this;
|
||||
const { x, y, w, h } = this.px(ann);
|
||||
ctx.save();
|
||||
ctx.strokeStyle = '#2563eb';
|
||||
ctx.setLineDash([5, 4]);
|
||||
ctx.lineWidth = 1.2;
|
||||
ctx.strokeRect(Math.min(x, x + w) - 3, Math.min(y, y + h) - 3, Math.abs(w) + 6, Math.abs(h) + 6);
|
||||
ctx.setLineDash([]);
|
||||
ctx.fillStyle = '#2563eb';
|
||||
for (const hd of this.handles(ann)) {
|
||||
ctx.fillRect(hd.px - HANDLE_SIZE / 2, hd.py - HANDLE_SIZE / 2, HANDLE_SIZE, HANDLE_SIZE);
|
||||
}
|
||||
ctx.restore();
|
||||
}
|
||||
|
||||
handles(ann) {
|
||||
const { x, y, w, h } = this.px(ann);
|
||||
if (POINT_TOOLS.has(ann.type)) {
|
||||
return [
|
||||
{ id: 'p1', px: x, py: y },
|
||||
{ id: 'p2', px: x + w, py: y + h },
|
||||
];
|
||||
}
|
||||
return [
|
||||
{ id: 'nw', px: x, py: y }, { id: 'n', px: x + w / 2, py: y }, { id: 'ne', px: x + w, py: y },
|
||||
{ id: 'w', px: x, py: y + h / 2 }, { id: 'e', px: x + w, py: y + h / 2 },
|
||||
{ id: 'sw', px: x, py: y + h }, { id: 's', px: x + w / 2, py: y + h }, { id: 'se', px: x + w, py: y + h },
|
||||
];
|
||||
}
|
||||
|
||||
drawCropOverlay() {
|
||||
const { ctx, canvas } = this;
|
||||
const r = this.cropRect;
|
||||
const x = Math.min(r.x0, r.x1) * canvas.width;
|
||||
const y = Math.min(r.y0, r.y1) * canvas.height;
|
||||
const w = Math.abs(r.x1 - r.x0) * canvas.width;
|
||||
const h = Math.abs(r.y1 - r.y0) * canvas.height;
|
||||
ctx.save();
|
||||
ctx.fillStyle = 'rgba(0,0,0,0.5)';
|
||||
ctx.beginPath();
|
||||
ctx.rect(0, 0, canvas.width, canvas.height);
|
||||
ctx.rect(x, y, w, h);
|
||||
ctx.fill('evenodd');
|
||||
ctx.strokeStyle = '#fff';
|
||||
ctx.setLineDash([6, 4]);
|
||||
ctx.strokeRect(x, y, w, h);
|
||||
ctx.restore();
|
||||
}
|
||||
|
||||
// ---- interactions ----
|
||||
hitTest(pt) {
|
||||
// topmost first (reverse draw order)
|
||||
const ordered = [...this.annotations].sort((a, b) => (DRAW_ORDER[b.type] ?? 3) - (DRAW_ORDER[a.type] ?? 3));
|
||||
for (const ann of ordered) {
|
||||
const x0 = Math.min(ann.x, ann.x + ann.w) - 0.008;
|
||||
const y0 = Math.min(ann.y, ann.y + ann.h) - 0.008;
|
||||
const x1 = Math.max(ann.x, ann.x + ann.w) + 0.008;
|
||||
const y1 = Math.max(ann.y, ann.y + ann.h) + 0.008;
|
||||
if (pt.x >= x0 && pt.x <= x1 && pt.y >= y0 && pt.y <= y1) return ann;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
handleAt(e) {
|
||||
const sel = this.selected();
|
||||
if (!sel) return null;
|
||||
const rect = this.canvas.getBoundingClientRect();
|
||||
const px = e.clientX - rect.left;
|
||||
const py = e.clientY - rect.top;
|
||||
for (const hd of this.handles(sel)) {
|
||||
if (Math.abs(px - hd.px) <= HANDLE_SIZE && Math.abs(py - hd.py) <= HANDLE_SIZE) return hd.id;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
onDown(e) {
|
||||
if (!this.image) return;
|
||||
this.canvas.setPointerCapture(e.pointerId);
|
||||
const pt = this.toNorm(e);
|
||||
|
||||
if (this.tool === 'crop') {
|
||||
this.cropRect = { x0: pt.x, y0: pt.y, x1: pt.x, y1: pt.y };
|
||||
this.drag = { kind: 'crop' };
|
||||
return;
|
||||
}
|
||||
if (this.tool === 'select') {
|
||||
const handle = this.handleAt(e);
|
||||
if (handle) {
|
||||
this.drag = { kind: 'resize', handle, start: pt, orig: { ...this.selected() } };
|
||||
return;
|
||||
}
|
||||
const hit = this.hitTest(pt);
|
||||
this.select(hit ? hit.id : null);
|
||||
if (hit) this.drag = { kind: 'move', start: pt, orig: { ...hit } };
|
||||
return;
|
||||
}
|
||||
|
||||
// creation tools
|
||||
const ann = {
|
||||
id: `ann-${Date.now().toString(36)}-${Math.floor(Math.random() * 1e6).toString(36)}`,
|
||||
type: this.tool,
|
||||
x: pt.x, y: pt.y, w: 0, h: 0,
|
||||
text: this.tool === 'tooltip' ? 'Tooltip' : this.tool === 'text' ? 'Text' : '',
|
||||
style: this.cb.defaultStyle ? this.cb.defaultStyle(this.tool) : {},
|
||||
};
|
||||
if (this.tool === 'number') ann.value = this.cb.nextNumber ? this.cb.nextNumber() : 1;
|
||||
if (this.tool === 'magnify') ann.zoom = 2;
|
||||
if (this.tool === 'blur') ann.radius = 8;
|
||||
this.annotations.push(ann);
|
||||
this.selectedId = ann.id;
|
||||
this.drag = { kind: 'create', start: pt, ann };
|
||||
}
|
||||
|
||||
onMove(e) {
|
||||
if (!this.drag) return;
|
||||
const pt = this.toNorm(e);
|
||||
const d = this.drag;
|
||||
|
||||
if (d.kind === 'crop') {
|
||||
this.cropRect.x1 = pt.x;
|
||||
this.cropRect.y1 = pt.y;
|
||||
this.render();
|
||||
return;
|
||||
}
|
||||
if (d.kind === 'create') {
|
||||
d.ann.w = pt.x - d.start.x;
|
||||
d.ann.h = pt.y - d.start.y;
|
||||
this.render();
|
||||
return;
|
||||
}
|
||||
const sel = this.selected();
|
||||
if (!sel) return;
|
||||
if (d.kind === 'move') {
|
||||
sel.x = d.orig.x + (pt.x - d.start.x);
|
||||
sel.y = d.orig.y + (pt.y - d.start.y);
|
||||
this.render();
|
||||
} else if (d.kind === 'resize') {
|
||||
this.resizeBy(sel, d, pt);
|
||||
this.render();
|
||||
}
|
||||
}
|
||||
|
||||
resizeBy(ann, d, pt) {
|
||||
const dx = pt.x - d.start.x;
|
||||
const dy = pt.y - d.start.y;
|
||||
const o = d.orig;
|
||||
const h = d.handle;
|
||||
if (h === 'p1') { ann.x = o.x + dx; ann.y = o.y + dy; ann.w = o.w - dx; ann.h = o.h - dy; return; }
|
||||
if (h === 'p2') { ann.w = o.w + dx; ann.h = o.h + dy; return; }
|
||||
if (h.includes('w')) { ann.x = o.x + dx; ann.w = o.w - dx; }
|
||||
if (h.includes('e')) { ann.w = o.w + dx; }
|
||||
if (h.includes('n')) { ann.y = o.y + dy; ann.h = o.h - dy; }
|
||||
if (h.includes('s')) { ann.h = o.h + dy; }
|
||||
}
|
||||
|
||||
onUp(e) {
|
||||
const d = this.drag;
|
||||
this.drag = null;
|
||||
if (!d) return;
|
||||
if (d.kind === 'crop') {
|
||||
const r = this.cropRect;
|
||||
this.cropRect = null;
|
||||
const rect = {
|
||||
x: Math.min(r.x0, r.x1), y: Math.min(r.y0, r.y1),
|
||||
w: Math.abs(r.x1 - r.x0), h: Math.abs(r.y1 - r.y0),
|
||||
};
|
||||
this.render();
|
||||
if (rect.w > 0.02 && rect.h > 0.02 && this.cb.onCrop) this.cb.onCrop(rect);
|
||||
return;
|
||||
}
|
||||
if (d.kind === 'create') {
|
||||
// degenerate drags get a sensible default size
|
||||
if (Math.abs(d.ann.w) < 0.01 && Math.abs(d.ann.h) < 0.01) {
|
||||
const defaults = { number: [0.05, 0.08], text: [0.2, 0.05], tooltip: [0.18, 0.07], cursor: [0.04, 0.06] };
|
||||
const [dw, dh] = defaults[d.ann.type] || [0.15, 0.1];
|
||||
d.ann.w = dw; d.ann.h = dh;
|
||||
}
|
||||
this.normalizeRect(d.ann);
|
||||
this.changed();
|
||||
this.select(d.ann.id);
|
||||
if ((d.ann.type === 'text' || d.ann.type === 'tooltip') && this.cb.onRequestText) {
|
||||
this.cb.onRequestText(d.ann);
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (d.kind === 'move' || d.kind === 'resize') {
|
||||
const sel = this.selected();
|
||||
if (sel) this.normalizeRect(sel);
|
||||
this.changed();
|
||||
}
|
||||
}
|
||||
|
||||
normalizeRect(ann) {
|
||||
if (POINT_TOOLS.has(ann.type)) return; // lines keep direction
|
||||
if (ann.w < 0) { ann.x += ann.w; ann.w = -ann.w; }
|
||||
if (ann.h < 0) { ann.y += ann.h; ann.h = -ann.h; }
|
||||
}
|
||||
|
||||
onDblClick(e) {
|
||||
const hit = this.hitTest(this.toNorm(e));
|
||||
if (hit && (hit.type === 'text' || hit.type === 'tooltip') && this.cb.onRequestText) {
|
||||
this.select(hit.id);
|
||||
this.cb.onRequestText(hit);
|
||||
}
|
||||
}
|
||||
|
||||
nudgeSelected(dx, dy) {
|
||||
const sel = this.selected();
|
||||
if (!sel) return false;
|
||||
sel.x += dx / this.canvas.width;
|
||||
sel.y += dy / this.canvas.height;
|
||||
this.changed();
|
||||
return true;
|
||||
}
|
||||
|
||||
deleteSelected() {
|
||||
if (!this.selectedId) return false;
|
||||
this.annotations = this.annotations.filter((a) => a.id !== this.selectedId);
|
||||
this.select(null);
|
||||
this.changed();
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
window.AnnotationCanvas = AnnotationCanvas;
|
||||
Reference in New Issue
Block a user