Source: papertools/polygon.mjs

/**
 * OpenSeadragon paperjs overlay plugin based on paper.js
 * @version 0.7.6
 * 
 * Includes additional open source libraries which are subject to copyright notices
 * as indicated accompanying those segments of code.
 * 
 * Original code:
 * Copyright (c) 2022-2026, Thomas Pearce
 * All rights reserved.
 * 
 * Redistribution and use in source and binary forms, with or without
 * modification, are permitted provided that the following conditions are met:
 * 
 * * Redistributions of source code must retain the above copyright notice, this
 *   list of conditions and the following disclaimer.
 * 
 * * Redistributions in binary form must reproduce the above copyright notice,
 *   this list of conditions and the following disclaimer in the documentation
 *   and/or other materials provided with the distribution.
 * 
 * * Neither the name of osd-paperjs-annotation nor the names of its
 *   contributors may be used to endorse or promote products derived from
 *   this software without specific prior written permission.
 * 
 * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
 * AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
 * IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
 * DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
 * FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
 * DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
 * SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
 * CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
 * OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
 * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
 * 
 */

import {AnnotationUITool, AnnotationUIToolbarBase} from './annotationUITool.mjs';
import { paper } from '../paperjs.mjs';
import { makeFaIcon } from '../utils/faIcon.mjs';

/**
 * Represents a polygon annotation tool that allows users to create and manipulate polygons on a canvas.
 * Inherits functionality from the AnnotationUITool class.
 * @extends AnnotationUITool
 * @class
 * @memberof OSDPaperjsAnnotation
 */
class PolygonTool extends AnnotationUITool{
    /**
     * Creates an instance of PolygonTool.
     * @constructor
     * @param {Object} paperScope - The Paper.js paper scope object, which provides context for working with Paper.js functionalities.
     * @description Initializes the PolygonTool by calling the base class (AnnotationUITool) constructor and setting up event handlers for drawing and editing polygons.
     * @property {paper.Tool} tool - The Paper.js Tool object associated with the PolygonTool.
     * @property {paper.Group} drawingGroup - The Paper.js Group used for drawing polygons.
     * @property {paper.Segment} draggingSegment - The currently dragged segment during editing.
     * @property {boolean} eraseMode - A flag indicating whether the tool is in erase mode.
     * @property {Object} simplifying - A flag indicating whether the tool is simplifying the drawn polygon.
     * @property {SimplifyJS} simplifier - An instance of the SimplifyJS library used for polygon simplification.
     */
    constructor(paperScope){
        super(paperScope);
        let _this = this;
        let tool = this.tool;
        this._lastClickTime = 0;
        this.drawingGroup = new paper.Group();
        this._currentItem = null;
        this._currentItemSelectedColor = null;
        this.project.toolLayer.addChild(this.drawingGroup);
        this.drawingGroup.visible=false;  
        this.draggingSegment = null;
        this.eraseMode=false;
        this.simplifying=null;
        this._geometryMutatedThisPress = false;
        // this.simplifier = new SimplifyJS();
        this.setToolbarControl(new PolygonToolbar(this));  
        this.registerOverlayCursorOwnedClasses('tool-action');
        
        /**
         * Event handler when the tool is activated.
         * Configures the tool settings and displays the drawing group on activation.
         * @private
         */
        this.extensions.onActivate = function(){
            tool.minDistance=4/_this.project.getZoom();
            tool.maxDistance=20/_this.project.getZoom();
            _this.drawingGroup.visible=true;
            _this.drawingGroup.selected=true;
            _this.targetLayer.addChild(_this.drawingGroup);
            _this._cacheCurrentItem();
        }
        /**
         * Event handler when the tool is deactivated.
         * Finalizes the current interaction if finished is true.
         * @private
         * @param {boolean} finished - A flag indicating whether the tool interaction is finished.
         */
        this.extensions.onDeactivate= function(finished){
            if(finished){
                _this.finish();
                _this.project.toolLayer.addChild(_this.drawingGroup);
                _this.drawingGroup.removeChildren();
                _this.drawingGroup.visible = false;
                _this.drawingGroup.selected = false;
                _this._restoreCachedItem();
                _this._currentItem = null;
            }
        }
        
        
        /**
         * Event handler for the key down event.
         * Handles keyboard shortcuts like toggling erase mode and undo/redo.
         * @private
         * @param {paper.KeyEvent} ev - The key event.
         */
        tool.extensions.onKeyDown=function(ev){
            if(ev.key=='e'){
                if(_this.eraseMode===false){
                    _this.setEraseMode(true);
                }
                else if(_this.eraseMode===true) {
                    _this.eraseMode='keyhold';
                }
            }
            if ((ev.event.metaKey||ev.event.ctrlKey) && !ev.event.shiftKey && ev.event.key === 'z') {
                _this.undo();
            }
            if ((ev.event.metaKey||ev.event.ctrlKey) && ev.event.shiftKey && ev.event.key === 'z') {
                _this.redo();
            }
        }
        /**
         * Event handler for the key up event.
         * Handles releasing keys, such as exiting erase mode.
         * @private
         * @param {paper.KeyEvent} ev - The key event.
         */
        tool.extensions.onKeyUp=function(ev){
            if(ev.key=='e' && _this.eraseMode=='keyhold'){
                _this.setEraseMode(false);
            }
            
        }
    
    }
    _cacheCurrentItem(){
        this._currentItem = this.item;
        this._currentItem && (this._currentItem.selectedColor = this._currentItemSelectedColor);
    }
    _restoreCachedItem(){
        this._currentItem && (this._currentItem.selectedColor = this._currentItemSelectedColor);
    }
    /**
     * Set {@link PolygonTool#_geometryMutatedThisPress} when a hit-test mutation applies to the selected annotation item
     * (not a draft path in the drawing group).
     * @param {paper.HitResult} hitResult
     * @private
     */
    _markIfHitMutatesSelectedItem(hitResult) {
        if (!hitResult?.item || !this.item) return;
        let p = hitResult.item;
        while (p) {
            if (p === this.item) {
                this._geometryMutatedThisPress = true;
                return;
            }
            p = p.parent;
        }
    }

