UNPKG

@cogic/annotorious

Version:

A JavaScript image annotation library

508 lines (396 loc) 15.6 kB
import EventEmitter from 'tiny-emitter'; import { drawShape, shapeArea } from './selectors'; import { SVG_NAMESPACE, addClass, hasClass, removeClass } from './util/SVG'; import DrawingTools from './tools/ToolsRegistry'; import Crosshair from './Crosshair'; import { format } from './util/Formatting'; import { getSnippet } from './util/ImageSnippet'; import { isTouchDevice, enableTouchTranslation } from './util/Touch'; const isTouch = isTouchDevice(); export default class AnnotationLayer extends EventEmitter { constructor(props) { super(); const { wrapperEl, config, env } = props; this.imageEl = env.image; this.readOnly = config.readOnly; this.allowDrawingWithSelection = config.allowDrawingWithSelection; // Deprecate the old 'formatter' option if (config.formatter) this.formatters = [ config.formatter ]; else if (config.formatters) this.formatters = Array.isArray(config.formatters) ? config.formatters : [ config.formatters ]; this.disableSelect = config.disableSelect; this.drawOnSingleClick = config.drawOnSingleClick; // Annotation layer SVG element this.svg = document.createElementNS(SVG_NAMESPACE, 'svg'); this.svg.setAttribute('tabindex', 0); if (isTouch) { this.svg.setAttribute('class', 'a9s-annotationlayer touch'); // Translates touch events to simulated mouse events enableTouchTranslation(this.svg); // Adds additional logic because touch doesn't have hover this.svg.addEventListener('touchstart', () => { this.currentHover = null; this.selectCurrentHover(); }); } else { this.svg.setAttribute('class', 'a9s-annotationlayer'); } const { naturalWidth, naturalHeight } = this.imageEl; if (!naturalWidth && !naturalHeight) { // Might be because a) the image has not loaded yet, or b) because it's not // an image element (but maybe a CANVAS etc.)! Allow for both possibilities. const { width, height } = this.imageEl; this.svg.setAttribute('viewBox', `0 0 ${width} ${height}`); // Plus: monkey-patch the element (won't work for images) if(this.imageEl.nodeName.toLowerCase() !== 'img') { this.imageEl.naturalWidth = width; this.imageEl.naturalHeight = height; } this.imageEl.addEventListener('load', () => { this.emit('load', this.imageEl.src); this.svg.setAttribute('viewBox', `0 0 ${this.imageEl.naturalWidth} ${this.imageEl.naturalHeight}`) }); } else { this.svg.setAttribute('viewBox', `0 0 ${naturalWidth} ${naturalHeight}`); } // Don't attach directly, but in a group this.g = document.createElementNS(SVG_NAMESPACE, 'g'); this.svg.appendChild(this.g); wrapperEl.appendChild(this.svg); if (config.crosshair) { this.crosshair = new Crosshair(this.g, naturalWidth, naturalHeight); if (!config.crosshairWithCursor){ addClass(this.svg, 'no-cursor'); } } this.selectedShape = null; this.tools = new DrawingTools(this.g, config, env); this.tools.on('startSelection', pt => this.emit('startSelection', pt)); this.tools.on('cancel', this.selectCurrentHover); this.tools.on('complete', this.selectShape); this.svg.addEventListener('mousedown', this._onMouseDown); this.currentHover = null; // On image resize... if (window.ResizeObserver) { this.resizeObserver = new ResizeObserver(() => { // ...counter-scale non-scaling annotations... this._refreshNonScalingAnnotations(); // ...and resize formatter elements (shape labels etc.) this._scaleFormatterElements(); }); this.resizeObserver.observe(this.svg.parentNode); } } _attachMouseListeners = (elem, annotation) => { elem.addEventListener('mouseenter', () => { if (!this.tools?.current?.isDrawing) { if (this.currentHover !== elem) this.emit('mouseEnterAnnotation', annotation, elem); this.currentHover = elem; } }); elem.addEventListener('mouseleave', () => { if (!this.tools?.current?.isDrawing) { this.emit('mouseLeaveAnnotation', annotation, elem); this.currentHover = null; } }); if (isTouch) { elem.addEventListener('touchstart', evt => { evt.stopPropagation(); this.currentHover = elem; }); elem.addEventListener('touchend', evt => { const { clientX, clientY } = evt.changedTouches[0]; const realTarget = document.elementFromPoint(clientX, clientY); evt.stopPropagation(); if (elem.contains(realTarget)) { this.currentHover = elem; this.selectCurrentHover(); } }); } } /** * Helper - executes immediately if the image is loaded, * or defers to image.onload if not */ _lazy = fn => { if (this.imageEl.naturalWidth) fn(); else this.imageEl.addEventListener('load', () => fn()); } _onMouseDown = evt => { if (evt.button !== 0) return; // Left click if ( !( this.readOnly || (this.allowDrawingWithSelection ? this.selectedShape && this.selectedShape.element === this.currentHover : this.selectedShape) || this.tools.current.isDrawing ) ) { // No active selection (or active selection is not current Hover when allowDrawingWithSelection) & not drawing now? Start drawing. this.tools.current.start(evt, this.drawOnSingleClick && !this.currentHover); } else if (!this.tools?.current?.isDrawing && this.selectedShape !== this.currentHover) { // Not drawing and another shape was clicked? Select. this.selectCurrentHover(); } } _refreshNonScalingAnnotations = () => { const scale = this.getCurrentScale(); // This happens after .destroy(), when this.svg still exists, // but has 0x0 size! if (scale === Infinity) return; Array.from(this.svg.querySelectorAll('.a9s-non-scaling')).forEach(shape => { shape.setAttribute('transform', `scale(${scale})`); }); } _scaleFormatterElements = opt_shape => { const scale = this.getCurrentScale(); if (scale === Infinity) return; if (opt_shape) { const el = opt_shape.querySelector('.a9s-formatter-el'); if (el) el.firstChild.setAttribute('transform', `scale(${scale})`); } else { const elements = Array.from(this.g.querySelectorAll('.a9s-formatter-el')); elements.forEach(el => el.firstChild.setAttribute('transform', `scale(${scale})`)); } } addAnnotation = annotation => { const g = drawShape(annotation, this.imageEl); addClass(g, 'a9s-annotation'); g.setAttribute('data-id', annotation.id); g.annotation = annotation; this._attachMouseListeners(g, annotation); this.g.appendChild(g); format(g, annotation, this.formatters); this._scaleFormatterElements(g); return g; } addDrawingTool = plugin => this.tools?.registerTool(plugin); addOrUpdateAnnotation = (annotation, previous) => { if (this.selectedShape && (this.selectedShape.annotation.isEqual(annotation) || this.selectedShape.annotation.isEqual(previous))) { this.deselect(); this.emit('select', {}); } if (previous) this.removeAnnotation(previous); this.removeAnnotation(annotation); const shape = this.addAnnotation(annotation); // Counter-scale non-scaling annotations if (hasClass(shape, 'a9s-non-scaling')) shape.setAttribute('transform', `scale(${this.getCurrentScale()})`); // Make sure rendering order is large-to-small this.redraw(); } deselect = skipRedraw => { if (this.selectedShape) { this.tools?.current.stop(); const { annotation } = this.selectedShape; if (this.selectedShape.destroy) { // Modifiable shape: destroy and re-add the annotation this.selectedShape.destroy(); this.selectedShape = null; if (!annotation.isSelection) { this.addAnnotation(annotation); if (!skipRedraw) this.redraw(); } } else { // Not modifiable - just clear removeClass(this.selectedShape, 'selected'); this.selectedShape = null; } } } destroy = () => { this.deselect(); this.currentHover = null; this.svg.parentNode.removeChild(this.svg); } findShape = annotationOrId => { const id = annotationOrId?.id ? annotationOrId.id : annotationOrId; return this.g.querySelector(`.a9s-annotation[data-id="${id}"]`); } getAnnotations = () => { const shapes = Array.from(this.g.querySelectorAll('.a9s-annotation')); return shapes.map(s => s.annotation); } getCurrentScale = () => { const svgBounds = this.svg.getBoundingClientRect(); const { width, height } = this.svg.viewBox.baseVal; return Math.max( width / svgBounds.width, height / svgBounds.height ); } getSelectedImageSnippet = () => { if (this.selectedShape) { const element = this.selectedShape.element || this.selectedShape; return getSnippet(this.imageEl, element); } } init = annotations => { // Clear existing this.deselect(); this.currentHover = null; const shapes = Array.from(this.g.querySelectorAll('.a9s-annotation')); shapes.forEach(s => this.g.removeChild(s)); // Add this._lazy(() => { annotations.sort((a, b) => shapeArea(b, this.imageEl) - shapeArea(a, this.imageEl)); annotations.forEach(this.addAnnotation); }); // Counter-scale non-scaling annotations this._refreshNonScalingAnnotations(); } listDrawingTools = () => this.tools?.listTools(); /** * Forces a new ID on the annotation with the given ID. * @returns the updated annotation for convenience */ overrideId = (originalId, 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; return updated; } /** * Redraws the whole layer with annotations sorted by * size, so that larger ones don't occlude smaller ones. */ redraw = () => { const shapes = Array.from(this.g.querySelectorAll('.a9s-annotation:not(.selected)')); const annotations = shapes.map(s => s.annotation); annotations.sort((a, b) => shapeArea(b, this.imageEl) - shapeArea(a, this.imageEl)); // Clear the SVG element shapes.forEach(s => this.g.removeChild(s)); // Redraw annotations.forEach(this.addAnnotation); } removeAnnotation = annotationOrId => { // Removal won't work if the annotation is currently selected - deselect! const id = annotationOrId.type ? annotationOrId.id : annotationOrId; if (this.selectedShape?.annotation.id === id) this.deselect(); const toRemove = this.findShape(annotationOrId); if (toRemove) { if (this.selectedShape?.annotation === toRemove.annotation) this.deselect(); if (this.currentHover?.annotation === toRemove.annotation) this.currentHover = null; toRemove.parentNode.removeChild(toRemove); } } removeDrawingTool = id => this.tools?.unregisterTool(id); /** * Programmatic selection via the API. Should work as normal, * but the selectAnnotation event should not be fired to the outside. */ selectAnnotation = (annotationOrId, skipEvent) => { // Deselect first 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(); } } selectCurrentHover = () => { if (this.currentHover) { if (this.disableSelect) { // Click only - no select this.emit('clickAnnotation', this.currentHover.annotation, this.currentHover); } else { this.selectShape(this.currentHover); } } else { this.deselect(); this.emit('select', { skipEvent: true }); } } 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(true); } const { annotation } = shape; const readOnly = this.readOnly || annotation.readOnly; if (!readOnly) { const toolForAnnotation = this.tools.forAnnotation(annotation); // Replace the shape with an editable version if (toolForAnnotation) { shape.parentNode.removeChild(shape); this.selectedShape = toolForAnnotation.createEditableShape(annotation, this.formatters); // Yikes... hack to make the tool act like SVG annotation shapes - needs redesign this.selectedShape.element.annotation = annotation; this._scaleFormatterElements(this.selectedShape.element); this.selectedShape.on('update', fragment => { if (this.selectedShape) this.emit('updateTarget', this.selectedShape.element, fragment); }); // If we attach immediately 'mouseEnter' will fire when the editable shape // is added to the DOM! setTimeout(() => { if (this.selectedShape != null) this._attachMouseListeners(this.selectedShape.element, annotation); // Bit of a hack... // We need to make the selection the current hover manually (because // 'mouseEnter' won't have fired. But ONLY if the selection was not // done programmatically (which was the case for 'skipEvent') if (!skipEvent && this.selectedShape?.element) this.currentHover = this.selectedShape.element; }, 1); } else { this.selectedShape = shape; } if (!skipEvent) this.emit('select', { annotation, element: this.selectedShape.element || this.selectedShape }); } else { addClass(shape, 'selected'); this.selectedShape = shape; if (!skipEvent) this.emit('select', { annotation, element: shape, skipEvent }); } } 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 = () => { this.tools?.current?.stop(); } }