Source: overlays/configuration/configuration.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 { PaperOverlay } from '../../paper-overlay.mjs';
import { domObjectFromHTML } from '../../utils/domObjectFromHTML.mjs';
import { makeFaIcon } from '../../utils/faIcon.mjs';

/** Schema version for {@link ConfigurationWidget} toolbar visibility JSON in localStorage. */
const CONFIG_TOOLBAR_PERSIST_VERSION = 1;

/**
 * Persist id for the annotation pencil toolbar row (ConfigurationWidget custom section).
 * @type {string}
 */
export const ANNOTATION_TOOLBAR_PERSIST_ID_PENCIL = 'annotation:pencil';

/**
 * Persist id for the annotation save/load toolbar row.
 * @type {string}
 */
export const ANNOTATION_TOOLBAR_PERSIST_ID_FILE = 'annotation:file';

/**
 * A unified configuration widget that appears as a gear button on the OpenSeadragon viewer.
 * Opens a dialog allowing users to manage registered overlays (show/hide buttons,
 * activate/deactivate) and provides a container for custom configuration sections.
 *
 * @class
 * @memberof OSDPaperjsAnnotation
 */
export class ConfigurationWidget {
    /**
     * @param {OpenSeadragon.Viewer} viewer - The OpenSeadragon viewer instance.
     * @param {Object} [widgetOpts]
     * @param {string|null} [widgetOpts.storageKey] - If set, toolbar "Show Button" preferences for registered overlays and annotation rows are read from and written to localStorage under this key. Omit or null to disable persistence.
     */
    constructor(viewer, widgetOpts = {}) {
        this.viewer = viewer;
        this.viewer.configurationWidget = this;
        this._overlays = [];
        this._sections = [];
        this._open = false;
        this._storageKey = widgetOpts.storageKey != null && widgetOpts.storageKey !== ''
            ? String(widgetOpts.storageKey)
            : null;

        this.overlay = new PaperOverlay(viewer, { overlayType: 'viewer', renderless: true });
        this.button = this.overlay.addViewerButton({
            faIconClass: 'fa-gear',
            tooltip: 'Configuration',
            onClick: () => this._open ? this.close() : this.open(),
        });
        this.button.element.querySelector('svg.icon')?.style.setProperty('width', '1em');

        this._makeDialog();
    }

    /**
     * Register an overlay with the configuration widget.
     * @param {Object} overlay - The overlay instance (must have activate/deactivate methods).
     * @param {Object} [opts]
     * @param {string} [opts.label] - Display label. Falls back to overlay.constructor.label or constructor name.
     * @param {string} [opts.faIconClass] - FA icon class for the row. Falls back to overlay.constructor.faIconClass.
     * @param {boolean} [opts.showButton=true] - Initial toolbar button visibility when nothing is stored for this key.
     * @param {string} [opts.overlayKey] - Stable id for persistence (defaults to overlay.constructor.name). Use distinct keys if multiple instances of the same class register on one viewer.
     */
    register(overlay, opts = {}) {
        if (this._overlays.find(e => e.overlay === overlay)) return;
        const label = opts.label || overlay.constructor.label || overlay.constructor.name;
        const faIconClass = opts.faIconClass || overlay.constructor.faIconClass || null;
        const overlayKey = opts.overlayKey || overlay.constructor.name;
        let showButton = opts.showButton !== false;
        if (this._storageKey) {
            const persisted = this.getPersistedToolbarVisibility(overlayKey);
            if (typeof persisted === 'boolean') {
                showButton = persisted;
            }
        }
        const entry = { overlay, label, faIconClass, showButton, overlayKey, opts };
        this._overlays.push(entry);
        this._addOverlayRow(entry);
        this._updateEmptyState();
    }

    /**
     * True when this widget was constructed with a non-empty storageKey (toolbar visibility is persisted).
     * @returns {boolean}
     */
    persistToolbarVisibilityEnabled() {
        return !!this._storageKey;
    }

    /**
     * Read persisted toolbar button visibility for a logical overlay id (including annotation ids).
     * @param {string} overlayKey - Same as register opts.overlayKey or ANNOTATION_TOOLBAR_PERSIST_ID_*.
     * @returns {boolean|undefined} Undefined if persistence is off or no stored value for this key.
     */
    getPersistedToolbarVisibility(overlayKey) {
        if (!this._storageKey) return undefined;
        const doc = this._loadPersistedDoc();
        const row = doc.overlays && doc.overlays[overlayKey];
        if (!row || typeof row.showButton !== 'boolean') return undefined;
        return row.showButton;
    }

