Source: overlays/screenshot/screenshot.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 { OpenSeadragon } from '../../osd-loader.mjs';
import { paper } from '../../paperjs.mjs';
import { loadScreenshotSettings, normalizeScreenshotSettings, saveScreenshotSettings } from './screenshot-settings.mjs';
import { buildDownsampleOptions, computeOutputSize } from './screenshot-sizing.mjs';
import { ViewerOverlayBase } from '../base.mjs';
import { ScreenshotTool } from './screenshot-tool.mjs';
import { createScreenshotDialogElement } from './screenshot-dialog.mjs';
import { renderBaseScreenshot, composeScreenshotWithScalebar, computeRenderSignature } from './screenshot-render.mjs';

class ScreenshotOverlay extends ViewerOverlayBase {
    static get label() { return 'Take Screenshot'; }
    static get faIconClass() { return 'fa-camera'; }

    /**
     * Creates an instance of the ScreenshotOverlay.
     *
     * @param {OpenSeadragon.Viewer} viewer - The OpenSeadragon viewer object.
     * @param {Object} [options]
     * @param {String} [options.downloadMessage] - A message to display in the download window
     * @param {boolean} [options.registerWithConfig=true] Set false to suppress auto-registration with ConfigurationWidget
     */
    constructor(viewer, options = {}){
        super(viewer, options);

        // --- Core objects ---
        this.overlay = new PaperOverlay(viewer, { overlayType: 'viewer' });
        this.tool = new ScreenshotTool(this.overlay.paperScope, this);
        this.dummyTool = new this.overlay.paperScope.Tool();
        this.dummyTool.activate();
        this.dialog = null; // populated by _makeDialog
        this.blobURL = null;

        // --- Settings & state ---
        this.settings = loadScreenshotSettings();
        this._state = 'inactive'; // inactive | config | freeSelectArmed | fixedPlaceArmed | regionChosen | regionEdit | creating | created
        this._mouseNavEnabledAtActivation = true;
        this._saveSettingsTimeout = null;
        this._region = null; // { viewportRect, screenWidth, screenHeight, rotation }
        this._lastRegionKey = null;
        this._lastBasePxForOutput = null; // { baseW, baseH }

        // --- Screenshot cache ---
        this._baseScreenshot = null; // { blob, url, pixelRatio, signature }
        this._lastScreenshotRequest = null; // { data, signature }

        // --- Scalebar state ---
        this._mpp = null;
        this._includeScalebar = false;
        this._scalebarWidth = 0.5;
        this._scalebarHeight = 4;
        this._scalebarShowLabel = false;
        this._scalebarWasTouched = false;

        // --- Output UI refs (populated by _wireOutputSizingControls) ---
        this._fixedOutputUI = null;
        this._resultOutputUI = null;

        this.button = this.overlay.addViewerButton({
            faIconClass:'fa-camera',
            tooltip:'Take Screenshot',
            onClick:()=>{
                this._active ? this.deactivate() : this.activate();
            }
        });

        this.button.element.querySelector('svg.icon')?.style.setProperty('width', '1em');

        this._makeDialog(options); //creates this.dialog

        this.tool.addEventListener('region-selected',(payload)=>this._onRegionSelected(payload));

        this._autoRegister();
    }
    /**
     * Activates the overlay.
     */
    activate(){
        let reactivate = this.overlay.setOSDMouseNavEnabled(false);
        this._mouseNavEnabledAtActivation = this._mouseNavEnabledAtActivation || reactivate;
        this.overlay.bringToFront();
        this._setActive(true);
        this.tool.activate();
        this.tool.setMode('idle');
        this._setState('config');
    }
    /**
     * Deactivates the overlay.
     */
    deactivate(){
        this._setActive(false);
        this._setState('inactive');
        this.tool.setMode('idle');
        this.tool.deactivate(true);
        this.dummyTool.activate();
        this.overlay.setOSDMouseNavEnabled(this._mouseNavEnabledAtActivation);
        this._mouseNavEnabledAtActivation = false;
        this.overlay.sendToBack();
    }

    _setState(next){
        this._state = next;
        if(!this.dialog) return;

        // Visibility map: which panels are shown/hidden and result status for each state.
        // Keys: dialog (root), setup, after, edit; values: true = visible.
        // resultAdd/resultRemove control CSS classes on .screenshot-results.
        const STATE_MAP = {
            inactive:       { dialog:false, setup:true,  after:false, edit:false, resultAdd:[],          resultRemove:['pending','created'] },
            config:         { dialog:true,  setup:true,  after:false, edit:false, resultAdd:[],          resultRemove:['pending','created'], afterEntry: () => this._syncDialogFromSettings() },
            freeSelectArmed:{ dialog:false },
            fixedPlaceArmed:{ dialog:false },
            regionChosen:   { dialog:true,  setup:false, after:true,  edit:false, resultAdd:[],          resultRemove:['pending','created'] },
            regionEdit:     { dialog:true,  setup:false, after:false, edit:true },
            creating:       { dialog:true,  setup:false, after:true,  edit:false, resultAdd:['pending'], resultRemove:['created'] },
            created:        { dialog:true,  setup:false, after:true,  edit:false, resultAdd:['created'], resultRemove:['pending'] },
        };

        const spec = STATE_MAP[next];
        if(!spec) return;

        const el = this.dialog;
        el.classList.toggle('hidden', !spec.dialog);

        const panels = { setup: '.screenshot-setup', after: '.screenshot-after', edit: '.screenshot-edit' };
        for(const [key, sel] of Object.entries(panels)){
            if(key in spec) el.querySelector(sel)?.classList.toggle('hidden', !spec[key]);
        }

        const results = el.querySelector('.screenshot-results');
        if(results){
            (spec.resultRemove || []).forEach(c => results.classList.remove(c));
            (spec.resultAdd || []).forEach(c => results.classList.add(c));
        }

        spec.afterEntry?.();
    }

    _scheduleSaveSettings(){
        this._saveSettingsTimeout && clearTimeout(this._saveSettingsTimeout);
        this._saveSettingsTimeout = setTimeout(()=>{
            this._saveSettingsTimeout = null;
            saveScreenshotSettings(this.settings);
        }, 200);
    }

    _revokeObjectURL(url){
        if(url){
            try{ URL.revokeObjectURL(url); } catch { /* ignore */ }
        }
    }

    _invalidateScreenshotCache(){
        if(this._baseScreenshot?.url){
            this._revokeObjectURL(this._baseScreenshot.url);
        }
        this._baseScreenshot = null;
        if(this.blobURL){
            this._revokeObjectURL(this.blobURL);
            this.blobURL = null;
        }
        this._lastScreenshotRequest = null;
    }

    _parsePositiveNumber(raw, { minExclusive = 0 } = {}){
        const s = String(raw ?? '').trim();
        if(s === '') return { ok: false, reason: 'Required' };
        const n = Number(s);
        if(!Number.isFinite(n)) return { ok: false, reason: 'Not a number' };
        if(n <= minExclusive) return { ok: false, reason: `Must be > ${minExclusive}` };
        return { ok: true, value: n };
    }

    _isFocused(el){
        return Boolean(el && typeof document !== 'undefined' && document.activeElement === el);
    }

    _setIfNotFocused(input, valueString){
        if(!input) return;
        if(this._isFocused(input)) return;
        input.value = String(valueString ?? '');
    }

