Source: annotationtoolkit.mjs

/**
 * OpenSeadragon annotation 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-2023, 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 this project 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 { OpenSeadragon } from './osd-loader.mjs';
import { paper } from './paperjs.mjs';
import { AnnotationUI } from './annotationui.mjs';
import { AnnotationToolset } from './annotationtoolset.mjs';
import { AnnotationToolbar } from './annotationtoolbar.mjs';
import { LayerUI } from './layerui.mjs';
import { AnnotationLayout } from './annotationlayout.mjs';
import { attachAnnotationToolkitConfigurationWidget } from './overlays/annotations/index.mjs';
import { PaperOverlay } from './paper-overlay.mjs';
import { AnnotationItemFactory } from './paperitems/annotationitem.mjs';
import { MultiPolygon } from './paperitems/multipolygon.mjs';
import { Placeholder } from './paperitems/placeholder.mjs';
import { Linestring } from './paperitems/linestring.mjs';
import { MultiLinestring } from './paperitems/multilinestring.mjs';
import { RulerMeasurement } from './paperitems/rulermeasurement.mjs';
import { Raster } from './paperitems/raster.mjs';
import { Point } from './paperitems/point.mjs';
import { PointText } from './paperitems/pointtext.mjs';
import { Rectangle } from './paperitems/rectangle.mjs';
import { Ellipse } from './paperitems/ellipse.mjs';
import { cyrb53 } from './utils/hash.mjs';
import { ANNOTATION_UI_SCOPE_CLASS } from './utils/annotationUIScope.mjs';
import { ensureAnnotationUIStyles } from './utils/annotationUIStyles.mjs';

//extend paper prototypes to add functionality
//property definitions

Object.defineProperty(paper.Item.prototype, 'displayName', displayNamePropertyDef());
Object.defineProperty(paper.Item.prototype, 'featureCollection', featureCollectionPropertyDef());
Object.defineProperty(paper.TextItem.prototype, 'content', textItemContentPropertyDef());
Object.defineProperty(paper.Project.prototype, 'descendants', descendantsDefProject());

//extend remove function to emit events for GeoJSON type annotation objects
let origRemove=paper.Item.prototype.remove;
paper.Item.prototype.remove=function(){
    const childrenToFireRemove = this.getItems({match: item=>item.isGeoJSONFeatureCollection});
    (this.isGeoJSONFeature || this.isGeoJSONFeatureCollection) && this.project.emit('item-removed',{item: this});
    childrenToFireRemove.forEach(fc => this.project.emit('item-removed', {item: fc}));
    origRemove.call(this);
    (this.isGeoJSONFeature || this.isGeoJSONFeatureCollection) && this.emit('removed',{item: this});
    childrenToFireRemove.forEach(fc => fc.emit('removed', {item: fc}));
}
//function definitions
paper.Group.prototype.insertChildren=getInsertChildrenDef();
paper.Color.prototype.toJSON = paper.Color.prototype.toCSS;//for saving/restoring colors as JSON
paper.Style.prototype.toJSON = styleToJSON;
paper.View.prototype.getImageData = paperViewGetImageData;
paper.PathItem.prototype.toCompoundPath = toCompoundPath;
paper.PathItem.prototype.applyBounds = applyBounds;
paper.Item.prototype.select = paperItemSelect;
paper.Item.prototype.deselect = paperItemDeselect;
paper.Item.prototype.toggle = paperItemToggle;
//to do: should these all be installed on project instead of scope?
paper.PaperScope.prototype.findSelectedNewItem = findSelectedNewItem;
paper.PaperScope.prototype.findSelectedItems = findSelectedItems;
paper.PaperScope.prototype.findSelectedItem = findSelectedItem;
paper.PaperScope.prototype.scaleByCurrentZoom = function (v) { return v / this.view.getZoom(); };
paper.PaperScope.prototype.getActiveTool = function(){ return this.tool ? this.tool._toolObject : null; }        


/**
 * A class for creating and managing annotation tools on an OpenSeadragon viewer.
 * @class 
 * @memberof OSDPaperjsAnnotation
 * @extends OpenSeadragon.EventSource
 */