    /**
     * Store toolbar button visibility for a logical overlay id.
     * @param {string} overlayKey
     * @param {boolean} visible
     */
    setPersistedToolbarVisibility(overlayKey, visible) {
        if (!this._storageKey) return;
        try {
            const doc = this._loadPersistedDoc();
            doc.v = CONFIG_TOOLBAR_PERSIST_VERSION;
            if (!doc.overlays) doc.overlays = {};
            if (!doc.overlays[overlayKey]) doc.overlays[overlayKey] = {};
            doc.overlays[overlayKey].showButton = !!visible;
            localStorage.setItem(this._storageKey, JSON.stringify(doc));
        } catch (e) {
            /* quota / private mode */
        }
    }

    /**
     * Remove all persisted toolbar visibility data for this widget's storageKey from localStorage.
     * Does not change the current session UI until rows are rebuilt or toggles change.
     */
    clearPersistedOverlayToolbarState() {
        if (!this._storageKey) return;
        try {
            localStorage.removeItem(this._storageKey);
        } catch (e) {
            /* */
        }
    }

    _loadPersistedDoc() {
        if (!this._storageKey) {
            return { v: CONFIG_TOOLBAR_PERSIST_VERSION, overlays: {} };
        }
        try {
            const raw = localStorage.getItem(this._storageKey);
            if (!raw) return { v: CONFIG_TOOLBAR_PERSIST_VERSION, overlays: {} };
            const parsed = JSON.parse(raw);
            if (!parsed || typeof parsed !== 'object') {
                return { v: CONFIG_TOOLBAR_PERSIST_VERSION, overlays: {} };
            }
            if (!parsed.overlays || typeof parsed.overlays !== 'object') {
                parsed.overlays = {};
            }
            return parsed;
        } catch (e) {
            return { v: CONFIG_TOOLBAR_PERSIST_VERSION, overlays: {} };
        }
    }

    /**
     * Unregister an overlay from the configuration widget.
     * @param {Object} overlay - The overlay instance to remove.
     */
    unregister(overlay) {
        const idx = this._overlays.findIndex(e => e.overlay === overlay);
        if (idx >= 0) {
            const entry = this._overlays[idx];
            if (entry._activeChangeHandler && entry.overlay.removeHandler) {
                entry.overlay.removeHandler('active-change', entry._activeChangeHandler);
            }
            this._overlays.splice(idx, 1);
            this._rebuildOverlayRows();
            this._updateEmptyState();
        }
    }

    /**
     * Add a custom section to the configuration dialog.
     * @param {string} label - Section heading text.
     * @param {HTMLElement} element - The DOM element to inject.
     * @returns {HTMLElement} The element, for chaining.
     */
    addSection(label, element) {
        const entry = { label, element };
        this._sections.push(entry);
        const container = this.dialog.querySelector('.config-custom-sections');
        const wrapper = document.createElement('div');
        wrapper.classList.add('config-section-wrapper');
        const heading = document.createElement('div');
        heading.classList.add('config-section-heading');
        heading.textContent = label;
        wrapper.appendChild(heading);
        wrapper.appendChild(element);
        container.appendChild(wrapper);
        entry._wrapper = wrapper;
        this._updateEmptyState();
        return element;
    }

    /**
     * Remove a custom section from the configuration dialog.
     * @param {HTMLElement} element - The element previously passed to addSection.
     */
    removeSection(element) {
        const idx = this._sections.findIndex(s => s.element === element);
        if (idx >= 0) {
            const entry = this._sections[idx];
            if (entry._wrapper) entry._wrapper.remove();
            this._sections.splice(idx, 1);
            this._updateEmptyState();
        }
    }

    /**
     * Open the configuration dialog.
     */
    open() {
        this._open = true;
        this.dialog.classList.remove('hidden');
    }

    /**
     * Close the configuration dialog.
     */
    close() {
        this._open = false;
        this.dialog.classList.add('hidden');
    }

    /**
     * Destroy the configuration widget and clean up.
     */
    destroy() {
        this.dialog.remove();
        this.overlay.destroy();
        if (this.viewer.configurationWidget === this) {
            this.viewer.configurationWidget = null;
        }
    }

