Source: featureui.mjs

/**
 * OpenSeadragon paperjs overlay plugin based on paper.js
 * @version 0.4.13
 * 
 * Includes additional open source libraries which are subject to copyright notices
 * as indicated accompanying those segments of code.
 * 
 * Original code:
 * Copyright (c) 2022-2024, 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 {EditableContent} from './utils/editablecontent.mjs';
import { OpenSeadragon } from './osd-loader.mjs';
import { domObjectFromHTML } from './utils/domObjectFromHTML.mjs';
import { datastore } from './utils/datastore.mjs';
import { convertFaIcons } from './utils/faIcon.mjs';

/**
 * A user interface for managing features.
 * @class
 * @memberof OSDPaperjsAnnotation
 */
class FeatureUI{
    /**
     * Create a new FeatureUI instance.
     * @constructor
     * @param {paper.Item} paperItem - The paper item object.
     * @param {object} [opts] - The initialization options.
     * @param {IconFactory} [opts.iconFactory] - the IconFactory to use
     */
    constructor(paperItem, opts){
        
        this.paperItem=paperItem;
        let el = this._element = makeFeatureElement();
        opts.iconFactory ? opts.iconFactory.convertFaIcons(el) : convertFaIcons(el);

        this.paperItem.FeatureUI = this;
        this._editableName = new EditableContent();
        el.querySelector('.feature-item.name').appendChild(this._editableName.element);
        this._editableName.onChanged = text => {
            this.setLabel(text,'user-defined');
        };
        this._editableName.onEditClicked = function(event){
            event.preventDefault();
            event.stopPropagation();
        }
        
        datastore.set(el, {feature:this});
        el.addEventListener('click', ev => {
            if(ev.target.matches('[data-action]')){
                //don't bubble up
                ev.stopPropagation();
                ev.stopImmediatePropagation();
                ev.preventDefault();
                let action = ev.target.dataset.action;
                switch(action){
                    case 'trash': this.removeItem(); break;
                    case 'bounds': this.useAsBoundingElement(true); break;
                    case 'style':this.openStyleEditor(ev); break;
                    case 'zoom-to':this.centerItem(); break;
                    default: console.log('No function set for action:',action);
                }
            }
        });
        
        el.addEventListener('click',ev => {
            ev.stopPropagation();
            this.paperItem.toggle((ev.metaKey || ev.ctrlKey));
        })
        
        this.element = el;
        this.paperItem.on({
            'selected':()=>{ 
                el.classList.add('selected');
                el.dispatchEvent(new Event('selected')); 
            },
            'deselected':()=>{ 
                el.classList.remove('selected');
                el.dispatchEvent(new Event('deselected')); 
            },
            'selection:mouseenter':()=>{ 
                el.classList.add('item-hovered');
            },
            'selection:mouseleave':()=>{ 
                el.classList.remove('item-hovered');
            },
            'item-replaced':(ev)=>{ 
                // console.log('item-replaced',ev);
                //check label first because it is dynamically fetched from the referenced this.paperItem object
                if(this.label.source=='user-defined'){
                    ev.item.displayName = this.label;
                }
                this.paperItem = ev.item;
                this.paperItem.FeatureUI=this;
                this.updateLabel();
            },
            'display-name-changed':(ev)=>{ 
                this.updateLabel();
            },
            'removed':(ev)=>{ 
                if(ev.item == this.paperItem){
                    this.remove();
                }
            }
        });

        if(this.paperItem.selected){
            this.paperItem.emit('selected');
        }

        this.label ? this.updateLabel() : this.setLabel('Creating...', 'initializing');
        
    }

    get label(){
        return this.paperItem.displayName;
    }
    set label(l){
        return this.setLabel(l)
    }
    /**
     * Set the label of the feature with a source.
     * @param {string} text - The new label of the feature.
     * @param {string} source - The source of the label (e.g. 'user-defined' or 'initializing').
     * @returns {string} The new label of the feature.
     */
    setLabel(text,source){
        let l = new String(text);
        l.source=source;
        this.paperItem.displayName = l;
        this.updateLabel();
        return l;
    }
    /**
     * Update the label of the feature in the UI element.
     */
    updateLabel(){
        // this._element.find('.feature-item.name').text(this.label);//.trigger('value-changed',[l]);
        this._editableName.setText(this.label);
    }
    /**
     * Remove the paper item associated with the feature.
     */
    removeItem(){        
        //clean up paperItem
        this.paperItem.remove();
        this.paperItem.deselect();
    }
    /**
     * Remove the UI element associated with the feature.
     */
    remove(){
        this._element.remove();
        this._element.dispatchEvent( new Event('removed') );
    }
    
    
    /**
     * Use the feature as a bounding element.
     * @param {boolean} [toggle=false] - Whether to toggle the bounding element status or not.
     * @returns {boolean} Whether the feature is used as a bounding element or not.
     */
    useAsBoundingElement(toggle=false){
        if(!this.paperItem.canBeBoundingElement) return false;
        let element = this._element.querySelector('[data-action="bounds"]');
        if(toggle){
            element.classList.toggle('active');
        } else {
            element.classList.add('active');
        }
        let isActive = element.classList.contains('active');
        this.paperItem.isBoundingElement = isActive;
        return isActive;
    }   
    /**
     * Open the style editor for the feature.
     */ 
    openStyleEditor(){
        let heard = this.paperItem.project.emit('edit-style',{item:this.paperItem});
        if(!heard){
            console.warn('No event listeners are registered for paperScope.project for event \'edit-style\'');
        }
    }
    /**
     * Center the feature in the viewport.
     * @param {boolean} [immediately=false] - Whether to center the feature immediately or not.
     */
    centerItem(immediately = false){
        let viewport = this.paperItem.project.overlay.viewer.viewport;
        let bounds = this.paperItem.bounds;
        let center = viewport.imageToViewportCoordinates(bounds.center.x,bounds.center.y);
        let scale=1.5;
        let xy = viewport.imageToViewportCoordinates(bounds.center.x - bounds.width/scale, bounds.center.y - bounds.height/scale);
        let wh = viewport.imageToViewportCoordinates(2*bounds.width/scale, 2*bounds.height/scale);
        let rect=new OpenSeadragon.Rect(xy.x, xy.y, wh.x,wh.y);
        let vb = viewport.getBounds();
        if(rect.width > vb.width || rect.height > vb.height){
            viewport.fitBounds(rect, immediately);
        }
        else{
            viewport.panTo(center, immediately);
        }
    }
    
}
export {FeatureUI};
/**
  * Create an HTML element for the feature UI.
 * @private
 * @returns {jQuery} The jQuery object of the HTML element.
 */
function makeFeatureElement(){
    let html = `
    <div class='feature'>
        <div class='annotation-header hoverable-actions'>
            <span class='onhover fa-solid fa-crop-simple bounding-element' data-action="bounds" title='Bounding element'></span>
            <span class='feature-item name'></span>
            <span class='onhover fa-solid fa-palette' data-action='style' title='Open style editor'></span>
            <span class='onhover fa-solid fa-binoculars' data-action='zoom-to' title='View this feature'></span>
            <span class='onhover fa-solid fa-trash-can' data-action='trash' title='Remove'></span>
        </div>
    </div>
    `;
    return domObjectFromHTML(html);
}