class AnnotationToolkit extends OpenSeadragon.EventSource{
    /**
     * Create a new AnnotationToolkit instance.
     * @constructor
     * @param {OpenSeadragon.Viewer} openSeadragonViewer - The OpenSeadragon viewer object.
     * @param {object} [opts]
     * @param {object|boolean} [opts.addUI] - Deprecated. If set, annotation UI is created via addAnnotationUI. Prefer toolbar/layerUI/layout for new code.
     * @param {object|boolean} [opts.toolbar] - If set, toolbar is created at construction (same as getToolbar after). Value: true or { tools?: string[] }.
     * @param {object|boolean} [opts.layerUI] - If set, layer UI is created at construction (same as getLayerUI after). Value: true or { addFileButton?: boolean }.
     * @param {object|boolean} [opts.layout] - If set, AnnotationLayout (grid + optional toggle) is created at construction (same as addAnnotationLayout after). Value: true or { addButton?, initialOpen?, buttonTogglesToolbar?, buttonTogglesLayerUI? }.
     * @param {object} [opts.overlay] a PaperOverlay object to use
     * @param {object} [opts.destroyOnViewerClose] whether to destroy the toolkit and its overlay when the viewer closes
     * @param {object} [opts.cacheAnnotations] whether to keep annotations in memory for images which aren't currently open
     * @param {boolean} [opts.strictGeometry=false] when true, `convertPaperItemToAnnotation` throws if `toGeoJSONGeometry()`
     *   has coordinates whose array nesting does not match RFC 7946 for the declared `geometry.type` (e.g. Polygon vs MultiPolygon).
     *   When false (default), the same mismatch only logs a console warning. Does not replace a full GeoJSON schema validator.
     * @param {boolean} [opts.strictPaperItemContract=false] when true, `convertPaperItemToAnnotation` throws if `paperItem`
     *   violates `AnnotationItem.constructor.paperItemShapeContract` (e.g. MultiPolygon requires `paper.CompoundPath`).
     *   When false (default), a violation only logs a console warning.
     */
    constructor(openSeadragonViewer, opts = {}) {
        super();

        
        if(!opts){
            opts = {};
        }

        this._defaultOptions = {
            addUI: false,
            toolbar: false,
            layerUI: false,
            layout: false,
            overlay: null,
            destroyOnViewerClose: false,
            cacheAnnotations: false,
            strictGeometry: false,
            strictPaperItemContract: false,
            events: {
                project: true,
                toolkit: false,
            },
        }
        this.options = Object.assign({}, this._defaultOptions, opts);
        // normalize nested options (Object.assign is shallow)
        if (opts && typeof opts.events === 'object') {
            this.options.events = Object.assign({}, this._defaultOptions.events, opts.events);
        }
        
        this._defaultStyle = {
            fillColor: new paper.Color('white'),
            strokeColor: new paper.Color('black'),
            fillOpacity:1,
            strokeOpacity:1,
            strokeWidth: 1,
            rescale: {
                strokeWidth: 1
            }
        };
        this.viewer = openSeadragonViewer;
        if (this.viewer?.element) {
            this.viewer.element.classList.add(ANNOTATION_UI_SCOPE_CLASS);
        }

        // set up overlay. If one is passed in, use it. Otherwise, create one.
        if(this.options.overlay){
            if(this.options.overlay instanceof PaperOverlay){
                this.overlay = this.options.overlay;
            }
        } else {
            this.overlay = new PaperOverlay(this.viewer, { overlayType: 'image' });
        }
        this.paperScope.project.defaultStyle = new paper.Style();
        this.paperScope.project.defaultStyle.set(this.defaultStyle);

        // set the overlay to auto rescale items
        this.overlay.autoRescaleItems(true);

        // optionally destroy the annotation toolkit when the viewer closes
        if(this.options.destroyOnViewerClose){
            this.viewer.addOnceHandler('close', ()=>this.destroy());
        }

        //bind a reference to this to the viewer and the paperScope, for convenient access
        this.viewer.annotationToolkit = this;
        this.paperScope.annotationToolkit = this;

        this.viewer.world.addHandler('add-item',ev=>{
            if(this.options.cacheAnnotations){
                this._loadCachedAnnotations(ev.item);
            }
        })
        this.viewer.world.addHandler('remove-item',ev=>{
            if(this.options.cacheAnnotations){
                this._cacheAnnotations(ev.item);
            }
        }, false, 1);


        //register item constructors
        AnnotationItemFactory.register(MultiPolygon);
        AnnotationItemFactory.register(Placeholder);
        AnnotationItemFactory.register(Linestring);
        AnnotationItemFactory.register(MultiLinestring);
        AnnotationItemFactory.register(RulerMeasurement);
        AnnotationItemFactory.register(Raster);
        AnnotationItemFactory.register(Point);
        AnnotationItemFactory.register(PointText);
        AnnotationItemFactory.register(Rectangle);
        AnnotationItemFactory.register(Ellipse);

        paper.Item.fromGeoJSON = AnnotationItemFactory.itemFromGeoJSON;
        paper.Item.fromAnnotationItem = AnnotationItemFactory.itemFromAnnotationItem;

        this._cached = {};

        if (this.options.addUI) {
            let uiOpts = {};
            if (typeof this.options.addUI === 'object') {
                uiOpts = this.options.addUI;
            }
            this.addAnnotationUI(uiOpts);
        } else {
            if (this.options.toolbar) {
                this.getToolbar(this.options.toolbar === true ? {} : this.options.toolbar);
            }
            if (this.options.layerUI) {
                this.getLayerUI(this.options.layerUI === true ? {} : this.options.layerUI);
            }
            if (this.options.layout) {
                this.addAnnotationLayout(this.options.layout === true ? {} : this.options.layout);
            }
        }

    }

