Source: featurecollectionui.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 { FeatureUI } from './featureui.mjs';
import { EditableContent } from './utils/editablecontent.mjs';
import { domObjectFromHTML } from './utils/domObjectFromHTML.mjs';
import { datastore } from './utils/datastore.mjs';
import { DragAndDrop } from './utils/draganddrop.mjs';
import { Placeholder } from './paperitems/placeholder.mjs';
import { OpenSeadragon } from './osd-loader.mjs';
import { convertFaIcons } from './utils/faIcon.mjs';
import { paper } from './paperjs.mjs';

/**
 * A user interface for managing feature collections. The FeatureCollectionUI class provides a user
 *  interface to manage feature collections on a paper.Layer object. It allows users to create, edit,
 *  and organize features within the collection. The class includes various functionalities, such as 
 * adding and removing features, setting opacity and fill opacity for the paper layer, and more.
 * @class
 * @memberof OSDPaperjsAnnotation
 */
class FeatureCollectionUI{
    /**
     * Create a new FeatureCollectionUI instance.
     * 
     * @constructor
     * @property {string} displayName - The display name of the group.
     * @property {paper.Item} paperItem - The paper item object.
     * @property {HTMLElement} element - The HTML element of the feature collection UI.
     * @param {paper.Group} group - The paper group object.
     * @param {object} [opts] - The initialization options.
     * @param {IconFactory} [opts.iconFactory] - the IconFactory to use
     */
    constructor(group,opts){
        
        // this.toolbar = init.toolbar;
        this.element = makeFeatureCollectionElement();
        opts.iconFactory ? opts.iconFactory.convertFaIcons(this.element) : convertFaIcons(this.element);

        this._editableName = new EditableContent({iconFactory: opts.iconFactory});
        this.element.querySelector('.annotation-name.name').appendChild(this._editableName.element);
        this._editableName.onChanged = text => {
            this.label = text;
        }
        this._editableName.onEditClicked = event => {
            event.preventDefault();
            event.stopPropagation();
        }

        this._featurelist=this.element.querySelector('.features-list');

        this._dragAndDrop = new DragAndDrop({
            parent: this.element, 
            selector: '.features-list .feature', 
            dropTarget: this._featurelist,
            onDrop: ()=>{
                this.features.forEach(f => this.group.addChild(f.paperItem));
            }
        });

        this.group = group;
        // add paperjs event handlers
        this.group.on({
            'selection:mouseenter':()=>{
                this.element.classList.add('svg-hovered');
                this.element.dispatchEvent(new Event('mouseover'));
            },
            'selection:mouseleave':()=>{
                this.element.classList.remove('svg-hovered');
                this.element.dispatchEvent(new Event('mouseout'));
            },
            'selected':()=>{
                this.element.classList.add('selected');
                this.element.dispatchEvent(new Event('selected'));
            },
            'deselected':()=>{
                this.element.classList.remove('selected');
                this.element.dispatchEvent(new Event('deselected'));
            },
            'display-name-changed':()=>{
                this.updateLabel();
            },
            'removed':()=>{
                this.remove();
            },
            'child-added':(ev)=>{
                let featureUI = ev.item.FeatureUI || new FeatureUI(ev.item, opts);
                this._addFeature(featureUI);
            }
        });

        // expose this object as a property of the paper.js group
        this.group.featureCollectionUI = this;

        
        this.remove = ()=>{
            this.element.remove();
        }
        /**
         * Get the number of features in the feature collection.
         * @member
         * @returns {number} The number of features.
         */
        this.numFeatures = ()=>{
            return this.features.length;
        }

        /**
         * Add a feature to the feature collection UI element.
         * @member
         * @param {FeatureUI} f - The feature to add.
         * @returns {jQuery} The jQuery object of the feature element.
         */
        this._addFeature = f => {
            f.paperItem.updateFillOpacity();
            this._featurelist.appendChild(f.element);
            this._sortableDebounce && window.clearTimeout(this._sortableDebounce);
            self._sortableDebounce = window.setTimeout(()=>this._dragAndDrop.refresh(), 15);
            return f.element; 
        }
        /**
         * Create a new feature and add it to the paper group using the default style properties of the group.
         * This function also creates a geoJSON object for the feature and converts it to a paper item.
         * @member
        * @property {paper.Color} fillColor - The fill color of the group.
        * @property {paper.Color} strokeColor - The stroke color of the group.
        * @property {Object} rescale - The rescale properties of the group.
        * @property {number} fillOpacity - The fill opacity of the group.
        * @property {number} strokeOpacity - The stroke opacity of the group.
        * @property {number} strokeWidth - The stroke width of the group.
        * 
        * @property {string} type - The type of the feature (e.g., "Feature").
        * @property {Object} geometry - The geometry object.
        * @property {Object} properties - The properties object containing style information. 
        * 
        * @returns {paper.Item} The paper item object of the new feature that was added to the group.
         */
        this.createFeature=function(){
            //define a new feature
            let props = this.group.defaultStyle;
            let clonedProperties = {
                fillColor:new paper.Color(props.fillColor),
                strokeColor:new paper.Color(props.strokeColor),
                rescale:OpenSeadragon.extend(true,{},props.rescale),
                fillOpacity:props.fillOpacity,
                strokeOpacity:props.strokeOpacity,
                strokeWidth:props.strokeWidth,
            }
            let placeholder = new Placeholder(clonedProperties);
            this.group.addChild(placeholder.paperItem);
            return placeholder.paperItem;
        }

        
        const setOpacity = o=>{
            this.group.opacity = o;
        }
        
        const setFillOpacity = o => {
            this.group.fillOpacity = o;
        }

        this.ui={
            setOpacity:setOpacity,
            setFillOpacity:setFillOpacity,
        }
        
        datastore.set(this.element, {featureCollection: this});
        
        this.label = this.group.displayName;

        this.element.addEventListener('click',ev=>{
            ev.stopPropagation();
        })
        this.element.querySelector('.toggle-list').addEventListener('click',ev=>{
            let numFeatures = this._featurelist.children.length;
            this.element.querySelector('.num-annotations').textContent = numFeatures;
            this.element.querySelector('.features-summary').dataset.numElements = numFeatures;
            this.element.querySelector('.features').classList.toggle('collapsed');
            ev.stopPropagation();
            ev.preventDefault();
        });

        this.element.addEventListener('click', ev => {

            if(ev.target.matches('.annotation-header [data-action]')){
                //don't bubble up
                ev.stopPropagation();
                ev.stopImmediatePropagation();
                ev.preventDefault();
                
                let action = ev.target.dataset.action;
                switch(action){
                    case 'trash': this.removeLayer(true); break;
                    case 'style': this.openStyleEditor(ev); break;
                    case 'show': this.toggleVisibility(); break;
                    case 'hide': this.toggleVisibility(); break;
                    default: console.log('No function set for action:',action);
                }
            }
            
        });
        this.element.querySelector('.new-feature').addEventListener('click',ev => {
            ev.stopPropagation();
            let item = this.createFeature();
            item.select();
        });

        return this;
    }
    
