From 7c84104b215f942b0384e72ebce149264a986e64 Mon Sep 17 00:00:00 2001 From: Aiosa <469130@mail.muni.cz> Date: Thu, 13 Nov 2025 09:36:19 +0100 Subject: [PATCH 01/11] fix: copy/paste valid behavior wrt history and modifications --- plugins/annotations/annotationsGUI.js | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/plugins/annotations/annotationsGUI.js b/plugins/annotations/annotationsGUI.js index b624ba51..90e5914b 100644 --- a/plugins/annotations/annotationsGUI.js +++ b/plugins/annotations/annotationsGUI.js @@ -2107,6 +2107,8 @@ class="btn m-2">Set for left click ` y: bounds.top - mousePos.y, }; this._copiedAnnotation = annotation; + this._copiedIsCopy = true; + this._deleteAnnotation(annotation); } _cutAnnotation(mousePos, annotation) { @@ -2116,7 +2118,8 @@ class="btn m-2">Set for left click ` y: bounds.top - mousePos.y, }; this._copiedAnnotation = annotation; - this._deleteAnnotation(annotation); + this._copiedIsCopy = false; + this._deleteAnnotation(annotation); } _deleteAnnotation(annotation) { @@ -2141,7 +2144,8 @@ class="btn m-2">Set for left click ` const annotation = this._copiedAnnotation; const factory = annotation._factory(); - const copy = factory.copy(annotation); + const copy = factory.copy(annotation); + // todo with polygon, translate creates 'yet another copy' -> avoid. const res = factory.translate( copy, { @@ -2150,6 +2154,9 @@ class="btn m-2">Set for left click ` }, true ); + if (this._copiedIsCopy) { + delete copy.internalID; // ensure internal ID is changed + } this.context.addAnnotation(res); factory.renderAllControls(res); } From c10f6a9d41717625df54c073e2df49769898299a Mon Sep 17 00:00:00 2001 From: Aiosa <469130@mail.muni.cz> Date: Wed, 19 Nov 2025 09:51:58 +0100 Subject: [PATCH 02/11] fix: more stable binary mask retriever, more stable polygon simplification --- modules/annotations/freeFormTool.js | 76 +++++++++++--------- modules/annotations/objects.js | 20 +++++- modules/annotations/viewport-segmentation.js | 34 +++++---- 3 files changed, 78 insertions(+), 52 deletions(-) diff --git a/modules/annotations/freeFormTool.js b/modules/annotations/freeFormTool.js index f8cc4cbd..ebe0a053 100644 --- a/modules/annotations/freeFormTool.js +++ b/modules/annotations/freeFormTool.js @@ -28,13 +28,14 @@ OSDAnnotations.FreeFormTool = class { this._node = document.getElementById("annotation-cursor"); this._windowCanvas = document.createElement('canvas'); - this._windowCanvas.width = this._windowSize.width + 4 * this.maxRadius; - this._windowCanvas.height = this._windowSize.height + 4 * this.maxRadius; + this._windowCanvas.width = this._windowSize.width + 2 * this.maxRadius; + this._windowCanvas.height = this._windowSize.height + 2 * this.maxRadius; this._ctxWindow = this._windowCanvas.getContext('2d', { willReadFrequently: true }); this._annotationCanvas = document.createElement('canvas'); - this._annotationCanvas.width = this._windowSize.width * 3; - this._annotationCanvas.height = this._windowSize.height * 3; + + this._annotationCanvas.width = this._windowSize.width * 2; + this._annotationCanvas.height = this._windowSize.height * 2; this._ctxAnnotationFull = this._annotationCanvas.getContext('2d', { willReadFrequently: true }); this.MagicWand = OSDAnnotations.makeMagicWand(); @@ -92,12 +93,12 @@ OSDAnnotations.FreeFormTool = class { _updateCanvasSize() { if (this._isWindowSizeUpdated()) { - this._windowCanvas.width = this._windowSize.width + 4 * this.maxRadius; - this._windowCanvas.height = this._windowSize.height + 4 * this.maxRadius; + this._windowCanvas.width = this._windowSize.width + 2 * this.maxRadius; + this._windowCanvas.height = this._windowSize.height + 2 * this.maxRadius; this._ctxWindow = this._windowCanvas.getContext('2d', { willReadFrequently: true }); - this._annotationCanvas.width = this._windowSize.width * 3; - this._annotationCanvas.height = this._windowSize.height * 3; + this._annotationCanvas.width = this._windowSize.width * 2; + this._annotationCanvas.height = this._windowSize.height * 2; this._ctxAnnotationFull = this._annotationCanvas.getContext('2d', { willReadFrequently: true }); return; } @@ -566,7 +567,7 @@ OSDAnnotations.FreeFormTool = class { } _processContours(nextMousePos, fillColor) { - if (!this.polygon || this._toDistancePointsAsObjects(this.mousePos, nextMousePos) < this.radius / 3) return false; + if (!this.polygon || this._toDistancePointsAsObjects(this.mousePos, nextMousePos) < this.radius / 2) return false; this._offset = {x: 2 * this.maxRadius, y: 2 * this.maxRadius}; this._convert = this._convertOSD; @@ -632,18 +633,29 @@ OSDAnnotations.FreeFormTool = class { let contours = this.MagicWand.traceContours(mask); contours = this._getValidContours(contours, ctx, {x: bbox.x, y: bbox.y}, zoomed); - contours = this.MagicWand.simplifyContours(contours, 0, 30); - - const imageContours = contours.map(contour => ({ - ...contour, - points: contour.points.map(point => { - point.x += bbox.x + 0.5; - point.y += bbox.y + 0.5; - return this._convert(point); - }) - })); + // contours = this.MagicWand.simplifyContours(contours, 0, 30); + + const MAX_POINTS = 4000; + + for (let contour of contours) { + contour.points = OSDAnnotations.PolygonUtilities.simplify( + contour.points.map(point => { + // empirically found: the polygon + point.x += bbox.x + 0.3; + point.y += bbox.y + 0.1; + return this._convert(point); + }) + ); - return imageContours; + if (contour.points.length > MAX_POINTS) { + // Aggressive extra simplification if still insane + contour.points = OSDAnnotations.PolygonUtilities.simplifyQuality( + contour.points, + 0.2 // lower quality → higher tolerance + ); + } + } + return contours; } _getBinaryMask(data, width, height) { @@ -654,9 +666,15 @@ OSDAnnotations.FreeFormTool = class { for (let x = 0; x < width; x++) { const index = (y * width + x) * 4; const r = data[index]; + const g = data[index + 1]; + const b = data[index + 2]; + const a = data[index + 3]; - if (r === 255) { - mask[y * width + x] = 1; + // simple luminance threshold + alpha guard + const lum = 0.299 * r + 0.587 * g + 0.114 * b; + if (a > 0 && lum >= 200) { // instead of r === 255 + const idx = y * width + x; + mask[idx] = 1; if (x < minX) minX = x; if (x > maxX) maxX = x; @@ -669,20 +687,10 @@ OSDAnnotations.FreeFormTool = class { if (maxX === -1 || maxY === -1) { bounds = null; } else { - bounds = { - minX: minX, - minY: minY, - maxX: maxX, - maxY: maxY - } + bounds = { minX, minY, maxX, maxY }; } - return { - data: mask, - width: width, - height: height, - bounds: bounds, - } + return { data: mask, width, height, bounds }; } _toDistancePointsAsObjects(pointA, pointB) { diff --git a/modules/annotations/objects.js b/modules/annotations/objects.js index fd4f512d..00dda1a2 100644 --- a/modules/annotations/objects.js +++ b/modules/annotations/objects.js @@ -782,11 +782,25 @@ OSDAnnotations.PolygonUtilities = { }, simplify: function (points, highestQuality = true) { - // both algorithms combined for performance, simplifies the object based on zoom level if (points.length <= 2) return points; - let tolerance = 15 / VIEWER.scalebar.imagePixelSizeOnScreen(); - points = highestQuality ? points : this._simplifyRadialDist(points, Math.pow(tolerance, 2)); + // desired visual tolerance in screen pixels + const desiredScreenTol = 15; + let pxSize = VIEWER.scalebar.imagePixelSizeOnScreen() || 1; + + // convert to image coords + let tolerance = desiredScreenTol / pxSize; + + // CLAMP to keep polygons sane at huge zooms + const MIN_TOL = 1.5; // at least ~1–2 image pixels + const MAX_TOL = 100; // avoid over-simplifying at tiny zoom + if (!isFinite(tolerance)) tolerance = MIN_TOL; + tolerance = Math.max(MIN_TOL, Math.min(MAX_TOL, tolerance)); + + points = highestQuality + ? points + : this._simplifyRadialDist(points, tolerance * tolerance); + return this._simplifyDouglasPeucker(points, tolerance); }, diff --git a/modules/annotations/viewport-segmentation.js b/modules/annotations/viewport-segmentation.js index b4f1bb53..9505adb6 100644 --- a/modules/annotations/viewport-segmentation.js +++ b/modules/annotations/viewport-segmentation.js @@ -176,24 +176,23 @@ OSDAnnotations.ViewportSegmentation = class extends OSDAnnotations.AnnotationSta return this.data; } - _getBinaryMask(data, width, height, alpha) { + _getBinaryMask(data, width, height) { let mask = new Uint8ClampedArray(width * height); let maxX = -1, minX = width, maxY = -1, minY = height, bounds; - let compareAlpha; - if (!alpha) { - compareAlpha = (a) => a <= 10; - } else { - compareAlpha = (a) => a > 10; - } - for (let y = 0; y < height; y++) { for (let x = 0; x < width; x++) { - const index = (y * width + x) * 4 + 3; - let a = data[index]; - - if (compareAlpha(a)) { - mask[y * width + x] = 1; + const index = (y * width + x) * 4; + const r = data[index]; + const g = data[index + 1]; + const b = data[index + 2]; + const a = data[index + 3]; + + // simple luminance threshold + alpha guard + const lum = 0.299 * r + 0.587 * g + 0.114 * b; + if (a > 0 && lum >= 200) { // instead of r === 255 + const idx = y * width + x; + mask[idx] = 1; if (x < minX) minX = x; if (x > maxX) maxX = x; @@ -203,8 +202,13 @@ OSDAnnotations.ViewportSegmentation = class extends OSDAnnotations.AnnotationSta } } - bounds = maxX === -1 || maxY === -1 ? null : { minX, minY, maxX, maxY }; - return { data: mask, width: width, height: height, bounds: bounds }; + if (maxX === -1 || maxY === -1) { + bounds = null; + } else { + bounds = { minX, minY, maxX, maxY }; + } + + return { data: mask, width, height, bounds }; } _getViewportScreenshotDimensions() { From 2ccc2e1af65a33c535746f50dce4adcae9651295 Mon Sep 17 00:00:00 2001 From: Simona Sarvasova Date: Thu, 27 Nov 2025 12:56:59 +0100 Subject: [PATCH 03/11] fixed some merge bugs, export issues, and board visuals, changed logic of deleteObject, added docs --- modules/annotations/annotations-canvas.js | 103 ++++++++++++------ modules/annotations/annotations.js | 1 + modules/annotations/convert/asapXml.js | 4 +- modules/annotations/convert/convertor.js | 76 ++++++++++--- modules/annotations/convert/geoJSON.js | 4 +- modules/annotations/convert/qupath.js | 14 +-- modules/annotations/history.js | 37 ++++++- .../annotations/objectAdvancedFactories.js | 3 +- modules/annotations/objectGenericFactories.js | 11 +- plugins/annotations/annotationsGUI.js | 48 ++------ 10 files changed, 190 insertions(+), 111 deletions(-) diff --git a/modules/annotations/annotations-canvas.js b/modules/annotations/annotations-canvas.js index 5db75443..5b752dc8 100644 --- a/modules/annotations/annotations-canvas.js +++ b/modules/annotations/annotations-canvas.js @@ -360,8 +360,8 @@ OSDAnnotations.FabricWrapper = class extends OpenSeadragon.EventSource { objects[i].lockScalingY = false; objects[i].lockUniScaling = false; } - if (this.cachedTargetCanvasSelection) { - const selection = this.cachedTargetCanvasSelection; + if (this._cachedTargetCanvasSelection) { + const selection = this._cachedTargetCanvasSelection; if (selection.type === 'activeSelection') { selection.getObjects().forEach(obj => { this.selectAnnotation(obj, true); @@ -373,7 +373,7 @@ OSDAnnotations.FabricWrapper = class extends OpenSeadragon.EventSource { } } } else { - this.cachedTargetCanvasSelection = this.canvas.getActiveObject(); + this._cachedTargetCanvasSelection = this.canvas.getActiveObject(); for (let i = 0; i < objects.length; i++) { //set all objects as invisible and lock in position objects[i].visible = false; @@ -401,7 +401,7 @@ OSDAnnotations.FabricWrapper = class extends OpenSeadragon.EventSource { * @return {OSDAnnotations.Layer} layer it belongs to */ checkLayer(ofObject) { - if (ofObject.hasOwnProperty("layerID") && String(ofObject.layerID) && !this._layers.hasOwnProperty(ofObject.layerID)) { + if (!this._layers.hasOwnProperty(ofObject.layerID) && ofObject.hasOwnProperty("layerID") && String(ofObject.layerID)) { this._createLayer({id: ofObject.layerID, _objects: []}); } } @@ -409,6 +409,7 @@ OSDAnnotations.FabricWrapper = class extends OpenSeadragon.EventSource { /** * Set current active layer * @param layer layer to set + * @event active-layer-changed */ setActiveLayer(layer) { if (typeof layer === 'number') layer = this._layers[layer]; @@ -422,7 +423,7 @@ OSDAnnotations.FabricWrapper = class extends OpenSeadragon.EventSource { /** * Unset the current active layer (keeps selection intact) - * @param {boolean} silent do not emit event + * @event active-layer-changed */ unsetActiveLayer() { if (!this._layer) return; @@ -506,6 +507,7 @@ OSDAnnotations.FabricWrapper = class extends OpenSeadragon.EventSource { /** * Emit selection change event + * @event layer-selection-changed * @private */ _emitLayerSelectionChanged(layerIds, isSelected) { @@ -545,7 +547,7 @@ OSDAnnotations.FabricWrapper = class extends OpenSeadragon.EventSource { const data = layer.toObject(); if (this.module.historyManager) { - data._position = this.module.historyManager.getBoardIndex('layer', layerId); + data.position = this.module.historyManager.getBoardIndex('layer', layerId); } return data; } @@ -651,15 +653,61 @@ OSDAnnotations.FabricWrapper = class extends OpenSeadragon.EventSource { } /** - * Delete object without knowledge of its identity (fully-fledged annotation or helper one) - * @param {fabric.Object} o - * @param _raise @private - * @return {boolean} true if object was deleted - */ - deleteObject(o, _raise=true) { - // this._deletedObject = o; - if (this.isAnnotation(o)) return this.deleteAnnotation(o, _raise); - return this.deleteHelperAnnotation(o); + * Delete object(s) depending on their kind: + * - Layer: delete via history. + * - Helper annotations: delete immediately (no history). + * - Full annotations: delete via history. + * + * Accepts a single annotation, an array of annotations, or any container with an `_objects` array + * (e.g., ActiveSelection, Group, Canvas, or a plain object). + * + * @param {fabric.Object|fabric.Object[]|fabric.ActiveSelection|Object|OSDAnnotations.Layer} target + * @param {boolean} [_raise=true] + * @return {boolean} true if processed + */ + deleteObject(target, _raise=true) { + if (!target) return false; + + function handleAnnotation(annot) { + // If full annotation, keep it (returned for filtering) + if (this.isAnnotation(annot)) return annot; + // Otherwise delete helper and drop from filtering + this.deleteHelperAnnotation(annot); + return undefined; + } + + if (target.type === 'layer') { + this.deleteLayer(target.id); + return true; + } + + const boundHandle = handleAnnotation.bind(this); + + const targetAnnots = [] + const targets = Array.isArray(target) + ? target + : target._objects && Array.isArray(target._objects) + ? target._objects + : [target]; + + targetAnnots.push(...targets.map(boundHandle).filter(Boolean)); + + for (const obj of targetAnnots) { + if (obj.layerID) { + const layer = this.getLayer(obj.layerID); + obj._position = layer ? layer.getAnnotationIndex(obj) : undefined; + } else { + obj._position = this.module.historyManager.getBoardIndex('annotation', obj.incrementId); + } + } + targetAnnots.sort((a, b) => (a._position ?? 0) - (b._position ?? 0)); + + this.module.history.push( + () => targetAnnots.forEach(annot => this._deleteAnnotation(annot, _raise)), + () => targetAnnots.forEach(annot => this._addAnnotation(annot, _raise)) + ) + + return true; } /** @@ -799,14 +847,14 @@ OSDAnnotations.FabricWrapper = class extends OpenSeadragon.EventSource { } _createLayer(layerData) { - const restoredLayer = new OSDAnnotations.Layer(this, layerData.id); + const restoredLayer = new OSDAnnotations.Layer(this.module, layerData.id); for (let [key, value] of Object.entries(layerData)) { restoredLayer[key] = value; } this._layers[layerData.id] = restoredLayer; - const boardIndex = restoredLayer.hasOwnProperty("_position") ? restoredLayer._position : undefined; + const boardIndex = restoredLayer.hasOwnProperty("position") ? restoredLayer.position : undefined; this.module.historyManager.addLayerToBoard(restoredLayer, boardIndex); for (let obj of layerData._objects) { @@ -1248,6 +1296,7 @@ OSDAnnotations.FabricWrapper = class extends OpenSeadragon.EventSource { /** * Emit annotation selection change + * @event annotation-selection-changed */ _emitAnnotationSelectionChanged(annotationIds, isSelected, fromCanvas) { const ids = Array.isArray(annotationIds) @@ -1332,7 +1381,7 @@ OSDAnnotations.FabricWrapper = class extends OpenSeadragon.EventSource { } const combined = [ - ...layersData.map(ld => ({ type: "layer", data: ld, pos: ld._position })), + ...layersData.map(ld => ({ type: "layer", data: ld, pos: ld.position })), ...annToDelete.map(a => ({ type: "annotation", data: a, pos: a._position })) ].sort((a, b) => (a.pos ?? 0) - (b.pos ?? 0)); @@ -1407,15 +1456,7 @@ OSDAnnotations.FabricWrapper = class extends OpenSeadragon.EventSource { return; } - if (activeObject.type === 'activeSelection') { - activeObject.getObjects().forEach(obj => { - // todo this overloads history, needs to do in one step - this.deleteObject(obj); - }); - this.canvas.requestRenderAll(); - } else { - this.deleteObject(activeObject); - } + this.deleteObject(activeObject); } /** @@ -1430,11 +1471,7 @@ OSDAnnotations.FabricWrapper = class extends OpenSeadragon.EventSource { let objects = this.canvas.getObjects(); if (!objects || objects.length === 0) return; - let objectsLength = objects.length; - for (let i = 0; i < objectsLength; i++) { - //todo this overloads history, needs to do in one step - this.deleteObject(objects[objectsLength - i - 1]); - } + this.deleteObject(objects); } /** @@ -1963,7 +2000,7 @@ OSDAnnotations.FabricWrapper = class extends OpenSeadragon.EventSource { for (let obj of fabricObjects) { initObject(obj); } - self.historyManager.assignIDs(_this.getObjects()); + self.module.historyManager.assignIDs(_this.getObjects()); }); } diff --git a/modules/annotations/annotations.js b/modules/annotations/annotations.js index 20c500b9..ad7925fc 100644 --- a/modules/annotations/annotations.js +++ b/modules/annotations/annotations.js @@ -556,6 +556,7 @@ window.OSDAnnotations = class extends XOpatModuleSingleton { */ setAnnotationCommonVisualProperty(propertyName, propertyValue) { for (let instance of OSDAnnotations.FabricWrapper.instances()) { + instance.module.presets.setCommonVisualProp(propertyName, propertyValue); instance.updateAnnotationVisuals(); } } diff --git a/modules/annotations/convert/asapXml.js b/modules/annotations/convert/asapXml.js index 08069bcb..9936fba1 100644 --- a/modules/annotations/convert/asapXml.js +++ b/modules/annotations/convert/asapXml.js @@ -68,12 +68,12 @@ OSDAnnotations.Convertor.register("asap-xml", class extends OSDAnnotations.Conve const xml_annotation = doc.createElement("Annotation"); let coordinates=[]; - let factory = this.context.getAnnotationObjectFactory(obj.factoryID); + let factory = this.context.module.getAnnotationObjectFactory(obj.factoryID); if (factory) { // Todo some better reporting mechanism if (factory.factoryID === "multipolygon") { wasError = 'ASAP XML Does not support multipolygons - saved as polygon.'; - coordinates = this.context.polygonFactory.toPointArray({points: obj.points[0]}, + coordinates = this.context.module.polygonFactory.toPointArray({points: obj.points[0]}, OSDAnnotations.AnnotationObjectFactory.withArrayPoint); } else { coordinates = factory.toPointArray(obj, OSDAnnotations.AnnotationObjectFactory.withArrayPoint); diff --git a/modules/annotations/convert/convertor.js b/modules/annotations/convert/convertor.js index 9e9b59d3..0dcb4368 100644 --- a/modules/annotations/convert/convertor.js +++ b/modules/annotations/convert/convertor.js @@ -100,19 +100,35 @@ OSDAnnotations.Convertor = class { return parserCls; } - /** - * Encodes the annotation data using asynchronous communication. - * @param options - * @param context - * @param withAnnotations - * @param withPresets - * @returns serialized or plain list of strings of objects based on this.options.serialize: - * { - * objects: [...serialized annotations... ], - * presets: [...serialzied presets... ], - * } - * - */ + /** + * Encodes the annotation data using asynchronous communication. + * @param {Object} options + * @param {string} options.format + * Format ID of the converter to use. + * @param {boolean} [options.exportsObjects] + * Whether annotation objects should be exported. + * @param {boolean} [options.exportsPresets] + * Whether annotation presets should be exported. + * @param {boolean} [options.scopeSelected=false] + * If true, only currently selected annotations/layers are exported. + * Throws EXPORT_NO_SELECTION if nothing is selected. + * @param {{x:number, y:number}} [options.imageCoordinatesOffset] + * Optional image coordinate offset applied on export/import. + * @param {boolean} [options.serialize] + * Optimization flag: if true, converters may return non-serialized data and encodeFinalize() will serialize them. + * @param {OSDAnnotations} context + * Annotations module instance. + * @param {boolean} withAnnotations + * Request exporting annotation objects. + * @param {boolean} withPresets + * Request exporting presets. + * + * @returns serialized or plain list of strings of objects based on this.options.serialize: + * { + * objects: [...serialized annotations... ], + * presets: [...serialized presets... ], + * } + */ static async encodePartial(options, context, withAnnotations=true, withPresets=true) { const parserCls = this.get(options.format); const exportAll = parserCls.includeAllAnnotationProps; @@ -120,6 +136,32 @@ OSDAnnotations.Convertor = class { options.exportsObjects = withAnnotations && parserCls.exportsObjects; options.exportsPresets = withPresets && parserCls.exportsPresets; + let selectedIds = new Set(); + if (options.exportsObjects && options.scopeSelected) { + const selectedAnns = (context.getSelectedAnnotations?.() || []); + const layers = (context.getSelectedLayers?.() || []) + .filter(Boolean); + + const layerAnns = layers.length + ? layers.flatMap(l => l.getObjects?.() || []) + : []; + + const pushUnique = (arr) => { + for (const o of arr) { + const id = String(o?.id ?? ''); + if (id) selectedIds.add(id); + } + }; + + pushUnique(selectedAnns); + pushUnique(layerAnns); + if (!selectedIds.size) { + const err = new Error('No annotations selected'); + err.code = 'EXPORT_NO_SELECTION'; + throw err; + } + } + const annotationsGetter = (...exportedProps) => { if (!options.exportsObjects) return undefined; let objs = context.toObject( @@ -129,17 +171,15 @@ OSDAnnotations.Convertor = class { ...exportedProps ).objects; - const ids = options?.filter?.ids; - if (Array.isArray(ids) && ids.length) { - const set = new Set(ids.map(String)); - objs = objs.filter(o => set.has(String(o.id))); + if (options.scopeSelected && selectedIds.size) { + objs = objs.filter(o => selectedIds.has(String(o.id))); } return objs; }; const encoded = await new parserCls(context, options).encodePartial( annotationsGetter, - () => context.presets.toObject() + () => context.module.presets.toObject() ); encoded.format = options.format; return encoded; diff --git a/modules/annotations/convert/geoJSON.js b/modules/annotations/convert/geoJSON.js index 45e80b91..296be999 100644 --- a/modules/annotations/convert/geoJSON.js +++ b/modules/annotations/convert/geoJSON.js @@ -10,7 +10,7 @@ OSDAnnotations.Convertor.register("geo-json", class extends OSDAnnotations.Conve //linear ring has the first and last vertex equal, geojson uses arrays _asGEOJsonFeature(object, type="Polygon", deleteProps=[], asLinearRing=false) { - const factory = this.context.getAnnotationObjectFactory(object.factoryID); + const factory = this.context.module.getAnnotationObjectFactory(object.factoryID); const poly = factory?.toPointArray(object, OSDAnnotations.AnnotationObjectFactory.withArrayPoint, fabric.Object.NUM_FRACTION_DIGITS) if (poly?.length > 0) { if (asLinearRing) { //linear ring @@ -87,7 +87,7 @@ OSDAnnotations.Convertor.register("geo-json", class extends OSDAnnotations.Conve return object; }, "ruler": (object) => { - const factory = this.context.getAnnotationObjectFactory(object.factoryID); + const factory = this.context.module.getAnnotationObjectFactory(object.factoryID); const converter = OSDAnnotations.AnnotationObjectFactory.withArrayPoint; return { geometry: { diff --git a/modules/annotations/convert/qupath.js b/modules/annotations/convert/qupath.js index 15c57a1b..8a2951bf 100644 --- a/modules/annotations/convert/qupath.js +++ b/modules/annotations/convert/qupath.js @@ -51,7 +51,7 @@ OSDAnnotations.Convertor.register("qupath", class extends OSDAnnotations.Convert preset = this._presetReplacer; } - const factory = this.context.getAnnotationObjectFactory(object.factoryID); + const factory = this.context.module.getAnnotationObjectFactory(object.factoryID); let poly = factory?.toPointArray(object, OSDAnnotations.AnnotationObjectFactory.withArrayPoint, fabric.Object.NUM_FRACTION_DIGITS); let coordinates; @@ -161,23 +161,23 @@ OSDAnnotations.Convertor.register("qupath", class extends OSDAnnotations.Convert decoders = { Point: (object, featureParentDict) => { let factory = OSDAnnotations.instance().getAnnotationObjectFactory("point"); - return factory.create({x: object.coordinates[0], y: object.coordinates[1]}, this.context.presets.getCommonProperties()); + return factory.create({x: object.coordinates[0], y: object.coordinates[1]}, this.context.module.presets.getCommonProperties()); }, MultiPoint: (object, featureParentDict) => this._decodeMulti(object, featureParentDict, "Point"), LineString: (object, featureParentDict) => { let factory = OSDAnnotations.instance().getAnnotationObjectFactory("polyline"); - return factory.create(this._toNativeRing(object.coordinates, false), this.context.presets.getCommonProperties()); + return factory.create(this._toNativeRing(object.coordinates, false), this.context.module.presets.getCommonProperties()); }, MultiLineString: (object, featureParentDict) => this._decodeMulti(object, featureParentDict, "LineString"), Polygon: (object, featureParentDict) => { if (object.coordinates.length > 1) { let factory = OSDAnnotations.instance().getAnnotationObjectFactory("multipolygon"); const rings = object.coordinates.map(ring => this._toNativeRing(ring)); - return factory.create(rings, this.context.presets.getCommonProperties()); + return factory.create(rings, this.context.module.presets.getCommonProperties()); } let factory = OSDAnnotations.instance().getAnnotationObjectFactory("polygon"); - return factory.create(this._toNativeRing(object.coordinates[0] || []), this.context.presets.getCommonProperties()); + return factory.create(this._toNativeRing(object.coordinates[0] || []), this.context.module.presets.getCommonProperties()); }, MultiPolygon: (object, featureParentDict) => this._decodeMulti(object, featureParentDict, "Polygon"), GeometryCollection: (object, featureParentDict) => { @@ -210,7 +210,7 @@ OSDAnnotations.Convertor.register("qupath", class extends OSDAnnotations.Convert if (!this.options.exportsObjects) return result; this.offset = this.options.addOffset ? this.options.imageCoordinatesOffset : undefined; this._presetReplacer = this.options.trimToDefaultPresets ? - OSDAnnotations.Preset.fromJSONFriendlyObject(this._defaultQuPathPresets[0], this.context) : false; + OSDAnnotations.Preset.fromJSONFriendlyObject(this._defaultQuPathPresets[0], this.context.module) : false; this._validPresets = this._presetReplacer ? this._defaultQuPathPresets.map(x => x.presetID) : null; const annotations = annotationsGetter(); @@ -273,7 +273,7 @@ OSDAnnotations.Convertor.register("qupath", class extends OSDAnnotations.Convert // define if not exists if (!presets[pid]) { presets[pid] = builtInPreset || { - color: this.context.presets.randomColorHexString(), + color: this.context.module.presets.randomColorHexString(), factoryID: "polygon", presetID: pid, meta: { diff --git a/modules/annotations/history.js b/modules/annotations/history.js index 1cf72229..ec7a883f 100644 --- a/modules/annotations/history.js +++ b/modules/annotations/history.js @@ -265,6 +265,19 @@ ${this._lastOpenedInDetachedWindow ? '' : 'overflow-y: auto; max-height: ' + thi } /* Layers */ + #history-board-for-annotations-body .history-selected[data-type="layer"] { + position: relative; + } + #history-board-for-annotations-body .history-selected[data-type="layer"]::before { + content: ""; + position: absolute; + left: 0; + top: 0; + width: 16px; + height: 28px; + background: rgba(60,180,90,0.18); + pointer-events: none; + } .history-selected[data-type="layer"] > .d-flex { background: rgba(60,180,90,0.18); } @@ -287,13 +300,29 @@ ${this._lastOpenedInDetachedWindow ? '' : 'overflow-y: auto; max-height: ' + thi opacity: 1; pointer-events: auto; } - /* Indentation: indent items inside layer containers, not board-level */ #history-board-for-annotations-body [id^="annotation-log-layer-"] { - padding-left: 16px; - min-height: 10px; /* enables drop into empty layer */ + padding-left: 0px; + min-height: 10px; border-left: 1px dashed transparent; } + /* Flush annotation row background to layer border; keep content indented */ + #history-board-for-annotations-body [id^="annotation-log-layer-"] > [data-type="annotation"] { + position: relative; + margin-left: -16px; /* extend background left */ + padding-left: 26px; /* visible icon/text indent */ + width: calc(100% + 16px); /* compensate width */ + box-sizing: border-box; + background: transparent; + } + + #history-board-for-annotations-body #layer-logs > [data-type="layer"] { + margin-left: 0; /* avoid overflowing outside the board */ + padding-left: 16px; /* keep visible content indented */ + width: 100%; /* full length within the board */ + box-sizing: border-box; + } + /* Hover targets for drop */ #history-board-for-annotations-body [id^="annotation-log-layer-"].drop-hover { background: rgba(60,180,90,0.08); @@ -2037,6 +2066,6 @@ else { ${_this._globalSelf}._boardItemSave(); } return false;">edit` : '' _emitLayerObjectsChanged(layerId) { if (!layerId) return; - this._context.raiseEvent('layer-objects-changed', { layerId: String(layerId) }); + this._context.fabric.raiseEvent('layer-objects-changed', { layerId: String(layerId) }); } }; diff --git a/modules/annotations/objectAdvancedFactories.js b/modules/annotations/objectAdvancedFactories.js index 647fb791..ac70d223 100644 --- a/modules/annotations/objectAdvancedFactories.js +++ b/modules/annotations/objectAdvancedFactories.js @@ -45,6 +45,7 @@ OSDAnnotations.Ruler = class extends OSDAnnotations.AnnotationObjectFactory { this._configureParts(instance.item(0), instance.item(1), options); this._configureWrapper(instance, instance.item(0), instance.item(1), options); } + return instance; } /** @@ -352,7 +353,7 @@ OSDAnnotations.Ruler = class extends OSDAnnotations.AnnotationObjectFactory { } toPointArray(obj, converter, digits=undefined, quality=1) { - const line = obj._objects ? obj._objects[0] : []; + const line = obj.objects ? obj.objects[0] : []; let x1 = line.x1; let y1 = line.y1; diff --git a/modules/annotations/objectGenericFactories.js b/modules/annotations/objectGenericFactories.js index f7ea8ccc..2ad49ea7 100644 --- a/modules/annotations/objectGenericFactories.js +++ b/modules/annotations/objectGenericFactories.js @@ -1,6 +1,6 @@ OSDAnnotations.Rect = class extends OSDAnnotations.AnnotationObjectFactory { - constructor(context, autoCreationStrategy, presetManager) { - super(context, autoCreationStrategy, presetManager, "rect", "rect"); + constructor(context, presetManager) { + super(context, presetManager, "rect", "rect"); this._origX = null; this._origY = null; this._current = null; @@ -1820,8 +1820,9 @@ OSDAnnotations.Multipolygon = class extends OSDAnnotations.AnnotationObjectFacto } configure(object, options) { - super.configure(object, options); - object.fillRule = "evenodd"; + const obj = super.configure(object, options); + obj.fillRule = "evenodd"; + return obj; } _createPathFromPoints(multiPoints) { @@ -1992,7 +1993,7 @@ OSDAnnotations.Multipolygon = class extends OSDAnnotations.AnnotationObjectFacto return fabric.util.transformPoint( { x, y }, fabric.util.multiplyTransformMatrices( - fabricObject.fabric.canvas.viewportTransform, + fabricObject.canvas.viewportTransform, fabricObject.calcTransformMatrix() ) ); diff --git a/plugins/annotations/annotationsGUI.js b/plugins/annotations/annotationsGUI.js index c7bd24aa..36ccfc31 100644 --- a/plugins/annotations/annotationsGUI.js +++ b/plugins/annotations/annotationsGUI.js @@ -1698,12 +1698,10 @@ style="height: 22px; width: 60px;" onchange="${this.THIS}.context.freeFormTool.s * @param withPresets * @return {Promise<*>} */ - async getExportData(preferredFormat = null, withObjects=true, withPresets=true, scope='all', selected=[]) { + async getExportData(preferredFormat = null, withObjects=true, withPresets=true) { const ioArgs = { ...this._ioArgs }; ioArgs.format = preferredFormat || this._defaultFormat; - if (withObjects && scope === 'selected') { - ioArgs.filter = { ids: selected }; - } + ioArgs.scopeSelected = this.exportOptions.scope === 'selected' || false; return this.context.fabric.export(ioArgs, withObjects, withPresets); } @@ -1712,48 +1710,20 @@ style="height: 22px; width: 60px;" onchange="${this.THIS}.context.freeFormTool.s */ exportToFile(withObjects=true, withPresets=true) { const toFormat = this.exportOptions.format; - - let scope = 'all'; - let selectedItems = []; - if (withObjects) { - scope = this.exportOptions.scope === 'selected' ? 'selected' : 'all'; - if (scope === 'selected') { - const selectedAnns = (this.context.fabric.getSelectedAnnotations?.() || []); - const layers = (this.context.fabric.getSelectedLayers?.() || []) - .filter(Boolean); - const layerAnns = layers.length - ? layers.flatMap(l => l.getObjects?.() || []) - : []; - - const seen = new Set(); - const pushUnique = (arr) => { - for (const o of arr) { - const key = String(o?.incrementId ?? ''); - if (!key || seen.has(key)) continue; - seen.add(key); - selectedItems.push(o); - } - }; - pushUnique(selectedAnns); - pushUnique(layerAnns); - - if (!selectedItems.length) { - Dialogs.show("No annotations selected to export.", 2500, Dialogs.MSG_WARN); - return; - } - } - } - - selectedItems = selectedItems.map(o => o.id); - const scopeSuffix = withObjects && scope === 'selected' ? "-selection" : ""; + const scopeSuffix = withObjects && this.exportOptions.scope === 'selected' ? "-selection" : ""; const name = APPLICATION_CONTEXT.referencedName(true) + "-" + UTILITIES.todayISOReversed() + "-" + (withPresets && withObjects ? "all" : (withObjects ? "annotations" : "presets")) + scopeSuffix; - this.getExportData(toFormat, withObjects, withPresets, scope, selectedItems).then(result => { + this.getExportData(toFormat, withObjects, withPresets).then(result => { UTILITIES.downloadAsFile(name + this.context.getFormatSuffix(toFormat), result); }).catch(e => { + if (e?.code === 'EXPORT_NO_SELECTION') { + Dialogs.show("No annotations selected to export.", 2500, Dialogs.MSG_WARN); + return; + } + Dialogs.show("Could not export annotations in the selected format.", 5000, Dialogs.MSG_WARN); console.error(e); }); From c88c8d3c7769ddcc98952840de79d6c35068f98d Mon Sep 17 00:00:00 2001 From: Simona Sarvasova Date: Thu, 27 Nov 2025 22:20:01 +0100 Subject: [PATCH 04/11] added constraints during edit mode, resolved editing of text annotation, fixed bugs --- modules/annotations/history.js | 70 ++++++++++++++++--- modules/annotations/objectGenericFactories.js | 7 +- modules/annotations/objects.js | 3 +- plugins/annotations/annotationsGUI.js | 49 ++++++++++--- 4 files changed, 107 insertions(+), 22 deletions(-) diff --git a/modules/annotations/history.js b/modules/annotations/history.js index ec7a883f..f9f5165a 100644 --- a/modules/annotations/history.js +++ b/modules/annotations/history.js @@ -85,6 +85,10 @@ OSDAnnotations.AnnotationHistoryManager = class { return `log-object-${label}`; } + getHistoryHeaderElementId() { + return 'history-board-for-annotations-header'; + } + /** * Open external menu window with the history toolbox * focuses window if already opened. @@ -229,7 +233,10 @@ ${this._lastOpenedInDetachedWindow ? '' : 'overflow-y: auto; max-height: ' + thi } getHistoryWindowHeadHtml() { + const headId = this.getHistoryHeaderElementId(); + return ` +