    /**
     * Emit an integration hook event to the configured targets.
     * This is the single entrypoint for toolkit-owned "public hook" events so we can
     * keep payloads consistent and optionally avoid extra calls.
     * @param {string} name - event name (kebab-case)
     * @param {Object} payload - event payload (will be shallow-cloned and enriched)
     * @param {Object} [ctx]
     * @param {OSDPaperjsAnnotation.ToolBase} [ctx.tool] - tool instance associated with the event
     */
    _emitIntegrationEvent(name, payload = {}, ctx = {}) {
        const tool = ctx && ctx.tool ? ctx.tool : null;
        const enriched = Object.assign({}, payload, {
            tool: payload.tool ?? tool ?? undefined,
            toolName: payload.toolName ?? tool?.toolName ?? undefined,
        });

        const ev = this.options?.events || this._defaultOptions.events;
        if (ev?.project !== false) {
            this.paperScope?.project?.emit?.(name, enriched);
        }
        if (ev?.toolkit) {
            this.raiseEvent(name, enriched);
        }
    }

    /**
     * Subscribe to Paper.js project events without reaching into paperScope internals.
     * Equivalent to: `tk.paperScope.project.on(name, fn)`.
     * @param {string} name
     * @param {Function} fn
     * @returns {this}
     */
    on(name, fn) {
        this.paperScope?.project?.on?.(name, fn);
        return this;
    }

    /**
     * Unsubscribe from Paper.js project events.
     * Equivalent to: `tk.paperScope.project.off(name, fn)`.
     * @param {string} name
     * @param {Function} fn
     * @returns {this}
     */
    off(name, fn) {
        this.paperScope?.project?.off?.(name, fn);
        return this;
    }

    /**
     * Get the default style for the annotation items.
     * 
     * @returns {object} The default style object.
     */
    get defaultStyle(){
        return this._defaultStyle;
    }
    
    /**
     * Get the default style for the annotation items.
     * 
     * @returns {object} The default style object.
     */
    get annotationUI(){
        return this._annotationUI;
    }


    /**
     * Get the paperScope associated with this toolkit
     * 
     * @returns {object} The paperScope object for this toolkit's PaperOverlay.
     */
    get paperScope(){
        return this.overlay.paperScope;
    }

    /**
     * Empty any cached annotations
     */
    clearCache(){
        this._cached = {};
    }

    /**
     * save the current feature collections to the cache
     * @param {TiledImage} tiledImage 
     * @private
     */
    _cacheAnnotations(tiledImage){
        try{
            const key = cyrb53(JSON.stringify(tiledImage.source));
            const tileLayer = this.overlay.getPaperLayer(tiledImage);
            if (!tileLayer) {
                return;
            }
            const featureCollections = tileLayer.getItems({match: item=>item.isGeoJSONFeatureCollection});
            this._cached[key] = featureCollections;
        } catch(e){
            console.error('Error with caching', e);
        }
    }

    _loadCachedAnnotations(tiledImage){
        try{
            const key = cyrb53(JSON.stringify(tiledImage.source));
            const featureCollections = this._cached[key] || [];
            const tileLayer = this.overlay.getPaperLayer(tiledImage);
            if (!tileLayer) {
                return;
            }
            for(const fcGroup of featureCollections){
                this._addFeatureCollectionGroupToLayer(fcGroup, tileLayer);
            }
        } catch(e){
            console.error('Error with fetching from cache', e);
        }
    }

    /**
     * Add an annotation UI to the toolkit.
     * 
     * @param {object} [opts={}] - The options for the annotation UI.
     * @returns {AnnotationUI} The annotation UI object.
     */
    addAnnotationUI(opts = {}){
        if (!this._annotationUI) {
            console.warn('[osd-paperjs-annotation] addAnnotationUI is deprecated. Use getToolbar(), getLayerUI(), and addAnnotationLayout() (or place the returned .element nodes in your own layout). See the layout configuration demo.');
            this._toolset = this._toolset || new AnnotationToolset(this.paperScope, opts.tools);
            if (opts.addToolbar !== false) this.getToolbar({ tools: opts.tools });
            if (opts.addLayerUI !== false) this.getLayerUI({ addFileButton: opts.addFileButton });
            this._annotationUI = new AnnotationUI(this, this._toolset, opts);
        }
        return this._annotationUI;
    }