    _makeDialog() {
        const css = `<style data-type="osd-configuration-widget">
.config-dialog {
    position: absolute;
    top: 50%;
    left: 50%;
    transform: translate(-50%, -50%);
    width: min(380px, calc(100% - 24px));
    max-height: calc(100% - 24px);
    overflow-y: auto;
    background: white;
    border-radius: 10px;
    box-shadow: 0 4px 24px rgba(0,0,0,0.18);
    font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
    font-size: 14px;
    z-index: 10000;
    padding: 0;
    color: #222;
}
.config-dialog.hidden {
    display: none;
}
.config-dialog-header {
    display: flex;
    align-items: center;
    justify-content: space-between;
    padding: 12px 16px;
    border-bottom: 1px solid #eee;
}
.config-dialog-header h3 {
    margin: 0;
    font-size: 16px;
    font-weight: 600;
}
.config-dialog-close {
    cursor: pointer;
    background: none;
    border: none;
    font-size: 20px;
    line-height: 1;
    color: #666;
    padding: 4px 8px;
    border-radius: 4px;
}
.config-dialog-close:hover {
    background: #f0f0f0;
    color: #222;
}
.config-dialog-body {
    padding: 12px 16px;
}
.config-overlay-list {
    display: grid;
    grid-template-columns: 18px 1fr auto auto;
    column-gap: 10px;
    row-gap: 0;
    align-items: center;
}
.config-overlay-heading-row {
    display: contents;
}
.config-overlay-heading-row .config-heading-title {
    grid-column: 1 / 3;
    font-weight: 600;
    font-size: 13px;
    text-transform: uppercase;
    letter-spacing: 0.5px;
    color: #666;
    padding-bottom: 8px;
    white-space: nowrap;
}
.config-overlay-heading-row .config-col-header {
    font-size: 11px;
    font-weight: 600;
    text-transform: uppercase;
    letter-spacing: 0.3px;
    color: #999;
    padding-bottom: 8px;
    justify-self: center;
}
.config-overlay-row {
    display: contents;
}
.config-overlay-row > *:not(.config-toggle) {
    padding: 8px 0;
    border-bottom: 1px solid #f5f5f5;
}
.config-overlay-row:last-of-type > *:not(.config-toggle) {
    border-bottom: none;
}
.config-overlay-row > .config-toggle {
    border-bottom: 1px solid #f5f5f5;
}
.config-overlay-row:last-of-type > .config-toggle {
    border-bottom: none;
}
.config-overlay-icon {
    width: 18px;
    height: 18px;
    display: flex;
    align-items: center;
    justify-content: center;
    color: #555;
}
.config-overlay-icon svg {
    width: 14px;
    height: 14px;
}
.config-overlay-label {
    white-space: nowrap;
    overflow: hidden;
    text-overflow: ellipsis;
}
.config-overlay-row button {
    cursor: pointer;
    background: #f0f0f0;
    border: 1px solid #ddd;
    border-radius: 4px;
    padding: 4px 8px;
    font-size: 12px;
    line-height: 1;
    color: #333;
}
.config-overlay-row button:hover {
    background: #e0e0e0;
}
.config-overlay-row button.active {
    background: #2a6ef5;
    border-color: #2a6ef5;
    color: white;
}
.config-toggle {
    position: relative;
    width: 32px;
    height: 18px;
    display: inline-block;
    justify-self: center;
}
.config-toggle input {
    opacity: 0;
    width: 0;
    height: 0;
}
.config-toggle-slider {
    position: absolute;
    cursor: pointer;
    top: 0; left: 0; right: 0; bottom: 0;
    background: #ccc;
    border-radius: 18px;
    transition: background 0.2s;
}
.config-toggle-slider:before {
    content: "";
    position: absolute;
    width: 14px;
    height: 14px;
    left: 2px;
    bottom: 2px;
    background: white;
    border-radius: 50%;
    transition: transform 0.2s;
}
.config-toggle input:checked + .config-toggle-slider {
    background: #2a6ef5;
}
.config-toggle input:checked + .config-toggle-slider:before {
    transform: translateX(14px);
}
.config-custom-sections:not(:empty) {
    border-top: 1px solid #eee;
    margin-top: 12px;
    padding-top: 12px;
}
.config-section-wrapper + .config-section-wrapper {
    margin-top: 12px;
    padding-top: 12px;
    border-top: 1px solid #eee;
}
.config-section-heading {
    font-weight: 600;
    font-size: 13px;
    text-transform: uppercase;
    letter-spacing: 0.5px;
    color: #666;
    margin-bottom: 8px;
}
.config-empty-message {
    font-size: 13px;
    color: #999;
    font-style: italic;
    padding: 8px 0;
}
.config-custom-sections:empty {
    display: none;
}
</style>`;

        const html = `<div class="config-dialog hidden">
    <div class="config-dialog-header">
        <h3>Configuration</h3>
        <button class="config-dialog-close" title="Close">&times;</button>
    </div>
    <div class="config-dialog-body">
        <div class="config-empty-message">No configurable options have been set up for this viewer.</div>
        <div class="config-overlay-list"><div class="config-overlay-heading-row"><span class="config-heading-title">Available Overlays</span><span class="config-col-header" title="Include a button directly in the viewer's toolbar">Show Button</span><span></span></div></div>
        <div class="config-custom-sections"></div>
    </div>
</div>`;

        if (!document.querySelector('style[data-type="osd-configuration-widget"]')) {
            document.querySelector('head')?.appendChild(domObjectFromHTML(css));
        }

        const el = domObjectFromHTML(html);
        this.viewer.container.appendChild(el);
        el.addEventListener('mousemove', ev => ev.stopPropagation());

        el.querySelector('.config-dialog-close').addEventListener('click', () => this.close());

        this.dialog = el;

        for (const entry of this._overlays) {
            this._addOverlayRow(entry);
        }
        this._updateEmptyState();
    }