    onSelectionChanged(){
        if(this.item !== this._currentItem){
            this._restoreCachedItem();
            this._cacheCurrentItem();
            
            this.targetLayer.addChild(this.drawingGroup);
            this.drawingGroup.removeChildren();

            this.setEraseMode(this.eraseMode);
        }
    }
    onMouseDown(ev){
        this.draggingSegment=null;
        this._geometryMutatedThisPress = false;
        // Tool action cursor is derived from hover-hit-testing; clear it when the interaction
        // state changes so it can't get stuck without a follow-up mousemove.
        this.project.overlay.removeClass('tool-action').setAttribute('data-tool-action','');
        let now = Date.now();
        let interval=now-this._lastClickTime;
        let dblClick = interval < 300;
        this._lastClickTime=now;

        this.simplifying && this.cancelSimplify();  
        
        if(this.itemToCreate){
            this.itemToCreate.initializeGeoJSONFeature('MultiPolygon');
            this.refreshItems();
            this._cacheCurrentItem();
            this.saveHistory();        
        }

        let dr = this.drawing();
        if(dr && dblClick){
            this.finishCurrentPath();
            this.draggingSegment=null;
            return;
        }
        
        let hitResult = (dr&&dr.path ||this.item).hitTest(ev.point,{fill:false,stroke:true,segments:true,tolerance:this.getTolerance(5)})
        if(hitResult){
            //if erasing and hitResult is a segment, hitResult.segment.remove()
            if(hitResult.type=='segment' && this.eraseMode){
                hitResult.segment.remove();
                this._markIfHitMutatesSelectedItem(hitResult);
            }
            //if hitResult is a segment and NOT erasing, save reference to hitResult.segment for dragging it
            else if(hitResult.type=='segment'){
                this.draggingSegment = hitResult.segment;
            }
            //if hitResult is a stroke, add a point:
            else if(hitResult.type=='stroke'){
                let insertIndex = hitResult.location.index +1;
                let ns = hitResult.item.insert(insertIndex, ev.point);
                this._markIfHitMutatesSelectedItem(hitResult);
            }
        }
        else if(dr){ //already drawing, add point to the current path object
            if(ev.point.subtract(dr.path.lastSegment).length<(5/this.project.getZoom())) return;
            dr.path.add(ev.point);
        }
        else{ //not drawing yet, but start now!
            this.drawingGroup.removeChildren();
            this.drawingGroup.addChild(new paper.Path([ev.point]));
            this.drawingGroup.visible=true;
            this.drawingGroup.selected=true;
            this.drawingGroup.selectedColor= this.eraseMode ? 'red' : null;
        }
        
        
    }
    onMouseUp(ev){
        let closedPathThisUp = false;
        let dr = this.drawing();
        if(dr && dr.path.segments.length>1){
            let hitResult = dr.path.hitTest(ev.point,{fill:false,stroke:false,segments:true,tolerance:this.getTolerance(5)})
            if(hitResult && hitResult.segment == dr.path.firstSegment){
                this.finishCurrentPath();
                closedPathThisUp = true;
            }
        }
        if(this.draggingSegment){
            this.draggingSegment=null;
            if(!this.item.isBoundingElement){
                let boundingItems = this.item.parent.children.filter(i=>i.isBoundingElement);
                this.item.applyBounds(boundingItems);
            }
            if (this.item) this.emitItemEvent('item-updated', { item: this.item, tool: this });
        }
        else if(!closedPathThisUp && this._geometryMutatedThisPress && this.item){
            this.emitItemEvent('item-updated', { item: this.item, tool: this, reason: 'geometry-edit' });
        }
        this._geometryMutatedThisPress = false;
        this.saveHistory()
    }
    onMouseMove(ev){
        let dr = this.drawing();
        let hitResult = this.item && (dr&&dr.path ||this.item).hitTest(ev.point,{fill:false,stroke:true,segments:true,tolerance:this.getTolerance(5)})
        if(hitResult){
            let action = hitResult.type + (this.eraseMode ? '-erase' : '');
            this.project.overlay.addClass('tool-action').setAttribute('data-tool-action',action);
        }
        else{
            this.project.overlay.removeClass('tool-action').setAttribute('data-tool-action','');
        }  
    }
    onMouseDrag(ev){
        let dr = this.drawing();
        if(dr){
            dr.path.add(ev.point)
        }
        else if (this.draggingSegment){
            this.draggingSegment.point = this.draggingSegment.point.add(ev.delta);
        }
    }
    /**
     * Retrieves the current drawing state, including the active path being drawn.
     * @returns {?{path: paper.Path}} The current drawing state or null if no path is being drawn.
     */
    drawing(){
        return this.drawingGroup.lastChild && {
            path: this.drawingGroup.lastChild,
        }
    }
    /**
     * Finalizes the current polygon drawing and performs necessary cleanup.
     */
    finish(){
        // this.finishCurrentPath();
        this.setEraseMode(false);
        this.draggingSegment=null;
        this.project.overlay.removeClass('tool-action').setAttribute('data-tool-action','');
        this.deactivate();
        this.drawingGroup.selected=false;      
        this.drawingGroup.visible=false;  
    }
    