    /**
     * Get the toolkit's toolbar, creating it on first call with the given opts. Use .element to get the root DOM node (official API; do not look up by CSS class or id).
     * @param {Object} [opts] - Used only when creating: opts.tools - optional array of tool names.
     * @returns {AnnotationToolbar|null} The toolbar instance, or null if addAnnotationUI exists but did not create a toolbar.
     */
    getToolbar(opts = {}) {
        if (this._toolbar) return this._toolbar;
        if (this._annotationUI) return this._toolbar ?? null;
        ensureAnnotationUIStyles();
        this._toolset = this._toolset || new AnnotationToolset(this.paperScope, opts.tools ?? null);
        this._toolbar = new AnnotationToolbar(this._toolset);
        return this._toolbar;
    }

    /**
     * Get the toolkit's layer UI, creating it on first call with the given opts. Use .element to get the root DOM node (official API; do not look up by CSS class or id).
     * @param {Object} [opts] - Used only when creating: opts.addFileButton - optional boolean.
     * @returns {LayerUI|null} The layer UI instance, or null if addAnnotationUI exists but did not create a layer UI.
     */
    getLayerUI(opts = {}) {
        if (this._layerUI) return this._layerUI;
        if (this._annotationUI) return this._layerUI ?? null;
        ensureAnnotationUIStyles();
        this._layerUI = new LayerUI(this, opts.addFileButton);
        return this._layerUI;
    }

    /**
     * Get the root DOM element for the toolbar (official API). Returns null if no toolbar exists.
     * @param {Object} [opts] - Passed to getToolbar(opts) when lazy-creating.
     * @returns {HTMLElement|null}
     */
    getToolbarElement(opts = {}) {
        return this.getToolbar(opts)?.element ?? null;
    }

    /**
     * Get the root DOM element for the layer UI (official API). Returns null if no layer UI exists.
     * @param {Object} [opts] - Passed to getLayerUI(opts) when lazy-creating.
     * @returns {HTMLElement|null}
     */
    getLayerUIElement(opts = {}) {
        return this.getLayerUI(opts)?.element ?? null;
    }

    /**
     * Build the annotation layout (grid, resize, toggle button) using AnnotationLayout.
     * Uses getToolbar() and getLayerUI() (lazy-creating with default opts if needed). Mutually exclusive with addAnnotationUI.
     * @param {Object} [opts] - addButton, buttonTogglesToolbar, buttonTogglesLayerUI, initialOpen (all optional).
     * @returns {AnnotationLayout|null} The layout, or null if addAnnotationUI already exists.
     */
    addAnnotationLayout(opts = {}) {
        if (this._annotationUI) return null;
        if (this._annotationLayout) return this._annotationLayout;
        const toolbar = this.getToolbar();
        const layerUI = this.getLayerUI();
        const toolbarRef = toolbar ? {
            element: toolbar.element,
            show: () => toolbar.show(),
            hide: () => toolbar.hide(),
        } : null;
        const layerUIRef = layerUI ? {
            element: layerUI.element,
            show: () => layerUI.show(),
            hide: () => layerUI.hide(),
        } : null;
        this._annotationLayout = new AnnotationLayout(this.viewer, {
            toolbar: toolbarRef,
            layerUI: layerUIRef,
            addButton: opts.addButton !== false,
            addViewerButton: (config) => this.overlay.addViewerButton(config),
            buttonTogglesToolbar: opts.buttonTogglesToolbar !== false,
            buttonTogglesLayerUI: opts.buttonTogglesLayerUI !== false,
            initialOpen: opts.initialOpen !== false,
        });
        if (opts.initialOpen !== false) {
            toolbar && toolbar.show();
            layerUI && layerUI.show();
        } else {
            toolbar && toolbar.hide();
            layerUI && layerUI.hide();
        }
        return this._annotationLayout;
    }

    /**
     * Opt in to a {@link ConfigurationWidget} by adding an "Annotations" custom section (generic addSection only).
     * The toolkit owns the section DOM (toolbar visibility toggles for the pencil and save/load buttons when present).
     * Requires `addAnnotationUI()` first. Idempotent when called again with the same widget. Unregisters automatically on `destroy()`.
     *
     * @param {OSDPaperjsAnnotation.ConfigurationWidget} configurationWidget
     */
    registerWithConfigurationWidget(configurationWidget) {
        if (this._annotationConfigSectionEl?.isConnected && this._annotationConfigSectionWidget === configurationWidget) {
            return;
        }
        this._unregisterConfigurationWidgetSection();
        const root = attachAnnotationToolkitConfigurationWidget(this, configurationWidget);
        if (root) {
            this._annotationConfigSectionEl = root;
            this._annotationConfigSectionWidget = configurationWidget;
        }
    }