    _parsePositiveFloatDraft(text){
        return this._parsePositiveNumber(text, { minExclusive: 0 });
    }

    _parsePositiveIntDraft(text){
        const p = this._parsePositiveNumber(text, { minExclusive: 0 });
        if(!p.ok) return p;
        return { ok: true, value: Math.round(p.value) };
    }

    _syncAspectValidationUI(){
        if(!this.dialog) return true;
        const lock = this.dialog.querySelector('.lock-aspect-ratio');
        const enabled = Boolean(lock?.checked);
        const aw = this.dialog.querySelector('.aspect-width');
        const ah = this.dialog.querySelector('.aspect-height');
        const err = this.dialog.querySelector('.aspect-error');
        const start = this.dialog.querySelector('button.start');

        if(!enabled){
            if(err){
                err.textContent = '';
                err.classList.add('hidden');
            }
            if(start) start.disabled = false;
            return true;
        }

        const pw = this._parsePositiveFloatDraft(aw?.value);
        const ph = this._parsePositiveFloatDraft(ah?.value);
        const ok = pw.ok && ph.ok;
        if(err){
            if(ok){
                err.textContent = '';
                err.classList.add('hidden');
            }else{
                err.textContent = 'Enter numbers > 0';
                err.classList.remove('hidden');
            }
        }
        if(start) start.disabled = !ok;
        return ok;
    }

    _syncFixedBaseValidationUI(){
        if(!this.dialog) return true;
        const el = this.dialog;
        const modeFixed = this.settings?.mode === 'fixed';
        const bw = el.querySelector('.fixed-base-width');
        const bh = el.querySelector('.fixed-base-height');
        const err = el.querySelector('.fixed-base-error');
        const start = el.querySelector('button.start');

        const pw = this._parsePositiveIntDraft(bw?.value);
        const ph = this._parsePositiveIntDraft(bh?.value);
        const ok = pw.ok && ph.ok;

        if(err){
            if(ok || !modeFixed){
                err.textContent = '';
                err.classList.add('hidden');
            }else{
                err.textContent = 'Enter integers ≥ 1';
                err.classList.remove('hidden');
            }
        }
        if(start && modeFixed){
            start.disabled = !ok;
        }
        return ok;
    }

    _getCurrentOutputFactorFromUI(kind = 'result'){
        const sel = this.dialog?.querySelector(kind === 'fixed' ? '.select-output-size-fixed' : '.select-output-size-result');
        const opt = sel?.options?.[sel.selectedIndex];
        if(opt?.value === '__other__' || this.settings.output.mode === 'other'){
            return Number(this.settings.output.otherFactor);
        }
        const v = Number(opt?.value);
        return Number.isFinite(v) && v > 0 ? v : Number(this.settings.output.presetFactor);
    }

    _resolveEffectiveOutputFactor(){
        const factor = this.settings.output.mode === 'other'
            ? Number(this.settings.output.otherFactor)
            : Number(this.settings.output.presetFactor);
        return Number.isFinite(factor) && factor > 0 ? factor : 1;
    }

    _formatReadout(outW, outH, factor){
        return `→ ${outW} × ${outH} (${this._formatDownsampleLabel(factor)})`;
    }

    _tryRecomposeOrReset(){
        const isCreated = this.dialog?.querySelector('.screenshot-results')?.classList.contains('created');
        const v = this._renderScalebarValidation({ mode: 'soft' });
        if(isCreated && this._baseScreenshot && v?.ok){
            this._recomposeFromCachedBaseIfPossible().catch(()=>this._resetScreenshotResults());
        }else{
            this._resetScreenshotResults();
        }
    }

    _computeSmartScalebarLengthMm(){
        if(!this._mpp || !this._mpp.x || !this._region) return null;
        const built = this._buildCurrentScreenshotRequestFromUI();
        if(!built) return null;
        const outW = Number(built.data.w);
        const scaleFactor = Number(built.data.scaleFactor);
        if(!Number.isFinite(outW) || outW <= 0 || !Number.isFinite(scaleFactor) || scaleFactor <= 0) return null;

        const targetPx = Math.max(24, Math.round(outW * 0.125)); // ~12.5% of output width
        const candidatesMm = [1, 0.5, 0.2, 0.1, 0.05, 0.02, 0.01];

        let best = null;
        for(const mm of candidatesMm){
            const px = Math.max(1, Math.round(mm * 1000 / this._mpp.x * scaleFactor));
            // Prefer bars that land in a reasonable range, but always pick a closest.
            const tooSmall = px < 16;
            const tooBig = px > Math.round(outW * 0.4);
            const penalty = (tooSmall || tooBig) ? outW : 0;
            const score = Math.abs(px - targetPx) + penalty;
            if(!best || score < best.score){
                best = { mm, px, score };
            }
        }
        return best?.mm ?? null;
    }

    _formatMmLabel(mm){
        const n = Number(mm);
        if(!Number.isFinite(n)) return '';
        // Trim trailing zeros (e.g. 0.10 -> 0.1) while avoiding scientific notation for small numbers.
        const s = n.toFixed(4).replace(/\.?0+$/,'');
        return s;
    }

    _scalebarLabelText(){
        return `${this._formatMmLabel(this._scalebarWidth)} mm`;
    }

    _scalebarLabelFit({ scaleFactor }){
        const enabled = Boolean(this._includeScalebar && this._mpp && this._scalebarShowLabel);
        if(!enabled) return { enabled:false, fits:true, label:'' };
        const label = this._scalebarLabelText();
        // Bar pixel size in output space.
        const pxLen = Math.max(1, Math.round(this._scalebarWidth * 1000 / this._mpp.x * scaleFactor));
        const pxH = Math.max(1, Math.round(this._scalebarHeight));

        // Measure text with the same font we’ll draw with.
        let ctx = null;
        try{
            const c = document.createElement('canvas');
            ctx = c.getContext('2d');
        }catch{ /* ignore */ }
        if(!ctx){
            return { enabled:true, fits:true, label, pxLen, pxH, textWidth:0, fontPx:12 };
        }
        const fontPx = 12;
        ctx.font = `${fontPx}px system-ui, -apple-system, Segoe UI, Roboto, sans-serif`;
        const textWidth = ctx.measureText(label).width;
        const fits = textWidth <= (pxLen - 6) && fontPx <= (pxH - 2);
        return { enabled:true, fits, label, pxLen, pxH, textWidth, fontPx };
    }

    _maybeApplySmartScalebarDefaults(){
        // Don’t override user-edited values.
        if(this._scalebarWasTouched) return;
        if(!this._mpp) return;

        if(!Number.isFinite(this._scalebarHeight) || this._scalebarHeight < 1){
            this._scalebarHeight = 4;
        }

        const mm = this._computeSmartScalebarLengthMm();
        if(mm != null){
            this._scalebarWidth = mm;
        }
    }

