import { Skin } from './Skin.js';
import { Util } from './Util.js';
import { simplify, smooth, smoothToPath } from './Simplify.js'
import { LayerSvgAnnotation } from './LayerSvgAnnotation.js'
import { CoordinateSystem } from './CoordinateSystem.js'
/**
* EditorSvgAnnotation enables creation and editing of SVG annotations in OpenLIME.
* Optimized version with simplified erase tool functionality.
*/
class EditorSvgAnnotation {
constructor(viewer, layer, options) {
this.layer = layer;
Object.assign(this, {
viewer: viewer,
panning: false,
tool: null,
startPoint: null,
currentLine: [],
annotation: null,
priority: 20000,
pinSize: 36, // Default pin size in pixels at zoom level 1
classes: {
'': { stroke: '#000', label: '' },
'class1': { stroke: '#770', label: '' },
'class2': { stroke: '#707', label: '' },
'class3': { stroke: '#777', label: '' },
'class4': { stroke: '#070', label: '' },
'class5': { stroke: '#007', label: '' },
'class6': { stroke: '#077', label: '' },
},
tools: {
point: {
img: '<svg width=24 height=24><circle cx=12 cy=12 r=3 fill="red" stroke="gray"/></svg>',
tooltip: 'New point',
tool: Point,
},
pin: {
template: (x, y, annotation, size) => {
const idx = annotation?.data?.idx || '?';
return `<svg xmlns='http://www.w3.org/2000/svg' x='${x}' y='${y}' width='${size}' height='${size}' class='pin'
viewBox='0 0 18 18'><path d='M 0,0 C 0,0 4,0 8,0 12,0 16,4 16,8 16,12 12,16 8,16 4,16 0,12 0,8 0,4 0,0 0,0 Z'/><text class='pin-text' x='7' y='8' text-anchor='middle' dominant-baseline='middle'>${idx}</text></svg>`;
},
tooltip: 'New pin',
tool: Pin
},
pen: {
img: '<svg width=24 height=24><circle cx=12 cy=12 r=3 fill="red" stroke="gray"/></svg>',
tooltip: 'New polyline',
tool: Pen,
},
line: {
img: `<svg width=24 height=24>
<path d="m 4.7,4.5 c 0.5,4.8 0.8,8.5 3.1,11 2.4,2.6 4.2,-4.8 6.3,-5 2.7,-0.3 5.1,9.3 5.1,9.3" stroke-width="3" fill="none" stroke="grey"/>
<path d="m 4.7,4.5 c 0.5,4.8 0.8,8.5 3.1,11 2.4,2.6 4.2,-4.8 6.3,-5 2.7,-0.3 5.1,9.3 5.1,9.3" stroke-width="1" fill="none" stroke="red"/></svg>`,
tooltip: 'New line',
tool: Line,
},
erase: {
img: '',
tooltip: 'Erase elements',
tool: Erase,
},
box: {
img: '<svg width=24 height=24><rect x=5 y=5 width=14 height=14 fill="red" stroke="gray"/></svg>',
tooltip: 'New box',
tool: Box,
},
circle: {
img: '<svg width=24 height=24><circle cx=12 cy=12 r=7 fill="red" stroke="gray"/></svg>',
tooltip: 'New circle',
tool: Circle,
},
},
annotation: null,
enableState: false,
customState: null,
customData: null,
editWidget: null,
selectedCallback: null,
createCallback: null,
updateCallback: null,
deleteCallback: null
}, options);
layer.style += Object.entries(this.classes).map((g) => {
console.assert(g[1].hasOwnProperty('stroke'), "Classes needs a stroke property");
return `[data-class=${g[0]}] { stroke:${g[1].stroke}; }`;
}).join('\n');
// Add default pin sizing based on zoom level - but more advanced
if (!options.annotationUpdate) {
layer.annotationUpdate = (anno, transform) => {
this.updateAnnotationPins(anno, transform);
};
}
// Register for pointer events
viewer.pointerManager.onEvent(this);
document.addEventListener('keyup', (e) => this.keyUp(e), false);
layer.addEvent('selected', (anno) => {
if (!anno || anno == this.annotation)
return;
if (this.selectedCallback) this.selectedCallback(anno);
this.showEditWidget(anno);
});
layer.annotationsEntry = () => {
let entry = {
html: `<div class="openlime-tools"></div>`,
list: [],
classes: 'openlime-annotations',
status: () => 'active',
oncreate: () => {
if (Array.isArray(layer.annotations))
layer.createAnnotationsList();
let tools = {
'add': { action: () => { this.createAnnotation(); }, title: "New annotation" },
'edit': { action: () => { this.toggleEditWidget(); }, title: "Edit annotations" },
'export': { action: () => { this.exportAnnotations(); }, title: "Export annotations" },
'trash': { action: () => { this.deleteSelected(); }, title: "Delete selected annotations" },
};
(async () => {
for (const [label, tool] of Object.entries(tools)) {
let icon = await Skin.appendIcon(entry.element.firstChild, '.openlime-' + label);
icon.setAttribute('title', tool.title);
icon.addEventListener('click', tool.action);
}
})();
}
}
layer.annotationsListEntry = entry;
return entry;
}
// IMPORTANT: Capture clicks in capture phase for erase tool
// This prevents the annotation layer from handling the click first
this.viewer.containerElement.addEventListener('click', (ev) => {
// Only process if erase tool is active AND we have an annotation selected
if (this.tool !== 'erase' || !this.annotation) {
return; // Let other handlers process the event normally
}
// Don't intercept clicks on UI elements (toolbar, menus, etc.)
const target = ev.target;
if (target && (
target.closest('.openlime-toolbar') ||
target.closest('.openlime-layers-menu') ||
target.closest('.openlime-annotation-edit') ||
target.classList.contains('openlime-tool') ||
target.classList.contains('openlime-button') ||
target.closest('button') ||
target.closest('.openlime-dialog')
)) {
return; // Let UI elements handle their own clicks
}
// Find the target element
const targetElement = this._findElementUnderPointer(ev);
if (targetElement) {
// Save current state for undo
this.saveCurrent();
// Remove element from annotation
const index = this.annotation.elements.indexOf(targetElement);
if (index > -1) {
this.annotation.elements.splice(index, 1);
// Check if annotation is now empty
if (this.annotation.elements.length === 0) {
// Remove the entire annotation instead of keeping empty annotation
this.deleteAnnotation(this.annotation.id);
// Hide edit widget since annotation is gone
this.hideEditWidget();
} else {
// Save and update for non-empty annotations
this.saveAnnotation();
this.annotation.needsUpdate = true;
this.viewer.redraw();
}
}
// Stop event propagation to prevent other handlers
ev.stopImmediatePropagation();
ev.preventDefault();
}
// No target element found, let the click be handled normally
}, true); // true = capture phase (runs before other event handlers)
}
/**
* Finds the SVG element under the pointer for erase tool
* @param {Event} e - The event object
* @private
*/
_findElementUnderPointer(e) {
// Temporarily disable overlay pointer events
const overlay = this.viewer?.overlayElement || document.querySelector('.openlime-overlay');
const prevPointerEvents = overlay ? overlay.style.pointerEvents : null;
if (overlay) {
overlay.style.pointerEvents = 'none';
}
let targetElement = null;
try {
// Get element from document
let element = document.elementFromPoint(e.clientX, e.clientY);
// If we hit a shadow host, try to get element from shadow root
if (element && element.shadowRoot) {
const shadowElement = element.shadowRoot.elementFromPoint(e.clientX, e.clientY);
if (shadowElement) {
element = shadowElement;
}
}
// Check if this element (or any parent) is in our annotation
if (element && this.annotation && Array.isArray(this.annotation.elements)) {
let current = element;
const elementSet = new Set(this.annotation.elements);
// Walk up the DOM tree
while (current && current !== document) {
if (elementSet.has(current)) {
targetElement = current;
break;
}
// Check if current element is a child of any annotation element
for (const annotationEl of this.annotation.elements) {
if (annotationEl.contains && annotationEl.contains(current)) {
targetElement = annotationEl;
break;
}
}
if (targetElement) break;
current = current.parentNode;
}
}
} finally {
// Restore overlay pointer events
if (overlay) {
overlay.style.pointerEvents = prevPointerEvents ?? '';
}
}
return targetElement;
}
/**
* Calculates the correct pin size based on current zoom level
* @returns {number} Pin size in pixels
* @private
*/
getCurrentPinSize() {
const transform = this.viewer.camera.getCurrentTransform(performance.now());
return this.pinSize / transform.z;
}
/**
* Updates pin sizes in an annotation based on transform
* @param {Object} anno - Annotation object
* @param {Object} transform - Current transform
* @private
*/
updateAnnotationPins(anno, transform) {
let size = this.pinSize / transform.z;
if (size !== anno.previous_pin_size) {
anno.elements.forEach(element => {
if (element.classList.contains('pin')) {
element.setAttribute('width', size + 'px');
element.setAttribute('height', size + 'px');
}
});
anno.previous_pin_size = size;
}
}
/**
* Creates a new annotation with correct initial state
* @returns {void}
*/
createAnnotation() {
let anno = this.layer.newAnnotation();
if (this.customData) this.customData(anno);
if (this.enableState) this.setAnnotationCurrentState(anno);
anno.data.idx = this.layer.annotations.length;
anno.publish = 1;
anno.label = anno.description = anno.class = '';
let post = {
id: anno.id, label: anno.label, description: anno.description, 'class': anno.class, svg: null,
publish: anno.publish, data: anno.data
};
if (this.enableState) post = { ...post, state: anno.state };
if (this.createCallback) {
let result = this.createCallback(post);
if (!result)
alert("Failed to create annotation!");
}
this.layer.setSelected(anno);
}
toggleEditWidget() {
if (this.annotation)
return this.hideEditWidget();
let id = this.layer.selected.values().next().value;
if (!id)
return;
let anno = this.layer.getAnnotationById(id);
this.showEditWidget(anno);
}
updateEditWidget() {
let anno = this.annotation;
let edit = this.editWidget;
if (!anno.class)
anno.class = '';
edit.querySelector('[name=label]').value = anno.label || '';
edit.querySelector('[name=description]').value = anno.description || '';
Object.entries(anno.data).map(k => {
edit.querySelector(`[name=data-data-${k[0]}]`).value = k[1] || '';
});
edit.querySelector('[name=classes]').value = anno.class;
edit.querySelector('[name=publish]').checked = anno.publish == 1;
edit.classList.remove('hidden');
let button = edit.querySelector('.openlime-select-button');
button.textContent = this.classes[anno.class].label;
button.style.background = this.classes[anno.class].stroke;
}
showEditWidget(anno) {
this.annotation = anno;
// Add reference to editor for pin size calculations
anno.editor = this;
this.setTool(null);
this.setActiveTool();
this.layer.annotationsListEntry.element.querySelector('.openlime-edit').classList.add('active');
(async () => {
await this.createEditWidget();
this.updateEditWidget();
})();
}
hideEditWidget() {
this.annotation = null;
this.setTool(null);
if (this.editWidget) {
this.editWidget.classList.add('hidden');
}
this.layer.annotationsListEntry.element.querySelector('.openlime-edit').classList.remove('active');
}
async createEditWidget() {
if (this.editWidget)
return;
let html = `
<div class="openlime-annotation-edit">
<label for="label">Title:</label> <input name="label" type="text"><br>
<label for="description">Description:</label><br>
<textarea name="description" cols="30" rows="5"></textarea><br>
<span>Class:</span>
<div class="openlime-select">
<input type="hidden" name="classes" value=""/>
<div class="openlime-select-button"></div>
<ul class="openlime-select-menu">
${Object.entries(this.classes).map((c) =>
`<li data-class="${c[0]}" style="background:${c[1].stroke};">${c[1].label}</li>`).join('\n')}
</ul>
</div>
${Object.entries(this.annotation.data).map(k => {
let label = k[0];
let str = `<label for="data-data-${k[0]}">${label}:</label> <input name="data-data-${k[0]}" type="text"><br>`
return str;
}).join('\n')}
<br>
<span><button class="openlime-state">SAVE</button></span>
<span><input type="checkbox" name="publish" value=""> Publish</span><br>
<div class="openlime-annotation-edit-tools"></div>
</div>`;
let template = document.createElement('template');
template.innerHTML = html.trim();
let edit = template.content.firstChild;
let select = edit.querySelector('.openlime-select');
let button = edit.querySelector('.openlime-select-button');
let ul = edit.querySelector('ul');
let options = edit.querySelectorAll('li');
let input = edit.querySelector('[name=classes]');
let state = edit.querySelector('.openlime-state');
state.addEventListener('click', (e) => {
if (this.enableState) this.setAnnotationCurrentState(this.annotation);
this.saveCurrent();
this.saveAnnotation();
});
button.addEventListener('click', (e) => {
e.stopPropagation();
for (let o of options)
o.classList.remove('selected');
select.classList.toggle('active');
});
ul.addEventListener('click', (e) => {
e.stopPropagation();
input.value = e.srcElement.getAttribute('data-class');
input.dispatchEvent(new Event('change'));
button.style.background = this.classes[input.value].stroke;
button.textContent = e.srcElement.textContent;
select.classList.toggle('active');
});
document.addEventListener('click', (e) => {
select.classList.remove('active');
});
document.querySelector('.openlime-layers-menu').appendChild(edit);
let tools = edit.querySelector('.openlime-annotation-edit-tools');
let pin = await Skin.appendIcon(tools, '.openlime-pin');
pin.addEventListener('click', (e) => {
if (this.tool === 'pin') { this.setTool(null); this.setActiveTool(); }
else { this.setTool('pin'); this.setActiveTool(pin); }
});
let draw = await Skin.appendIcon(tools, '.openlime-draw');
draw.addEventListener('click', (e) => {
if (this.tool === 'line') { this.setTool(null); this.setActiveTool(); }
else { this.setTool('line'); this.setActiveTool(draw); }
});
let erase = await Skin.appendIcon(tools, '.openlime-erase');
erase.addEventListener('click', (e) => {
if (this.tool === 'erase') { this.setTool(null); this.setActiveTool(); }
else { this.setTool('erase'); this.setActiveTool(erase); }
});
let undo = await Skin.appendIcon(tools, '.openlime-undo');
undo.addEventListener('click', (e) => { this.undo(); });
let redo = await Skin.appendIcon(tools, '.openlime-redo');
redo.addEventListener('click', (e) => { this.redo(); });
// Setup form field event listeners
this._setupFormEventListeners(edit);
edit.classList.add('hidden');
this.editWidget = edit;
}
_setupFormEventListeners(edit) {
let label = edit.querySelector('[name=label]');
label.addEventListener('blur', (e) => {
if (this.annotation.label != label.value) {
this.saveCurrent();
this.saveAnnotation();
}
});
let descr = edit.querySelector('[name=description]');
descr.addEventListener('blur', (e) => {
if (this.annotation.description != descr.value) {
this.saveCurrent();
this.saveAnnotation();
}
});
let idx = edit.querySelector('[name=data-data-idx]');
if (idx) {
idx.addEventListener('blur', (e) => {
if (this.annotation.data.idx != idx.value) {
const svgPinIdx = this.annotation.elements[0];
if (svgPinIdx) {
const txt = svgPinIdx.querySelector(".pin-text");
if (txt) {
txt.textContent = idx.value;
}
}
this.saveCurrent();
this.saveAnnotation();
}
});
}
Object.entries(this.annotation.data).map(k => {
let dataElm = edit.querySelector(`[name=data-data-${k[0]}]`);
if (dataElm) {
dataElm.addEventListener('blur', (e) => {
if (this.annotation.data[k[0]] != dataElm.value) {
this.saveCurrent();
this.saveAnnotation();
}
});
}
});
let classes = edit.querySelector('[name=classes]');
classes.addEventListener('change', (e) => {
if (this.annotation.class != classes.value) {
this.saveCurrent();
this.saveAnnotation();
}
});
let publish = edit.querySelector('[name=publish]');
publish.addEventListener('change', (e) => {
if (this.annotation.publish != (publish.checked ? 1 : 0)) {
this.saveCurrent();
this.saveAnnotation();
}
});
}
setAnnotationCurrentState(anno) {
anno.state = window.structuredClone(this.viewer.canvas.getState());
if (this.customState) this.customState(anno);
}
saveAnnotation() {
let edit = this.editWidget;
let anno = this.annotation;
anno.label = edit.querySelector('[name=label]').value || '';
anno.description = edit.querySelector('[name=description]').value || '';
Object.entries(anno.data).map(k => {
const element = edit.querySelector(`[name=data-data-${k[0]}]`);
if (element) {
anno.data[k[0]] = element.value || '';
}
});
anno.publish = edit.querySelector('[name=publish]').checked ? 1 : 0;
let select = edit.querySelector('[name=classes]');
anno.class = select.value || '';
let button = edit.querySelector('.openlime-select-button');
button.style.background = this.classes[anno.class].stroke;
for (let e of this.annotation.elements)
e.setAttribute('data-class', anno.class);
let post = {
id: anno.id, label: anno.label, description: anno.description, class: anno.class,
publish: anno.publish, data: anno.data
};
if (this.enableState) post = { ...post, state: anno.state };
let serializer = new XMLSerializer();
post.svg = `<svg xmlns="http://www.w3.org/2000/svg">
${anno.elements.map((s) => { s.classList.remove('selected'); return serializer.serializeToString(s) }).join("\n")}
</svg>`;
if (this.updateCallback) {
let result = this.updateCallback(post);
if (!result) {
alert("Failed to update annotation");
return;
}
}
// Recreate the annotations list
if (this.layer.annotationsListEntry && this.layer.annotationsListEntry.element && this.layer.annotationsListEntry.element.parentElement) {
const list = this.layer.annotationsListEntry.element.parentElement.querySelector('.openlime-list');
if (list) {
const selectContainer = list.querySelector('.openlime-annotations-select');
const wasActive = selectContainer && selectContainer.classList.contains('active');
if (selectContainer && selectContainer._cleanup) {
selectContainer._cleanup();
}
this.layer.createAnnotationsList();
if (wasActive) {
const newSelectContainer = list.querySelector('.openlime-annotations-select');
if (newSelectContainer) {
newSelectContainer.classList.add('active');
}
}
}
}
this.layer.setSelected(anno);
}
deleteSelected() {
let id = this.layer.selected.values().next().value;
if (id)
this.deleteAnnotation(id);
}
deleteAnnotation(id) {
let anno = this.layer.getAnnotationById(id);
if (this.deleteCallback) {
if (!confirm(`Deleting annotation ${anno.label}, are you sure?`))
return;
let result = this.deleteCallback(anno);
if (!result) {
alert("Failed to delete this annotation.");
return;
}
}
// Remove SVG elements from the canvas
this.layer.svgGroup.querySelectorAll(`[data-annotation="${anno.id}"]`).forEach(e => e.remove());
// Remove entry from the list
let list = this.layer.annotationsListEntry.element.parentElement.querySelector('.openlime-list');
list.querySelectorAll(`[data-annotation="${anno.id}"]`).forEach(e => e.remove());
this.layer.annotations = this.layer.annotations.filter(a => a !== anno);
this.layer.clearSelected();
this.hideEditWidget();
}
exportAnnotations() {
let svgElement = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
const bBox = this.layer.boundingBox();
svgElement.setAttribute('viewBox', `0 0 ${bBox.xHigh - bBox.xLow} ${bBox.yHigh - bBox.yLow}`);
let style = Util.createSVGElement('style');
style.textContent = this.layer.style;
svgElement.appendChild(style);
let serializer = new XMLSerializer();
for (let anno of this.layer.annotations) {
for (let e of anno.elements) {
if (e.tagName == 'path') {
let d = e.getAttribute('d');
e.setAttribute('d', d.replaceAll(',', ' '));
}
svgElement.appendChild(e.cloneNode());
}
}
let svg = serializer.serializeToString(svgElement);
var e = document.createElement('a');
e.setAttribute('href', 'data:text/plain;charset=utf-8,' + encodeURIComponent(svg));
e.setAttribute('download', 'annotations.svg');
e.style.display = 'none';
document.body.appendChild(e);
e.click();
document.body.removeChild(e);
}
setActiveTool(e) {
if (!this.editWidget) return;
let tools = this.editWidget.querySelector('.openlime-annotation-edit-tools');
tools.querySelectorAll('svg').forEach(a =>
a.classList.remove('active'));
if (e)
e.classList.add('active');
}
setTool(tool) {
this.tool = tool;
if (this.factory && this.factory.quit)
this.factory.quit();
if (tool) {
if (!(tool in this.tools)) throw "Unknown editor tool: " + tool;
this.factory = new this.tools[tool].tool(this.tools[tool]);
this.factory.annotation = this.annotation;
this.factory.layer = this.layer;
// Add reference to editor for pin size calculations
if (this.annotation) {
this.annotation.editor = this;
}
}
document.querySelector('.openlime-overlay').classList.toggle('erase', tool == 'erase');
document.querySelector('.openlime-overlay').classList.toggle('crosshair', tool && tool != 'erase');
}
// UNDO/REDO SYSTEM
undo() {
let anno = this.annotation;
if (!anno) return;
if (this.factory && this.factory.undo && this.factory.undo()) {
anno.needsUpdate = true;
this.viewer.redraw();
return;
}
if (anno.history && anno.history.length) {
anno.future.push(this.annoToData(anno));
let data = anno.history.pop();
this.dataToAnno(data, anno);
anno.needsUpdate = true;
this.viewer.redraw();
this.updateEditWidget();
}
}
redo() {
let anno = this.annotation;
if (!anno) return;
if (this.factory && this.factory.redo && this.factory.redo()) {
anno.needsUpdate = true;
this.viewer.redraw();
return;
}
if (anno.future && anno.future.length) {
anno.history.push(this.annoToData(anno));
let data = anno.future.pop();
this.dataToAnno(data, anno);
anno.needsUpdate = true;
this.viewer.redraw();
this.updateEditWidget();
}
}
saveCurrent() {
let anno = this.annotation;
if (!anno.history) anno.history = [];
anno.history.push(this.annoToData(anno));
anno.future = [];
}
annoToData(anno) {
let data = {};
for (let i of ['id', 'label', 'description', 'class', 'publish', 'data'])
data[i] = `${anno[i] || ''}`;
data.elements = anno.elements.map(e => {
let n = e.cloneNode();
n.points = e.points;
return n;
});
return data;
}
dataToAnno(data, anno) {
for (let i of ['id', 'label', 'description', 'class', 'publish', 'data'])
anno[i] = `${data[i]}`;
anno.elements = data.elements.map(e => {
let n = e.cloneNode();
n.points = e.points;
return n;
});
}
// EVENT HANDLERS
keyUp(e) {
if (e.defaultPrevented) return;
switch (e.key) {
case 'Escape':
if (this.tool) {
this.setActiveTool();
this.setTool(null);
e.preventDefault();
}
break;
case 'Delete':
this.deleteSelected();
break;
case 'z':
if (e.ctrlKey) this.undo();
break;
case 'Z':
if (e.ctrlKey) this.redo();
break;
}
}
panStart(e) {
if (e.buttons != 1 || e.ctrlKey || e.altKey || e.shiftKey || e.metaKey)
return;
if (!['line', 'box', 'circle'].includes(this.tool))
return;
this.panning = true;
e.preventDefault();
this.saveCurrent();
const pos = this.mapToSvg(e);
let shape = this.factory.create(pos, e);
this.annotation.needsUpdate = true;
this.viewer.redraw();
}
panMove(e) {
if (!this.panning) return false;
const pos = this.mapToSvg(e);
this.factory.adjust(pos, e);
}
panEnd(e) {
if (!this.panning) return false;
this.panning = false;
const pos = this.mapToSvg(e);
let changed = this.factory.finish(pos, e);
if (!changed) {
this.annotation.history.pop();
} else {
this.saveAnnotation();
}
this.annotation.needsUpdate = true;
this.viewer.redraw();
}
fingerHover(e) {
if (this.tool != 'line') return;
e.preventDefault();
const pos = this.mapToSvg(e);
let changed = this.factory.hover(pos, e);
this.annotation.needsUpdate = true;
this.viewer.redraw();
}
fingerSingleTap(e) {
if (!['point', 'pin', 'line', 'erase'].includes(this.tool))
return;
e.preventDefault();
// For erase tool, ensure we have an annotation and factory
if (this.tool === 'erase' && (!this.annotation || !this.factory)) {
return;
}
this.saveCurrent();
const pos = this.mapToSvg(e);
let changed = this.factory.tap(pos, e);
if (!changed) {
this.annotation.history.pop();
} else {
this.saveAnnotation();
}
this.annotation.needsUpdate = true;
this.viewer.redraw();
}
fingerDoubleTap(e) {
if (!['line'].includes(this.tool)) return;
e.preventDefault();
this.saveCurrent();
const pos = this.mapToSvg(e);
let changed = this.factory.doubleTap(pos, e);
if (!changed) {
this.annotation.history.pop();
} else {
this.saveAnnotation();
}
this.annotation.needsUpdate = true;
this.viewer.redraw();
}
mapToSvg(e) {
// For erase tool, find the element under the pointer
if (this.tool === 'erase') {
e.targetElement = this._findElementUnderPointer(e);
}
const p = { x: e.offsetX, y: e.offsetY };
const layerT = this.layer.transform;
const useGL = false;
const layerbb = this.layer.boundingBox();
const layerSize = { w: layerbb.width(), h: layerbb.height() };
let pos = CoordinateSystem.fromCanvasHtmlToImage(p, this.viewer.camera, layerT, layerSize, useGL);
p.x += 1;
let pos1 = CoordinateSystem.fromCanvasHtmlToImage(p, this.viewer.camera, layerT, layerSize, useGL);
pos.pixelSize = Math.abs(pos1.x - pos.x);
return pos;
}
}
// TOOL CLASSES
class Point {
tap(pos) {
let point = Util.createSVGElement('circle', { cx: pos.x, cy: pos.y, r: 10, class: 'point' });
this.annotation.elements.push(point);
return true;
}
}
class Pin {
constructor(options) {
Object.assign(this, options);
}
tap(pos) {
// Calculate correct pin size for current zoom level
const currentSize = this.annotation.editor?.getCurrentPinSize() || 36;
const str = this.template(pos.x, pos.y, this.annotation, currentSize);
let parser = new DOMParser();
let point = parser.parseFromString(str, "image/svg+xml").documentElement;
// Add reference to editor for future updates
if (!this.annotation.editor) {
// Find the editor instance from the factory
if (this.layer && this.layer.editor) {
this.annotation.editor = this.layer.editor;
}
}
// Add to elements array
this.annotation.elements.push(point);
return true;
}
}
class Pen {
constructor() {
this.points = [];
}
create(pos) {
this.points.push(pos);
if (this.points.length == 1) {
this.path = Util.createSVGElement('path', { d: `M${pos.x} ${pos.y}`, class: 'line' });
return this.path;
}
let p = this.path.getAttribute('d');
this.path.setAttribute('d', p + ` L${pos.x} ${pos.y}`);
this.path.points = this.points;
}
undo() {
if (!this.points.length) return;
this.points.pop();
let d = this.points.map((p, i) => `${i == 0 ? 'M' : 'L'}${p.x} ${p.y}`).join(' ');
this.path.setAttribute('d', d);
if (this.points.length < 2) {
this.points = [];
this.annotation.elements = this.annotation.elements.filter((e) => e != this.path);
}
}
}
class Box {
constructor() {
this.origin = null;
this.box = null;
}
create(pos) {
this.origin = pos;
this.box = Util.createSVGElement('rect', { x: pos.x, y: pos.y, width: 0, height: 0, class: 'rect' });
this.annotation.elements.push(this.box);
return this.box;
}
adjust(pos) {
let p = this.origin;
this.box.setAttribute('x', Math.min(p.x, pos.x));
this.box.setAttribute('width', Math.abs(pos.x - p.x));
this.box.setAttribute('y', Math.min(p.y, pos.y));
this.box.setAttribute('height', Math.abs(pos.y - p.y));
}
finish(pos) {
return true;
}
}
class Circle {
constructor() {
this.origin = null;
this.circle = null;
}
create(pos) {
this.origin = pos;
this.circle = Util.createSVGElement('circle', { cx: pos.x, cy: pos.y, r: 0, class: 'circle' });
this.annotation.elements.push(this.circle);
return this.circle;
}
adjust(pos) {
let p = this.origin;
let r = Math.hypot(pos.x - p.x, pos.y - p.y);
this.circle.setAttribute('r', r);
}
finish() {
return true;
}
}
class Line {
constructor() {
this.history = []
}
create(pos) {
for (let e of this.annotation.elements) {
if (!e.points || e.points.length < 2)
continue;
if (Line.distance(e.points[0], pos) / pos.pixelSize < 5) {
e.points.reverse();
this.path = e;
this.path.setAttribute('d', Line.svgPath(e.points));
this.history = [this.path.points.length];
return;
}
if (Line.distanceToLast(e.points, pos) < 5) {
this.path = e;
this.adjust(pos);
this.history = [this.path.points.length];
return;
}
}
this.path = Util.createSVGElement('path', { d: `M${pos.x} ${pos.y}`, class: 'line' });
this.path.points = [pos];
this.history = [this.path.points.length];
this.annotation.elements.push(this.path);
}
tap(pos) {
if (!this.path) {
this.create(pos);
return false;
} else {
if (this.adjust(pos))
this.history = [this.path.points.length - 1];
return true;
}
}
doubleTap(pos) {
if (!this.path) return false;
if (this.adjust(pos)) {
this.history = [this.path.points.length - 1];
this.path = null;
}
return false;
}
hover(pos, event) {
return;
}
quit() {
return;
}
adjust(pos) {
let gap = Line.distanceToLast(this.path.points, pos);
if (gap / pos.pixelSize < 4) return false;
this.path.points.push(pos);
this.path.setAttribute('d', Line.svgPath(this.path.points));
return true;
}
finish() {
this.path.setAttribute('d', Line.svgPath(this.path.points));
return true;
}
undo() {
if (!this.path || !this.history.length)
return false;
this.path.points = this.path.points.slice(0, this.history.pop());
this.path.setAttribute('d', Line.svgPath(this.path.points));
return true;
}
redo() {
return false;
}
static svgPath(points) {
let tolerance = 1.5 * points[0].pixelSize;
let tmp = simplify(points, tolerance);
let smoothed = smooth(tmp, 90, true);
return smoothToPath(smoothed);
}
static distanceToLast(line, point) {
let last = line[line.length - 1];
return Line.distance(last, point);
}
static distance(a, b) {
let dx = a.x - b.x;
let dy = a.y - b.y;
return Math.sqrt(dx * dx + dy * dy);
}
}
/**
* Simplified Erase class that removes entire SVG elements
*/
class Erase {
tap(pos, event) {
// Get the target element from the event (set in mapToSvg)
const targetElement = event.targetElement;
if (!targetElement) {
return false; // No element found under pointer
}
// Simply remove from annotation elements array
const index = this.annotation.elements.indexOf(targetElement);
if (index > -1) {
this.annotation.elements.splice(index, 1);
// Mark annotation as needing update so it gets redrawn
this.annotation.needsUpdate = true;
return true; // Element was successfully removed
}
return false;
}
// These methods are required by the factory system but not used for simple erase
create(pos, event) { return this.tap(pos, event); }
adjust(pos, event) { return false; }
finish(pos, event) { return false; }
}
export { EditorSvgAnnotation }