UNPKG

@cogic/annotorious-openseadragon

Version:

Annotorious image annotation for OpenSeadragon

862 lines (665 loc) 27.5 kB
import EventEmitter from 'tiny-emitter'; import OpenSeadragon from 'openseadragon'; import { SVG_NAMESPACE, addClass, hasClass, removeClass } from '@cogic/annotorious/src/util/SVG'; import DrawingTools from '@cogic/annotorious/src/tools/ToolsRegistry'; import { drawShape } from '@cogic/annotorious/src/selectors'; import { format } from '@cogic/annotorious/src/util/Formatting'; import { isTouchDevice, enableTouchTranslation } from '@cogic/annotorious/src/util/Touch'; import Crosshair from './OSDCrosshair'; import AnnotationStore from './AnnotationStore'; import { getSnippet } from './util/ImageSnippet'; const isTouch = isTouchDevice(); /** * Code shared between (normal) OSDAnnotationLayer and * GigapixelAnnotationLayer */ export class AnnotationLayer extends EventEmitter { constructor(props) { super(); this.viewer = props.viewer; this.config = props.config; this.env = props.env; this.readOnly = props.config.readOnly; this.headless = props.config.headless; // Deprecate the old 'formatter' option if (props.config.formatter) this.formatters = [ props.config.formatter ]; else if (props.config.formatters) this.formatters = Array.isArray(props.config.formatters) ? props.config.formatters : [ props.config.formatters ]; this.disableSelect = props.config.disableSelect; this.drawOnSingleClick = props.config.drawOnSingleClick; this.svg = document.createElementNS(SVG_NAMESPACE, 'svg'); if (isTouch) { this.svg.setAttribute('class', 'a9s-annotationlayer a9s-osd-annotationlayer touch'); enableTouchTranslation(this.svg); } else { this.svg.setAttribute('class', 'a9s-annotationlayer a9s-osd-annotationlayer'); } this.g = document.createElementNS(SVG_NAMESPACE, 'g'); this.svg.appendChild(this.g); this.viewer.canvas.appendChild(this.svg); this.viewer.addHandler('animation', () => this.resize()); this.viewer.addHandler('rotate', () => this.resize()); this.viewer.addHandler('resize', () => this.resize()); this.viewer.addHandler('flip', () => this.resize()); this.loaded = false; const onLoad = () => { const { x, y } = this.viewer.world.getItemAt(0).source.dimensions; const src = this.viewer.world.getItemAt(0).source['@id'] || new URL(this.viewer.world.getItemAt(0).source.url, document.baseURI).href; this.env.image = { src, naturalWidth: x, naturalHeight: y }; if (props.config.crosshair) { if (!this.crosshair) { this.crosshair = new Crosshair(this.svg); if (!props.config.crosshairWithCursor){ addClass(this.svg, 'no-cursor'); } } } if (!this.loaded) this.emit('load', src); this.loaded = true; this.g.style.display = 'inline'; this.resize(); } // Store image properties on open (incl. after page change) and on addTiledImage this.viewer.addHandler('open', onLoad); this.viewer.world.addHandler('add-item', onLoad); // Or: if Annotorious gets initialized on a loaded image if (this.viewer.world.getItemAt(0)) onLoad(); this.store = new AnnotationStore(this.env); this.selectedShape = null; this.hoveredShape = null; this.hiddenAnnotationIdSet = new Set(); this._initMouseEvents(); } _getShapeAt = evt => { const getSVGPoint = evt => { const pt = this.svg.createSVGPoint(); if (window.TouchEvent && (evt instanceof TouchEvent)) { const bbox = this.svg.getBoundingClientRect(); const e = evt.touches[0]; const x = e.clientX - bbox.x; const y = e.clientY - bbox.y; const { left, top } = this.svg.getBoundingClientRect(); pt.x = x + left; pt.y = y + top; return pt.matrixTransform(this.g.getScreenCTM().inverse()); } else { pt.x = evt.offsetX; pt.y = evt.offsetY; return pt.matrixTransform(this.g.getCTM().inverse()); } } const { x, y } = getSVGPoint(evt); const excludedAnnotationIds = [...this.hiddenAnnotationIdSet] if (this.selectedShape) { excludedAnnotationIds.push(this.selectedShape.annotation.id); } const annotation = this.store.getAnnotationAt(x, y, this.currentScale(), excludedAnnotationIds); if (annotation) return this.findShape(annotation); } /** Initializes the OSD MouseTracker used for drawing **/ _initDrawingTools = gigapixelMode => { let started = false; let firstDragDone = false; let dragging = false; this.tools = new DrawingTools(this.g, this.config, this.env); this.tools.on('complete', shape => { firstDragDone = false; this.onDrawingComplete(shape); }); this.mouseTracker = new OpenSeadragon.MouseTracker({ element: this.svg, preProcessEventHandler: info => { if (!this.mouseTracker.enabled) { info.preventDefault = false; info.preventGesture = true; } if (this.selectedShape && info.eventType === 'wheel') { info.preventDefault = false; this.viewer.canvas.dispatchEvent(new info.originalEvent.constructor(info.eventType, info.originalEvent)); } }, pressHandler: evt => { if (!this.tools.current.isDrawing) { this.tools.current.start(evt.originalEvent, this.drawOnSingleClick && !this.hoveredShape); if (!gigapixelMode) this.scaleTool(this.tools.current); } if (this.tools.current.isDrawing && this.tools.current.onMouseDown) { const { x , y } = this.tools.current.getSVGPoint(evt.originalEvent); this.tools.current.onMouseDown(x, y, evt.originalEvent); } }, moveHandler: evt => { if (this.tools.current.isDrawing) { const { x , y } = this.tools.current.getSVGPoint(evt.originalEvent); if (!firstDragDone && !this.config.allowPropagationOnDrawing) { evt.originalEvent.stopPropagation(); } this.tools.current.onMouseMove(x, y, evt.originalEvent); if (!started) { this.emit('startSelection', { x , y }); started = true; } if (!dragging && this.tools.current.onDragStart) { this.tools.current.onDragStart(x, y, evt.originalEvent); dragging = true; } } }, releaseHandler: evt => { if (this.tools.current.isDrawing) { // continue in dragging mode if moveHandler has not been fired // if (!started) return; const { x , y } = this.tools.current.getSVGPoint(evt.originalEvent); if (started) { this.emit('endSelection', { x , y }); firstDragDone = true; } this.tools.current.onMouseUp(x, y, evt.originalEvent); if (dragging && this.tools.current.onDragEnd) this.tools.current.onDragEnd(); } started = false; dragging = false; } }); // Draw mode hotkey const hotkey = this.config.hotkey ? (this.config.hotkey.key ? this.config.hotkey.key.toLowerCase() : this.config.hotkey.toLowerCase()) : 'shift'; // Inverted mode const inverted = this.config.hotkey?.inverted; this.mouseTracker.enabled = inverted; // Keep tracker disabled until Shift is held if (this.onKeyDown) document.removeEventListener('keydown', this.onKeyDown); if (this.onKeyUp) document.removeEventListener('keydown', this.onKeyDown); this.onKeyDown = evt => { if (evt.key.toLowerCase() === hotkey && !this.selectedShape) { const enabled = !this.readOnly && !inverted; this.mouseTracker.enabled = enabled; this.tools.current.enabled = enabled; } }; this.onKeyUp = evt => { if (evt.key.toLowerCase() === hotkey && !this.tools.current.isDrawing) { this.mouseTracker.enabled = inverted; this.tools.current.enabled = inverted; firstDragDone = false; } }; document.addEventListener('keydown', this.onKeyDown); document.addEventListener('keyup', this.onKeyUp); } _removeHoverShapeClass = () => { if (this.hoveredShape) { const element = this.hoveredShape.element || this.hoveredShape; removeClass(element, 'hover'); this.emit('mouseLeaveAnnotation', this.hoveredShape.annotation, this.hoveredShape); } } _initMouseEvents = () => { // We use mouse-move to track which annotation is currently hovered on. // Keep in mind that annotations are NOT automatically stacked from large // to small. Therefore, smaller ones might be obscured underneath larger // ones. That's the reason we can't use native mouseEnter/mouseLeave events // on the SVG shapes! this.svg.addEventListener('mousemove', evt => { // Don't track mouseEnter/mouseLeave while drawing or if tools.current.enabled and disableHoverWhenToolEnabled are true if (!this.tools?.current.isDrawing && !(this.config.disableHoverWhenToolEnabled && this.tools?.current.enabled)) { // Don't do anything if the move happens over the current selection const isMoveSelection = evt.target.closest('.a9s-annotation.editable.selected'); if (!isMoveSelection && !this.selectedShape?.grabbedElem) { const shape = this._getShapeAt(evt); // Hovered annotation changed if (shape?.annotation !== this.hoveredShape?.annotation) { this._removeHoverShapeClass(); if (shape) { addClass(shape, 'hover'); this.emit('mouseEnterAnnotation', shape.annotation, shape); } } this.hoveredShape = shape; } } }); this.svg.parentElement.addEventListener('mouseleave', () => { if (this.hoveredShape) { if (this.hoveredShape instanceof Element) { removeClass(this.hoveredShape, 'hover'); } this.emit('mouseLeaveAnnotation', this.hoveredShape.annotation, this.hoveredShape); this.hoveredShape = null; } }); // Unfortunately, drag ALSO creates a click // event - ignore in this case. let lastMouseDown = null; this.viewer.addHandler('canvas-press', () => lastMouseDown = new Date().getTime()); this.viewer.addHandler('canvas-click', evt => { const { originalEvent } = evt; // Click & no drawing in progress if (!(this.tools.current?.isDrawing || this.disableSelect)) { // Ignore "false click" after drag! const timeSinceMouseDown = new Date().getTime() - lastMouseDown; // Real click (no drag) if (timeSinceMouseDown < 250) { // Click happened on the current selection? const isSelection = originalEvent.target.closest('.a9s-annotation.editable.selected'); const hoveredShape = isSelection ? this.selectedShape : this._getShapeAt(originalEvent); // Ignore clicks on selection if (hoveredShape && hoveredShape !== this.selectedShape?.element) { evt.preventDefaultAction = true; // No zoom on click this.selectShape(hoveredShape); } else { this.deselect(); this.emit('select', {}); } } } if (this.disableSelect && this.hoveredShape) this.emit('clickAnnotation', this.hoveredShape.annotation, this.hoveredShape); }); } /** * Helper - executes immediately if the tilesource is loaded, * or defers to after load if not */ _lazy = fn => { if (this.viewer.world.getItemAt(0)) { fn(); } else { const onLoad = () => { fn(); this.viewer.removeHandler('open', onLoad); this.viewer.world.removeHandler('add-item', onLoad); }; this.viewer.addHandler('open', onLoad); this.viewer.world.addHandler('add-item', onLoad); } } _refreshNonScalingAnnotations = () => { const scale = this.currentScale(); Array.from(this.svg.querySelectorAll('.a9s-non-scaling')).forEach(shape => // This could check if the shape is actually inside the viewport. // However, the lookup might be just as costly as setting the attribute. // Alternatively, a future implementation of the annotation store which caches // the bounds might be able to provide this info without a DOM lookup. // Either way: this is for the future. No need to optimize prematurely. shape.setAttribute('transform', `scale(${1 / scale})`)); } /** * Adds an annotation to the annotation layer. * Returns the shape for convenience. */ addAnnotation = (annotation, optBuffer) => { const g = optBuffer || this.g; const shape = drawShape(annotation, this.env.image); addClass(shape, 'a9s-annotation'); shape.setAttribute('data-id', annotation.id); shape.annotation = annotation; if (g.lastChild && !g.lastChild.annotation) { // Avoid this shape overlapping the editable shape g.insertBefore(shape, g.lastChild); } else { g.appendChild(shape); } format(shape, annotation, this.formatters); this.scaleFormatterElements(shape); return shape; } addDrawingTool = plugin => this.tools.registerTool(plugin); addOrUpdateAnnotation = (annotation, previous) => { const selected = this.selectedShape?.annotation; if (selected === annotation || selected?.isSelection || selected == previous) this.deselect(); if (previous) this.removeAnnotation(annotation); this.removeAnnotation(annotation); const shape = this.addAnnotation(annotation); if (hasClass(shape, 'a9s-non-scaling')) shape.setAttribute('transform', `scale(${1 / this.currentScale()})`); this.store.insert(annotation); } currentScale = () => { const containerWidth = this.viewer.viewport.getContainerSize().x; const zoom = this.viewer.viewport.getZoom(true); return zoom * containerWidth / this.viewer.world.getContentFactor(); } deselect = () => { this.tools?.current.stop(); if (this.selectedShape) { const { annotation } = this.selectedShape; if (this.selectedShape.destroy) { // Modifiable shape: destroy and re-add the annotation this.selectedShape.mouseTracker.destroy(); this.selectedShape.destroy(); if (!annotation.isSelection) { const shape = this.addAnnotation(annotation); if (hasClass(shape, 'a9s-non-scaling')) shape.setAttribute('transform', `scale(${1 / this.currentScale()})`); } } else { // Non-editable shape or read-only removeClass(this.selectedShape, 'selected'); } this.selectedShape = null; } } destroy = () => { this.deselect(); this.svg.parentNode.removeChild(this.svg); } findShape = annotationOrId => { const id = annotationOrId?.id ? annotationOrId.id : annotationOrId; return this.g.querySelector(`.a9s-annotation[data-id="${id}"]`); } // Common code for fitBounds and fitBoundsWithConstraints _fit = (annotationOrId, opts, fn) => { const shape = this.findShape(annotationOrId); if (shape) { const immediately = opts ? ( typeof opts == 'boolean' ? opts : opts.immediately ) : false; const padding = (opts?.padding || 0); const containerBounds = this.viewer.container.getBoundingClientRect(); const paddingRelative = Math.min( 2 * padding / containerBounds.width, 2 * padding / containerBounds.height ); const { x, y, width, height } = shape.getBBox(); // SVG element bounds, image coordinates const padX = x - paddingRelative * width; const padY = y - paddingRelative * height; const padW = width + 2 * paddingRelative * width; const padH = height + 2 * paddingRelative * height; const rect = this.viewer.viewport.imageToViewportRectangle(padX, padY, padW, padH); this.viewer.viewport[fn](rect, immediately); } } fitBounds = (annotationOrId, immediately) => this._fit(annotationOrId, immediately, 'fitBounds'); fitBoundsWithConstraints = (annotationOrId, immediately) => this._fit(annotationOrId, immediately, 'fitBoundsWithConstraints'); getAnnotations = () => { const shapes = Array.from(this.g.querySelectorAll('.a9s-annotation')); return shapes.map(s => s.annotation); } getAnnotationsIntersecting = annotationOrId => { const annotation = annotationOrId.id ? annotationOrId : this.findShape(annotationOrId).annotation; return this.store.getAnnotationsIntersecting(annotation); } getImageSnippetById = annotationId => { const shape = this.findShape(annotationId); if (shape) return getSnippet(this.viewer, shape); } getSelectedImageSnippet = () => { if (this.selectedShape) { const shape = this.selectedShape.element ?? this.selectedShape; return getSnippet(this.viewer, shape); } } init = annotations => { // Clear existing this.deselect(); this.hiddenAnnotationIdSet.clear(); const shapes = Array.from(this.g.querySelectorAll('.a9s-annotation')); shapes.forEach(s => this.g.removeChild(s)); this.store.clear(); this._lazy(() => { // Draw annotations if (!this.loaded) this.g.style.display = 'none'; annotations.forEach(annotation => this.addAnnotation(annotation)); // Insert into store (and spatial index) this.store.insert(annotations); this.resize(); }); } listDrawingTools = () => this.tools.listTools(); overrideId = (originalId, forcedId) => { if (this.hiddenAnnotationIdSet.has(originalId)) { this.hiddenAnnotationIdSet.delete(originalId); this.hiddenAnnotationIdSet.add(forcedId); } // Update SVG shape data attribute const shape = this.findShape(originalId); shape.setAttribute('data-id', forcedId); // Update annotation const { annotation } = shape; const updated = annotation.clone({ id : forcedId }); shape.annotation = updated; // Update spatial index this.store.remove(annotation); this.store.insert(updated); return updated; } hideAnnotation = annotationOrId => { const id = annotationOrId?.id ? annotationOrId.id : annotationOrId; this.hiddenAnnotationIdSet.add(id); const shape = this.findShape(annotationOrId); if (shape) { shape.style.display = 'none'; } } showAnnotation = annotationOrId => { const id = annotationOrId?.id ? annotationOrId.id : annotationOrId; this.hiddenAnnotationIdSet.delete(id); const shape = this.findShape(annotationOrId); if (shape) { shape.style.display = null; } } panTo = (annotationOrId, immediately) => { const shape = this.findShape(annotationOrId); if (shape) { const { top, left, width, height } = shape.getBoundingClientRect(); const x = left + width / 2 + window.scrollX; const y = top + height / 2 + window.scrollY; const center = this.viewer.viewport.windowToViewportCoordinates(new OpenSeadragon.Point(x, y)); this.viewer.viewport.panTo(center, immediately); } } removeAnnotation = annotationOrId => { // Removal won't work if the annotation is currently selected - deselect! const id = annotationOrId.type ? annotationOrId.id : annotationOrId; this.hiddenAnnotationIdSet.delete(id); if (this.selectedShape?.annotation.id === id) this.deselect(); const toRemove = this.findShape(annotationOrId); if (toRemove) { const { annotation } = toRemove; if (this.selectedShape?.annotation === annotation) this.deselect(); toRemove.parentNode.removeChild(toRemove); // Remove from spatial tree! this.store.remove(annotation); } } removeDrawingTool = id => this.tools?.unregisterTool(id); resize() { const flipped = this.viewer.viewport.getFlip(); const p = this.viewer.viewport.pixelFromPoint(new OpenSeadragon.Point(0, 0), true); if (flipped) p.x = this.viewer.viewport._containerInnerSize.x - p.x; const scaleY = this.currentScale(); const scaleX = flipped ? - scaleY : scaleY; const rotation = this.viewer.viewport.getRotation(); this.g.setAttribute('transform', `translate(${p.x}, ${p.y}) scale(${scaleX}, ${scaleY}) rotate(${rotation})`); this._refreshNonScalingAnnotations(); this.scaleFormatterElements(); if (this.selectedShape) { if (this.selectedShape.element) { // Editable shape this.scaleTool(this.selectedShape); this.emit('viewportChange', this.selectedShape.element); } else { this.emit('viewportChange', this.selectedShape); } } if (this.tools?.current) this.scaleTool(this.tools.current); } scaleFormatterElements = opt_shape => { const scale = 1 / this.currentScale(); if (opt_shape) { const el = opt_shape.querySelector('.a9s-annotation:not(.a9s-non-scaling) .a9s-formatter-el'); if (el) el.firstChild.setAttribute('transform', `scale(${scale})`); } else { const elements = Array.from(this.g.querySelectorAll('.a9s-annotation:not(.a9s-non-scaling) .a9s-formatter-el')); elements.forEach(el => el.firstChild.setAttribute('transform', `scale(${scale})`)); } } scaleTool = tool => { if (tool) { const scale = 1 / this.currentScale(); tool.scale = scale; if (tool.onScaleChanged) tool.onScaleChanged(scale); } } selectAnnotation = (annotationOrId, skipEvent) => { if (this.selectedShape) this.deselect(); const selected = this.findShape(annotationOrId); if (selected) { this.selectShape(selected, skipEvent); const element = this.selectedShape.element ? this.selectedShape.element : this.selectedShape; return { annotation: selected.annotation, element }; } else { this.deselect(); } } selectShape = (shape, skipEvent) => { if (!skipEvent && !shape.annotation.isSelection) this.emit('clickAnnotation', shape.annotation, shape); // Don't re-select if (this.selectedShape?.annotation === shape.annotation) return; // If another shape is currently selected, deselect first if (this.selectedShape && this.selectedShape.annotation !== shape.annotation) this.deselect(); const { annotation } = shape; const readOnly = this.readOnly || annotation.readOnly; if (!(readOnly || this.headless)) { const toolForAnnotation = this.tools.forAnnotation(annotation); if (toolForAnnotation) { if (!this.config.syncRemoveOriginalShape) { setTimeout(() => { shape.parentNode.removeChild(shape); // Fire the event AFTER the original shape was removed. Otherwise, // people calling `.getAnnotations()` in the `onSelectAnnotation` // handler will receive a duplicate annotation // (See issue https://github.com/recogito/annotorious-openseadragon/issues/63) if (!skipEvent) this.emit('select', { annotation, element: this.selectedShape.element }); }, 1); } this.selectedShape = toolForAnnotation.createEditableShape(annotation, this.formatters); this.scaleTool(this.selectedShape); this.scaleFormatterElements(this.selectedShape.element); this.selectedShape.element.annotation = annotation; // Disable normal OSD nav const editableShapeMouseTracker = new OpenSeadragon.MouseTracker({ element: this.svg, preProcessEventHandler: info => { if (!(info.originalEvent instanceof KeyboardEvent)) { // Allow keyboard events to propagate info.stopPropagation = true; } info.preventDefault = false; info.preventGesture = true; } }).setTracking(false); // En-/disable OSD nav based on hover status this.selectedShape.element.addEventListener('mouseenter', () => { this._removeHoverShapeClass(); this.hoveredShape = this.selectedShape; editableShapeMouseTracker.setTracking(true); }); this.selectedShape.element.addEventListener('mouseleave', () => { this.hoveredShape = null; editableShapeMouseTracker.setTracking(false); }); this.selectedShape.mouseTracker = editableShapeMouseTracker; this.selectedShape.on('update', fragment => this.emit('updateTarget', this.selectedShape.element, fragment)); if (this.config.syncRemoveOriginalShape) { shape.parentNode.removeChild(shape); if (!skipEvent) this.emit('select', { annotation, element: this.selectedShape.element }); } } else { this.selectedShape = shape; if (!skipEvent) this.emit('select', { annotation, element: this.selectedShape }); } } else { this.selectedShape = shape; addClass(shape, 'selected'); if (!skipEvent) this.emit('select', { annotation, element: shape, skipEvent }); } } setDrawingEnabled = (enable, notSetTracking) => { if (this.mouseTracker) { const enabled = enable && !this.readOnly; this.mouseTracker.enabled = enabled; if (!notSetTracking) { this.mouseTracker.setTracking(enabled); } if (this.tools.current) this.tools.current.enabled = enabled; } } setDrawingTool = shape => { if (this.tools) { this.tools.current?.stop(); this.tools.setCurrent(shape); } } setVisible = visible => { if (visible) { this.svg.style.display = null; } else { this.deselect(); this.svg.style.display = 'none'; } } stopDrawing = () => { if (this.tools?.current) { if (this.tools.current.isDrawing) this.tools.current.stop(); // this.mouseTracker.enabled = false; // this.tools.current.enabled = false; } } } export default class OSDAnnotationLayer extends AnnotationLayer { constructor(props) { super(props); this._initDrawingTools(); } onDrawingComplete = shape => { this.mouseTracker.enabled = this.config.hotkey?.inverted; this.selectShape(shape); this.emit('createSelection', shape.annotation); } }