    _validateScalebarDraft(){
        if(!this.dialog) return { enabled:false, ok:true };
        const hasMpp = Boolean(this._mpp && this._mpp.x);
        const enabled = Boolean(this._includeScalebar) && hasMpp;
        if(!enabled) return { enabled:false, ok:true };

        const lenInput = this.dialog.querySelector('.scalebar-width');
        const hInput = this.dialog.querySelector('.scalebar-height');
        const lenText = String(lenInput?.value ?? '').trim();
        const hText = String(hInput?.value ?? '').trim();

        const lenParsed = this._parsePositiveFloatDraft(lenText);
        const heightNum = Number(hText);
        const heightOk = Number.isFinite(heightNum) && heightNum >= 1;
        const lengthOk = lenParsed.ok;
        const ok = lengthOk && heightOk;

        return {
            enabled,
            ok,
            lengthOk,
            heightOk,
            lengthMm: lenParsed.ok ? lenParsed.value : null,
            heightPx: heightOk ? Math.round(heightNum) : null,
            lenText,
            hText,
        };
    }

    _renderScalebarValidation({ mode = 'soft' } = {}){
        if(!this.dialog) return { ok:true };
        const errRow = this.dialog.querySelector('.scalebar-error-row');
        const err = this.dialog.querySelector('.scalebar-error');
        const createBtn = this.dialog.querySelector('.create-screenshot');

        const v = this._validateScalebarDraft();
        if(createBtn) createBtn.disabled = v.enabled ? !v.ok : false;

        if(!errRow || !err) return v;
        if(!v.enabled){
            err.textContent = '';
            errRow.classList.add('hidden');
            return v;
        }
        if(v.ok){
            err.textContent = '';
            errRow.classList.add('hidden');
            return v;
        }

        const parts = [];
        if(mode === 'soft'){
            if(v.lenText === '') parts.push('Enter a length');
            else if(!v.lengthOk) parts.push('Length must be > 0');

            if(v.hText === '') parts.push('Enter a height');
            else if(!v.heightOk) parts.push('Height must be ≥ 1');
        }else{
            if(!v.lengthOk) parts.push('Enter a length > 0');
            if(!v.heightOk) parts.push('Enter a height ≥ 1');
        }
        err.textContent = parts.join(' · ');
        errRow.classList.remove('hidden');
        return v;
    }

    _syncScalebarUI(){
        if(!this.dialog) return;
        const row = this.dialog.querySelector('.scalebar-main');
        const include = this.dialog.querySelector('.include-scalebar');
        const reason = this.dialog.querySelector('.scalebar-disabled-reason');
        const opts = this.dialog.querySelector('.scalebar-opts');
        const lenInput = this.dialog.querySelector('.scalebar-width');
        const hInput = this.dialog.querySelector('.scalebar-height');
        const hint = this.dialog.querySelector('.scalebar-px-hint');
        const showLabel = this.dialog.querySelector('.scalebar-show-label');
        const labelHint = this.dialog.querySelector('.scalebar-label-hint');
        const errRow = this.dialog.querySelector('.scalebar-error-row');
        const err = this.dialog.querySelector('.scalebar-error');

        const hasMpp = Boolean(this._mpp && this._mpp.x);
        if(row){
            if(hasMpp) row.classList.remove('hidden');
            else row.classList.add('hidden');
        }
        if(include){
            include.disabled = !hasMpp;
        }
        if(reason){
            if(!hasMpp){
                reason.textContent = 'Requires mpp metadata';
                reason.classList.remove('hidden');
            }else{
                reason.textContent = '';
                reason.classList.add('hidden');
            }
        }

        // Render from numeric source of truth when not focused.
        this._setIfNotFocused(lenInput, String(this._scalebarWidth));
        this._setIfNotFocused(hInput, String(this._scalebarHeight));
        if(include) include.checked = Boolean(this._includeScalebar);
        if(showLabel) showLabel.checked = Boolean(this._scalebarShowLabel);

        if(opts){
            if(hasMpp && this._includeScalebar) opts.classList.remove('hidden');
            else opts.classList.add('hidden');
        }

        if(errRow && err){
            // Default state: hidden; validation will toggle these.
            if(!this._includeScalebar || !hasMpp){
                err.textContent = '';
                errRow.classList.add('hidden');
            }
        }

        if(hint){
            // Keep hint in layout (no jumping); hide via visibility when not applicable.
            if(!(hasMpp && this._includeScalebar)){
                hint.style.visibility = 'hidden';
                hint.textContent = '';
            }else if(hasMpp && this._includeScalebar && this._region){
                const v = this._validateScalebarDraft();
                if(!v.lengthOk){
                    hint.style.visibility = 'hidden';
                    hint.textContent = '';
                }else{
                    hint.style.visibility = 'visible';
                const baseW = this._region.screenWidth;
                const factor = this._getCurrentOutputFactorFromUI('result');
                const safeFactor = Number.isFinite(factor) && factor > 0 ? factor : 1;
                const { outW } = computeOutputSize(baseW, this._region.screenHeight, safeFactor);
                const scaleFactor = outW / baseW;
                const pxLen = Math.max(1, Math.round(v.lengthMm * 1000 / this._mpp.x * scaleFactor));
                hint.textContent = `≈ ${pxLen} px at output`;

                if(labelHint){
                    const fit = this._scalebarLabelFit({ scaleFactor });
                    if(fit.enabled && !fit.fits){
                        labelHint.textContent = 'Increase length/height to fit label';
                        labelHint.classList.remove('hidden');
                    }else{
                        labelHint.textContent = '';
                        labelHint.classList.add('hidden');
                    }
                }
                }
            }else{
                hint.style.visibility = 'hidden';
                hint.textContent = '';
                if(labelHint){
                    labelHint.textContent = '';
                    labelHint.classList.add('hidden');
                }
            }
        }
        this._renderScalebarValidation({ mode: 'soft' });
    }

    _buildCurrentScreenshotRequestFromUI(){
        if(!this.dialog || !this._region || !this._lastBasePxForOutput) return null;
        const base = this._lastBasePxForOutput;
        const factor = this._getCurrentOutputFactorFromUI('result');
        const safeFactor = Number.isFinite(factor) && factor > 0 ? factor : 1;
        const { outW, outH } = computeOutputSize(base.baseW, base.baseH, safeFactor);
        return {
            data: {
                w: outW,
                h: outH,
                viewportRect: this._region.viewportRect,
                scaleFactor: outW / base.baseW,
            },
            base,
        };
    }

    async _recomposeFromCachedBaseIfPossible(){
        if(!this._baseScreenshot || !this.dialog) return false;
        const built = this._buildCurrentScreenshotRequestFromUI();
        if(!built) return false;
        const { data } = built;
        const ctx = this._buildRenderContext({ w: data.w, h: data.h, viewportRect: data.viewportRect });
        const sig = computeRenderSignature({ w: data.w, h: data.h, viewportRect: data.viewportRect, rotation: ctx.rotation, tileSource: ctx.tileSource });
        if(sig !== this._baseScreenshot.signature) return false;

        let finalUrl = null;
        if(this._includeScalebar && this._mpp){
            const blob = await composeScreenshotWithScalebar({
                baseBlob: this._baseScreenshot.blob,
                pixelRatio: this._baseScreenshot.pixelRatio,
                scaleFactor: data.scaleFactor,
                scalebar: {
                    include: true,
                    widthMm: this._scalebarWidth,
                    heightPx: this._scalebarHeight,
                    mppX: this._mpp.x,
                    label: this._scalebarShowLabel ? this._scalebarLabelFit({ scaleFactor: data.scaleFactor }) : null,
                },
            });
            if(this.blobURL) this._revokeObjectURL(this.blobURL);
            this.blobURL = URL.createObjectURL(blob);
            finalUrl = this.blobURL;
        }else{
            if(this.blobURL && this.blobURL !== this._baseScreenshot.url){
                this._revokeObjectURL(this.blobURL);
            }
            this.blobURL = this._baseScreenshot.url;
            finalUrl = this.blobURL;
        }

        this.dialog.querySelectorAll('.screenshot-link').forEach(a=>a.href = finalUrl);
        return true;
    }