    _unregisterConfigurationWidgetSection() {
        const el = this._annotationConfigSectionEl;
        const w = this._annotationConfigSectionWidget;
        this._annotationConfigSectionEl = null;
        this._annotationConfigSectionWidget = null;
        if (el && w && typeof w.removeSection === 'function') {
            w.removeSection(el);
        }
    }

    /**
     * Add a set of tools without the full UI (toolbar). Use when addUI is false.
     * Creates a toolset (tool layer + tool instances) so getTool(name) and activation work.
     * @param {string[]} [toolNames] - Array of tool names (e.g. ['default', 'ruler']). Default tool is always included.
     */
    addTools(toolNames) {
        if (!this._toolset) this._toolset = new AnnotationToolset(this.paperScope, toolNames);
    }
    /**
     * Get a tool instance by name (e.g. 'ruler', 'default'). Works with full UI or headless addTools().
     * @param {string} name - Tool name.
     * @returns {OSDPaperjsAnnotation.ToolBase|null}
     */
    getTool(name) {
        return this._toolset ? this._toolset.getTool(name) : null;
    }
    /**
     * Destroy the toolkit and its components.
     */
    destroy() {
        this.raiseEvent('before-destroy');
        let tool=this.paperScope && this.paperScope.getActiveTool();
        if(tool) tool.deactivate(true);

        this._unregisterConfigurationWidgetSection();

        this.viewer.annotationToolkit = null;
        if (this.viewer?.element) {
            this.viewer.element.classList.remove(ANNOTATION_UI_SCOPE_CLASS);
        }
        if (this._annotationLayout) {
            this._annotationLayout.destroy();
            this._annotationLayout = null;
        }
        this._annotationUI && this._annotationUI.destroy();
        this._annotationUI = null;
        if (this._toolbar) {
            this._toolbar.destroy();
            this._toolbar = null;
        }
        if (this._layerUI) {
            this._layerUI.destroy();
            this._layerUI = null;
        }
        if (this._toolset) this._toolset.destroy();
        this._toolset = null;
        this.overlay.destroy();
        this.raiseEvent('destroy');
    }
    /**
     * Close the toolkit and remove its feature collections.
     */
    close() {
        this.raiseEvent('before-close');
        let tool=this.paperScope && this.paperScope.getActiveTool();
        if(tool) tool.deactivate(true);

        this.addFeatureCollections([],true);
    }
    /**
     * Set the global visibility of the toolkit.
     * @param {boolean} [show=false] - Whether to show or hide the toolkit.
     */
    setGlobalVisibility(show = false){
        this.paperScope.view._element.setAttribute('style', 'visibility:' + (show ? 'visible;' : 'hidden;'));
    }
    /**
     * Add feature collections to the toolkit from GeoJSON objects.
     * @param {object[]} featureCollections - The array of GeoJSON objects representing feature collections.
     * @param {boolean} replaceCurrent - Whether to replace the current feature collections or not.
     * @param {OpenSeadragon.TiledImage | OpenSeadragon.Viewport | false} [parentImage] - which image to add the feature collections to
     */
    addFeatureCollections(featureCollections,replaceCurrent, parentImage){
        this.loadGeoJSON(featureCollections,replaceCurrent, parentImage);
        this.overlay.rescaleItems();
        this.paperScope.project.emit('items-changed');
    }
    /**
     * Get the feature collection groups that the toolkit is managing.
     * @param {paper.Layer} [parentLayer]  The layer to find feature collections within. If not specified, finds across all layers.
     * @returns {paper.Group[]} The array of paper groups representing feature collections.
     */
    getFeatureCollectionGroups(parentLayer){
        // return this.overlay.paperScope.project.layers.filter(l=>l.isGeoJSONFeatureCollection);
        return this.paperScope.project.getItems({match: item=>item.isGeoJSONFeatureCollection && (parentLayer ? item.layer === parentLayer : true)});
    }
    /**
     * Get the features in the toolkit.
     * @returns {paper.Item[]} The array of paper item objects representing features.
     */
    getFeatures(){
        return this.paperScope.project.getItems({match:i=>i.isGeoJSONFeature});
    }
     /**
     * Register an item as a GeoJSONFeature that the toolkit should track
     * @param {paper.Item} item - The item to track as a geoJSONFeature
     */
    static registerFeature(item){
        item.isGeoJSONFeature = true;
    }
     /**
     * Register a group as a GeoJSONFeatureCollection that the toolkit should track
     * @param {paper.Group} group - The group to track as a geoJSONFeatureCollection
     */
    static registerFeatureCollection(group){
        group.isGeoJSONFeatureCollection = true;
    }