    /**
     * Sets the erase mode, enabling or disabling removal of segments or entire polygons.
     * @param {boolean} erase - True to enable erase mode, false to disable.
     */
    setEraseMode(erase){
        const erasing = !!erase;
        const changed = this.eraseMode !== erasing;
        this.eraseMode = erasing;
        this.item && (this.item.selectedColor = erasing ? 'red' : null);
        this.drawingGroup.selectedColor = erasing ? 'red' : null;
        this.toolbarControl.setEraseMode(erasing);
        this.project.overlay.removeClass('tool-action').setAttribute('data-tool-action','');
        if (changed) {
            const tk = this.project?.paperScope?.annotationToolkit;
            if (tk && tk._emitIntegrationEvent) tk._emitIntegrationEvent('polygon-erase-mode-changed', { erasing }, { tool: this });
        }
    }
    /**
     * Completes the current polygon path and updates the annotation accordingly.
     */
    finishCurrentPath(){
        let dr = this.drawing();
        if(!dr || !this.item || !this.item.parent) return;
        const hadRingsBefore = this.item.children.length > 0;
        const pathAdded = dr.path;
        dr.path.closed=true;
            
        let result = this.eraseMode ? this.item.subtract(dr.path,{insert:false}) : this.item.unite(dr.path,{insert:false});
        if(result){
            result=result.toCompoundPath();
            if(!this.item.isBoundingElement){
                let boundingItems = this.item.parent.children.filter(i=>i.isBoundingElement);
                result.applyBounds(boundingItems);
            }
            this.item.removeChildren();
            this.item.addChildren(result.children);
            this.item.children.forEach(child=>child.selected=false);//only have the parent set selected status
            result.remove();
            if (!hadRingsBefore) this.emitItemEvent('item-created', { item: this.item, tool: this });
            else this.emitItemEvent('item-updated', { item: this.item, tool: this, subpathAdded: true, subpath: pathAdded });
        }
        this.drawingGroup.removeChildren();
        this.project.overlay.removeClass('tool-action').setAttribute('data-tool-action','');
        
    }
    /**
     * Saves the current state of the annotation to the history stack for undo/redo functionality.
     */
    saveHistory(){
        //push current state onto history stack
        const historyLength = 10;
        let idx = (this.item.history||[]).position || 0;
        this.item.history=[{
            children:this.item.children.map(x=>x.clone({insert:false,deep:true})),
            drawingGroup:this.drawingGroup.children.map(x=>x.clone({insert:false,deep:true})),
        }].concat((this.item.history||[]).slice(idx,historyLength));
    }
    /**
     * Undoes the last annotation action, restoring the previous state.
     */
    undo(){
        
        let history=(this.item.history||[]);
        let idx = (history.position || 0) +1;
        if(idx<history.length){
            this.drawingGroup.removeChildren();
            this.item.removeChildren();
            this.item.children = history[idx].children.map(x=>x.clone({insert:true,deep:true}));
            this.drawingGroup.children = history[idx].drawingGroup.map(x=>x.clone({insert:true,deep:true}));
            history.position=idx;
            if (this.item) this.emitItemEvent('item-updated', { item: this.item, tool: this, reason: 'undo' });
            const tk = this.project?.paperScope?.annotationToolkit;
            if (tk && tk._emitIntegrationEvent) tk._emitIntegrationEvent('polygon-history-changed', { action: 'undo' }, { tool: this });
        }
    }
    /**
     * Redoes the previously undone annotation action, restoring the next state.
     */
    redo(){
        
        let history=(this.item.history||[]);
        let idx = (history.position || 0) -1;
        if(idx>=0){
            this.drawingGroup.removeChildren();
            this.item.removeChildren();
            this.item.children = history[idx].children.map(x=>x.clone({insert:true,deep:true}));
            this.drawingGroup.children = history[idx].drawingGroup.map(x=>x.clone({insert:true,deep:true}));
            history.position=idx;
            if (this.item) this.emitItemEvent('item-updated', { item: this.item, tool: this, reason: 'redo' });
            const tk = this.project?.paperScope?.annotationToolkit;
            if (tk && tk._emitIntegrationEvent) tk._emitIntegrationEvent('polygon-history-changed', { action: 'redo' }, { tool: this });
        }
    }