    _formatDownsampleLabel(factor){
        const f = Number(factor);
        if(!Number.isFinite(f) || f <= 0) return '';
        if(f === 1) return '1/1';
        if(Number.isInteger(f)) return `1/${f}`;
        return `d=${f}`;
    }

    _syncAspectInputsEnabled(){
        if(!this.dialog) return;
        const el = this.dialog;
        const lock = el.querySelector('.lock-aspect-ratio');
        const enabled = Boolean(lock?.checked);
        el.querySelectorAll('.aspect-width, .aspect-height').forEach(inp=>{
            inp.disabled = !enabled;
        });
    }

    _armFreeSelect(){
        this.tool.setMode('freeSelect');
        this._setState('freeSelectArmed');
    }

    _armFixedPlace(){
        this._syncDialogFromSettings();
        this.tool.setMode('fixedPlace');
        this._setState('fixedPlaceArmed');
    }

    _handleFixedPlacement(payload){
        const region = payload?.region;
        if(!region){
            alert('Missing fixed placement region metadata.');
            this._setState('regionChosen');
            return;
        }

        this.tool.setMode('idle');
        this._setRegion(region);

        if(payload.autoCreate){
            const safeFactor = this._resolveEffectiveOutputFactor();
            const { outW, outH } = computeOutputSize(region.screenWidth, region.screenHeight, safeFactor);
            const data = {
                w: outW, h: outH,
                viewportRect: region.viewportRect,
                scaleFactor: outW / region.screenWidth,
            };
            this._setState('creating');
            this._createScreenshot(data).then(()=>{
                this.dialog.querySelectorAll('.screenshot-link').forEach(a=>a.href = this.blobURL);
                const cs = this.dialog.querySelector('.created-summary');
                if(cs){
                    cs.textContent = `Region ${Math.round(region.screenWidth)}×${Math.round(region.screenHeight)} → Output ${outW}×${outH}`;
                }
                this._setState('created');
            }).catch(e=>{
                alert('There was a problem creating the screenshot. ' + e);
                this._setState('regionChosen');
            });
            return;
        }

        this._setState('regionChosen');
    }

    _onRegionSelected(payload){
        if(!payload) return;
        const isObjectPayload = payload && typeof payload === 'object' && payload.bounds;
        const bounds = isObjectPayload ? payload.bounds : payload;

        if(isObjectPayload && payload.kind === 'fixed'){
            this._handleFixedPlacement(payload);
            return;
        }

        this.tool.setMode('idle');
        this._resolveActiveTiledImageOrThrow();
        const region = this._viewerElementRectToRegion(bounds);
        this._setRegion(region);
        this._setState('regionChosen');
    }