    /**
     * Convert the feature collections in the toolkit to GeoJSON objects.
     * @param {Object} [options] 
     * @param {Layer} [options.layer] The specific layer to use
     * @returns {Object[]} The array of GeoJSON objects representing feature collections.
     */
    toGeoJSON(options){
        const defaults = {
            layer:null,
        }
        options = Object.assign(defaults, options);

        const parent = options.layer || this.paperScope.project;
        //find all featureCollection items and convert to GeoJSON compatible structures
        return parent.getItems({match:i=>i.isGeoJSONFeatureCollection}).map(grp=>{
            
            let geoJSON = {
                type:'FeatureCollection',
                features: grp.descendants.filter(d=>d.annotationItem).map(d=>d.annotationItem.toGeoJSONFeature()),
                properties:{
                    defaultStyle: grp.defaultStyle.toJSON(),
                    userdata: grp.data.userdata,
                },
                label:grp.displayName,
            }
            
            return geoJSON;
        })
    }
    
    /**
     * Convert the feature collections in the project to a JSON string.
     * @param {function} [replacer] - The replacer function for JSON.stringify().
     * @param {number|string} [space] - The space argument for JSON.stringify().
     * @returns {string} The JSON string representing the feature collections.
     */
    toGeoJSONString(replacer,space){
        return JSON.stringify(this.toGeoJSON(),replacer,space);
    }
    /**
     * Load feature collections from GeoJSON objects and add them to the project.
     * @param {object[]} geoJSON - The array of GeoJSON objects representing feature collections.
     * @param {boolean} replaceCurrent - Whether to replace the current feature collections or not.
     * @param {OpenSeadragon.TiledImage | OpenSeadragon.Viewport | false} [parentImage] - Which image (or viewport) to add the object to
     * @param {boolean} [pixelCoordinates]
     */
    loadGeoJSON(geoJSON, replaceCurrent, parentImage){
        let parentLayer = parentImage ? this.overlay.getPaperLayer(parentImage) : false;
        if(replaceCurrent){
            this.getFeatureCollectionGroups(parentImage).forEach(grp=>grp.remove());
        }
        if(!Array.isArray(geoJSON)){
            geoJSON = [geoJSON];
        }
        
        geoJSON.forEach(obj=>{
            if(obj.type=='FeatureCollection'){
                let group = this._createFeatureCollectionGroup({label: obj.label, parent: parentLayer});
                let props = (obj.properties || {});
                group.data.userdata = Object.assign({},props.userdata);
                group.defaultStyle.set(props.defaultStyle);
                obj.features.forEach(feature=>{
                    let item = paper.Item.fromGeoJSON(feature);
                    group.addChild(item);
                })
            }
            else{
                console.warn('GeoJSON object not loaded: wrong type. Only FeatureCollection objects are currently supported');
            }
        })
    }

    /**
     * Add a new, empty FeatureCollection with default label and parent
     * @returns {paper.Group} The paper group object representing the feature collection.
     */
    addEmptyFeatureCollectionGroup(){
        return this._createFeatureCollectionGroup();
    }
    
    /**
     * Create a new feature collection group in the project scope.
     * @private
     * @param {Object} [opts] - Object with fields label and parent
     * @returns {paper.Group} The paper group object representing the feature collection.
     */
    _createFeatureCollectionGroup(opts = {}) {
        let defaultOpts = {
            label:null,
            parent:null
        }
        opts = Object.assign({}, defaultOpts, opts);

        let displayLabel = opts.label;
        
        let parent = opts.parent;
        if(!parent){
            let numItems = this.viewer.world.getItemCount();
            if( numItems == 1){
                parent = this.overlay.getPaperLayer(this.viewer.world.getItemAt(0));
            } else if (numItems == 0){
                parent = this.overlay.getPaperLayer(this.viewer.viewport);
            } else {
                //TODO: Update the UI and associated APIs to allow selecting specific tiled images for multi-image use
                console.warn('Use of AnnotationToolkit with multi-image is not yet fully supported. All annotations will be added to the top-level tiled image.');
                parent = this.overlay.getPaperLayer(this.viewer.world.getItemAt(numItems - 1));
            }
        }
        if(!parent){
            console.error('Failed to create feature collection group: no parent could be found');
            return;
        }

        let grp = new paper.Group();
        this._addFeatureCollectionGroupToLayer(grp, parent);
        let grpNum = this.getFeatureCollectionGroups().length;
        grp.name = grp.displayName = displayLabel!==null ? displayLabel : `Annotation Group ${grpNum}`;
        grp.defaultStyle = new paper.Style(this.paperScope.project.defaultStyle);
        return grp;
    }