    /**
    * Get the features in the feature collection.
    * @member
    * @returns {FeatureUI[]} The array of features.
    */
    get features(){
       return Array.from(this._featurelist.querySelectorAll('.feature')).map(element => {
           return datastore.get(element, 'feature');
       });
    }
    get label(){
        return this.group.displayName;
    }
    set label(l){
        return this.setLabel(l)
    }
    /**
     * Set the label of the feature collection with a source.
     * @param {string} text - The new label of the feature collection.
     * @param {string} source - The source of the label (e.g. 'user-defined' or 'initializing').
     * @returns {string} The new label of the feature collection.
     */
    setLabel(text,source){
        let l = new String(text);
        l.source=source;
        this.group.displayName = l;
        this.updateLabel();
        return l;
    }
    /**
     * Update the label of the feature collection in the UI element.
     */
    updateLabel(){
        this._editableName.setText(this.label);
    }
    /**
     * Toggle the visibility of the feature collection UI element and the paper group.
     */
    toggleVisibility(){
        this.element.classList.toggle('annotation-hidden');
        this.group.visible = !this.element.classList.contains('annotation-hidden');
    }
    /**
     * Remove the paper layer associated with the feature collection.
     * @param {boolean} [confirm=true] - Whether to confirm before removing or not.
     */
    removeLayer(confirm = true){
        if(confirm && window.confirm('Remove this layer?')==true){
            this.group.remove();
        } else {

        }
    }
    /**
     * Open the style editor for the feature collection.
     * @function 
     * @param {object} ev - The event object.
     */
    openStyleEditor(ev){
        let heard = this.group.project.emit('edit-style',{item:this.group});
        if(!heard){
            console.warn('No event listeners are registered for paperScope.project for event \'edit-style\'');
        }
    }
}

export {FeatureCollectionUI};

/**
 * Create an HTML element for the feature collection UI.
 * @private
 * @returns {jQuery} The jQuery object of the HTML element.
 */
function makeFeatureCollectionElement(){
    let html = `
    <div class='feature-collection'>
        <div class='annotation-header hoverable-actions'>
            <span class="visibility-toggle"><span class="fa fa-eye" data-action="hide"></span><span class="fa fa-eye-slash" data-action="show"></span></span>
            <span class='annotation-name name'></span>
            <span class='onhover fa-solid fa-palette' data-action='style' title='Open style editor'></span>
            <span class='onhover fa-solid fa-trash-can' data-action='trash' title='Remove feature collection'></span>
        </div>
        <div class="flex-row features">
            <div class="toggle-list btn-group btn-group-sm"><button class="btn btn-default"><span class='fa-solid fa-caret-down' data-action="collapse-down"></span><span class='fa-solid fa-caret-up' data-action="collapse-up"></span></button></div>
            <div class="annotation-details">
                <div>
                    <div class='features-summary feature-item name'><span class='num-annotations'></span> annotation element<span class='pluralize'></span></div>
                    <div class='features-list'></div>
                </div>
                <div class='new-feature feature'><span class='fa fa-plus' data-action="add-feature"></span>Add feature</div>
            </div>
        </div>
    </div>
    `;
    return domObjectFromHTML(html);
}