    _makeDialog(options){
        const el = createScreenshotDialogElement();
        this.viewer.container.appendChild(el);

        el.addEventListener('mousemove',ev=>ev.stopPropagation());
        el.querySelectorAll('.close').forEach(e=>e.addEventListener('click',()=>this.deactivate()));
        el.querySelectorAll('.back-to-setup').forEach(e=>e.addEventListener('click',()=>{ this.tool.setMode('idle'); this._setState('config'); }));
        el.querySelectorAll('.rect').forEach(e=>e.addEventListener('click',()=>{
            if(this.settings.mode === 'fixed'){
                this._armFixedPlace();
            } else {
                this._armFreeSelect();
            }
        }));
        el.querySelectorAll('.edit-region').forEach(e=>e.addEventListener('click',()=>{
            this._setState('regionEdit');
        }));
        el.querySelectorAll('.done-edit').forEach(e=>e.addEventListener('click',()=>{
            this._setState('regionChosen');
        }));
        el.querySelectorAll('.cancel-screenshot').forEach(e=>e.addEventListener('click',()=>{
            if(this._region){
                this._setRegion(this._region);
                this._setState('regionChosen');
                return;
            }
            el.querySelector('.screenshot-results').classList.remove('pending','created');
        }));
        el.querySelectorAll('.create-screenshot').forEach(e=>e.addEventListener('click',()=>{
            const sb = this._renderScalebarValidation({ mode: 'hard' });
            if(sb && sb.ok === false) return;

            if(!this._region){
                alert('Missing screenshot region metadata.');
                return;
            }
            const base = this._lastBasePxForOutput;
            if(!base){
                alert('Missing base pixel sizing metadata.');
                return;
            }
            const sel = el.querySelector('.select-output-size-result');
            const selectedOption = sel.options[sel.selectedIndex];
            let factor;
            if(selectedOption?.value === '__other__' || this.settings.output.mode === 'other'){
                const otherInput = el.querySelector('.result-output-other-factor');
                const draft = String(otherInput?.value ?? '').trim();
                const parsed = this._parsePositiveFloatDraft(draft);
                if(!parsed.ok){
                    const wrap = el.querySelector('.result-output-other');
                    const err = wrap?.querySelector('.downsample-error');
                    if(err){
                        err.textContent = 'Enter a number > 0';
                        err.classList.remove('hidden');
                    }
                    return;
                }
                factor = parsed.value;
            }else{
                const fromSelect = Number(selectedOption?.value);
                factor = Number.isFinite(fromSelect) && fromSelect > 0 ? fromSelect : Number(this.settings.output.presetFactor);
            }
            const safeFactor = Number.isFinite(factor) && factor > 0 ? factor : 1;
            const { outW, outH } = computeOutputSize(base.baseW, base.baseH, safeFactor);
            const data = {
                w: outW,
                h: outH,
                viewportRect: this._region.viewportRect,
                scaleFactor: outW / base.baseW,
            };
            this.dialog.querySelector('.screenshot-results').classList.add('pending');
            this._createScreenshot(data).then(()=>{
                const x = this.dialog.querySelector('.screenshot-results');
                x.classList.remove('pending');
                x.classList.add('created');
                this.dialog.querySelectorAll('.screenshot-link').forEach(a=>a.href = this.blobURL);
                const cs = this.dialog.querySelector('.created-summary');
                if(cs){
                    cs.textContent = `Region ${Math.round(base.baseW)}×${Math.round(base.baseH)} → Output ${outW}×${outH}`;
                }
            }).catch(e=>{
                alert('There was a problem creating the screenshot. ' + e );
            });
        }));
        el.querySelectorAll('button.download-screenshot').forEach(e=>e.addEventListener('click',()=>{
            el.querySelector('a.download-screenshot')?.click();
        }));
        el.querySelectorAll('.aspect-width').forEach(e=>{
            const onDraft = ()=>{
                // Draft lives in DOM; soft-validate only (no commits while typing).
                this._syncAspectValidationUI();
            };
            e.addEventListener('input', onDraft);
            e.addEventListener('change', onDraft);
            e.addEventListener('blur',()=>{
                const parsed = this._parsePositiveFloatDraft(e.value);
                if(parsed.ok){
                    this.settings.free.aspectWidth = parsed.value;
                    this.settings = normalizeScreenshotSettings(this.settings);
                    this.tool.setAspectWidth(this.settings.free.aspectWidth);
                    this._scheduleSaveSettings();
                    e.value = String(this.settings.free.aspectWidth);
                }else{
                    e.value = String(this.settings.free.aspectWidth);
                }
                this._syncAspectValidationUI();
            });
        });
        el.querySelectorAll('.aspect-height').forEach(e=>{
            const onDraft = ()=>{
                this._syncAspectValidationUI();
            };
            e.addEventListener('input', onDraft);
            e.addEventListener('change', onDraft);
            e.addEventListener('blur',()=>{
                const parsed = this._parsePositiveFloatDraft(e.value);
                if(parsed.ok){
                    this.settings.free.aspectHeight = parsed.value;
                    this.settings = normalizeScreenshotSettings(this.settings);
                    this.tool.setAspectHeight(this.settings.free.aspectHeight);
                    this._scheduleSaveSettings();
                    e.value = String(this.settings.free.aspectHeight);
                }else{
                    e.value = String(this.settings.free.aspectHeight);
                }
                this._syncAspectValidationUI();
            });
        });
        el.querySelectorAll('.lock-aspect-ratio').forEach(e=>{
            e.addEventListener('change',ev=>{
                this.settings.free.aspectLocked = ev.target.checked;
                this.settings = normalizeScreenshotSettings(this.settings);
                this.tool.setAspectLocked(this.settings.free.aspectLocked);
                this._syncAspectInputsEnabled();
                this._syncAspectValidationUI();
                this._syncFixedBaseValidationUI();
                this._scheduleSaveSettings();
            });
        });
        el.querySelectorAll('.region-dim').forEach(e=>e.addEventListener('change',()=>this._updateROI()));
        el.querySelectorAll('.scalebar-width').forEach(e=>{
            const onInput = (ev)=>{
                const text = String(ev.target.value ?? '');
                if(!this._scalebarWasTouched) this._scalebarWasTouched = true;
                // Soft validation derives from DOM draft.
                const v = this._renderScalebarValidation({ mode: 'soft' });
                // If draft is valid, update numeric state so preview/compose can use it.
                const parsed = this._parsePositiveFloatDraft(text.trim());
                if(parsed.ok){
                    this._scalebarWidth = parsed.value;
                }
                this._syncScalebarUI();
                this._tryRecomposeOrReset();
            };
            e.addEventListener('input', onInput);
            e.addEventListener('change', onInput);
            e.addEventListener('blur',()=>{
                const text = String(e.value ?? '').trim();
                const parsed = this._parsePositiveFloatDraft(text);
                if(parsed.ok){
                    this._scalebarWidth = parsed.value;
                    e.value = String(parsed.value);
                }else{
                    e.value = String(this._scalebarWidth);
                }
                this._syncScalebarUI();
            });
        });
        el.querySelectorAll('.scalebar-height').forEach(e=>{
            const onInput = (ev)=>{
                const text = String(ev.target.value ?? '');
                if(!this._scalebarWasTouched) this._scalebarWasTouched = true;
                const v = Number(text.trim());
                if(Number.isFinite(v) && v >= 1){
                    this._scalebarHeight = Math.round(v);
                }
                this._syncScalebarUI();
                this._tryRecomposeOrReset();
            };
            e.addEventListener('input', onInput);
            e.addEventListener('change', onInput);
            e.addEventListener('blur',()=>{
                const text = String(e.value ?? '').trim();
                const v = Number(text);
                if(Number.isFinite(v) && v >= 1){
                    this._scalebarHeight = Math.round(v);
                    e.value = String(this._scalebarHeight);
                }else{
                    e.value = String(this._scalebarHeight);
                }
                this._syncScalebarUI();
            });
        });
        el.querySelectorAll('.include-scalebar').forEach(e=>{
            e.addEventListener('change',ev=>{
                this._includeScalebar = Boolean(ev.target.checked);
                if(this._includeScalebar){
                    this._maybeApplySmartScalebarDefaults();
                }
                this._syncScalebarUI();
                this._tryRecomposeOrReset();
            });
        });
        el.querySelectorAll('.scalebar-show-label').forEach(e=>{
            e.addEventListener('change',ev=>{
                this._scalebarShowLabel = Boolean(ev.target.checked);
                this._syncScalebarUI();
                this._tryRecomposeOrReset();
            });
        });
        this.dialog = el;

        // mode + fixed-size controls
        el.querySelectorAll('input[name="screenshot-mode"]').forEach(r=>{
            r.addEventListener('change',()=>{
                const checked = el.querySelector('input[name="screenshot-mode"]:checked');
                this.settings.mode = checked?.value === 'fixed' ? 'fixed' : 'free';
                this.settings = normalizeScreenshotSettings(this.settings);
                this._syncDialogFromSettings();
                this._scheduleSaveSettings();
            });
        });
        const wireFixedBaseInput = (selector, settingKey) => {
            el.querySelectorAll(selector).forEach(i=>{
                const onDraft = ()=>{
                    this._syncFixedBaseValidationUI();
                    this._syncFixedBaseDraftReadout();
                };
                i.addEventListener('input', onDraft);
                i.addEventListener('change', onDraft);
                i.addEventListener('blur',()=>{
                    const parsed = this._parsePositiveIntDraft(i.value);
                    if(parsed.ok){
                        this.settings.fixed[settingKey] = parsed.value;
                        this.settings = normalizeScreenshotSettings(this.settings);
                        this._scheduleSaveSettings();
                        this._syncDialogFromSettings();
                    }else{
                        i.value = String(this.settings.fixed[settingKey]);
                    }
                    this._syncFixedBaseValidationUI();
                });
            });
        };
        wireFixedBaseInput('.fixed-base-width', 'baseWidthPx');
        wireFixedBaseInput('.fixed-base-height', 'baseHeightPx');
        if(el.querySelector('.fixed-output-controls')){
            this._wireOutputSizingControls(el.querySelector('.fixed-output-controls'), 'fixed');
        }
        el.querySelectorAll('.fixed-auto-create').forEach(cb=>{
            cb.addEventListener('change',()=>{
                this.settings.fixed.autoCreateOnClick = cb.checked;
                this.settings = normalizeScreenshotSettings(this.settings);
                // Keep the live fixed-placement tool params in sync immediately.
                this.tool.setFixedParams({ autoCreateOnClick: this.settings.fixed.autoCreateOnClick });
                this._scheduleSaveSettings();
            });
        });
        el.querySelectorAll('.start').forEach(b=>{
            b.addEventListener('click',()=>{
                if(this.settings.mode === 'fixed'){
                    this._armFixedPlace();
                } else {
                    this._armFreeSelect();
                }
            });
        });

        this._syncDialogFromSettings();
        this._syncAspectInputsEnabled();
        this._syncAspectValidationUI();
        if(el.querySelector('.screenshot-results')){
            this._wireOutputSizingControls(el.querySelector('.instructions'), 'result');
        }
    }