    _addFeatureCollectionGroupToLayer(fcGroup, layer){
        layer.addChild(fcGroup);
        AnnotationToolkit.registerFeatureCollection(fcGroup);
        this.paperScope.project.emit('feature-collection-added',{group:fcGroup});
        // re-insert children to trigger events
        if(fcGroup.children){
            fcGroup.insertChildren(0, fcGroup.children);
        }
    }

    /**
     * Ensure there is a selected placeholder in a feature collection so a tool can draw into it (e.g. when activating with createNewItem and no layer UI).
     * @private
     * @param {Object} [style] - Optional style for the placeholder (e.g. strokeColor). If omitted, project default is used.
     */
    _ensureNewItemForTool(style) {
        const groups = this.getFeatureCollectionGroups();
        const fcGroup = groups.length > 0 ? groups[0] : this._createFeatureCollectionGroup();
        if (!fcGroup) return;
        // Ensure a visible default style for headless use when no explicit style is provided.
        // Use the feature collection's defaultStyle if available; otherwise fall back to toolkit defaultStyle.
        const effectiveStyle = (style != null)
            ? style
            : (fcGroup.defaultStyle?.toJSON ? fcGroup.defaultStyle.toJSON() : this.defaultStyle);
        const placeholder = this.makePlaceholderItem(effectiveStyle);
        fcGroup.addChild(placeholder.paperItem);
        AnnotationToolkit.registerFeature(placeholder.paperItem);
        placeholder.paperItem.select();
    }

    /**
     * Make a placeholder annotation item
     * @param {Object} style - options (e.g strokeColor) to pass to the paper item
     */
    makePlaceholderItem(style){
        return new Placeholder(style);
    }
    
};

export {AnnotationToolkit as AnnotationToolkit};



// private functions

/**
 * Create a compound path from a path item.
 * @private
 * @returns {paper.CompoundPath} The compound path object.
 */
function toCompoundPath() {
    if (this.constructor !== paper.CompoundPath) {
        let np = new paper.CompoundPath({ children: [this], fillRule: 'evenodd' });
        np.selected = this.selected;
        this.selected = false;
        return np;
    }
    return this;
}
/**
 * Apply bounds to a path item.
 * @private
 * @param {paper.Item[]} boundingItems - The array of paper items to use as bounds.
 */
function applyBounds(boundingItems) {
    if (boundingItems.length == 0)
        return;
    let intersection;
    if (boundingItems.length == 1) {
        let bounds = boundingItems[0];
        intersection = bounds.intersect(this, { insert: false });
    }
    else if (boundingItems.length > 1) {
        let bounds = new paper.CompoundPath(boundingItems.map(b => b.clone().children).flat());
        intersection = bounds.intersect(this, { insert: false });
        bounds.remove();
    }
    if (this.children) {
        //compound path
        this.removeChildren();
        this.addChildren(intersection.children ? intersection.children : [intersection]);
    }
    else {
        //simple path
        this.segments = intersection.segments ? intersection.segments : intersection.firstChild.segments;
    }

}
/**
 * Select a paper item and emit events.
 * @private
 * @param {boolean} [keepOtherSelectedItems=false] - Whether to keep other selected items or not.
 */
function paperItemSelect(keepOtherSelectedItems) {
    if(!keepOtherSelectedItems){
        this.project._scope.findSelectedItems().forEach(item => item.deselect());
    }
    this.selected = true;
    this.emit('selected');
    this.project.emit('item-selected', { item: this });
    const tk = this.project?._scope?.annotationToolkit;
    if (tk && tk._emitIntegrationEvent) {
        const selected = this.project?._scope?.findSelectedItems?.() ?? [];
        tk._emitIntegrationEvent('selection-changed', {
            selectedCount: selected.length,
            primary: selected.length ? selected[0] : null,
        });
    }
}
/**
 * Deselect a paper item and emit events.
 * @private
 * @param {boolean} [keepOtherSelectedItems=false] - Whether to keep other selected items or not.
 */
function paperItemDeselect(keepOtherSelectedItems) {
    if(!keepOtherSelectedItems){
        this.project._scope.findSelectedItems().forEach(item => item.deselect(true));
        return;
    }
    this.selected = false;
    this.emit('deselected');
    this.project.emit('item-deselected', { item: this });
    const tk = this.project?._scope?.annotationToolkit;
    if (tk && tk._emitIntegrationEvent) {
        const selected = this.project?._scope?.findSelectedItems?.() ?? [];
        tk._emitIntegrationEvent('selection-changed', {
            selectedCount: selected.length,
            primary: selected.length ? selected[0] : null,
        });
    }
}
/**
 * Toggle the selection of a paper item and emit events.
 * @private
 * @param {boolean} [keepOtherSelectedItems=false] - Whether to keep other selected items or not.
 */
