diff --git a/Gruntfile.js b/Gruntfile.js index c9a39c91..20e0e3a9 100644 --- a/Gruntfile.js +++ b/Gruntfile.js @@ -136,6 +136,11 @@ module.exports = function(grunt) { }); grunt.registerTask('plugins', ["uglify:plugins"]); grunt.registerTask('modules', ["uglify:modules"]); + grunt.registerTask('buildUI', function (){ + grunt.log.writeln('esbuild'); + const result = exec("npx esbuild --bundle --sourcemap --format=esm --outfile=ui/index.js ui/index.mjs"); + grunt.log.writeln(result); + }); grunt.registerTask('css', 'Generate Tailwind CSS files for usage.', function (file) { grunt.log.writeln('Tailwind'); //TODO change back to minify diff --git a/modules/annotations/EVENTS.md b/modules/annotations/EVENTS.md index e1c2db65..29525a48 100644 --- a/modules/annotations/EVENTS.md +++ b/modules/annotations/EVENTS.md @@ -14,6 +14,84 @@ For global events, it is enough to say ``OSDAnnotations.addHandler(...)``. For c ##### enabled | ``{isEnabled: boolean}`` +##### layer-added + +##### layer-removed + +##### annotation-create | ``{object: fabric.Object}`` +Fires when annotation object is created. This does not apply when +``annotation-replace`` is called - in that case, the replacement is +considered as the creation. + +##### annotation-before-create | ``{object: fabric.Object, isCancelled: () => boolean, setCancelled: (cancelled: boolean) => void}`` +This event is fired prior to inserting any annotation, including promotion (simple helper annotation creation is not affected). +`isCancelled` can be called to check if the deletion was already requested to be cancelled (by another plugin/module for example) +`setCancelled` can be used to request to cancel the deletion + +##### annotation-delete | ``{object: fabric.Object}`` + +##### annotation-before-delete | ``{object: fabric.Object, isCancelled: () => boolean, setCancelled: (cancelled: boolean) => void}`` +This event is fired prior to deleting any annotation. +`isCancelled` can be called to check if the deletion was already requested to be cancelled (by another plugin/module for example) +`setCancelled` can be used to request to cancel the deletion + +##### annotation-replace | ``{previous: fabric.Object, next: fabric.Object}`` +This event is fired when annotation is replaced, e.g. free-form-tool edit. Such edits +in fact replace annotation with a new one, although the annotation identity as perceived +by the user remains the same. This event is called only once per update, +at the end. + +##### annotation-before-replace | ``{object: fabric.Object, isCancelled: () => boolean, setCancelled: (cancelled: boolean) => void}`` +This event is fired prior to replacing annotation. Same usage as `annotation-before-delete` + +##### annotation-replace-doppelganger | ``{previous: fabric.Object, next: fabric.Object}`` +This event is fired when annotations are replaced, but only temporarily (e.g. via free form tool). +It can be called several times during one edit action. + +##### annotation-before-replace-doppelganger | ``{object: fabric.Object, isCancelled: () => boolean, setCancelled: (cancelled: boolean) => void}`` +This event is fired prior to replacing doppelganger annotation. Same usage as `annotation-before-delete` + +##### annotation-edit | ``{object: fabric.Object}`` +This event is fired when user performs direct annotation editing. + +##### annotation-before-edit | ``{object: fabric.Object, isCancelled: () => boolean, setCancelled: (cancelled: boolean) => void}`` +This event is fired prior to editing annotation. Same usage as `annotation-before-delete` + +##### annotation-selected | ``{object: fabric.Object}`` +This event is fired when user selects an annotation. + +##### annotation-deselected | ``{object: fabric.Object}`` +This event is fired when user deselects an annotation. + +##### annotation-set-private | ``{object: fabric.Object}`` +This event is fired when the `private` property of an annotation changes. + +##### annotation-add-comment | ``{object: fabric.Object, comment: AnnotationComment}`` +This event is fired when a comment is added, one by one. +```ts +type AnnotationComment = { + id: string; + author: { + id: string; + name: string; + }; + reference: string; + content: string; + replyTo?: string; + createdAt: number; + modifiedAt: number; + removed?: boolean; +} +``` + +##### annotation-delete-comment | ``{object: fabric.Object, comment: AnnotationComment}`` +This event is fired when a comment is deleted, one by one. +See `annotation-add-comment` for `AnnotationComment` definition. + +##### annotation-updated-comment | ``{object: fabric.Object, commentId: string, newComment: AnnotationComment | null}`` +This event is fired when comment data gets updated and is expected to re-render. `newComment` will be `null` if the comment was deleted. +See `annotation-add-comment` for `AnnotationComment` definition. + ##### comments-control-clicked This event is fired when user clicks the control for comments diff --git a/modules/annotations/annotations-canvas.js b/modules/annotations/annotations-canvas.js index 80c16745..1834c5f8 100644 --- a/modules/annotations/annotations-canvas.js +++ b/modules/annotations/annotations-canvas.js @@ -13,13 +13,13 @@ OSDAnnotations.FabricWrapper = class extends OpenSeadragon.EventSource { this.overlay.resizecanvas(); //if plugin loaded at runtime, 'open' event not called // this._debugActiveObjectBinder(); - this.__selectionSnapshot = null; - this.__snapshotIsModifierToggle = false; + this.__selectionSnapshot = []; + this.__programmaticClear = false; // to avoid firing clear selection events from fabric on empty clicks + this._trackedDoppelGangers = {}; this._dopperlGangerCount = 0; // todo move layers functionality? - this._layers = {}; // all existing layers (id -> layer) this._layer = undefined; // active layer (last selected) this._selectedLayers = new Set(); // all selected layers (ids) @@ -150,6 +150,8 @@ OSDAnnotations.FabricWrapper = class extends OpenSeadragon.EventSource { * @return Promise((string|object)) serialized data or object of serialized annotations and presets (if applicable) */ async export(options={}, withAnnotations=true, withPresets=true) { + if (this.module.historyManager.isOngoingEdit()) this.module.historyManager._boardItemSave(); + if (!options?.format) options.format = "native"; //prevent immediate serialization as we feed it to a merge immediately, -> we don't reuse exportPartial(..) options.serialize = false; @@ -302,6 +304,7 @@ OSDAnnotations.FabricWrapper = class extends OpenSeadragon.EventSource { props = Array.from(new Set(props)); let objectsToExport = this.canvas.getObjects(); + objectsToExport = objectsToExport.filter(o => !o.excludeFromExport); if (filter && typeof filter === "function") { objectsToExport = objectsToExport.filter(filter); } @@ -360,20 +363,18 @@ OSDAnnotations.FabricWrapper = class extends OpenSeadragon.EventSource { objects[i].lockScalingY = false; objects[i].lockUniScaling = false; } - if (this.cachedTargetCanvasSelection) { - const selection = this.cachedTargetCanvasSelection; - if (selection.type === 'activeSelection') { - selection.getObjects().forEach(obj => { + if (this._cachedTargetCanvasSelection) { + const selection = this._cachedTargetCanvasSelection; + if (Array.isArray(selection) && selection.length > 1) { + selection.forEach(obj => { this.selectAnnotation(obj, true); - this.updateSingleAnnotationVisuals(obj); }); } else { - this.selectAnnotation(selection, true); - this.updateSingleAnnotationVisuals(selection); + this.selectAnnotation(selection[0], true); } } } else { - this.cachedTargetCanvasSelection = this.canvas.getActiveObject(); + this._cachedTargetCanvasSelection = this.getSelectedAnnotations(); for (let i = 0; i < objects.length; i++) { //set all objects as invisible and lock in position objects[i].visible = false; @@ -401,14 +402,15 @@ 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: []}); } } /** * Set current active layer - * @param layer layer to set + * @param {OSDAnnotations.Layer|number} layer layer instance or id + * @event active-layer-changed */ setActiveLayer(layer) { if (typeof layer === 'number') layer = this._layers[layer]; @@ -416,13 +418,13 @@ OSDAnnotations.FabricWrapper = class extends OpenSeadragon.EventSource { this._layer.setActive(true); this.raiseEvent('active-layer-changed', { - id: String(this._layer.id) + layer: this._layer }); } /** * Unset the current active layer (keeps selection intact) - * @param {boolean} silent do not emit event + * @event active-layer-changed */ unsetActiveLayer() { if (!this._layer) return; @@ -430,14 +432,13 @@ OSDAnnotations.FabricWrapper = class extends OpenSeadragon.EventSource { this._layer = undefined; this.raiseEvent('active-layer-changed', { - id: undefined + layer: undefined }); } /** - * Select a layer (optionally append for multi-selection). + * Select a layer, supports multi selection * @param {OSDAnnotations.Layer|number} layer layer instance or id - * @param {boolean} append true to keep existing selection and add this one * @param {boolean} silent do not emit event */ selectLayer(layer, silent=false) { @@ -448,44 +449,41 @@ OSDAnnotations.FabricWrapper = class extends OpenSeadragon.EventSource { this._selectedLayers.add(layer.id); layer.setActive(true); - if (!silent) this._emitLayerSelectionChanged(layer.id, true); + if (!silent) this._emitLayerSelectionChanged([layer], []); } } /** - * Deselect a specific layer (if it was active, active layer becomes last remaining or undefined) - * @param {number} layerId - * @param {boolean} silent + * Deselect a specific layer (if it was active, active layer becomes last remaining or none) + * @param {OSDAnnotations.Layer|number} layer layer instance or id + * @param {boolean} silent do not emit event */ - deselectLayer(layerId, silent=false) { - layerId = String(layerId); - if (!this._selectedLayers.has(layerId)) return; - const layer = this._layers[layerId]; - this._selectedLayers.delete(layerId); - if (layer) layer.setActive(false); + deselectLayer(layer, silent=false) { + layer = typeof layer === 'number' ? this._layers[layer] : layer; + if (!layer || !this._selectedLayers.has(layer.id)) return; - if (this._layer && this._layer.id === layerId) { + this._selectedLayers.delete(layer.id); + layer.setActive(false); + + if (this._layer && this._layer.id === layer.id) { const last = Array.from(this._selectedLayers).at(-1); last ? this.setActiveLayer(Number(last)) : this.unsetActiveLayer(); } - if (!silent) this._emitLayerSelectionChanged(layerId, false); + if (!silent) this._emitLayerSelectionChanged([], [layer]); } /** * Clear all layer selection (no active layer afterwards) - * @param {boolean} silent + * @param {boolean} silent do not emit event */ clearLayerSelection(silent=false) { - if (this._selectedLayers.size === 0) return; - const ids = this.getSelectedLayerIds(); - - this._selectedLayers.forEach(id => { - const l = this._layers[id]; - if (l) l.setActive(false); - }); + const deselect = this.getSelectedLayers(); + if (deselect.length === 0) return; + deselect.forEach(layer => layer.setActive(false)); this._selectedLayers.clear(); - if (!silent) this._emitLayerSelectionChanged(ids, false); + + if (!silent) this._emitLayerSelectionChanged([], deselect); } /** @@ -505,17 +503,20 @@ OSDAnnotations.FabricWrapper = class extends OpenSeadragon.EventSource { } /** - * Emit selection change event + * Emit layer selection change event + * @event layer-selection-changed * @private */ - _emitLayerSelectionChanged(layerIds, isSelected) { - const ids = Array.isArray(layerIds) - ? layerIds.map(String) - : (layerIds !== undefined && layerIds !== null ? String(layerIds) : layerIds); + _emitLayerSelectionChanged(selected, deselected) { + const norm = v => (v === undefined || v === null) ? [] : (Array.isArray(v) ? v : [v]); + + const sel = norm(selected); + const desel = norm(deselected); + if (sel.length === 0 && desel.length === 0) return; this.raiseEvent('layer-selection-changed', { - ids: ids, - isSelected: isSelected + selected: sel, + deselected: desel }); } @@ -545,7 +546,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 +652,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; } /** @@ -758,8 +805,8 @@ OSDAnnotations.FabricWrapper = class extends OpenSeadragon.EventSource { this.addAnnotationToLayer(annotation, layerIndex); if (!_dangerousSkipHistory) this.module.historyManager.addAnnotationToBoard(annotation, undefined, boardIndex); - // this.clearAnnotationSelection(true); - // this.selectAnnotation(annotation, true); + //this.clearAnnotationSelection(true); + this.selectAnnotation(annotation, true, true); if (_raise) this.raiseEvent('annotation-create', {object: annotation}); this.canvas.requestRenderAll(); @@ -789,8 +836,7 @@ OSDAnnotations.FabricWrapper = class extends OpenSeadragon.EventSource { if (updateUI) { this.module.historyManager.addAnnotationToBoard(next, previous, boardIndex); - // this.clearAnnotationSelection(true); - // this.selectAnnotation(next, true); + this.selectAnnotation(next, true, true); this.raiseEvent('annotation-replace', {previous, next}); this.module.raiseEvent('history-change'); } @@ -799,14 +845,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) { @@ -1136,12 +1182,12 @@ OSDAnnotations.FabricWrapper = class extends OpenSeadragon.EventSource { } /** - * Get array of selected annotation IDs (incrementId as strings) + * Get array of selected annotations internalIDs * @returns {string[]} */ getSelectedAnnotationIds() { return this.getSelectedAnnotations() - .map(obj => String(obj.incrementId)) + .map(obj => String(obj.internalID)) .filter(Boolean); } @@ -1153,94 +1199,97 @@ OSDAnnotations.FabricWrapper = class extends OpenSeadragon.EventSource { const activeObject = this.canvas.getActiveObject(); if (!activeObject) return []; - return activeObject.type === 'activeSelection' - ? activeObject._objects - : [activeObject]; + return [ ...(this.__selectionSnapshot.slice(0, -1)), activeObject]; } /** * Select an annotation by object or incrementId; supports multi-select. - * @param {fabric.Object|number|string} annotation + * @param {fabric.Object|number|string} annotation object or incrementId of the annotation to select * @param {boolean} [fromCanvas=false] if the selection event originates on canvas, set true + * @param {boolean} [clearPrevious=false] if true, clears previous selections * @returns {void} */ - selectAnnotation(annotation, fromCanvas=false) { - //todo fix this method + selectAnnotation(annotation, fromCanvas=false, clearPrevious=false) { if (annotation === null || annotation === undefined) return; const obj = typeof annotation === 'object' ? annotation : this.findObjectOnCanvasByIncrementId(Number(annotation)); - if (!obj) return; + + let deselect = []; + if (clearPrevious) { + this.removeHighlight(); + + deselect = this.getSelectionSnapshot(); + deselect = deselect.filter(x => { return x.internalID !== obj.internalID }); + + this.clearSelectionSnapshot(); + this.__programmaticClear = true; + this.canvas.discardActiveObject(); + } + const canvas = this.canvas; - const baseIds = this.__snapshotIsModifierToggle ? (this.__selectionSnapshot?.map(x => x.incrementId) || []) : this.getSelectionSnapshot(); - if (!baseIds.includes(obj.incrementId)) baseIds.push(obj.incrementId); - this._applySelectionFromIds(baseIds, true); + let baseAnnotations = clearPrevious ? [] : this.getSelectionSnapshot(); + if (!baseAnnotations.includes(obj)) baseAnnotations.push(obj); + this._applySelection(baseAnnotations); canvas.requestRenderAll(); - this._emitAnnotationSelectionChanged(baseIds, true, fromCanvas); + this._emitAnnotationSelectionChanged(baseAnnotations, deselect, fromCanvas); } - /** * Deselect an annotation by incrementId. * Handles single and multi-selection. - * @param {fabric.Object|string|number} object object or incrementId of the annotation to deselect - * @param {boolean} [fromCanvas=false] + * @param {fabric.Object|string|number} annotation object or incrementId of the annotation to deselect + * @param {boolean} [fromCanvas=false] if the deselection event originates on canvas, set true * @returns {void} */ - deselectAnnotation(object, fromCanvas=false) { + deselectAnnotation(annotation, fromCanvas=false) { const canvas = this.canvas; - const obj = typeof object === 'object' ? object : this.findObjectOnCanvasByIncrementId(object); + const obj = typeof annotation === 'object' ? annotation : this.findObjectOnCanvasByIncrementId(Number(annotation)); if (!obj) return; - let baseIds = this.__snapshotIsModifierToggle ? (this.__selectionSnapshot?.map(x => x.incrementId) || []) : this.getSelectionSnapshot(); - baseIds = baseIds.filter(x => x !== obj.incrementId); - this._applySelectionFromIds(baseIds); + let baseAnnotations = this.getSelectionSnapshot(); + baseAnnotations = baseAnnotations.filter(x => x.internalID !== obj.internalID); + this._applySelection(baseAnnotations); canvas.requestRenderAll(); - this._emitAnnotationSelectionChanged(baseIds, false, fromCanvas); + this._emitAnnotationSelectionChanged(baseAnnotations, [obj], fromCanvas); } /********************* PRIVATE **********************/ getSelectionSnapshot() { - const active = this.canvas.getActiveObject(); - if (!active) return []; - if (active.type === 'activeSelection') return active._objects.map(o => o.incrementId); - return [active.incrementId]; + return this.__selectionSnapshot || []; } - clearSelectionSnapshot(keepCached=false) { - if (!keepCached) this.__selectionSnapshot = null; - this.__snapshotIsModifierToggle = false; + clearSelectionSnapshot() { + this.__selectionSnapshot = []; } - _applySelectionFromIds(ids) { + _applySelection(annotations) { const canvas = this.canvas; - if (!ids || ids.length === 0) { + if (!annotations || annotations.length === 0) { + this.removeHighlight(); + this.clearSelectionSnapshot(); + + this.__programmaticClear = true; canvas.discardActiveObject(); canvas.requestRenderAll(); return; } - const objs = ids - .map(id => this.findObjectOnCanvasByIncrementId(Number(id))) - .filter(Boolean); - if (objs.length === 1) { - const obj = objs[0]; + if (annotations.length === 1) { + const obj = annotations[0]; + this.highlightAnnotation(obj); canvas.setActiveObject(obj); + this.__selectionSnapshot = [obj]; } else { - // active is single object, convert to ActiveSelection - const sel = new fabric.ActiveSelection([], { canvas }); - // Build selection incrementally to keep child positions intact - for (let o of objs) { - sel.addWithUpdate(o); - } - sel.hasBorders = false; - sel.hasControls = false; - canvas.setActiveObject(sel); - this.__selectionSnapshot = sel._objects; + const last = annotations[annotations.length - 1]; + + canvas.setActiveObject(last); + this.highlightAnnotation(last); + this.__selectionSnapshot = [...annotations]; } canvas.requestRenderAll(); @@ -1248,33 +1297,40 @@ OSDAnnotations.FabricWrapper = class extends OpenSeadragon.EventSource { /** * Emit annotation selection change + * @event annotation-selection-changed */ - _emitAnnotationSelectionChanged(annotationIds, isSelected, fromCanvas) { - const ids = Array.isArray(annotationIds) - ? annotationIds.map(id => String(id)) - : (annotationIds !== undefined && annotationIds !== null ? String(annotationIds) : annotationIds); + _emitAnnotationSelectionChanged(selected, deselected, fromCanvas) { + const norm = (v) => + (v === undefined || v === null) ? [] : (Array.isArray(v) ? v : [v]); + + const sel = norm(selected); + const desel = norm(deselected); + + if (sel.length === 0 && desel.length === 0) return; - // todo selection event collision this.raiseEvent('annotation-selection-changed', { - ids: ids, - isSelected: isSelected, - fromCanvas: fromCanvas + selected: sel, + deselected: desel, + fromCanvas }); } /** * Clear all annotation selection + * @param {boolean} [fromCanvas=false] if the clear selection event originates on canvas, set true */ clearAnnotationSelection(fromCanvas=false) { - this.__selectionSnapshot = []; - const selected = this.getSelectedAnnotationIds(); - if (!selected.length) return; + this.removeHighlight(); + + const deselect = this.getSelectionSnapshot(); + if (!deselect.length) return; + this.clearSelectionSnapshot(); + this.__programmaticClear = true; this.canvas.discardActiveObject(); this.canvas.requestRenderAll(); - // this.__oldSelection = null; - this._emitAnnotationSelectionChanged(selected, false, fromCanvas); + this._emitAnnotationSelectionChanged([], deselect, fromCanvas); } /** @@ -1287,16 +1343,12 @@ OSDAnnotations.FabricWrapper = class extends OpenSeadragon.EventSource { isAnnotationSelected(object, reference = null) { if (!object) return false; - if (Array.isArray(reference)) { - return reference.find(x => x.incrementId === object.incrementId); - } - - const active = reference || this.canvas.getActiveObject(); + const active = reference || this.getSelectedAnnotations(); if (!active) return false; - if (active.type === 'activeSelection') { - return !!(active._objects && active._objects.find(x => x.incrementId === object.incrementId)); + + if (Array.isArray(active)) { + return !!active.find(x => x.internalID === object.internalID); } - return active.incrementId === object.incrementId; } /** @@ -1332,10 +1384,13 @@ 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)); + this.clearLayerSelection?.(true); + this.clearAnnotationSelection?.(true); + this.module.history.push( () => { for (const item of [...combined].reverse()) { @@ -1345,8 +1400,6 @@ OSDAnnotations.FabricWrapper = class extends OpenSeadragon.EventSource { this._deleteLayer?.(String(item.data.id)); } } - this.clearLayerSelection?.(true); - this.clearAnnotationSelection?.(true); }, () => { for (const item of combined) { @@ -1407,14 +1460,7 @@ OSDAnnotations.FabricWrapper = class extends OpenSeadragon.EventSource { return; } - if (activeObject.type === 'activeSelection') { - activeObject.getObjects().forEach(obj => { - this.deleteObject(obj); - }); - this.canvas.requestRenderAll(); - } else { - this.deleteObject(activeObject); - } + this.deleteObject(activeObject); } /** @@ -1429,18 +1475,16 @@ 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++) { - this.deleteObject(objects[objectsLength - i - 1]); - } + this.deleteObject(objects); } /** * Update single object visuals * @param {fabric.Object} object - * @return {boolean} true on update success + * @param {Array} [selection=null] current selection array + * @return {boolean} true if visuals were updated */ - updateSingleAnnotationVisuals(object) { + updateSingleAnnotationVisuals(object, selection=null) { if (object.isHighlight) return false; let preset = this.module.presets.get(object.presetID); @@ -1448,8 +1492,9 @@ OSDAnnotations.FabricWrapper = class extends OpenSeadragon.EventSource { const factory = this.module.getAnnotationObjectFactory(object.factoryID); const visuals = {...this.module.presets.commonAnnotationVisuals}; factory.updateRendering(object, preset, visuals, visuals); + factory.renderPresetText(object); - const isSelected = this.isAnnotationSelected(object); + const isSelected = this.isAnnotationSelected(object, selection); if (isSelected) factory?.applySelectionStyle?.(object, this); return true; } @@ -1472,14 +1517,9 @@ OSDAnnotations.FabricWrapper = class extends OpenSeadragon.EventSource { _setListeners() { const _this = this; - // todo this event is called too often when clicking, the update should be preformed once to reflect the correct state - e.g. prevent by flag, or add deferred execution this.addHandler('annotation-selection-changed', (payload) => { - const ids = Array.isArray(payload.ids) ? payload.ids : [payload.ids]; - - ids.forEach(id => { - const annotation = _this.findObjectOnCanvasByIncrementId(Number(id)); - if (annotation) _this.updateSingleAnnotationVisuals(annotation); - }); + let annotations = payload.selected.concat(payload.deselected); + annotations.forEach(annot => _this.updateSingleAnnotationVisuals(annot, payload.selected)); }); this.viewer.addHandler('screenshot', e => { @@ -1649,13 +1689,10 @@ OSDAnnotations.FabricWrapper = class extends OpenSeadragon.EventSource { } }); - const handleDeselectionFromCanvas = (e) => { - const ids = (e?.deselected || []).map(obj => obj.incrementId); - if (ids.length) _this._emitAnnotationSelectionChanged(ids, false, true); - this.clearSelectionSnapshot(true); - }; - this.canvas.on('selection:updated', handleDeselectionFromCanvas); - this.canvas.on('selection:cleared', handleDeselectionFromCanvas); + this.canvas.on('selection:cleared', function(e) { + if (!_this.__programmaticClear && e.deselected && e.deselected.length > 0) _this.canvas.setActiveObject(e.deselected[0]); + _this.__programmaticClear = false; + }); /****** E V E N T L I S T E N E R S: OSD (called when navigating) **********/ @@ -1766,49 +1803,6 @@ OSDAnnotations.FabricWrapper = class extends OpenSeadragon.EventSource { // non-user event, selection fired by the system (e.g. annotation added to canvas) if (!originalEvent || !clickedObject) return; - // const isOnActive = !!(active && active.containsPoint && active.containsPoint(pointer)); - - if (clickedObject.type === 'activeSelection') { - // todo simona moved code here to compute only when needed, select one to use - - const pointer = this.canvas.getPointer(event); - - // const point = new OpenSeadragon.Point(originalEvent.x, originalEvent.y); - // const pointer = this.viewer.scalebar.getReferencedTiledImage().imageToWindowCoordinates(point); - - // todo simona removed pointer creation here, re-using the created one above, either we use image-coords-level pointer or window, don't create a third one, if you prefer creation of - // fabric pointer, create it above - for (const obj of clickedObject._objects) { - let currObj = { - left: obj.left + clickedObject.left + (clickedObject.width / 2), - top: obj.top + clickedObject.top + (clickedObject.height / 2), - width: obj.width, - height: obj.height - } - - if ( - pointer.x >= currObj.left && - pointer.x <= currObj.left + currObj.width && - pointer.y >= currObj.top && - pointer.y <= currObj.top + currObj.height - ) { - // todo simona this needs to do fine selection upon AAB hit similar to what brush tool does, but RN it is point VS polygon - simpler - - // select only if not already selected - if (clickedObject !== obj) { - clickedObject = obj; - } - break; - } - } - } - - if (clickedObject.type === 'activeSelection') { - // this happens if the active selection is 'above' the clicked object, which is not part of the selection, - // but hidden underneath -> find the object! - clickedObject = this.canvas.findNextObjectUnderMouse(event.pointer, clickedObject); - } - if (!clickedObject) { this.clearAnnotationSelection(true); return; @@ -1826,24 +1820,20 @@ OSDAnnotations.FabricWrapper = class extends OpenSeadragon.EventSource { return; } + if (originalEvent.altKey) { + let nextObject = this.canvas.findNextObjectUnderMouse(event.pointer, clickedObject); + if (!nextObject) return; + clickedObject = nextObject; + } + if (originalEvent.ctrlKey || originalEvent.metaKey || originalEvent.shiftKey) { - this.__snapshotIsModifierToggle = true; - // - // // check old selection - // const isSelected = this.isAnnotationSelected(clickedObject, this.__oldSelection); - // // If the mode does not allow, skip - // if (isSelected && this.module.mode.objectDeselected(event, object)) { - // this.deselectAnnotation(clickedObject, true); - // } else if (!isSelected && this.module.mode.objectSelected(event, object)) { - // this.selectAnnotation(clickedObject, true); - // } - - const ref = this.__selectionSnapshot || []; + + const ref = this.getSelectionSnapshot() || []; const isSelected = this.isAnnotationSelected(clickedObject, ref); if (isSelected && this.module.mode.objectDeselected(event, clickedObject)) { this.deselectAnnotation(clickedObject, true); } else if (!isSelected && this.module.mode.objectSelected(event, clickedObject)) { - this.selectAnnotation(clickedObject, true); + this.selectAnnotation(clickedObject, true, false); } return; } @@ -1853,10 +1843,7 @@ OSDAnnotations.FabricWrapper = class extends OpenSeadragon.EventSource { return; } - this.__snapshotIsModifierToggle = false; - // todo: this calls the event with updates TWICE -> it would be better to have a single event with all the updates - this.clearAnnotationSelection(true); - this.selectAnnotation(clickedObject, true); + this.selectAnnotation(clickedObject, true, true); } catch (e) { console.error(e); } @@ -1874,19 +1861,18 @@ OSDAnnotations.FabricWrapper = class extends OpenSeadragon.EventSource { * helper annotation * @param {fabric.Object} selectedObject selected annotation to highlight */ - async highlightAnnotation(selectedObject) { + highlightAnnotation(selectedObject) { this.removeHighlight(); let factory = this.module.getAnnotationObjectFactory(selectedObject.factoryID); if (!factory) return; - const highlight = await factory.selected(selectedObject); + const highlight = factory.createHighlight(selectedObject); if (!highlight) return; this.setHighlight(highlight); } - /** * Sets the highlight object, removing any existing one first * @param {fabric.Object} highlightObject the object to be set as highlight @@ -1923,6 +1909,7 @@ OSDAnnotations.FabricWrapper = class extends OpenSeadragon.EventSource { fabricObjects.push(obj); } else { const factory = this.module.getAnnotationObjectFactory(obj.factoryID); + if (!factory) continue; factory.initializeBeforeImport(obj); nonFabricObjects.push(obj); } @@ -1937,7 +1924,7 @@ OSDAnnotations.FabricWrapper = class extends OpenSeadragon.EventSource { this.module.historyManager.clearBoard(); this._layers = {}; this._layer = undefined; - this.clearAnnotationSelection(); + this.clearAnnotationSelection(true); this.clearLayerSelection(); this.unsetActiveLayer(); } @@ -1961,7 +1948,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..56aa0034 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(); } } @@ -771,10 +772,11 @@ window.OSDAnnotations = class extends XOpatModuleSingleton { }; fabric.Object.prototype.zooming = function(zoom, _realZoom) { if (this.isHighlight) { - object.set({ - strokeWidth: (object.originalStrokeWidth / graphicZoom) * 7, - strokeDashArray: [object.strokeWidth * 4, object.strokeWidth * 2] + this.set({ + strokeWidth: (this.originalStrokeWidth / zoom) * 5, + strokeDashArray: [this.strokeWidth * 3, this.strokeWidth * 2] }); + return; } this._factory()?.onZoom(this, zoom, _realZoom); } @@ -1539,7 +1541,7 @@ OSDAnnotations.StateFreeFormToolAdd = class extends OSDAnnotations.StateFreeForm handleClickUp(o, point, isLeftClick, objectFactory) { let result = this.context.freeFormTool.finish(); if (result) { - this.context.fabric.selectAnnotation(result, true); + this.context.fabric.rerender(); } return true; } 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/freeFormTool.js b/modules/annotations/freeFormTool.js index dfe6e929..77f81588 100644 --- a/modules/annotations/freeFormTool.js +++ b/modules/annotations/freeFormTool.js @@ -28,12 +28,13 @@ 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.width = this._windowSize.width + 4 * this.maxRadius; this._windowCanvas.height = this._windowSize.height + 4 * this.maxRadius; this._ctxWindow = this._windowCanvas.getContext('2d', { willReadFrequently: true }); this._annotationCanvas = document.createElement('canvas'); - this._annotationCanvas.width = this._windowSize.width * 3; + + this._annotationCanvas.width = this._windowSize.width * 3; this._annotationCanvas.height = this._windowSize.height * 3; this._ctxAnnotationFull = this._annotationCanvas.getContext('2d', { willReadFrequently: true }); @@ -91,11 +92,11 @@ OSDAnnotations.FreeFormTool = class { _updateCanvasSize() { if (this._isWindowSizeUpdated()) { - this._windowCanvas.width = this._windowSize.width + 4 * this.maxRadius; + this._windowCanvas.width = this._windowSize.width + 4 * this.maxRadius; this._windowCanvas.height = this._windowSize.height + 4 * this.maxRadius; this._ctxWindow = this._windowCanvas.getContext('2d', { willReadFrequently: true }); - this._annotationCanvas.width = this._windowSize.width * 3; + this._annotationCanvas.width = this._windowSize.width * 3; this._annotationCanvas.height = this._windowSize.height * 3; this._ctxAnnotationFull = this._annotationCanvas.getContext('2d', { willReadFrequently: true }); return; @@ -382,7 +383,7 @@ OSDAnnotations.FreeFormTool = class { } } - ctx.fill("evenodd"); + ctx.fill("nonzero"); } //initialize object so that it is ready to be modified @@ -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/history.js b/modules/annotations/history.js index 1cf72229..ea0ca953 100644 --- a/modules/annotations/history.js +++ b/modules/annotations/history.js @@ -24,16 +24,17 @@ OSDAnnotations.AnnotationHistoryManager = class { this._autoDomRenderer = null; this._lastOpenedInDetachedWindow = false; this._boardItems = []; + this._selectionSyncPaused = false; this._context.addFabricHandler('layer-selection-changed', e => { - this._updateSelectedLayersVisual(e.ids, e.isSelected); + this._updateSelectionVisuals(e.selected, e.deselected, 'layer'); }); this._context.addFabricHandler('active-layer-changed', e => { - this._updateActiveLayerVisual(e.id); + this._updateActiveLayerVisual(e.layer); }); this._context.addFabricHandler('annotation-selection-changed', e => { - if (e.fromCanvas) this._syncSelectionFromCanvas(e.ids, e.isSelected); - this._updateSelectedAnnotationsVisual(e.ids, e.isSelected); + if (e.fromCanvas) this._syncSelectionFromCanvas(e.selected, e.deselected); + this._updateSelectionVisuals(e.selected, e.deselected, 'annotation'); }); this._context.addFabricHandler('layer-objects-changed', e => { if (!e?.layerId) return; @@ -85,6 +86,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. @@ -172,31 +177,37 @@ window.addEventListener("beforeunload", (e) => { const boardEl = this._getNode("layer-logs"); this._clearDomSelection(boardEl); - const willOpenNewWindow = !this._lastOpenedInDetachedWindow; - if (willOpenNewWindow) { - this._context.raiseEvent('before-history-swap', { - inNewWindow: true, - }); - this.openHistoryWindow(undefined); - } else { - if (!this._autoDomRenderer) { - console.error("History window cannot be swapped when auto target ID has not been set or is invalid!"); - return; + this._withSelectionSyncPaused(() => { + const willOpenNewWindow = !this._lastOpenedInDetachedWindow; + if (willOpenNewWindow) { + this._context.raiseEvent('before-history-swap', { + inNewWindow: true, + }); + this.openHistoryWindow(undefined); + } else { + if (!this._autoDomRenderer) { + console.error("History window cannot be swapped when auto target ID has not been set or is invalid!"); + return; + } + this._context.raiseEvent('before-history-swap', { + inNewWindow: false, + }); + this.openHistoryWindow(this._autoDomRenderer); } - this._context.raiseEvent('before-history-swap', { - inNewWindow: false, + this._context.raiseEvent('history-swap', { + inNewWindow: willOpenNewWindow, }); - this.openHistoryWindow(this._autoDomRenderer); - } - this._context.raiseEvent('history-swap', { - inNewWindow: willOpenNewWindow, }); + + this.refresh(); } /** * Programmatically close the board window */ destroyHistoryWindow() { + if (this.isOngoingEdit()) this._boardItemSave(); + if (this._lastOpenedInDetachedWindow) { if (!Dialogs.closeWindow(this.containerId)) { return; @@ -229,7 +240,10 @@ ${this._lastOpenedInDetachedWindow ? '' : 'overflow-y: auto; max-height: ' + thi } getHistoryWindowHeadHtml() { + const headId = this.getHistoryHeaderElementId(); + return ` +