    _wireOutputSizingControls(container, kind){
        if(!container) return;
        const select = container.querySelector(kind === 'fixed' ? '.select-output-size-fixed' : '.select-output-size-result');
        const otherWrap = container.querySelector(kind === 'fixed' ? '.fixed-output-other' : '.result-output-other');
        const otherInput = container.querySelector(kind === 'fixed' ? '.fixed-output-other-factor' : '.result-output-other-factor');
        const readout = container.querySelector(kind === 'fixed' ? '.fixed-output-readout' : '.result-output-readout');
        const otherErr = otherWrap?.querySelector('.downsample-error');

        select?.addEventListener('change',()=>{
            const opt = select.options[select.selectedIndex];
            const isOther = opt?.value === '__other__';
            if(isOther){
                this.settings.output.mode = 'other';
                otherWrap?.classList.remove('hidden');
            }else{
                this.settings.output.mode = 'preset';
                const f = Number(opt?.value);
                if(Number.isFinite(f)) this.settings.output.presetFactor = f;
                otherWrap?.classList.add('hidden');
            }
            this.settings = normalizeScreenshotSettings(this.settings);
            if(isOther){
                // Ensure revealed input reflects committed settings (without clobbering mid-edit).
                this._setIfNotFocused(otherInput, String(this.settings.output.otherFactor));
            }
            this._scheduleSaveSettings();
            this._invalidateScreenshotCache();
            this._resetScreenshotResults();
            if(kind === 'fixed'){
                this._syncFixedOutputReadout();
            }else{
                this._syncResultOutputReadout();
                this._syncScalebarUI();
            }
        });

        const setOtherError = (msg)=>{
            if(!otherErr) return;
            if(!msg){
                otherErr.textContent = '';
                otherErr.classList.add('hidden');
            }else{
                otherErr.textContent = msg;
                otherErr.classList.remove('hidden');
            }
        };

        const softSyncOtherDraft = ()=>{
            if(!otherInput) return;
            const draft = String(otherInput.value ?? '').trim();
            const parsed = this._parsePositiveFloatDraft(draft);
            if(parsed.ok){
                setOtherError('');
                if(readout){
                    const base = kind === 'fixed'
                        ? { baseW: Number(this.settings.fixed.baseWidthPx), baseH: Number(this.settings.fixed.baseHeightPx) }
                        : this._lastBasePxForOutput;
                    if(base){
                        const { outW, outH } = computeOutputSize(base.baseW, base.baseH, parsed.value);
                        readout.textContent = this._formatReadout(outW, outH, parsed.value);
                    }
                }
                if(kind !== 'fixed') this._syncScalebarUI();
            }else{
                setOtherError('Enter a number > 0');
            }
        };

        otherInput?.addEventListener('input', softSyncOtherDraft);
        otherInput?.addEventListener('change', softSyncOtherDraft);
        otherInput?.addEventListener('blur', ()=>{
            if(!otherInput) return;
            const draft = String(otherInput.value ?? '').trim();
            const parsed = this._parsePositiveFloatDraft(draft);
            if(parsed.ok){
                // Commit on blur.
                this.settings.output.otherFactor = parsed.value;
                this.settings.output.mode = 'other';
                this.settings = normalizeScreenshotSettings(this.settings);
                this._scheduleSaveSettings();
                this._invalidateScreenshotCache();
                this._resetScreenshotResults();
                setOtherError('');
                // Ensure the field renders the committed value after normalization.
                otherInput.value = String(this.settings.output.otherFactor);
                if(kind === 'fixed') this._syncFixedOutputReadout();
                else { this._syncResultOutputReadout(); this._syncScalebarUI(); }
            }else{
                // Revert to last committed value.
                otherInput.value = String(this.settings.output.otherFactor);
                setOtherError('');
                if(kind === 'fixed') this._syncFixedOutputReadout();
                else { this._syncResultOutputReadout(); this._syncScalebarUI(); }
            }
        });

        // stash refs for convenience
        if(kind === 'fixed'){
            this._fixedOutputUI = { select, otherWrap, otherInput, readout };
        }else{
            this._resultOutputUI = { select, otherWrap, otherInput, readout };
        }
    }

    _populateOutputSelect(selectEl, baseW, baseH){
        if(!selectEl) return;
        selectEl.replaceChildren();
        const opts = buildDownsampleOptions({ baseW, baseH });
        let anyDisabled = false;
        opts.forEach(o=>{
            if(o.kind === 'other'){
                const opt = document.createElement('option');
                opt.value = '__other__';
                opt.textContent = 'Other…';
                selectEl.appendChild(opt);
                return;
            }
            const opt = document.createElement('option');
            opt.value = String(o.factor);
            opt.textContent = o.label;
            if(o.disabled){
                anyDisabled = true;
                opt.setAttribute('disabled','true');
                if(o.disabledReason) opt.setAttribute('data-disabled-reason', String(o.disabledReason));
            }
            selectEl.appendChild(opt);
        });

        // Compact helper message (shown only if relevant) for disabled output options.
        const root = selectEl.closest('.instructions') || selectEl.closest('.fixed-output-controls') || this.dialog;
        const help = root?.querySelector('.output-help');
        if(help){
            if(anyDisabled){
                help.textContent = 'Some output sizes are disabled (too large).';
                help.classList.remove('hidden');
            }else{
                help.textContent = '';
                help.classList.add('hidden');
            }
        }
    }

    _applyOutputUISelection(kind){
        const ui = kind === 'fixed' ? this._fixedOutputUI : this._resultOutputUI;
        if(!ui?.select) return;
        const s = this.settings.output;
        if(s.mode === 'other'){
            ui.select.value = '__other__';
            ui.otherWrap?.classList.remove('hidden');
            if(ui.otherInput) ui.otherInput.value = String(s.otherFactor);
        }else{
            const desired = String(s.presetFactor);
            let matched = Array.from(ui.select.options).find(o=>o.value === desired && !o.disabled);
            if(!matched){
                const desiredNum = Number(desired);
                if(Number.isFinite(desiredNum)){
                    const candidates = Array.from(ui.select.options).filter(o=>{
                        if(o.value === '__other__' || o.disabled) return false;
                        const n = Number(o.value);
                        return Number.isFinite(n);
                    }).map(o=>({ o, n: Number(o.value) }));
                    candidates.sort((a,b)=>Math.abs(a.n - desiredNum) - Math.abs(b.n - desiredNum));
                    matched = candidates[0]?.o;
                }
            }
            if(!matched){
                matched = Array.from(ui.select.options).find(o=>o.value !== '__other__' && !o.disabled);
            }
            if(matched){
                ui.select.value = matched.value;
            }
            ui.otherWrap?.classList.add('hidden');
        }
        if(kind === 'fixed') this._syncFixedOutputReadout();
        else this._syncResultOutputReadout();
    }

    _syncFixedBaseDraftReadout(){
        const bw = this.dialog?.querySelector('.fixed-base-width');
        const bh = this.dialog?.querySelector('.fixed-base-height');
        const ui = this._fixedOutputUI;
        const pw = this._parsePositiveIntDraft(bw?.value);
        const ph = this._parsePositiveIntDraft(bh?.value);
        if(ui?.readout && pw.ok && ph.ok){
            const factor = this._resolveEffectiveOutputFactor();
            const { outW, outH } = computeOutputSize(pw.value, ph.value, factor);
            ui.readout.textContent = this._formatReadout(outW, outH, factor);
        }
    }

    _syncFixedOutputReadout(){
        const ui = this._fixedOutputUI;
        if(!ui?.readout) return;
        const bw = Number(this.settings.fixed.baseWidthPx);
        const bh = Number(this.settings.fixed.baseHeightPx);
        const factor = this._resolveEffectiveOutputFactor();
        const { outW, outH } = computeOutputSize(bw, bh, factor);
        ui.readout.textContent = this._formatReadout(outW, outH, factor);
    }