    simplify(){
        this.doSimplify();
        this.saveHistory();
        for (const it of this.items) {
            this.emitItemEvent('item-updated', { item: it, tool: this, reason: 'simplify' });
        }
        const tk = this.project?.paperScope?.annotationToolkit;
        if (tk && tk._emitIntegrationEvent) tk._emitIntegrationEvent('polygon-simplified', {}, { tool: this });
    }
}
export {PolygonTool};
/**
 * Represents the toolbar for the PolygonTool, providing UI controls for polygon annotation.
 * Inherits functionality from the AnnotationUIToolbarBase class.
 * @extends AnnotationUIToolbarBase
 * @class
 * @memberof OSDPaperjsAnnotation.PolygonTool
 */
class PolygonToolbar extends AnnotationUIToolbarBase{
    /**
     * Create a new instance of the PolygonToolbar class.
     * @param {PolygonTool} polyTool - The associated PolygonTool instance.
     */
    constructor(polyTool){
        super(polyTool);
        
        const i = makeFaIcon('fa-draw-polygon');
        this.button.configure(i,'Polygon Tool');
        
        const fdd = document.createElement('div');
        fdd.classList.add('dropdown','polygon-toolbar');
        fdd.setAttribute('data-tool','polygon');
        this.dropdown.appendChild(fdd);
        const s = document.createElement('span');
        s.innerHTML = 'Click or Drag';
        fdd.appendChild(s);
    
        const simplifyDiv = document.createElement('div');
        fdd.appendChild(simplifyDiv);
        this.simplifyButton = document.createElement('button');
        this.simplifyButton.setAttribute('data-action', 'simplify');
        this.simplifyButton.innerHTML = 'Simplify';
        simplifyDiv.append(this.simplifyButton);
        this.simplifyButton.addEventListener('click',function(){
            polyTool.simplify();
        });


        this.eraseButton = document.createElement('button');
        fdd.appendChild(this.eraseButton);
        this.eraseButton.setAttribute('data-action', 'erase');
        this.eraseButton.innerHTML = 'Eraser';
        this.eraseButton.addEventListener('click',function(){
            let erasing = this.classList.toggle('active');
            polyTool.setEraseMode(erasing);
        });

        
        const undoRedo = document.createElement('span');
        fdd.appendChild(undoRedo);

        this.undoButton = document.createElement('button');
        undoRedo.appendChild(this.undoButton);
        this.undoButton.setAttribute('data-action', 'undo');
        this.undoButton.setAttribute('title', 'Undo (ctrl-Z)');
        this.undoButton.innerHTML = '<';
        this.undoButton.addEventListener('click',function(){
            polyTool.undo();
        });

        this.redoButton = document.createElement('button');
        undoRedo.appendChild(this.redoButton);
        this.redoButton.setAttribute('data-action', 'redo');
        this.redoButton.setAttribute('title', 'Redo (ctrl-shift-Z)');
        this.redoButton.innerHTML = '>';
        this.redoButton.addEventListener('click',function(){
            polyTool.redo();
        });
    }
    /**
     * Check if the toolbar is enabled for the given mode.
     * @param {string} mode - The annotation mode.
     * @returns {boolean} True if enabled, false otherwise.
     */
    isEnabledForMode(mode){
        return ['new','MultiPolygon', 'Polygon'].includes(mode);
    }
    /**
     * Set the erase mode for the toolbar, updating UI state.
     * @param {boolean} erasing - True to enable erase mode, false to disable.
     */
    setEraseMode(erasing){
        erasing ? this.eraseButton.classList.add('active') : this.eraseButton.classList.remove('active');
    }

}
export {PolygonToolbar};