From a340dbfceb3c56cbf9eb195dd61766222a396796 Mon Sep 17 00:00:00 2001 From: Aiosa <469130@mail.muni.cz> Date: Sat, 23 Nov 2024 20:54:00 +0100 Subject: [PATCH 1/5] feat: annotations history window order, instanceID introduction, ability to change preset in the history window, new event history-select --- modules/annotations/annotations.js | 34 +++++++++--- modules/annotations/convert/README.md | 14 ++++- modules/annotations/history.js | 74 +++++++++++++++++---------- modules/annotations/objects.js | 3 ++ plugins/annotations/annotationsGUI.js | 25 +++++++++ 5 files changed, 116 insertions(+), 34 deletions(-) diff --git a/modules/annotations/annotations.js b/modules/annotations/annotations.js index dd0efdb5..1dfff10a 100644 --- a/modules/annotations/annotations.js +++ b/modules/annotations/annotations.js @@ -525,6 +525,24 @@ window.OSDAnnotations = class extends XOpatModuleSingleton { return this.overlay.fabric; } + /** + * Find annotation by its increment ID + * @param id + * @return {null|fabric.Object} + */ + findObjectOnCanvasByIncrementId(id) { + //todo fabric.js should have some way how to avoid linear iteration over all objects... + let target = null; + this.canvas.getObjects().some(o => { + if (o.incrementId === id) { + target = o; + return true; + } + return false; + }); + return target; + } + /** * Hide or show annotations * @param {boolean} on @@ -661,7 +679,7 @@ window.OSDAnnotations = class extends XOpatModuleSingleton { return original; } - checkPreset(object) { + checkAnnotation(object) { let preset; if (object.presetID) { preset = this.presets.get(object.presetID); @@ -675,7 +693,6 @@ window.OSDAnnotations = class extends XOpatModuleSingleton { preset = this.presets.left || this.presets.getOrCreate("__default__"); object.presetID = preset.presetID; } - const props = this.presets.getCommonProperties(preset); if (!isNaN(object.zoomAtCreation)) { props.zoomAtCreation = object.zoomAtCreation; @@ -697,6 +714,7 @@ window.OSDAnnotations = class extends XOpatModuleSingleton { //todo make sure cached zoom value const zoom = this.canvas.getZoom(); + object.instanceID = object.instanceID || Date.now(); object.zooming(this.canvas.computeGraphicZoom(zoom), zoom); } @@ -822,6 +840,7 @@ window.OSDAnnotations = class extends XOpatModuleSingleton { annotation.sessionID = this.session; annotation.author = XOpatUser.instance().id; annotation.created = Date.now(); + annotation.instanceID = annotation.instaceID || annotation.created; this.history.push(annotation); this.canvas.setActiveObject(annotation); @@ -830,8 +849,10 @@ window.OSDAnnotations = class extends XOpatModuleSingleton { } /** - * Add annotation to the canvas. Annotation will have identity + * Add annotation to the canvas. Annotation will have NEW identity * (unlike helper annotation which is meant for visual purposes only). + * If you wish to update annotation (type / geometry) but keep identity, + * you must use replaceAnnotation() instead! * @param {fabric.Object} annotation * @param _raise @private */ @@ -851,7 +872,7 @@ window.OSDAnnotations = class extends XOpatModuleSingleton { if (factory !== undefined) { const options = this.presets.getAnnotationOptionsFromInstance(this.presets.get(presetID)); factory.configure(annotation, options); - if (_raise) this.raiseEvent('annotation-preset', {object: annotation, presetID: presetID}); + if (_raise) this.raiseEvent('annotation-preset-change', {object: annotation, presetID: presetID}); } } @@ -927,7 +948,7 @@ window.OSDAnnotations = class extends XOpatModuleSingleton { } /** - * Replace annotation with different one + * Replace annotation with different one. This must not be done by manual removal and creation of a new instance. * @param {fabric.Object} previous * @param {fabric.Object} next * @param {boolean} updateHistory false to ignore the history change, creates artifacts if used incorrectly @@ -942,6 +963,7 @@ window.OSDAnnotations = class extends XOpatModuleSingleton { previous.off('selected'); previous.off('deselected'); + next.instanceID = previous.instanceID; this.canvas.remove(previous); this.canvas.add(next); this.canvas.renderAll(); @@ -1636,7 +1658,7 @@ window.OSDAnnotations = class extends XOpatModuleSingleton { } self.checkLayer(obj); - self.checkPreset(obj); + self.checkAnnotation(obj); obj.on('selected', self._objectClicked.bind(self)); //todo consider annotation creation event? diff --git a/modules/annotations/convert/README.md b/modules/annotations/convert/README.md index ed59ca4b..24e54fc5 100644 --- a/modules/annotations/convert/README.md +++ b/modules/annotations/convert/README.md @@ -28,9 +28,18 @@ To register a new converter, register the converter class after its definition w Or possibly add the record manually to ``OSDAnnotations.ConvertorCONVERTERS`` map. ### Native Format -The format includes the most verbose, detailed export option. The output is a JSON object with three +This format is used when rendering annotations, and any other format is sooner or later converted to this +format. It includes the most detailed export data. The output is a JSON object with three major keys: ``metadata``, `objects` and `presets`. Metadata includes a timestamp and a version. +#### IDs +There are three ID types: + - ``id``: unused property, left for integration with other logics: gives the annotation identity of the + external (often storage) system + - ``incrementID``: unique ID per annotation memory object, even if object is perceived by user the same + after e.g. modification, it has different increment ID + - ``instanceID``: consistent ID of annotation as perceived by a user + ### Native Format: objects The objects build on fabricJS objects, extending them with multiple properties. You can use any fabricJS properties, however, a common behaviour is ensured by keeping the following property policies. `auto` keyword means this property @@ -66,7 +75,8 @@ is managed internally and is not advised to set. `preset` keyword means this pro meta custom metadata, unlike with presets this is only an override value: it is a {id: any} map presetID a numerical preset id binding layerID a numerical layer id binding, experimental - id annotation ID, can be undefined, unused by the core module + id annotation ID, can be undefined, unused by the core module, supported for external use + instanceID instance ID, defines consistently annotation as perceived by the user author created diff --git a/modules/annotations/history.js b/modules/annotations/history.js index 2dace937..d78c203c 100644 --- a/modules/annotations/history.js +++ b/modules/annotations/history.js @@ -25,6 +25,10 @@ OSDAnnotations.History = class { this._focusWithScreen = true; this._autoDomRenderer = null; this._lastOpenedInDetachedWindow = false; + + this._context.addHandler('annotation-preset-change', e => { + this._refreshBoardItem(e.object); + }); } /** @@ -443,13 +447,13 @@ ${this._globalSelf}._context.deleteAllAnnotations()" id="delete-all-annotations" } } - _focus(bbox, objectId = undefined, adjustZoom=true) { + _focus(bbox, incrementId = undefined, adjustZoom=true) { bbox.left = Number.parseFloat(bbox.left || bbox.x); bbox.top = Number.parseFloat(bbox.top || bbox.y); let targetObj = undefined; - if (objectId !== undefined) { - targetObj = this._findObjectOnCanvasById(objectId); + if (incrementId !== undefined) { + targetObj = this._context.findObjectOnCanvasByIncrementId(incrementId); if (targetObj) { this.highlight(targetObj); this._context.canvas.setActiveObject(targetObj); @@ -515,6 +519,16 @@ ${this._globalSelf}._context.deleteAllAnnotations()" id="delete-all-annotations" } } + _clickBoardElement(bbox, incrementId, pointerEvent) { + if (pointerEvent.isPrimary || pointerEvent.button === 0) this._focus(bbox, incrementId); + this._context.raiseEvent('history-select', {incrementId: incrementId, originalEvent: pointerEvent}); + } + + _refreshBoardItem(object) { + this._addToBoard(object, object); + this.highlight(object); + } + _addToBoard(object, replaced=undefined) { let desc, inputs = []; let factory = this._context.getAnnotationObjectFactory(object.factoryID); @@ -571,23 +585,44 @@ title="Edit annotation (disables navigation)" onclick="if (this.innerText === 'e ${_this._globalSelf}._boardItemEdit(this, ${focusBox}, ${object.incrementId}); } else { ${_this._globalSelf}._boardItemSave(); } return false;">edit` : ''; const html = ` -
+
${icon}
${inputs.join("")}
${editIcon}
`; + + const newPosition = object.instanceID; + function insertAt(containerRef, newObjectRef) { + let inserted = false; + containerRef.children('.item').each(function() { + const current = $(this); + const currentOrder = current.data('order'); + + if (newPosition < currentOrder) { + // Insert before the current element + newObjectRef.insertBefore(current); + inserted = true; + return false; // Exit the loop + } + }); + if (!inserted) { + containerRef.prepend(newObjectRef); + } + } + if (typeof replaced === "object" && !isNaN(replaced?.incrementId)) { this._performAtJQNode(`log-object-${replaced.incrementId}`, node => { if (node.length) { node.replaceWith(html); } else { - _this._performAtJQNode("annotation-logs", node => node.prepend(html)); + _this._performAtJQNode("annotation-logs", node => insertAt(node, $(html))); } }); } else { - this._performAtJQNode("annotation-logs", node => node.prepend(html)); + this._performAtJQNode("annotation-logs", node => insertAt(node, $(html))); } } @@ -600,12 +635,12 @@ ${editIcon} } this._focusWithScreen = false; - let objectId; + let incrementId; if (typeof object !== "object") { - objectId = object; - object = this._focus(focusBBox, objectId) || this._context.canvas.getActiveObject(); + incrementId = object; + object = this._focus(focusBBox, incrementId) || this._context.canvas.getActiveObject(); } else { - objectId = object.incrementId; + incrementId = object.incrementId; } if (object) { @@ -621,7 +656,7 @@ ${editIcon} self.html('save'); this._editSelection = { - incrementId: objectId, + incrementId: incrementId, self: self, target: object }; @@ -647,7 +682,7 @@ ${editIcon} if (!this._editSelection) return; try { - let obj = this._editSelection.target || this._findObjectOnCanvasById(this._editSelection.incrementId); + let obj = this._editSelection.target || this._context.findObjectOnCanvasByIncrementId(this._editSelection.incrementId); let self = this._editSelection.self, //from user testing: disable modification of meta? inputs = self.parent().find("input"), @@ -736,17 +771,4 @@ ${editIcon} let box = this._getFocusBBox(of, factory); return `{left: ${box.left},top: ${box.top},width: ${box.width},height: ${box.height}}`; } - - _findObjectOnCanvasById(id) { - //todo fabric.js should have some way how to avoid linear iteration over all objects... - let target = null; - this._context.canvas.getObjects().some(o => { - if (o.incrementId === id) { - target = o; - return true; - } - return false; - }); - return target; - } }; diff --git a/modules/annotations/objects.js b/modules/annotations/objects.js index 10850a15..4e7b36dc 100644 --- a/modules/annotations/objects.js +++ b/modules/annotations/objects.js @@ -53,6 +53,7 @@ OSDAnnotations.AnnotationObjectFactory = class { "lockMovementX", "lockMovementY", "meta", + "instanceID", "sessionID", "presetID", "layerID", @@ -69,11 +70,13 @@ OSDAnnotations.AnnotationObjectFactory = class { "sessionID", "zoomAtCreation", "meta", + "instanceID", "presetID", "layerID", "color", "author", "created", + "id", ]; /** diff --git a/plugins/annotations/annotationsGUI.js b/plugins/annotations/annotationsGUI.js index 2ca8afd0..301e3d07 100644 --- a/plugins/annotations/annotationsGUI.js +++ b/plugins/annotations/annotationsGUI.js @@ -411,6 +411,31 @@ onchange: this.THIS + ".setOption('importReplace', !!this.checked)", default: th USER_INTERFACE.DropDown.open(e.originalEvent, actions); }); + this.context.addHandler('history-select', e => { + if (e.originalEvent.isPrimary) return; + const annotationObject = this.context.findObjectOnCanvasByIncrementId(e.incrementId); + if (!annotationObject) return; //todo error message + + const actions = [{ + title: `Change annotation to:` + }]; + let handler = this._clickAnnotationChangePreset.bind(this, annotationObject); + this.context.presets.foreach(preset => { + let category = preset.getMetaValue('category') || 'unknown'; + let icon = preset.objectFactory.getIcon(); + actions.push({ + icon: icon, + iconCss: `color: ${preset.color};`, + title: category, + action: () => { + this._presetSelection = preset.presetID; + handler(); + }, + }); + }); + + USER_INTERFACE.DropDown.open(e.originalEvent, actions); + }); // this.context.forEachLayerSorted(l => { // this.insertLayer(l); From 015350dfeaf1b312c61376ce2d6041f7b4f60b60 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ji=C5=99=C3=AD=20Buchta?= Date: Mon, 25 Nov 2024 14:56:32 +0100 Subject: [PATCH 2/5] added new dockerfiles and dockerignore --- docker/node/Dockerfile | 52 +++++++++++++++++++++++++++ docker/node/docker-compose.yml | 45 +++++++++++++++++++++++ docker/php/docker-compose-example.yml | 46 ++++++++++++++++++++++++ docker/wsi-service/docker-compose.yml | 18 ++++++++++ 4 files changed, 161 insertions(+) create mode 100644 docker/node/Dockerfile create mode 100644 docker/node/docker-compose.yml create mode 100644 docker/php/docker-compose-example.yml create mode 100644 docker/wsi-service/docker-compose.yml diff --git a/docker/node/Dockerfile b/docker/node/Dockerfile new file mode 100644 index 00000000..b93b9701 --- /dev/null +++ b/docker/node/Dockerfile @@ -0,0 +1,52 @@ +################## +# VIEWER: BUILD### +################## +FROM node:20 AS viewer-build +WORKDIR /tmp +RUN git clone https://github.com/RationAI/openseadragon.git \ + && cd openseadragon \ + && git reset --hard ea54427f42a076e1a7a33f8590e0de22e7a335f4 \ + && npm i \ + && cd .. + +############################# +# VIEWER: PROD GIT #### +############################# +# Viewer that creates php runtime but does not include code - it must be fetched by the container on startup. +FROM node:20 AS viewer-git + +ENV LANG=en_US.UTF-8 +ENV LANGUAGE=en_US:en +ENV LC_ALL=en_US.UTF-8 + +ENV DEBIAN_FRONTEND=noninteractive +RUN apt-get update -y && apt-get install --no-install-recommends --fix-missing -y \ + curl \ + locales \ + tzdata \ + git \ + ca-certificates \ + vim \ + nano \ + && ln -fs /usr/share/zoneinfo/Europe/Prague /etc/localtime \ + && dpkg-reconfigure --frontend noninteractive tzdata \ + && apt clean \ + && rm -rf /var/lib/apt/lists/* + +WORKDIR /usr/app + +RUN rm -f /bin/sh && ln -s /bin/bash /bin/sh \ + # User Id 1000 for kubernetes + && usermod --non-unique --uid 1000 node + +EXPOSE 8000 +USER node +WORKDIR /app + +############################### +# VIEWER: PROD STANDALONE #### +############################### +# Viewer with all the necessities +FROM viewer-git AS viewer-standalone +COPY --chown=node:1000 . /app/xopat/ +RUN cd /app/xopat && npm install \ No newline at end of file diff --git a/docker/node/docker-compose.yml b/docker/node/docker-compose.yml new file mode 100644 index 00000000..a9b0804d --- /dev/null +++ b/docker/node/docker-compose.yml @@ -0,0 +1,45 @@ +services: + xopat-php: + image: ghcr.io/rationai/xopat:dev2.0.4 + ports: + - "9001:8000" + environment: + XOPAT_ENV: >- + { + "core": { + "gateway": "https://xopat.readthedocs.org", + "active_client": "localhost", + "client": { + "localhost": { + "domain": "http://localhost:9001", + "path": "/", + "image_group_server": "http://localhost:9002", + "image_group_protocol": "`$${path}/v3/batch/info?slides=$${data}`", + "image_group_preview": "`$${path}/v3/batch/thumbnail/max_size/250/250?slides=$${data}`", + "data_group_server": "http://localhost:9002", + "data_group_protocol": "`$${path}/v3/batch/info?slides=$${data.join(\",\")}`", + "headers": {}, + "js_cookie_expire": 365, + "js_cookie_path": "/", + "js_cookie_same_site": "", + "js_cookie_secure": "", + "secureMode": false, + } + }, + "setup": { + "locale": "en", + "customBlending": false, + "debugMode": false, + "webglDebugMode": false, + }, + "openSeadragonPrefix": "https://cdn.jsdelivr.net/npm/openseadragon@4.1.1/build/openseadragon/", + "openSeadragon": "openseadragon.min.js" + }, + "plugins": { + }, + "modules": { + "empaia-wsi-tile-source": { + "permaLoad": true + } + } + } \ No newline at end of file diff --git a/docker/php/docker-compose-example.yml b/docker/php/docker-compose-example.yml new file mode 100644 index 00000000..16ead908 --- /dev/null +++ b/docker/php/docker-compose-example.yml @@ -0,0 +1,46 @@ +services: + xopat-php: + image: ghcr.io/rationai/xopat:dev2.0.4 + ports: + - "9001:8000" + environment: + XOPAT_ENV: >- + { + "core": { + "gateway": "https://xopat.readthedocs.org", + "active_client": "localhost", + "client": { + "localhost": { + "domain": "http://localhost:9001", + "path": "/", + "image_group_server": "http://localhost:9002", + "image_group_protocol": "`$${path}/v3/batch/info?slides=$${data}`", + "image_group_preview": "`$${path}/v3/batch/thumbnail/max_size/250/250?slides=$${data}`", + "data_group_server": "http://localhost:9002", + "data_group_protocol": "`$${path}/v3/batch/info?slides=$${data.join(\",\")}`", + "headers": {}, + "js_cookie_expire": 365, + "js_cookie_path": "/", + "js_cookie_same_site": "", + "js_cookie_secure": "", + "secureMode": false, + } + }, + "setup": { + "locale": "en", + "customBlending": false, + "debugMode": false, + "webglDebugMode": false, + }, + "openSeadragonPrefix": "https://cdn.jsdelivr.net/npm/openseadragon@4.1.1/build/openseadragon/", + "openSeadragon": "openseadragon.min.js" + }, + "plugins": { + }, + "modules": { + "empaia-wsi-tile-source": { + "permaLoad": true + } + } + } + \ No newline at end of file diff --git a/docker/wsi-service/docker-compose.yml b/docker/wsi-service/docker-compose.yml new file mode 100644 index 00000000..33571f55 --- /dev/null +++ b/docker/wsi-service/docker-compose.yml @@ -0,0 +1,18 @@ +services: + wsi_service: + image: ghcr.io/rationai/wsi-service:test0.15 + environment: + - WS_CORS_ALLOW_CREDENTIALS=False + - WS_CORS_ALLOW_ORIGINS=["*"] + - WS_DEBUG=False + - WS_DISABLE_OPENAPI=True + - WS_MAPPER_ADDRESS=http://localhost:8080/slides/storage?slide={slide_id} + - WS_LOCAL_MODE=wsi_service.simple_mapper:SimpleMapper + - WS_ENABLE_VIEWER_ROUTES=False + - WS_INACTIVE_HISTO_IMAGE_TIMEOUT_SECONDS=600 + - WS_MAX_RETURNED_REGION_SIZE=25000000 + volumes: + - /home/novby/Documents/RationAI/test_wsis:/data + ports: + - 9002:8080 + \ No newline at end of file From 4500441cdf44f2fb4a9dba04e21761cac2dd5215 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ji=C5=99=C3=AD=20Buchta?= Date: Mon, 25 Nov 2024 14:59:50 +0100 Subject: [PATCH 3/5] added new dockerignore --- .dockerignore | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) create mode 100644 .dockerignore diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 00000000..42b3b335 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,16 @@ +* +!*.html +!*.js +!*.json +!*.md +!docs/assets +!src +!server +!plugins +!modules +!env +!/tmp/openseadragon/build +!docker/php/apache-dev.conf +!docker/php/apache.conf +!.htaccess +!*php \ No newline at end of file From 43192742c9b3979982bc05e7b2996ac74417bb0e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ji=C5=99=C3=AD=20Buchta?= Date: Mon, 25 Nov 2024 17:30:17 +0100 Subject: [PATCH 4/5] added docker compose for node server --- .dockerignore | 1 + docker/node/Dockerfile | 6 +-- docker/node/docker-compose.yml | 12 +++-- docker/php/docker-compose-example.yml | 2 +- docs/include.js | 4 +- package.json | 6 ++- server/node/README.md | 3 ++ server/node/index.js | 70 +++++++++++++-------------- 8 files changed, 58 insertions(+), 46 deletions(-) diff --git a/.dockerignore b/.dockerignore index 42b3b335..3e17c691 100644 --- a/.dockerignore +++ b/.dockerignore @@ -4,6 +4,7 @@ !*.json !*.md !docs/assets +!docs/*.js !src !server !plugins diff --git a/docker/node/Dockerfile b/docker/node/Dockerfile index b93b9701..8b90210a 100644 --- a/docker/node/Dockerfile +++ b/docker/node/Dockerfile @@ -39,7 +39,7 @@ RUN rm -f /bin/sh && ln -s /bin/bash /bin/sh \ # User Id 1000 for kubernetes && usermod --non-unique --uid 1000 node -EXPOSE 8000 +EXPOSE 9000 USER node WORKDIR /app @@ -48,5 +48,5 @@ WORKDIR /app ############################### # Viewer with all the necessities FROM viewer-git AS viewer-standalone -COPY --chown=node:1000 . /app/xopat/ -RUN cd /app/xopat && npm install \ No newline at end of file +COPY --chown=node:1000 . /app +RUN cd /app && npm install \ No newline at end of file diff --git a/docker/node/docker-compose.yml b/docker/node/docker-compose.yml index a9b0804d..eb55797f 100644 --- a/docker/node/docker-compose.yml +++ b/docker/node/docker-compose.yml @@ -1,13 +1,17 @@ services: - xopat-php: - image: ghcr.io/rationai/xopat:dev2.0.4 + xopat-node: + build: + context: ../../ + dockerfile: docker/node/Dockerfile + target: viewer-standalone + entrypoint: ["node", "index.js"] ports: - - "9001:8000" + - "9001:9000" environment: XOPAT_ENV: >- { "core": { - "gateway": "https://xopat.readthedocs.org", + "gateway": "https://xopat.readthedocs.io", "active_client": "localhost", "client": { "localhost": { diff --git a/docker/php/docker-compose-example.yml b/docker/php/docker-compose-example.yml index 16ead908..64d690ec 100644 --- a/docker/php/docker-compose-example.yml +++ b/docker/php/docker-compose-example.yml @@ -7,7 +7,7 @@ services: XOPAT_ENV: >- { "core": { - "gateway": "https://xopat.readthedocs.org", + "gateway": "https://xopat.readthedocs.io", "active_client": "localhost", "client": { "localhost": { diff --git a/docs/include.js b/docs/include.js index 3ef97d2a..60257ddd 100644 --- a/docs/include.js +++ b/docs/include.js @@ -1,11 +1,13 @@ /** + * TODO - Move to utils + * * Generates list of source files for documentation out of the ENV configuration. * For now, static only. */ 'use strict'; -var fs =require("fs"); +var fs = require("fs"); const { parse } = require('comment-json'); const parseJsonFile = (file, ...args) => { try { diff --git a/package.json b/package.json index 1b7dcfe0..0b15e0ff 100644 --- a/package.json +++ b/package.json @@ -14,7 +14,9 @@ "mkdocs": "cd docs/readthedocs && mkdocs serve", "format": "npx eslint", "test": "npx cypress run --browser chrome --e2e", - "test-w": "cross-env ELECTRON_ENABLE_LOGGING=1 npx cypress open" + "test-w": "cross-env ELECTRON_ENABLE_LOGGING=1 npx cypress open", + "ex-node": "docker-compose -f docker/node/docker-compose.yml -f docker/wsi-service/docker-compose.yml up -d", + "ex-php": "docker-compose -f docker/php/docker-compose.yml -f docker/wsi-service/docker-compose.yml up -d" }, "repository": { "type": "git", @@ -58,4 +60,4 @@ "jsdoc-class-hierarchy": "^1.1.2", "taffydb": "^2.6.2" } -} +} \ No newline at end of file diff --git a/server/node/README.md b/server/node/README.md index 0eacab3a..a9c60118 100644 --- a/server/node/README.md +++ b/server/node/README.md @@ -14,3 +14,6 @@ Furthermore, the built index file respects the state of the source files, meanin the source build task ``grunt build``, it recognizes there are files that are minified versions of the source code and loads these instead. The recognition is simply based on the minified source code index file presence and does not recognize file updates. + +### Ports: +the application is listening on port 9000 by default, But you can change the port by enviromental variable called "XOPAT_NODE_PORT" diff --git a/server/node/index.js b/server/node/index.js index d61b8700..27ce3491 100644 --- a/server/node/index.js +++ b/server/node/index.js @@ -18,12 +18,12 @@ const querystring = require('querystring'); const PROJECT_PATH = ""; -const {getCore} = require("../templates/javascript/core"); -const {loadPlugins} = require("../templates/javascript/plugins"); -const {throwFatalErrorIf} = require("./error"); +const { getCore } = require("../templates/javascript/core"); +const { loadPlugins } = require("../templates/javascript/plugins"); +const { throwFatalErrorIf } = require("./error"); const constants = require("./constants"); -const {files} = require("../../docs/include"); -const {ABSPATH} = require("./constants"); +const { files } = require("../../docs/include"); +const { ABSPATH } = require("./constants"); const rawReqToString = async (req) => { const buffers = []; @@ -49,7 +49,7 @@ const initViewerCoreAndPlugins = (req, res) => { loadPlugins(core, fs.existsSync, path => fs.readFileSync(path, { encoding: 'utf8', flag: 'r' }), dirName => fs.readdirSync(dirName).filter(f => fs.statSync(dirName + '/' + f).isDirectory()), - {t: function () {return "Unknown Error (e-translate).";}}); + { t: function () { return "Unknown Error (e-translate)."; } }); if (throwFatalErrorIf(res, core.exception, "Failed to parse the MODULES or PLUGINS initialization!")) return null; return core; } @@ -122,15 +122,15 @@ async function responseViewer(req, res) { try { switch (req.headers['content-type']) { - case 'application/x-www-form-urlencoded': - rawData = decodeURIComponent(rawData || ""); - postData = querystring.parse(rawData); - break; - - case 'application/json': - default: - postData = rawData && JSON.parse(rawData) || {}; - break; + case 'application/x-www-form-urlencoded': + rawData = decodeURIComponent(rawData || ""); + postData = querystring.parse(rawData); + break; + + case 'application/json': + default: + postData = rawData && JSON.parse(rawData) || {}; + break; } // Parse structure @@ -145,11 +145,11 @@ async function responseViewer(req, res) { if (!core) return; - const replacer = function(match, p1) { + const replacer = function (match, p1) { try { switch (p1) { - case "head": - return ` + case "head": + return ` ${core.requireCore("env")} ${core.requireLibs()} ${core.requireOpenseadragon()} @@ -158,8 +158,8 @@ ${core.requireCore("loader")} ${core.requireCore("deps")} ${core.requireCore("app")}`; - case "app": - return ` + case "app": + return ` `; - case "modules": - return core.requireModules(core.CORE.client.production); + case "modules": + return core.requireModules(core.CORE.client.production); - case "plugins": - return core.requirePlugins(core.CORE.client.production); + case "plugins": + return core.requirePlugins(core.CORE.client.production); - default: - //todo warn - return ""; + default: + //todo warn + return ""; } } catch (e) { //todo err @@ -209,18 +209,18 @@ async function responseDeveloperSetup(req, res) { if (!core) return; core.MODULES["webgl"].loaded = true; - const replacer = function(match, p1) { + const replacer = function (match, p1) { try { switch (p1) { - case "head": - return ` + case "head": + return ` ${core.requireLib('primer')} ${core.requireLib('jquery')} ${core.requireCore("env")} ${core.requireCore("deps")} ${core.requireModules(true)}`; - case "form-init": - return ` + case "form-init": + return ` `; - default: - return ""; + default: + return ""; } } catch (e) { //todo err @@ -274,7 +274,7 @@ const server = http.createServer(async (req, res) => { res.end(); } }); -server.listen(9000, 'localhost', () => { +server.listen(process.env.XOPAT_NODE_PORT || 9000, '0.0.0.0', () => { const ENV = process.env.XOPAT_ENV; const existsDefaultLocation = fs.existsSync(`${ABSPATH}env${path.sep}env.json`); if (!ENV && existsDefaultLocation) { From 73c82a581e5b08ea930fd7657a18ee982b0dd0a0 Mon Sep 17 00:00:00 2001 From: Aiosa <469130@mail.muni.cz> Date: Tue, 26 Nov 2024 13:57:45 +0100 Subject: [PATCH 5/5] feat: re-design of annotations visual style setting, redesing of annotation placement, introduce doppelgangers, bugfixes, dev_seutp reference bugfix, support for throttling, --- CHANGELOG.md | 9 +- modules/annotations/EVENTS.md | 9 +- modules/annotations/annotations.js | 242 +++++++++++++----- modules/annotations/convert/README.md | 4 +- modules/annotations/freeFormTool.js | 14 +- modules/annotations/history.js | 32 ++- .../annotations/objectAdvancedFactories.js | 11 +- modules/annotations/objectGenericFactories.js | 77 +++--- modules/annotations/objects.js | 47 ++-- modules/annotations/presets.js | 118 +++++---- .../openseadragon-fabricjs-overlay.js | 2 +- plugins/annotations/annotationsGUI.js | 33 ++- server/node/index.js | 4 + server/php/dev_setup.php | 3 + src/app.js | 3 + src/loader.js | 2 +- src/scripts.js | 49 ++++ src/shader-configurator.js | 2 +- 18 files changed, 453 insertions(+), 208 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6cda2a4f..af46d8a2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,12 +4,15 @@ The changelog file describes changes made since v2.0.0, which made significant c to the versions 1.x.x. ### Unreleased 2.1.0 -**Features:** new system for module/plugin building +**Features:** new system for module/plugin building, improvements of annotation listing features, +support for generic annotation visual style changes. -**Maintenance:** removed outdated plugins +**Maintenance:** removed outdated plugins. + +**Bugfixes:** plugins use also Cache API, annotation visuals updated also with history. ### 2.0.4 -**Features:** vertical magnification slider, allow 2x artificial zoom, annotation areas +**Features:** vertical magnification slider, allow 2x artificial zoom, annotation areas. **Bugfixes:** OIDC module, magic wand annotation tool, stacktrace capture. diff --git a/modules/annotations/EVENTS.md b/modules/annotations/EVENTS.md index abc464b1..016a0406 100644 --- a/modules/annotations/EVENTS.md +++ b/modules/annotations/EVENTS.md @@ -2,7 +2,8 @@ ##### factory-registered | e: `{factory: OSDAnnotations.AnnotationObjectFactory}` -##### opacity-changed | ``{opacity: float}`` +##### visual-property-changed | ``{[name]: any}`` +Common visual property changed. ##### osd-interactivity-toggle @@ -24,7 +25,7 @@ This event is fired when annotation is replaced, e.g. free-form-tool edit. Such in fact replace annotation with a new one. This event is called only once per update, at the end. -##### annotation-replace-helper | ``{previous: fabric.Object, next: fabric.Object}`` +##### 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. @@ -43,6 +44,10 @@ This event is fired when user performs direct annotation editing. ##### preset-meta-add | ``{preset: OSDAnnotations.Preset, key: string}`` +##### annotation-preset-change | ``{object: fabric.Object, presetID: string}`` + +##### history-select | ``{incrementId: number, originalEvent: MouseEvent}`` + ##### import | ``{options: object, clear: boolean, data: object}`` ##### export-partial | ``{options: object, data: object}`` diff --git a/modules/annotations/annotations.js b/modules/annotations/annotations.js index 1dfff10a..704ab0b7 100644 --- a/modules/annotations/annotations.js +++ b/modules/annotations/annotations.js @@ -436,30 +436,6 @@ window.OSDAnnotations = class extends XOpatModuleSingleton { /******************* SETTERS, GETTERS **********************/ - /** - * Set the annotations canvas overlay opacity - * @event opacity-changed - * @param {number} opacity - */ - setOpacity(opacity) { - this.opacity = opacity; - //this does not work for overlapping annotations: - //this.overlay.canvas.style.opacity = opacity; - this.canvas.forEachObject(function (obj) { - obj.opacity = opacity; - }); - this.raiseEvent('opacity-changed', {opacity: this.opacity}); - this.canvas.renderAll(); - } - - /** - * Get current opacity - * @return {number} - */ - getOpacity() { - return this.opacity; - } - /** * Change the interactivity - enable or disable navigation in OpenSeadragon * this is a change meant to be performed from the outside (correctly update pointer etc.) @@ -518,7 +494,7 @@ window.OSDAnnotations = class extends XOpatModuleSingleton { /** * FabricJS context - * @member OSDAnnotations + * @member OSDdAnAnnotations * @return {fabric.Canvas} */ get canvas() { @@ -617,6 +593,15 @@ window.OSDAnnotations = class extends XOpatModuleSingleton { setMode(mode, force=false) { if (this.disabledInteraction || mode === this.mode) return; + if (this._dopperlGangerCount > 0) { + console.warn("[setMode] doppelganger found while switching modes: this is a bug. Removing...", this._trackedDoppelGangers); + for (let dId in this._trackedDoppelGangers) { + this.canvas.remove(this._trackedDoppelGangers[dId]); + } + this._dopperlGangerCount = 0; + this._trackedDoppelGangers = {}; + } + if (this.mode === this.Modes.AUTO) { this._setModeFromAuto(mode); } else if (mode !== this.Modes.AUTO || force) { @@ -714,7 +699,7 @@ window.OSDAnnotations = class extends XOpatModuleSingleton { //todo make sure cached zoom value const zoom = this.canvas.getZoom(); - object.instanceID = object.instanceID || Date.now(); + object.internalID = object.internalID || Date.now(); object.zooming(this.canvas.computeGraphicZoom(zoom), zoom); } @@ -827,8 +812,9 @@ window.OSDAnnotations = class extends XOpatModuleSingleton { * Convert helper annotation to fully-fledged annotation * @param {fabric.Object} annotation helper annotation * @param _raise @private + * @param _dangerousSkipHistory @private, do not touch! */ - promoteHelperAnnotation(annotation, _raise=true) { + promoteHelperAnnotation(annotation, _raise=true, _dangerousSkipHistory=false) { annotation.off('selected'); annotation.on('selected', this._objectClicked.bind(this)); annotation.off('deselected'); @@ -840,8 +826,8 @@ window.OSDAnnotations = class extends XOpatModuleSingleton { annotation.sessionID = this.session; annotation.author = XOpatUser.instance().id; annotation.created = Date.now(); - annotation.instanceID = annotation.instaceID || annotation.created; - this.history.push(annotation); + annotation.internalID = annotation.instaceID || annotation.created; + if (!_dangerousSkipHistory) this.history.push(annotation); this.canvas.setActiveObject(annotation); if (_raise) this.raiseEvent('annotation-create', {object: annotation}); @@ -949,37 +935,116 @@ window.OSDAnnotations = class extends XOpatModuleSingleton { /** * Replace annotation with different one. This must not be done by manual removal and creation of a new instance. + * Previous annotation must be already full annotation (promoted). This method also supports **temporal** replacement + * of annotation by a doppelganger annotation. Doppelganger annotation is the same (structurally) as helper annotation, + * but user expects it to BEHAVE like full annotation (=interactive). Helper annotation is added by addHelperAnnotation, + * doppelganger is added by replaceAnnotation(.., dp, false), and must be removed by replaceAnnotation(dp, .., false) later on. * @param {fabric.Object} previous * @param {fabric.Object} next - * @param {boolean} updateHistory false to ignore the history change, creates artifacts if used incorrectly - * e.g. redo/undo buttons duplicate objects - * @param _raise invoke event if true (default) - */ - replaceAnnotation(previous, next, updateHistory=false, _raise=true) { - next.off('selected'); - next.on('selected', this._objectClicked.bind(this)); - next.off('deselected'); - next.on('deselected', this._objectDeselected.bind(this)); - previous.off('selected'); - previous.off('deselected'); - - next.instanceID = previous.instanceID; + * @param {boolean} isDoppelganger + * Example: + * - user selects annotation x and starts modification procedure: replaceAnnotation(x, y, false) + * - user drags mouse, the mouse events result in modification of the new HELPER annotation y that shows + * how user action changes the shape of the original object + * - user releases the mouse: system MUST call replaceAnnotation(y, x, false) that returns the previous + * state and optionally sets the final result by replaceAnnotation(x, y). + * + * It is possible to also perform full exchange circle: + * replaceAnnotation(x, y, false) replaceAnnotation(y, z, false) replaceAnnotation(z, x, false) + * and furthermore use z annotation to e.g. add it back to the canvas. + */ + replaceAnnotation(previous, next, isDoppelganger=false) { + // We have to skip history since we will add these to history anyway, avoid duplicate entries + + if (isDoppelganger) { + // Uses instance ID to track helper annotations on canvas + const prevIsBeingReplaced = !!previous.internalID; + const nextIsBeingReplaced = !!next.internalID; + if (prevIsBeingReplaced && nextIsBeingReplaced) { + // step backward, we come full circle (both have record of internalID) + if (!this.isAnnotation(next)) { + console.error("[replaceAnnotation] next object must be full annotation when returning to the original state!", previous, next); + this.canvas.remove(previous); + return; + } + this._trackDoppelganger(next.internalID, previous, next,false); + delete previous.internalID; + } else if (prevIsBeingReplaced) { + // step forward + this._trackDoppelganger(previous.internalID, previous, next, true); + next.internalID = previous.internalID; + } else if (nextIsBeingReplaced) { + // bad call, previous object must be on a canvas + console.error("[replaceAnnotation] next object is on a canvas, but previous object not!", previous, next); + } else { + // bad call, no object on the canvas + console.error("[replaceAnnotation] no full annotation object with temporary swap!", previous, next); + } + + } else { + if (!this.isAnnotation(previous)) { + // Try to recover + console.warn("[replaceAnnotation] annotation is a helper object!", previous); + this.promoteHelperAnnotation(previous, false, true); + } + + // !! keep reference of entity identity the same !! + next.internalID = previous.internalID; + if (!this.isAnnotation(next)) { + this.promoteHelperAnnotation(next, false, true); + } + } + this.canvas.remove(previous); this.canvas.add(next); this.canvas.renderAll(); - if (updateHistory) this.history.push(next, previous); - if (_raise) this.raiseEvent('annotation-replace', {previous, next}); - else this.raiseEvent('annotation-replace-helper', {previous, next}); + if (isDoppelganger) { + this.raiseEvent('annotation-replace-doppelganger', {previous, next}); + } else { + this.history.push(next, previous); + this.raiseEvent('annotation-replace', {previous, next}); + } + } + + /** + * Track doppelganger existence to ensure consistency of canvas + * @param id + * @param original + * @param doppelganger + * @param toAdd + * @private + */ + _trackDoppelganger(id, original, doppelganger, toAdd) { + if (toAdd) { + const existing = this._trackedDoppelGangers[id]; + if (existing === original) { + this._dopperlGangerCount--; + } else if (existing) { + console.error("Doppelganger annotation attempt to overwrite existing doppelganger!", id, original, doppelganger); + // try being consistent + this.canvas.remove(existing); + this._dopperlGangerCount--; + } + + this._trackedDoppelGangers[id] = doppelganger; + this._dopperlGangerCount++; + } else { + if (!this._trackedDoppelGangers[id]) { + console.error("Doppelganger annotation not consistently tracked!", id, original, doppelganger); + } + delete this._trackedDoppelGangers[id]; + this._dopperlGangerCount--; + } } /** - * Check whether object is not a helper annotation + * Check whether object is full annotation (not a helper or doppelganger) * @param {fabric.Object} o * @return {boolean} */ isAnnotation(o) { - return o.hasOwnProperty("incrementId"); + return o.hasOwnProperty("incrementId") && o.hasOwnProperty("sessionID"); } /** @@ -1091,12 +1156,13 @@ window.OSDAnnotations = class extends XOpatModuleSingleton { /** * Delete currently active object + * @param {boolean} [withWarning=true] whether user should get warning in case action did not do anything */ - removeActiveObject() { + removeActiveObject(withWarning=true) { let toRemove = this.canvas.getActiveObject(); if (toRemove) { this.deleteObject(toRemove); - } else { + } else if (withWarning) { Dialogs.show("Please select the annotation you would like to delete", 3000, Dialogs.MSG_INFO); } } @@ -1119,6 +1185,55 @@ window.OSDAnnotations = class extends XOpatModuleSingleton { } } + /** + * Update single object visuals + * @param {fabric.Object} object + * @return {boolean} true on update success + */ + updateSingleAnnotationVisuals(object) { + let preset = this.presets.get(object.presetID); + if (preset) { + const factory = this.getAnnotationObjectFactory(object.factoryID); + const visuals = {...this.presets.commonAnnotationVisuals}; + factory.updateRendering(object, preset, visuals, visuals); + return true; + } + // todo consider adding such preset + console.warn("[updateSingleAnnotationVisuals] annotation does not have according preset!", object); + return false; + } + + /** + * Update all object visuals + * @type function + */ + updateAnnotationVisuals = UTILITIES.makeThrottled(() => { + this.canvas.getObjects().forEach(o => this.updateSingleAnnotationVisuals(o)); + this.canvas.requestRenderAll(); + this.history.forEachHistoryCacheObject(o => this.updateSingleAnnotationVisuals(o), true); + this.raiseEvent('visual-property-changed', {visuals: this.presets.commonAnnotationVisuals}); + }, 180); + + /** + * Set annotation visual property to permanent value + * @param {string} propertyName one of OSDAnnotations.CommonAnnotationVisuals keys + * @param {any} propertyValue value for the property + */ + setAnnotationCommonVisualProperty(propertyName, propertyValue) { + if (this.presets.setCommonVisualProp(propertyName, propertyValue)) { + this.updateAnnotationVisuals(); + } + } + + /** + * Get annotations visual property + * @param {string} propertyName one of OSDAnnotations.CommonAnnotationVisuals keys + * @return {*} + */ + getAnnotationCommonVisualProperty(propertyName) { + return this.presets.getCommonVisualProp(propertyName); + } + /** * Create preset cache, this cache is loaded automatically with initPostIO request * @return {boolean} @@ -1183,12 +1298,13 @@ window.OSDAnnotations = class extends XOpatModuleSingleton { AUTO: new OSDAnnotations.AnnotationState(this, "", "", ""), }; this.mode = this.Modes.AUTO; - this.opacity = 1.0; this.disabledInteraction = false; this.autoSelectionEnabled = VIEWER.hasOwnProperty("bridge"); this.objectFactories = {}; this._extraProps = ["objects"]; this._wasModeFiredByKey = false; + this._trackedDoppelGangers = {}; + this._dopperlGangerCount = 0; this.cursor = { mouseTime: Infinity, //OSD handler click timer isDown: false, //FABRIC handler click down recognition @@ -1565,11 +1681,12 @@ window.OSDAnnotations = class extends XOpatModuleSingleton { if (e.focusCanvas) { if (!e.ctrlKey && !e.altKey) { if (e.key === "Delete" || e.key === "Backspace") { - this.mode.discard(); + this.mode.discard(true); return; } if (e.key === "Escape") { - this.mode.discard(); + this.deselectFabricObjects(); // this ensures discard does not delete created object! + this.mode.discard(false); this.history._boardItemSave(); this.setMode(this.Modes.AUTO); return; @@ -1957,9 +2074,10 @@ OSDAnnotations.AnnotationState = class { /** * Discard action: default deletes active object + * @param {boolean} [withWarning=true] whether user should get warning in case action did not do anything */ - discard() { - this.context.removeActiveObject(); + discard(withWarning=true) { + this.context.removeActiveObject(withWarning); } /** @@ -2132,14 +2250,20 @@ OSDAnnotations.StateFreeFormTool = class extends OSDAnnotations.AnnotationState height: ffTool.radius * 2 + offset }, getObjectAsCandidateForIntersectionTest); + const active = this.context.canvas.getActiveObject(); let max = 0, result = candidates; // by default return the whole list if intersections are <= 0 for (let i = 0; i < candidates.length; i++) { let candidate = candidates[i]; const intersection = OSDAnnotations.checkPolygonIntersect(brushPolygon, candidate.asPolygon); - if (intersection && intersection.length > max) { - max = intersection.length; - result = candidate; + if (intersection.length) { + if (active) { // prefer first encountered object if it is also the selection + return candidate; + } + if (intersection.length > max) { + max = intersection.length; + result = candidate; + } } } return result; @@ -2298,11 +2422,11 @@ OSDAnnotations.StateCustomCreate = class extends OSDAnnotations.AnnotationState this._lastUsed = null; } - discard() { + discard(withWarning) { if (this._lastUsed && this._lastUsed.getCurrentObject()) { this._lastUsed.discardCreate(); } else { - super.discard(); + super.discard(withWarning); } } diff --git a/modules/annotations/convert/README.md b/modules/annotations/convert/README.md index 24e54fc5..39c54931 100644 --- a/modules/annotations/convert/README.md +++ b/modules/annotations/convert/README.md @@ -38,7 +38,8 @@ There are three ID types: external (often storage) system - ``incrementID``: unique ID per annotation memory object, even if object is perceived by user the same after e.g. modification, it has different increment ID - - ``instanceID``: consistent ID of annotation as perceived by a user + - ``internalID``: consistent ID of annotation as perceived by a user, internal value not to be exported + and copied over between objects - the system manages this internally ### Native Format: objects The objects build on fabricJS objects, extending them with multiple properties. You can use any fabricJS properties, @@ -76,7 +77,6 @@ is managed internally and is not advised to set. `preset` keyword means this pro presetID a numerical preset id binding layerID a numerical layer id binding, experimental id annotation ID, can be undefined, unused by the core module, supported for external use - instanceID instance ID, defines consistently annotation as perceived by the user author created diff --git a/modules/annotations/freeFormTool.js b/modules/annotations/freeFormTool.js index c86bc6fc..7ff593fb 100644 --- a/modules/annotations/freeFormTool.js +++ b/modules/annotations/freeFormTool.js @@ -35,6 +35,8 @@ OSDAnnotations.FreeFormTool = class { */ init(object, created=false) { let objectFactory = this._context.getAnnotationObjectFactory(object.factoryID); + this._created = created; + if (objectFactory !== undefined) { if (objectFactory.factoryID !== "polygon") { //object can be used immedietaly let points = Array.isArray(created) ? points : ( @@ -62,7 +64,6 @@ OSDAnnotations.FreeFormTool = class { } this.mousePos = {x: -99999, y: -9999}; //first click can also update this.simplifier = OSDAnnotations.PolygonUtilities.simplify.bind(OSDAnnotations.PolygonUtilities); - this._created = created; this._updatePerformed = false; } @@ -227,13 +228,13 @@ OSDAnnotations.FreeFormTool = class { //fixme still small problem - updated annotaion gets replaced in the board, changing its position! if (_withDeletion) { //revert annotation replacement and delete the initial (annotation was erased by modification) - this._context.replaceAnnotation(this.polygon, this.initial, false, false); + this._context.replaceAnnotation(this.polygon, this.initial, true); this._context.deleteAnnotation(this.initial); } else if (!this._created) { //revert annotation replacement and when updated, really swap - this._context.replaceAnnotation(this.polygon, this.initial, false, false); + this._context.replaceAnnotation(this.polygon, this.initial, true); if (this._updatePerformed) { - this._context.replaceAnnotation(this.initial, this.polygon, true); + this._context.replaceAnnotation(this.initial, this.polygon); } } else { this._context.deleteHelperAnnotation(this.polygon); @@ -301,7 +302,7 @@ OSDAnnotations.FreeFormTool = class { this.initial = original; if (!this._created) { - this._context.replaceAnnotation(original, polyObject, false, false); + this._context.replaceAnnotation(original, polyObject, true); } else { this._context.addHelperAnnotation(polyObject); } @@ -313,9 +314,6 @@ OSDAnnotations.FreeFormTool = class { _createPolygonAndSetupFrom(points, object) { let polygon = this._context.polygonFactory.copy(object, points); polygon.factoryID = this._context.polygonFactory.factoryID; - //preventive update in case we modified a different-visual-like object - this._context.polygonFactory.updateRendering(this._context.presets.modeOutline, polygon, - polygon.color, OSDAnnotations.PresetManager._commonProperty.stroke); this._setupPolygon(polygon, object); } diff --git a/modules/annotations/history.js b/modules/annotations/history.js index d78c203c..8367baa4 100644 --- a/modules/annotations/history.js +++ b/modules/annotations/history.js @@ -14,9 +14,14 @@ OSDAnnotations.History = class { this._canvasFocus = ''; this._buffer = []; + // points to the current state in the redo/undo index in circular buffer this._buffidx = -1; - this.BUFFER_LENGTH = null; + // points to the most recent object in cache, when undo action comes full loop to _lastValidIndex + // it means the redo action went full circle on the buffer, and we cannot further undo, + // if we set this index to buffindex, we throw away ability to redo (diverging future) this._lastValidIndex = -1; + + this.BUFFER_LENGTH = null; this._autoIncrement = 0; this._boardSelected = null; this._context = context; @@ -224,6 +229,20 @@ ${this._globalSelf}._context.deleteAllAnnotations()" id="delete-all-annotations" return this._lastValidIndex >= 0 && this._buffidx !== this._lastValidIndex; } + /** + * Iterate over cached objects, not necessarily visible + * @param callback + * @param nonActiveOnly if true, only non-visible annotations in cache are iterated + */ + forEachHistoryCacheObject(callback, nonActiveOnly=false) { + //TODO possibly optimize by leaving out annotations not on canvas & also implement timeout + for (let cache of this._buffer) { + if (!cache) continue; + cache.forward && callback(cache.forward); + cache.back && callback(cache.back); + } + } + /** * Go step back in the history. Focuses the undo operation, updates window if opened. */ @@ -292,12 +311,9 @@ ${this._globalSelf}._context.deleteAllAnnotations()" id="delete-all-annotations" if (this._context.disabledInteraction) return; this._performAtJQNode("annotation-logs", node => node.html("")); - let _this = this; this._context.canvas.getObjects().forEach(o => { - if (!isNaN(o.incrementId)) { - let preset = this._presets.get(o.presetID); - if (preset) this._presets.updateObjectVisuals(o, preset); - _this._addToBoard(o); + if (this._context.isAnnotation(o) && this._context.updateSingleAnnotationVisuals(o)) { + this._addToBoard(o); } }); } @@ -585,7 +601,7 @@ title="Edit annotation (disables navigation)" onclick="if (this.innerText === 'e ${_this._globalSelf}._boardItemEdit(this, ${focusBox}, ${object.incrementId}); } else { ${_this._globalSelf}._boardItemSave(); } return false;">edit` : ''; const html = ` -
${icon} @@ -594,7 +610,7 @@ ${editIcon}
`; - const newPosition = object.instanceID; + const newPosition = object.internalID; function insertAt(containerRef, newObjectRef) { let inserted = false; containerRef.children('.item').each(function() { diff --git a/modules/annotations/objectAdvancedFactories.js b/modules/annotations/objectAdvancedFactories.js index 285b5a26..181c87fa 100644 --- a/modules/annotations/objectAdvancedFactories.js +++ b/modules/annotations/objectAdvancedFactories.js @@ -106,16 +106,17 @@ OSDAnnotations.Ruler = class extends OSDAnnotations.AnnotationObjectFactory { //not supported error? } - updateRendering(isTransparentFill, ofObject, withPreset, defaultStroke) { - //do nothing - always same 'transparent' + updateRendering(ofObject, preset, visualProperties, defaultVisualProperties) { + visualProperties.modeOutline = true; // we are always transparent + super.updateRendering(ofObject, preset, visualProperties, defaultVisualProperties); } onZoom(ofObject, graphicZoom, realZoom) { if (ofObject._objects) { ofObject._objects[1].set({ + //todo add geometric zoom, do not change opacity scaleX: 1/realZoom, scaleY: 1/realZoom, - opacity: realZoom / ofObject.zoomAtCreation }); super.onZoom(ofObject._objects[0], graphicZoom, realZoom); } @@ -161,7 +162,7 @@ OSDAnnotations.Ruler = class extends OSDAnnotations.AnnotationObjectFactory { pid = line.presetID; if (Math.abs(line.x1 - line.x2) < 0.1 && Math.abs(line.y1 - line.y2) < 0.1) { - return false; + return true; } const props = this._presets.getCommonProperties(); @@ -600,7 +601,7 @@ OSDAnnotations.Ruler = class extends OSDAnnotations.AnnotationObjectFactory { // let newObject = this.copy(theObject, {left: left, top: top}); // delete newObject.incrementId; //todo make this nicer, avoid always copy of this attr // theObject.calcACoords(); -// this._context.replaceAnnotation(theObject, newObject, true); +// this._context.replaceAnnotation(theObject, newObject); // } // // instantCreate(screenPoint, isLeftClick = true) { diff --git a/modules/annotations/objectGenericFactories.js b/modules/annotations/objectGenericFactories.js index c6921b70..e895f7f4 100644 --- a/modules/annotations/objectGenericFactories.js +++ b/modules/annotations/objectGenericFactories.js @@ -96,7 +96,7 @@ OSDAnnotations.Rect = class extends OSDAnnotations.AnnotationObjectFactory { left: left, top: top, width: width, height: height }); theObject.calcACoords(); - this._context.replaceAnnotation(theObject, newObject, true); + this._context.replaceAnnotation(theObject, newObject); } instantCreate(screenPoint, isLeftClick = true) { @@ -276,7 +276,7 @@ OSDAnnotations.Ellipse = class extends OSDAnnotations.AnnotationObjectFactory { left: left, top: top, rx: rx, ry: ry }); theObject.calcACoords(); - this._context.replaceAnnotation(theObject, newObject, true); + this._context.replaceAnnotation(theObject, newObject); } instantCreate(screenPoint, isLeftClick = true) { @@ -456,8 +456,14 @@ OSDAnnotations.Text = class extends OSDAnnotations.AnnotationObjectFactory { return object; } - updateRendering(isTransparentFill, ofObject, withPreset, defaultStroke) { - //do nothing - a text has no area + updateRendering(ofObject, preset, visualProperties, defaultVisualProperties) { + // ensure necessary properties are not modified! + delete visualProperties["stroke"]; + delete visualProperties["fill"]; + delete visualProperties["strokeWidth"]; + delete visualProperties["originalStrokeWidth"]; + // no support for internal logics, text driven its own scaling, just set the rest + ofObject.set(visualProperties); } onZoom(ofObject, graphicZoom, realZoom) { @@ -541,7 +547,7 @@ OSDAnnotations.Text = class extends OSDAnnotations.AnnotationObjectFactory { hasControls: false, lockMovementX: true, lockMovementY: true}); let newObject = this.copy(theObject, {left: left, top: top, text: text}); theObject.calcACoords(); - this._context.replaceAnnotation(theObject, newObject, true); + this._context.replaceAnnotation(theObject, newObject); } instantCreate(screenPoint, isLeftClick = true) { @@ -562,19 +568,11 @@ OSDAnnotations.Text = class extends OSDAnnotations.AnnotationObjectFactory { this._context.canvas.renderAll(); } - updateCreate(x, y) { - //do nothing - } - isImplicit() { //text is implicitly drawn (using fonts) return true; } - finishIndirect() { - //do nothing - } - title() { return "Text"; } @@ -660,9 +658,11 @@ OSDAnnotations.Point = class extends OSDAnnotations.Ellipse { ofObject.scaleY = 1/graphicZoom; } - updateRendering(isTransparentFill, ofObject, withPreset, defaultStroke) { - super.updateRendering(isTransparentFill, ofObject, withPreset, defaultStroke); - ofObject.set({fill: ofObject.color}); + updateRendering(ofObject, preset, visualProperties, defaultVisualProperties) { + visualProperties.modeOutline = false; + visualProperties.stroke = preset.color; + delete visualProperties.originalStrokeWidth; + super.updateRendering(ofObject, preset, visualProperties, defaultVisualProperties); } edit(theObject) { @@ -681,7 +681,7 @@ OSDAnnotations.Point = class extends OSDAnnotations.Ellipse { hasControls: false, lockMovementX: true, lockMovementY: true}); let newObject = this.copy(theObject, {x: left, y: top}); theObject.calcACoords(); - this._context.replaceAnnotation(theObject, newObject, true); + this._context.replaceAnnotation(theObject, newObject); } instantCreate(screenPoint, isLeftClick = true) { @@ -699,14 +699,6 @@ OSDAnnotations.Point = class extends OSDAnnotations.Ellipse { return true; } - updateCreate(x, y) { - //do nothing - } - - finishDirect() { - //do nothing - } - supportsBrush() { return false; } @@ -880,7 +872,7 @@ OSDAnnotations.ExplicitPointsObjectFactory = class extends OSDAnnotations.Annota (value, index) => value === this._origPoints[index])) { let newObject = this.copy(theObject, theObject.points); theObject.points = this._origPoints; - this._context.replaceAnnotation(theObject, newObject, true); + this._context.replaceAnnotation(theObject, newObject); this._context.canvas.renderAll(); } this._origPoints = null; @@ -1105,8 +1097,9 @@ OSDAnnotations.Line = class extends OSDAnnotations.AnnotationObjectFactory { return ["x1", "x2", "y1", "y2"]; } - updateRendering(isTransparentFill, ofObject, withPreset, defaultStroke) { - //do nothing - a line is always 'transparent' + updateRendering(ofObject, preset, visualProperties, defaultVisualProperties) { + visualProperties.modeOutline = true; + super.updateRendering(ofObject, preset, visualProperties, defaultVisualProperties); } /** @@ -1212,7 +1205,7 @@ OSDAnnotations.Line = class extends OSDAnnotations.AnnotationObjectFactory { theObject.x2 = this._origPoints[2]; theObject.y2 = this._origPoints[3]; - this._context.replaceAnnotation(theObject, newObject, true); + this._context.replaceAnnotation(theObject, newObject); this._context.canvas.renderAll(); } this._origPoints = null; @@ -1386,8 +1379,9 @@ OSDAnnotations.Polyline = class extends OSDAnnotations.ExplicitPointsObjectFacto return instance; } - updateRendering(isTransparentFill, ofObject, withPreset, defaultStroke) { - //do nothing - a line is always 'transparent' + updateRendering(ofObject, preset, visualProperties, defaultVisualProperties) { + visualProperties.modeOutline = true; + super.updateRendering(ofObject, preset, visualProperties, defaultVisualProperties); } getDescription(ofObject) { @@ -1433,7 +1427,7 @@ OSDAnnotations.Group = class extends OSDAnnotations.AnnotationObjectFactory { configure(object, options) { super.configure(object, options); - //or extend with all options? + //todo use factory instead object.forEachObject(o => { o.fill = options.fill; o.stroke = options.stroke; @@ -1508,7 +1502,7 @@ OSDAnnotations.Group = class extends OSDAnnotations.AnnotationObjectFactory { // // left: left, top: top, rx: rx, ry: ry // // }); // // theObject.calcACoords(); - // // this._context.replaceAnnotation(theObject, newObject, true); + // // this._context.replaceAnnotation(theObject, newObject); // } instantCreate(screenPoint, isLeftClick = true) { @@ -1545,21 +1539,10 @@ OSDAnnotations.Group = class extends OSDAnnotations.AnnotationObjectFactory { }); } - updateRendering(isTransparentFill, ofObject, color, defaultStroke) { + updateRendering(ofObject, preset, visualProperties, defaultVisualProperties) { ofObject.forEachObject(o => { - if (typeof o.color === 'string') { - if (isTransparentFill) { - o.set({ - fill: "", - stroke: color - }); - } else { - o.set({ - fill: color, - stroke: defaultStroke - }); - } - } + const factory = ofObject._factory(); + factory && factory.updateRendering(ofObject, preset, visualProperties, defaultVisualProperties); }); } diff --git a/modules/annotations/objects.js b/modules/annotations/objects.js index 4e7b36dc..07a8fe7e 100644 --- a/modules/annotations/objects.js +++ b/modules/annotations/objects.js @@ -24,6 +24,7 @@ OSDAnnotations.AnnotationObjectFactory = class { /** * Properties copied with 'all' (+exports()) + * instance ID is NOT exported and should not be exported. * @type {string[]} */ static copiedProperties = [ @@ -53,7 +54,6 @@ OSDAnnotations.AnnotationObjectFactory = class { "lockMovementX", "lockMovementY", "meta", - "instanceID", "sessionID", "presetID", "layerID", @@ -70,7 +70,6 @@ OSDAnnotations.AnnotationObjectFactory = class { "sessionID", "zoomAtCreation", "meta", - "instanceID", "presetID", "layerID", "color", @@ -372,11 +371,11 @@ OSDAnnotations.AnnotationObjectFactory = class { * Finish object creation, if in progress. Can be called also if no object * is being created. This action was performed directly by the user. * @return {boolean} true if object finished; when factory for example - * does not support direct finish, or decide not yet to finish, this should - * return false. + * decide not yet to finish, this should return false. Return true + * if you are not sure. */ finishDirect() { - return false; + return true; } /** @@ -487,24 +486,36 @@ OSDAnnotations.AnnotationObjectFactory = class { /** * Update object rendering based on rendering mode - * @param {boolean} isTransparentFill * @param {object} ofObject - * @param {string} color - * @param defaultStroke + * @param {OSDAnnotations.Preset} preset + * @param {OSDAnnotations.CommonAnnotationVisuals} visualProperties must be a modifiable object, will be used + * @param {OSDAnnotations.CommonAnnotationVisuals} defaultVisualProperties will not be touched */ - updateRendering(isTransparentFill, ofObject, color, defaultStroke) { + updateRendering(ofObject, preset, visualProperties, defaultVisualProperties) { + //todo possible issue if someone sets manually single object prop + // (e.g. show borders) and then system triggers update (open history window) + if (typeof ofObject.color === 'string') { - if (isTransparentFill) { - ofObject.set({ - fill: "", - stroke: color - }); + const props = visualProperties; + + const color = preset.color; + const stroke = visualProperties.stroke || defaultVisualProperties.stroke; + // todo consider respecting object property here? or implement by locking (see todo above) + const modeOutline = visualProperties.modeOutline !== undefined ? visualProperties.modeOutline : defaultVisualProperties.modeOutline; + if (modeOutline) { + props.stroke = color; + props.fill = ""; } else { - ofObject.set({ - fill: color, - stroke: defaultStroke - }); + props.stroke = stroke; + props.fill = color; + } + + if (visualProperties.originalStrokeWidth && visualProperties.originalStrokeWidth !== ofObject.strokeWidth) { + // Todo optimize this to avoid re-computation of the values... maybe set the value on object zooming event + const canvas = this._context.canvas; + props.strokeWidth = visualProperties.originalStrokeWidth / canvas.computeGraphicZoom(canvas.getZoom()); } + ofObject.set(props); } } diff --git a/modules/annotations/presets.js b/modules/annotations/presets.js index 19cf0dc4..e2728a88 100644 --- a/modules/annotations/presets.js +++ b/modules/annotations/presets.js @@ -85,18 +85,41 @@ OSDAnnotations.PresetManager = class { /** * Shared options, set to each annotation object. + * @typedef {Object} OSDAnnotations.CommonAnnotationVisuals + * @property {boolean} [selectable] - Whether the annotation is selectable. + * @property {number} [originalStrokeWidth] - The original width of the stroke. + * @property {string} [borderColor] - The color of the border. + * @property {string} [cornerColor] - The color of the corners. + * @property {string} [stroke] - The color of the stroke. + * @property {string} [strokeSide] - Position of the stroke (center, inside, outside). + * @property {number} [borderScaleFactor] - The factor by which the border is scaled. + * @property {boolean} [hasControls] - Whether the annotation has controls. + * @property {boolean} [lockMovementY] - Whether movement along the Y-axis is locked. + * @property {boolean} [lockMovementX] - Whether movement along the X-axis is locked. + * @property {boolean} [hasRotatingPoint] - Whether the annotation has a rotating point. + * @property {boolean} [modeOutline] - Whether the annotation is in outline mode. + * @property {number} [opacity] */ - static _commonProperty = { + + /** + * Default visual settings for annotations. + * todo make this cache-loaded, parametrized + * @type {OSDAnnotations.CommonAnnotationVisuals} + */ + static commonAnnotationVisuals = { selectable: true, originalStrokeWidth: 3, borderColor: '#fbb802', cornerColor: '#fbb802', stroke: 'black', borderScaleFactor: 3, + strokeSide: 'center', hasControls: false, lockMovementY: true, lockMovementX: true, hasRotatingPoint: false, + modeOutline: false, + opacity: 0.4 }; /** @@ -113,7 +136,24 @@ OSDAnnotations.PresetManager = class { this._colorSteps = 8; this._colorStep = 0; this._presetsImported = false; // todo remove this prop - this.modeOutline = this._context.cache.get('drawOutline', true); + + const cache = this._context.cache; + this.commonAnnotationVisuals = { ... this.constructor.commonAnnotationVisuals }; + + //todo: consider cache api that supports type conversions + const _parseCachedProps = (convertor, ...names) => { + for (let name of names) { + const value = cache.get('visuals.' + name); + if (value !== undefined && value !== null) { + this.commonAnnotationVisuals[name] = convertor ? convertor(value) : value; + } + } + }; + _parseCachedProps(x => !!x, 'modeOutline'); + _parseCachedProps(x => Number.parseFloat(x), 'opacity'); + _parseCachedProps(x => Number.parseInt(x), 'originalStrokeWidth'); + _parseCachedProps(undefined, 'borderColor', 'cornerColor', 'stroke'); + this._context.addHandler('preset-delete', e => { if (e.preset === this.left) this.selectPreset(undefined, true); else if (e.preset === this.right) this.selectPreset(undefined, false); @@ -141,22 +181,6 @@ OSDAnnotations.PresetManager = class { return this.getAnnotationOptionsFromInstance(preset, isLeftClick); } - /** - * Set annotations to mode filled or outlined - * @param isOutline true if outlined - */ - setModeOutline(isOutline) { - if (this.modeOutline === isOutline) return; - this._context.cache.set('drawOutline', isOutline); - this.modeOutline = isOutline; - this.updateAllObjectsVisuals(); - this._context.canvas.requestRenderAll(); - } - - getModeOutline() { - return this.modeOutline; - } - /** * Add new preset with default values * @param {string?} id to create, generates random otherwise @@ -184,7 +208,7 @@ OSDAnnotations.PresetManager = class { } /** - * Alias for static _commonProperty + * Alias for static commonAnnotationVisuals * @param {OSDAnnotations.Preset} withPreset */ getCommonProperties(withPreset=undefined) { @@ -192,7 +216,7 @@ OSDAnnotations.PresetManager = class { withPreset._used = true; return this._withDynamicOptions(this._populateObjectOptions(withPreset)); } - return this._withDynamicOptions(this.constructor._commonProperty); + return this._withDynamicOptions(this.commonAnnotationVisuals); } /** @@ -293,26 +317,6 @@ OSDAnnotations.PresetManager = class { return undefined; } - /** - * Correctly and safely reflect object appearance based on mode - * @param object object to update - * @param withPreset preset that obect belongs to - */ - updateObjectVisuals(object, withPreset) { - const factory = this._context.getAnnotationObjectFactory(object.factoryID); - factory.updateRendering(this.modeOutline, object, withPreset.color, this.constructor._commonProperty.stroke); - } - - /** - * Update all object visuals - */ - updateAllObjectsVisuals() { - this._context.canvas.getObjects().forEach(o => { - let preset = this.get(o.presetID); - if (preset) this.updateObjectVisuals(o, preset); - }); - } - /** * Add new metadata field to preset * @event preset-meta-add @@ -348,6 +352,31 @@ OSDAnnotations.PresetManager = class { return false; } + /** + * Set common rendering visual property (stroke, opacity...) + * @param {string} propertyName one of OSDAnnotations.CommonAnnotationVisuals keys + * @param {any} propertyValue value for the property + * @return {boolean} true if value changed, false if invalid key + */ + setCommonVisualProp(propertyName, propertyValue) { + if (this.commonAnnotationVisuals[propertyName] === undefined) { + console.error("[setCommonVisualProp] property name not one of", this.presets.constructor.commonAnnotationVisuals, propertyName); + return false; + } + this._context.cache.set('visuals.' + propertyName, propertyValue); + this.commonAnnotationVisuals[propertyName] = propertyValue; + return true; + } + + /** + * Get annotations visual property + * @param {string} propertyName one of OSDAnnotations.CommonAnnotationVisuals keys + * @return {*} + */ + getCommonVisualProp(propertyName) { + return this.commonAnnotationVisuals[propertyName]; + } + /** * Iterate call for each preset * @param {function} call @@ -462,9 +491,8 @@ OSDAnnotations.PresetManager = class { return $.extend(options, { layerID: this._context.getLayer().id, - opacity: this._context.getOpacity(), zoomAtCreation: zoom, - strokeWidth: 3 / gZoom + strokeWidth: this.commonAnnotationVisuals.originalStrokeWidth / gZoom }); } @@ -473,9 +501,9 @@ OSDAnnotations.PresetManager = class { console.warn("Attempt to retrieve metadata without a preset!"); return {}; } - if (this.modeOutline) { + if (this.commonAnnotationVisuals.modeOutline) { return $.extend({fill: ""}, - OSDAnnotations.PresetManager._commonProperty, + this.commonAnnotationVisuals, { presetID: withPreset.presetID, stroke: withPreset.color, @@ -485,7 +513,7 @@ OSDAnnotations.PresetManager = class { } else { //fill is copied as a color and can be potentially changed to more complicated stuff (Pattern...) return $.extend({fill: withPreset.color}, - OSDAnnotations.PresetManager._commonProperty, + this.commonAnnotationVisuals, { presetID: withPreset.presetID, color: withPreset.color, diff --git a/modules/fabricjs/openseadragon-fabricjs-overlay.js b/modules/fabricjs/openseadragon-fabricjs-overlay.js index 947076f4..badd06cd 100644 --- a/modules/fabricjs/openseadragon-fabricjs-overlay.js +++ b/modules/fabricjs/openseadragon-fabricjs-overlay.js @@ -77,7 +77,7 @@ * @return {number} */ fabric.Canvas.prototype.computeGraphicZoom = function (zoom) { - return Math.sqrt(zoom) / 2 + return Math.sqrt(zoom) / 2; }; if (!window.OpenSeadragon) { diff --git a/plugins/annotations/annotationsGUI.js b/plugins/annotations/annotationsGUI.js index 301e3d07..e9cb4af9 100644 --- a/plugins/annotations/annotationsGUI.js +++ b/plugins/annotations/annotationsGUI.js @@ -39,10 +39,17 @@ class AnnotationsGUI extends XOpatPlugin { //after html initialized, request preset assignment, let opacityControl = $("#annotations-opacity"); - opacityControl.val(this.context.getOpacity()); + opacityControl.val(this.context.getAnnotationCommonVisualProperty('opacity')); opacityControl.on("input", function () { if (_this.context.disabledInteraction) return; - _this.context.setOpacity(Number.parseFloat($(this).val())); + _this.context.setAnnotationCommonVisualProperty('opacity', Number.parseFloat($(this).val())); + }); + + let borderControl = $("#annotations-border-width"); + borderControl.val(this.context.getAnnotationCommonVisualProperty('originalStrokeWidth')); + borderControl.on("input", function () { + if (_this.context.disabledInteraction) return; + _this.context.setAnnotationCommonVisualProperty('originalStrokeWidth', Number.parseFloat($(this).val())); }); this.preview = new AnnotationsGUI.Previewer("preview", this); } @@ -98,8 +105,7 @@ class AnnotationsGUI extends XOpatPlugin { *****************************************************************************************************************/ setDrawOutline(drawOutline) { - this.setOption('drawOutline', drawOutline, true); - this.context.presets.setModeOutline(drawOutline); + this.context.setAnnotationCommonVisualProperty('modeOutline', drawOutline); } initHTML() { @@ -122,7 +128,10 @@ ${UIComponents.Elements.checkBox({ label: this.t('outlineOnly'), classes: "pl-2", onchange: `${this.THIS}.setDrawOutline(!!this.checked)`, - default: this.context.presets.getModeOutline()})} + default: this.context.getAnnotationCommonVisualProperty('modeOutline')})} +
+
+
Border Width
@@ -615,9 +624,11 @@ onchange: this.THIS + ".setOption('importReplace', !!this.checked)", default: th if (e.isEnabled) { $("#annotations-tool-bar").removeClass('disabled'); $("#annotations-opacity").attr("disabled", false); + $("#annotations-border-width").attr("disabled", false); } else { $("#annotations-tool-bar").addClass('disabled'); $("#annotations-opacity").attr("disabled", true); + $("#annotations-border-width").attr("disabled", true); } } @@ -922,8 +933,11 @@ oncontextmenu="return ${this.THIS}._clickPresetSelect(false, '${preset.presetID} if (!pushed) html.push(`To start annotating, please create some class presets.`); html.push('
'); - $("#preset-list-inner-mp").html(html.join(''));; - this.context.history.refresh(); + $("#preset-list-inner-mp").html(html.join('')); + if (this._fireBoardUpdate) { + this.context.history.refresh(); + } + this._fireBoardUpdate = true; } /** @@ -989,7 +1003,10 @@ ${this.THIS}.updatePresetWith('${preset.presetID}', 'objectFactory', this.value) return html.join(""); } - updatePresetWith(idOrBoolean, propName, value) { + updatePresetWith(idOrBoolean, propName, value, fireBoardUpdate=true) { + //optimization, update preset might trigger update of all annotations - do only if necessary (e.g. not a factory swap) + this._fireBoardUpdate = fireBoardUpdate; + //object factory can be changed, it does not change the semantic meaning if (!this.enablePresetModify && propName !== 'objectFactory') return; let preset = idOrBoolean; diff --git a/server/node/index.js b/server/node/index.js index d61b8700..59888907 100644 --- a/server/node/index.js +++ b/server/node/index.js @@ -208,16 +208,20 @@ async function responseDeveloperSetup(req, res) { const core = initViewerCoreAndPlugins(req, res); if (!core) return; + // Temporary, we also load opensedragon in case some modules got loaded too, + // When renderer becomes part of OSD, remove requireModules && requireOpenseadragon core.MODULES["webgl"].loaded = true; const replacer = function(match, p1) { try { switch (p1) { case "head": + console.log(core.MODULES); return ` ${core.requireLib('primer')} ${core.requireLib('jquery')} ${core.requireCore("env")} ${core.requireCore("deps")} +${core.requireOpenseadragon()} ${core.requireModules(true)}`; case "form-init": return ` diff --git a/server/php/dev_setup.php b/server/php/dev_setup.php index e216bb53..1e5e87af 100644 --- a/server/php/dev_setup.php +++ b/server/php/dev_setup.php @@ -15,12 +15,15 @@ $replacer = function($match) use ($i18n) { ob_start(); + // Temporary, we also load opensedragon in case some modules got loaded too, + // When renderer becomes part of OSD, remove requireModules && requireOpenseadragon switch ($match[1]) { case "head": require_lib("primer"); require_lib("jquery"); require_core("env"); require_core("deps"); + require_openseadragon(); include_once(PHP_INCLUDES . "plugins.php"); global $MODULES; diff --git a/src/app.js b/src/app.js index 90369521..466e5416 100644 --- a/src/app.js +++ b/src/app.js @@ -396,7 +396,10 @@ function initXopat(PLUGINS, MODULES, ENV, POST_DATA, PLUGINS_FOLDER, MODULES_FOL * OpenSeadragon Viewer Instance. Note the viewer instance * as well as OpenSeadragon namespace can (and is) extended with * additional classes and events. + * todo add type definitions for OSD + * * @namespace VIEWER + * @type OpenSeadragon.Viewer * @see {@link https://openseadragon.github.io/docs/OpenSeadragon.Viewer.html} */ window.VIEWER = OpenSeadragon({ diff --git a/src/loader.js b/src/loader.js index 0b8831bd..5ae1e888 100644 --- a/src/loader.js +++ b/src/loader.js @@ -882,7 +882,7 @@ function initXOpatLoader(PLUGINS, MODULES, PLUGINS_FOLDER, MODULES_FOLDER, POST_ * @param value */ setLocalOption(key, value) { - localStorage.setItem(`${this.id}.${key}`, value); + this.cache.set(key, value); } /** diff --git a/src/scripts.js b/src/scripts.js index 7af9e057..46bfef21 100644 --- a/src/scripts.js +++ b/src/scripts.js @@ -231,6 +231,55 @@ function initXopatScripts() { return (defaultValue && value === undefined) || (value && (typeof value !== "string" || value.trim().toLocaleLowerCase() !== "false")); }; + /** + * Convert given function to throttled function, that will be fired only once each delay ms. + * Usage: const logicImplementationOfTheFunction = ...; + * const calledInstanceOfTheFunction = UTILITIES.makeThrottled(logicImplementationOfTheFunction, 60); + * @param {function} callback + * @param {number} delay throttling in ms + * @return {function} wrapper + */ + window.UTILITIES.makeThrottled = function (callback, delay) { + let lastCallTime = 0; + let timeoutId = null; + let pendingArgs = null; + + const invoke = () => { + timeoutId = null; + lastCallTime = Date.now(); + if (pendingArgs) { + callback(...pendingArgs); + pendingArgs = null; + } + }; + + const wrapper = (...args) => { + const now = Date.now(); + + if (!lastCallTime || now - lastCallTime >= delay) { + // Execute immediately if outside the throttling window + lastCallTime = now; + callback(...args); + } else { + // Skip this call but store arguments for the next possible execution + pendingArgs = args; + + if (!timeoutId) { + timeoutId = setTimeout(invoke, delay - (now - lastCallTime)); + } + } + }; + + wrapper.finish = () => { + if (timeoutId) { + clearTimeout(timeoutId); + invoke(); + } + }; + + return wrapper; + } + /** * Set the App theme * @param {?string} theme primer_css theme diff --git a/src/shader-configurator.js b/src/shader-configurator.js index cc5c1866..2967eafd 100644 --- a/src/shader-configurator.js +++ b/src/shader-configurator.js @@ -134,7 +134,7 @@ var ShaderConfigurator = { "

", "
"); - html.push("
", JSON.stringify(ctrl.supports, null, 4) ,"
"); + html.push("
", JSON.stringify({type: ctrl.name, ...ctrl.supports}, null, 4) ,"
"); html.push("

"); } html.push("");