    _syncResultOutputReadout(){
        const ui = this._resultOutputUI;
        if(!ui?.readout || !this._lastBasePxForOutput) return;
        const { baseW, baseH } = this._lastBasePxForOutput;
        const factor = this._resolveEffectiveOutputFactor();
        const { outW, outH } = computeOutputSize(baseW, baseH, factor);
        ui.readout.textContent = this._formatReadout(outW, outH, factor);
    }

    _syncDialogFromSettings(){
        if(!this.dialog) return;
        const s = this.settings = normalizeScreenshotSettings(this.settings);
        const el = this.dialog;

        // mode
        const freeRadio = el.querySelector('input[name="screenshot-mode"][value="free"]');
        const fixedRadio = el.querySelector('input[name="screenshot-mode"][value="fixed"]');
        if(freeRadio) freeRadio.checked = s.mode === 'free';
        if(fixedRadio) fixedRadio.checked = s.mode === 'fixed';

        // toggle setup sections
        const setupFree = el.querySelector('.setup-free');
        const setupFixed = el.querySelector('.setup-fixed');
        if(setupFree && setupFixed){
            if(s.mode === 'fixed'){
                setupFree.classList.add('hidden');
                setupFixed.classList.remove('hidden');
            }else{
                setupFixed.classList.add('hidden');
                setupFree.classList.remove('hidden');
            }
        }

        // free-select values
        const aw = el.querySelector('.aspect-width');
        const ah = el.querySelector('.aspect-height');
        const al = el.querySelector('.lock-aspect-ratio');
        this._setIfNotFocused(aw, s.free.aspectWidth);
        this._setIfNotFocused(ah, s.free.aspectHeight);
        if(al) al.checked = s.free.aspectLocked;
        this.tool.setAspectWidth(s.free.aspectWidth);
        this.tool.setAspectHeight(s.free.aspectHeight);
        this.tool.setAspectLocked(s.free.aspectLocked);
        this._syncAspectInputsEnabled();
        this._syncAspectValidationUI();

        // fixed-size values
        const bw = el.querySelector('.fixed-base-width');
        const bh = el.querySelector('.fixed-base-height');
        this._setIfNotFocused(bw, s.fixed.baseWidthPx);
        this._setIfNotFocused(bh, s.fixed.baseHeightPx);
        const fixedSel = el.querySelector('.select-output-size-fixed');
        this._populateOutputSelect(fixedSel, s.fixed.baseWidthPx, s.fixed.baseHeightPx);
        this._applyOutputUISelection('fixed');

        const auto = el.querySelector('.fixed-auto-create');
        if(auto) auto.checked = Boolean(s.fixed.autoCreateOnClick);

        this.tool.setFixedParams({
            fullResWidthPx: s.fixed.baseWidthPx,
            fullResHeightPx: s.fixed.baseHeightPx,
            baseWidthPx: s.fixed.baseWidthPx,
            baseHeightPx: s.fixed.baseHeightPx,
            autoCreateOnClick: s.fixed.autoCreateOnClick,
        });
        this._syncFixedBaseValidationUI();
    }

    _computeFixedPlacementAtProjectPoint(projectPoint, fixedParams){
        const ps = this.overlay.paperScope;
        const vp = this.viewer.viewport;
        const fp = fixedParams || this.settings?.fixed || {};

        const baseW = Number(fp.fullResWidthPx ?? fp.baseWidthPx ?? this.settings.fixed.baseWidthPx);
        const baseH = Number(fp.fullResHeightPx ?? fp.baseHeightPx ?? this.settings.fixed.baseHeightPx);

        // Cursor position in viewer-element (screen pixel) space
        const viewPt = ps.view.projectToView(projectPoint);

        // Convert image dimensions to viewer-element pixel dimensions
        // using point-level conversions (no Rect, no normalization).
        const p0 = vp.pixelFromPoint(vp.imageToViewportCoordinates(0, 0), true);
        const pW = vp.pixelFromPoint(vp.imageToViewportCoordinates(baseW, 0), true);
        const pH = vp.pixelFromPoint(vp.imageToViewportCoordinates(0, baseH), true);
        const viewerW = p0.distanceTo(pW);
        const viewerH = p0.distanceTo(pH);

        // Screen-aligned viewer-element rect centered on cursor (degrees=0).
        // This matches the free-draw path: _viewerElementRectToRegion receives
        // a degrees=0 rect and correctly measures screen-axis image-pixel extents.
        const viewerRect = new OpenSeadragon.Rect(
            viewPt.x - viewerW / 2, viewPt.y - viewerH / 2, viewerW, viewerH);
        const region = this._viewerElementRectToRegion(viewerRect);

        // Preview bounds: same rect, converted to paper.js project space
        const tl = ps.view.viewToProject(new paper.Point(viewPt.x - viewerW / 2, viewPt.y - viewerH / 2));
        const br = ps.view.viewToProject(new paper.Point(viewPt.x + viewerW / 2, viewPt.y + viewerH / 2));
        const bounds = new paper.Rectangle(tl, br);

        return {
            kind: 'fixed',
            autoCreate: Boolean(fp.autoCreateOnClick ?? this.settings.fixed.autoCreateOnClick),
            bounds,
            region,
        };
    }

    _resolveActiveTiledImageOrThrow(){
        const world = this.viewer?.world;
        const count = world?.getItemCount ? world.getItemCount() : 0;
        if(count === 0){
            throw new Error('Screenshot overlay: no active image found (viewer.world has 0 items). Is the viewer open?');
        }
        if(count > 1){
            throw new Error(`Screenshot overlay: multiple images are active in viewer.world (itemCount=${count}). This overlay currently supports exactly one active image and cannot disambiguate which one to screenshot.`);
        }
        return world.getItemAt(0);
    }

    /**
     * Convert a viewer-element-space rectangle into a ScreenshotRegion.
     * The input rect may carry a `degrees` property (e.g. from OSD's fromSummits);
     * its actual corners (honoring degrees) are used for screen-axis measurements.
     *
     * @param {(OpenSeadragon.Rect|Object)} viewerElementRect - `OpenSeadragon.Rect` or plain object with `x`, `y`, `width`, `height`, and optional `degrees`
     * @returns {{viewportRect: OpenSeadragon.Rect, screenWidth: number, screenHeight: number, rotation: number}}
     */
    _viewerElementRectToRegion(viewerElementRect){
        const vp = this.viewer.viewport;
        const osdRect = (viewerElementRect instanceof OpenSeadragon.Rect)
            ? viewerElementRect
            : new OpenSeadragon.Rect(viewerElementRect.x, viewerElementRect.y,
                viewerElementRect.width, viewerElementRect.height, viewerElementRect.degrees || 0);

        const viewportRect = vp.viewerElementToViewportRectangle(osdRect);

        const tl = osdRect.getTopLeft();
        const tr = osdRect.getTopRight();
        const bl = osdRect.getBottomLeft();
        const iTL = vp.viewerElementToImageCoordinates(tl);
        const iTR = vp.viewerElementToImageCoordinates(tr);
        const iBL = vp.viewerElementToImageCoordinates(bl);

        return {
            viewportRect,
            screenWidth: iTR.distanceTo(iTL),
            screenHeight: iBL.distanceTo(iTL),
            rotation: vp.getRotation(true),
        };
    }