function paperItemToggle(keepOtherSelectedItems) {
    this.selected ? this.deselect(keepOtherSelectedItems) : this.select(keepOtherSelectedItems);
}

/**
 * Find the selected new item in the project scope.
 * @private
 * @returns {paper.Item} The selected new item, or null if none exists.
 */
function findSelectedNewItem() {
    //to do: change this to use type=='Feature' and geometry==null to match GeoJSON spec and AnnotationItemPlaceholder definition
    return this.project.getItems({ selected:true, match: function (i) { return i.isGeoJSONFeature && i.initializeGeoJSONFeature; } })[0];
}
/**
 * Find the selected items in the project scope.
 * @private
 * @returns {paper.Item[]} The array of selected items, or an empty array if none exists.
 */
function findSelectedItems() {
    return this.project.getItems({ selected: true, match: function (i) { return i.isGeoJSONFeature; } });
}
/**
 * Find the first selected item in the project scope.
 * @private
 * @returns {paper.Item} The first selected item, or null if none exists.
 */
function findSelectedItem() {
    return this.findSelectedItems()[0];
}



/**
 * Define the display name property for a paper item object.
 * The display name property defines the name used to identify a paper item object.
 * @private
 * @returns {object} The property descriptor object.
 * @property {function} set - The setter function for the display name property.
 *   @param {string} input - The display name value.
 * @property {function} get - The getter function for the display name property.
 *   @returns {string} The display name value.
 */
function displayNamePropertyDef(){
    return {
        set: function displayName(input){
            if(Array.isArray(input)){
                this._displayName = new String(input[0]);
                this._displayName.source=input[1];
            }
            else{
                this._displayName = input;
            }
            this.name = this._displayName;
            this.emit('display-name-changed',{displayName:this._displayName});
        },
        get: function displayName(){
            return this._displayName;
        }
    }
}

/**
 * Define the featureCollection property for a paper item object.
 * @private
 */
function featureCollectionPropertyDef(){
    return {
        get: function fc(){
            return this.hierarchy.filter(i=>i.isGeoJSONFeatureCollection)[0];
        }
    }
}


/**
 * Define the descendants property for a paper project object.
 * The descendants property represents all the descendants (layers and their children) of a paper project object.
 * @private
 * @returns {object} The property descriptor object.
 * @property {function} get - The getter function for the descendants property.
 *   @returns {paper.Item[]} The array of paper item objects representing the descendants.
 */
function descendantsDefProject(){
    return {
        get: function descendants(){
            // return this.layers ? this.layers.filter(layer=>layer.isGeoJSONFeatureCollection).map(child=>child.descendants).flat() : [this];
            return this.layers ? this.getItems({match: item=>item.isGeoJSONFeatureCollection}).map(child=>child.descendants).flat() : [this];
        }
    }
}

/**
 * Convert a paper style object to a JSON object.
 * @private
 * @returns {object} The JSON object representing the style.
 */
function styleToJSON(){
    let output={};
    Object.keys(this._values).forEach(key=>{
        output[key] = this[key];//invoke getter
    })
    return output;
}
/**
 * Get the image data of a paper view element.
 * @private
 * @returns {ImageData} The image data object of the view element.
 */
function paperViewGetImageData(){
    return this.element.getContext('2d').getImageData(0,0,this.element.width, this.element.height);
}

/**
 * Get the insert children method definition for a paper group object.
 * The insert children method emits events when children are added to the paper group object.
 * @private
 * @returns {function} The insert children method that emits events when children are added.
 */
function getInsertChildrenDef(){
    let origInsertChildren = paper.Group.prototype.insertChildren.original || paper.Group.prototype.insertChildren;
    function insertChildren(){ 
        let output = origInsertChildren.apply(this,arguments); 
        let index = arguments[0], children=Array.from(arguments[1]);
        children&&children.forEach((child,i)=>{
            if(child.isGeoJSONFeature){
                let idx = typeof index !== 'undefined' ? index+1 : -1; 
                this.emit('child-added',{item:child,index:idx});
            } 
        });
        return output;
    }
    insertChildren.original = origInsertChildren;
    return insertChildren;
}

/**
 * Define the fill opacity property for a paper style object.
 *  @private
 *  @returns {object} The property descriptor object with the following properties:
 * - get: A function that returns the text of the item.
 * - set: A function that sets the text of the item and causes the 'content-changed' event to be fired.
 */
function textItemContentPropertyDef(){
    let _set = paper.TextItem.prototype._setContent || Object.getOwnPropertyDescriptor(paper.TextItem.prototype, 'content').set;
    paper.TextItem.prototype._setContent = _set;
    return{
        get: function() {
            return this._content;
        },
        set: function(content) {
            _set.call(this, content);
            this.emit('content-changed');
        },
    }
}