    _addOverlayRow(entry) {
        const list = this.dialog.querySelector('.config-overlay-list');
        const row = document.createElement('div');
        row.classList.add('config-overlay-row');
        row.dataset.overlayIdx = this._overlays.indexOf(entry);

        const iconEl = document.createElement('div');
        iconEl.classList.add('config-overlay-icon');
        if (entry.faIconClass) {
            iconEl.appendChild(makeFaIcon(entry.faIconClass));
        }
        row.appendChild(iconEl);

        const labelEl = document.createElement('div');
        labelEl.classList.add('config-overlay-label');
        labelEl.textContent = entry.label;
        row.appendChild(labelEl);

        // Activate/deactivate button
        const activateBtn = document.createElement('button');
        activateBtn.textContent = 'Activate';
        activateBtn.title = 'Activate / Deactivate';
        activateBtn.addEventListener('click', () => {
            const overlay = entry.overlay;
            if (overlay._active) {
                overlay.deactivate();
            } else {
                overlay.activate();
                this.close();
            }
        });
        entry._activateBtn = activateBtn;
        if (entry.overlay.addHandler) {
            entry._activeChangeHandler = (ev) => {
                if (ev.active) {
                    activateBtn.classList.add('active');
                    activateBtn.textContent = 'Deactivate';
                } else {
                    activateBtn.classList.remove('active');
                    activateBtn.textContent = 'Activate';
                }
            };
            entry.overlay.addHandler('active-change', entry._activeChangeHandler);
        }

        // Show/hide button toggle
        const toggle = document.createElement('label');
        toggle.classList.add('config-toggle');
        toggle.title = 'Include a button directly in the viewer\'s toolbar';
        const checkbox = document.createElement('input');
        checkbox.type = 'checkbox';
        checkbox.checked = true;
        const slider = document.createElement('span');
        slider.classList.add('config-toggle-slider');
        toggle.appendChild(checkbox);
        toggle.appendChild(slider);

        const overlayBtn = entry.overlay.button || (entry.overlay._viewerButtons && entry.overlay._viewerButtons[0]);
        const originalDisplay = (overlayBtn && overlayBtn.element) ? (overlayBtn.element.style.display || 'inline-block') : 'inline-block';
        if (!entry.showButton) {
            checkbox.checked = false;
            if (overlayBtn && overlayBtn.element) {
                overlayBtn.element.style.display = 'none';
            }
        }
        checkbox.addEventListener('change', () => {
            if (overlayBtn && overlayBtn.element) {
                overlayBtn.element.style.display = checkbox.checked ? originalDisplay : 'none';
            }
            if (this._storageKey && entry.overlayKey) {
                this.setPersistedToolbarVisibility(entry.overlayKey, checkbox.checked);
            }
        });

        row.appendChild(toggle);
        row.appendChild(activateBtn);
        list.appendChild(row);
        entry._row = row;
    }

    _rebuildOverlayRows() {
        const list = this.dialog.querySelector('.config-overlay-list');
        list.innerHTML = '';
        for (const entry of this._overlays) {
            this._addOverlayRow(entry);
        }
    }

    _updateEmptyState() {
        const empty = this._overlays.length === 0 && this._sections.length === 0;
        this.dialog.querySelector('.config-empty-message').style.display = empty ? '' : 'none';
        this.dialog.querySelector('.config-overlay-list').style.display = this._overlays.length === 0 ? 'none' : '';
        const customSections = this.dialog.querySelector('.config-custom-sections');
        if (this._overlays.length === 0) {
            customSections.style.borderTop = 'none';
            customSections.style.marginTop = '0';
            customSections.style.paddingTop = '0';
        } else {
            customSections.style.borderTop = '';
            customSections.style.marginTop = '';
            customSections.style.paddingTop = '';
        }
    }
}