    /**
     * Store a ScreenshotRegion and update the dialog UI to reflect it.
     * This is the single entry point for all code paths that establish or
     * modify the selected screenshot region.
     *
     * @param {{viewportRect: OpenSeadragon.Rect, screenWidth: number, screenHeight: number, rotation: number}} region
     */
    _setRegion(region){
        this._resetScreenshotResults();
        this._region = region;

        const bw = Math.round(region.screenWidth);
        const bh = Math.round(region.screenHeight);

        // Invalidate cached screenshots when the region changes.
        const nextKey = JSON.stringify({
            vp: { x: region.viewportRect.x, y: region.viewportRect.y,
                  width: region.viewportRect.width, height: region.viewportRect.height,
                  degrees: region.viewportRect.degrees },
            rotation: region.rotation,
        });
        if(this._lastRegionKey !== nextKey){
            this._lastRegionKey = nextKey;
            this._invalidateScreenshotCache();
        }

        const ctx = this.dialog.querySelector('.output-context');
        const wEl = this.dialog.querySelector('.region-width');
        const hEl = this.dialog.querySelector('.region-height');
        if(wEl) wEl.value = bw;
        if(hEl) hEl.value = bh;

        let calculated_mm = false;
        this._mpp = null;

        if(this.viewer.world.getItemCount() === 1){
            const mpp = this.viewer.world.getItemAt(0).source.mpp;
            if(mpp){
                const mmEl = this.dialog.querySelector('.region-mm');
                if(mmEl){
                    mmEl.textContent = `${(mpp.x / 1000 * region.screenWidth).toFixed(3)} × ${(mpp.y / 1000 * region.screenHeight).toFixed(3)} mm`;
                    mmEl.classList.remove('hidden');
                }
                calculated_mm = true;
                this._mpp = mpp;

                if(ctx){
                    ctx.textContent = `Region ${bw}×${bh} px (${(mpp.x / 1000 * region.screenWidth).toFixed(2)}×${(mpp.y / 1000 * region.screenHeight).toFixed(2)} mm)`;
                }
            }
        }
        if(!calculated_mm){
            const mmEl = this.dialog.querySelector('.region-mm');
            if(mmEl){
                mmEl.textContent = '';
                mmEl.classList.add('hidden');
            }
            if(ctx){
                ctx.textContent = `Region ${bw}×${bh}`;
            }
        }

        const select = this.dialog.querySelector('.select-output-size-result');
        const baseW = region.screenWidth;
        const baseH = region.screenHeight;
        this._lastBasePxForOutput = { baseW, baseH };
        this._populateOutputSelect(select, baseW, baseH);
        this._applyOutputUISelection('result');
        if(this._includeScalebar){
            this._maybeApplySmartScalebarDefaults();
        }
        this._syncScalebarUI();
    }

    /**
     * Scale the current region's viewportRect proportionally based on new
     * screen-axis pixel dimensions, keeping the center and rotation fixed.
     * @param {number} newScreenW
     * @param {number} newScreenH
     */
    _resizeRegion(newScreenW, newScreenH){
        if(!this._region) return;
        const r = this._region;
        const scaleX = newScreenW / r.screenWidth;
        const scaleY = newScreenH / r.screenHeight;
        const vr = r.viewportRect;
        const center = vr.getCenter();
        const nextVR = new OpenSeadragon.Rect(
            center.x - (vr.width * scaleX) / 2,
            center.y - (vr.height * scaleY) / 2,
            vr.width * scaleX,
            vr.height * scaleY,
            vr.degrees
        );
        const viewerRect = this.viewer.viewport.viewportToViewerElementRectangle(nextVR);
        const region = this._viewerElementRectToRegion(viewerRect);
        this._setRegion(region);
    }

    _updateROI(){
        if(!this._region) return;
        const baseWIn = Number(this.dialog.querySelector('.region-width').value);
        const baseHIn = Number(this.dialog.querySelector('.region-height').value);
        let nextW = Math.max(1, Math.round(baseWIn));
        let nextH = Math.max(1, Math.round(baseHIn));

        if(this.dialog.querySelector('.lock-aspect-ratio').checked){
            const desiredRatio = this.tool._aspectWidth / this.tool._aspectHeight;
            const currentRatio = nextW / nextH;
            if(currentRatio / desiredRatio > 1){
                nextW = Math.round(nextH * desiredRatio);
            } else if (currentRatio / desiredRatio < 1){
                nextH = Math.round(nextW / desiredRatio);
            }
            nextW = Math.max(1, nextW);
            nextH = Math.max(1, nextH);
        }

        this._resizeRegion(nextW, nextH);
    }

    _resetScreenshotResults(){
        this.dialog?.querySelector('.screenshot-results').classList.remove('created','pending');
    }

    _setProgress(loaded, total){
        if(this.dialog){
            const progress = this.dialog.querySelector('progress');
            progress.value = loaded;
            progress.max = total;
            this.dialog.querySelector('.loaded').textContent = loaded;
            this.dialog.querySelector('.total').textContent = total;
        }
    }

    _buildRenderContext({ w, h, viewportRect }){
        const ti = this._resolveActiveTiledImageOrThrow();
        const ts = ti.source || this.viewer.tileSources[this.viewer.currentPage()];
        const rotation = this.viewer.viewport.getRotation(true);
        return { w, h, viewportRect, viewer: this.viewer, tiledImage: ti, tileSource: ts, rotation };
    }

    async _createScreenshot(data){
        const { w, h, viewportRect, scaleFactor } = data;
        const ctx = this._buildRenderContext({ w, h, viewportRect });
        const signature = computeRenderSignature({ w, h, viewportRect, rotation: ctx.rotation, tileSource: ctx.tileSource });

        if(!this._baseScreenshot || this._baseScreenshot.signature !== signature){
            const base = await renderBaseScreenshot({
                ...ctx,
                onProgress: (loaded, total) => this._setProgress(loaded, total),
            });
            if(this._baseScreenshot?.url){
                this._revokeObjectURL(this._baseScreenshot.url);
            }
            this._baseScreenshot = {
                blob: base.blob,
                url: URL.createObjectURL(base.blob),
                pixelRatio: base.pixelRatio,
                signature: base.signature,
            };
            this._lastScreenshotRequest = { data, signature: base.signature };
        }else{
            this._lastScreenshotRequest = { data, signature };
        }

        let finalBlob;
        if(this._includeScalebar && this._mpp){
            finalBlob = await composeScreenshotWithScalebar({
                baseBlob: this._baseScreenshot.blob,
                pixelRatio: this._baseScreenshot.pixelRatio,
                scaleFactor,
                scalebar: {
                    include: true,
                    widthMm: this._scalebarWidth,
                    heightPx: this._scalebarHeight,
                    mppX: this._mpp.x,
                    label: this._scalebarShowLabel ? this._scalebarLabelFit({ scaleFactor }) : null,
                },
            });
        }else{
            finalBlob = this._baseScreenshot.blob;
        }

        if(this.blobURL){
            this._revokeObjectURL(this.blobURL);
        }
        this.blobURL = URL.createObjectURL(finalBlob);
        return this.blobURL;
    }
}

export {ScreenshotOverlay, ScreenshotTool};