UNPKG

js-draw

Version:

Draw pictures using a pen, touchscreen, or mouse! JS-draw is a drawing library for JavaScript and TypeScript.

1,396 lines 59.1 kB
"use strict";
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
    if (k2 === undefined) k2 = k;
    var desc = Object.getOwnPropertyDescriptor(m, k);
    if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
      desc = { enumerable: true, get: function() { return m[k]; } };
    }
    Object.defineProperty(o, k2, desc);
}) : (function(o, m, k, k2) {
    if (k2 === undefined) k2 = k;
    o[k2] = m[k];
}));
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
    Object.defineProperty(o, "default", { enumerable: true, value: v });
}) : function(o, v) {
    o["default"] = v;
});
var __importStar = (this && this.__importStar) || (function () {
    var ownKeys = function(o) {
        ownKeys = Object.getOwnPropertyNames || function (o) {
            var ar = [];
            for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
            return ar;
        };
        return ownKeys(o);
    };
    return function (mod) {
        if (mod && mod.__esModule) return mod;
        var result = {};
        if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
        __setModuleDefault(result, mod);
        return result;
    };
})();
var __importDefault = (this && this.__importDefault) || function (mod) {
    return (mod && mod.__esModule) ? mod : { "default": mod };
};
Object.defineProperty(exports, "__esModule", { value: true });
exports.Editor = void 0;
const EditorImage_1 = __importDefault(require("./image/EditorImage"));
const ToolController_1 = __importDefault(require("./tools/ToolController"));
const types_1 = require("./types");
const inputEvents_1 = require("./inputEvents");
const UndoRedoHistory_1 = __importDefault(require("./UndoRedoHistory"));
const Viewport_1 = __importDefault(require("./Viewport"));
const EventDispatcher_1 = __importDefault(require("./EventDispatcher"));
const math_1 = require("@js-draw/math");
const Display_1 = __importStar(require("./rendering/Display"));
const SVGLoader_1 = __importDefault(require("./SVGLoader/SVGLoader"));
const Pointer_1 = __importDefault(require("./Pointer"));
const getLocalizationTable_1 = __importDefault(require("./localizations/getLocalizationTable"));
const IconProvider_1 = __importDefault(require("./toolbar/IconProvider"));
const CanvasRenderer_1 = __importDefault(require("./rendering/renderers/CanvasRenderer"));
const untilNextAnimationFrame_1 = __importDefault(require("./util/untilNextAnimationFrame"));
const uniteCommands_1 = __importDefault(require("./commands/uniteCommands"));
const SelectionTool_1 = __importDefault(require("./tools/SelectionTool/SelectionTool"));
const Erase_1 = __importDefault(require("./commands/Erase"));
const BackgroundComponent_1 = __importStar(require("./components/BackgroundComponent"));
const sendPenEvent_1 = __importDefault(require("./testing/sendPenEvent"));
const KeyboardShortcutManager_1 = __importDefault(require("./shortcuts/KeyboardShortcutManager"));
const EdgeToolbar_1 = __importDefault(require("./toolbar/EdgeToolbar"));
const StrokeKeyboardControl_1 = __importDefault(require("./tools/InputFilter/StrokeKeyboardControl"));
const guessKeyCodeFromKey_1 = __importDefault(require("./util/guessKeyCodeFromKey"));
const makeAboutDialog_1 = __importDefault(require("./dialogs/makeAboutDialog"));
const version_1 = __importDefault(require("./version"));
const editorImageToSVG_1 = require("./image/export/editorImageToSVG");
const ReactiveValue_1 = __importStar(require("./util/ReactiveValue"));
const listenForKeyboardEventsFrom_1 = __importDefault(require("./util/listenForKeyboardEventsFrom"));
const mitLicenseAttribution_1 = __importDefault(require("./util/mitLicenseAttribution"));
const ClipboardHandler_1 = __importDefault(require("./util/ClipboardHandler"));
const ContextMenuRecognizer_1 = __importDefault(require("./tools/InputFilter/ContextMenuRecognizer"));
/**
 * The main entrypoint for the full editor.
 *
 * ## Example
 * To create an editor with a toolbar,
 * ```ts,runnable
 * import { Editor } from 'js-draw';
 *
 * const editor = new Editor(document.body);
 *
 * const toolbar = editor.addToolbar();
 * toolbar.addSaveButton(() => {
 *   const saveData = editor.toSVG().outerHTML;
 *   // Do something with saveData...
 * });
 * ```
 *
 * See also
 * * [`examples.md`](https://github.com/personalizedrefrigerator/js-draw/blob/main/docs/examples.md).
 */
class Editor {
    /**
     * @example
     * ```ts,runnable
     * import { Editor } from 'js-draw';
     *
     * const container = document.body;
     *
     * // Create an editor
     * const editor = new Editor(container, {
     *   // 2e-10 and 1e12 are the default values for minimum/maximum zoom.
     *   minZoom: 2e-10,
     *   maxZoom: 1e12,
     * });
     *
     * // Add the default toolbar
     * const toolbar = editor.addToolbar();
     *
     * const createCustomIcon = () => {
     *   // Create/return an icon here.
     * };
     *
     * // Add a custom button
     * toolbar.addActionButton({
     *   label: 'Custom Button'
     *   icon: createCustomIcon(),
     * }, () => {
     *   // Do something here
     * });
     * ```
     */
    constructor(parent, settings = {}) {
        this.eventListenerTargets = [];
        this.previousAccessibilityAnnouncement = '';
        this.pointers = {};
        this.announceUndoCallback = (command) => {
            this.announceForAccessibility(this.localization.undoAnnouncement(command.description(this, this.localization)));
        };
        this.announceRedoCallback = (command) => {
            this.announceForAccessibility(this.localization.redoAnnouncement(command.description(this, this.localization)));
        };
        // Listeners to be called once at the end of the next re-render.
        this.nextRerenderListeners = [];
        this.rerenderQueued = false;
        this.closeAboutDialog = null;
        this.localization = {
            ...(0, getLocalizationTable_1.default)(),
            ...settings.localization,
        };
        // Fill default settings.
        this.settings = {
            wheelEventsEnabled: settings.wheelEventsEnabled ?? true,
            renderingMode: settings.renderingMode ?? Display_1.RenderingMode.CanvasRenderer,
            localization: this.localization,
            minZoom: settings.minZoom ?? 2e-10,
            maxZoom: settings.maxZoom ?? 1e12,
            keyboardShortcutOverrides: settings.keyboardShortcutOverrides ?? {},
            iconProvider: settings.iconProvider ?? new IconProvider_1.default(),
            notices: settings.notices ?? [],
            appInfo: settings.appInfo ? { ...settings.appInfo } : null,
            pens: {
                additionalPenTypes: settings.pens?.additionalPenTypes ?? [],
                filterPenTypes: settings.pens?.filterPenTypes ?? (() => true),
            },
            text: {
                fonts: settings.text?.fonts ?? ['sans-serif', 'serif', 'monospace'],
            },
            image: {
                showImagePicker: settings.image?.showImagePicker ?? undefined,
            },
            svg: {
                loaderPlugins: settings.svg?.loaderPlugins ?? [],
            },
            clipboardApi: settings.clipboardApi ?? null,
        };
        // Validate settings
        if (this.settings.minZoom > this.settings.maxZoom) {
            throw new Error('Minimum zoom must be lesser than maximum zoom!');
        }
        this.readOnly = ReactiveValue_1.MutableReactiveValue.fromInitialValue(false);
        this.icons = this.settings.iconProvider;
        this.shortcuts = new KeyboardShortcutManager_1.default(this.settings.keyboardShortcutOverrides);
        this.container = document.createElement('div');
        this.renderingRegion = document.createElement('div');
        this.container.appendChild(this.renderingRegion);
        this.container.classList.add('imageEditorContainer', 'js-draw');
        this.loadingWarning = document.createElement('div');
        this.loadingWarning.classList.add('loadingMessage');
        this.loadingWarning.ariaLive = 'polite';
        this.container.appendChild(this.loadingWarning);
        this.accessibilityControlArea = document.createElement('textarea');
        this.accessibilityControlArea.setAttribute('placeholder', this.localization.accessibilityInputInstructions);
        this.accessibilityControlArea.style.opacity = '0';
        this.accessibilityControlArea.style.width = '0';
        this.accessibilityControlArea.style.height = '0';
        this.accessibilityControlArea.style.position = 'absolute';
        this.accessibilityAnnounceArea = document.createElement('div');
        this.accessibilityAnnounceArea.setAttribute('aria-live', 'assertive');
        this.accessibilityAnnounceArea.className = 'accessibilityAnnouncement';
        this.container.appendChild(this.accessibilityAnnounceArea);
        this.renderingRegion.style.touchAction = 'none';
        this.renderingRegion.className = 'imageEditorRenderArea';
        this.renderingRegion.appendChild(this.accessibilityControlArea);
        this.renderingRegion.setAttribute('tabIndex', '0');
        this.renderingRegion.setAttribute('alt', '');
        this.notifier = new EventDispatcher_1.default();
        this.viewport = new Viewport_1.default((oldTransform, newTransform) => {
            this.notifier.dispatch(types_1.EditorEventType.ViewportChanged, {
                kind: types_1.EditorEventType.ViewportChanged,
                newTransform,
                oldTransform,
            });
        });
        this.display = new Display_1.default(this, this.settings.renderingMode, this.renderingRegion);
        this.image = new EditorImage_1.default();
        this.history = new UndoRedoHistory_1.default(this, this.announceRedoCallback, this.announceUndoCallback);
        this.toolController = new ToolController_1.default(this, this.localization);
        // TODO: Make this pipeline configurable (e.g. allow users to add global input stabilization)
        this.toolController.addInputMapper(StrokeKeyboardControl_1.default.fromEditor(this));
        this.toolController.addInputMapper(new ContextMenuRecognizer_1.default());
        parent.appendChild(this.container);
        this.viewport.updateScreenSize(math_1.Vec2.of(this.display.width, this.display.height));
        this.registerListeners();
        this.queueRerender();
        this.hideLoadingWarning();
        // Enforce zoom limits.
        this.notifier.on(types_1.EditorEventType.ViewportChanged, (evt) => {
            if (evt.kind !== types_1.EditorEventType.ViewportChanged)
                return;
            const getZoom = (mat) => mat.transformVec3(math_1.Vec2.unitX).length();
            const zoom = getZoom(evt.newTransform);
            if (zoom > this.settings.maxZoom || zoom < this.settings.minZoom) {
                const oldZoom = getZoom(evt.oldTransform);
                let resetTransform = math_1.Mat33.identity;
                if (oldZoom <= this.settings.maxZoom && oldZoom >= this.settings.minZoom) {
                    resetTransform = evt.oldTransform;
                }
                else {
                    // If 1x zoom isn't acceptable, try a zoom between the minimum and maximum.
                    resetTransform = math_1.Mat33.scaling2D((this.settings.minZoom + this.settings.maxZoom) / 2);
                }
                this.viewport.resetTransform(resetTransform);
            }
            else if (!isFinite(zoom)) {
                // Recover from possible division-by-zero
                console.warn(`Non-finite zoom (${zoom}) detected. Resetting the viewport. This was likely caused by division by zero.`);
                if (isFinite(getZoom(evt.oldTransform))) {
                    this.viewport.resetTransform(evt.oldTransform);
                }
                else {
                    this.viewport.resetTransform();
                }
            }
        });
    }
    /**
     * @returns a shallow copy of the current settings of the editor.
     *
     * Do not modify.
     */
    getCurrentSettings() {
        return {
            ...this.settings,
        };
    }
    /**
     * @returns a reference to the editor's container.
     *
     * @example
     * ```
     *   // Set the editor's height to 500px
     *   editor.getRootElement().style.height = '500px';
     * ```
     */
    getRootElement() {
        return this.container;
    }
    /**
     * @returns the bounding box of the main rendering region of the editor in the HTML viewport.
     *
     * @internal
     */
    getOutputBBoxInDOM() {
        return math_1.Rect2.of(this.renderingRegion.getBoundingClientRect());
    }
    /**
     * Shows a "Loading..." message.
     * @param fractionLoaded - should be a number from 0 to 1, where 1 represents completely loaded.
     */
    showLoadingWarning(fractionLoaded) {
        const loadingPercent = Math.round(fractionLoaded * 100);
        this.loadingWarning.innerText = this.localization.loading(loadingPercent);
        this.loadingWarning.style.display = 'block';
    }
    /** @see {@link showLoadingWarning} */
    hideLoadingWarning() {
        this.loadingWarning.style.display = 'none';
        this.announceForAccessibility(this.localization.doneLoading);
    }
    /**
     * Announce `message` for screen readers. If `message` is the same as the previous
     * message, it is re-announced.
     */
    announceForAccessibility(message) {
        // Force re-announcing an announcement if announced again.
        if (message === this.previousAccessibilityAnnouncement) {
            message = message + '. ';
        }
        this.accessibilityAnnounceArea.innerText = message;
        this.previousAccessibilityAnnouncement = message;
    }
    /**
     * Creates a toolbar. If `defaultLayout` is true, default buttons are used.
     * @returns a reference to the toolbar.
     */
    addToolbar(defaultLayout = true) {
        const toolbar = new EdgeToolbar_1.default(this, this.container, this.localization);
        if (defaultLayout) {
            toolbar.addDefaults();
        }
        return toolbar;
    }
    registerListeners() {
        this.handlePointerEventsFrom(this.renderingRegion);
        this.handleKeyEventsFrom(this.renderingRegion);
        this.handlePointerEventsFrom(this.accessibilityAnnounceArea);
        // Prevent selected text from control areas from being dragged.
        // See https://github.com/personalizedrefrigerator/joplin-plugin-freehand-drawing/issues/8
        const preventSelectionOf = [
            this.renderingRegion,
            this.accessibilityAnnounceArea,
            this.accessibilityControlArea,
            this.loadingWarning,
        ];
        for (const element of preventSelectionOf) {
            element.addEventListener('drag', (event) => {
                event.preventDefault();
                return false;
            });
            element.addEventListener('dragstart', (event) => {
                event.preventDefault();
                return false;
            });
        }
        this.container.addEventListener('wheel', (evt) => {
            this.handleHTMLWheelEvent(evt);
        });
        const handleResize = () => {
            this.viewport.updateScreenSize(math_1.Vec2.of(this.display.width, this.display.height));
            this.rerender();
            this.updateEditorSizeVariables();
        };
        if ('ResizeObserver' in window) {
            const resizeObserver = new ResizeObserver(handleResize);
            resizeObserver.observe(this.renderingRegion);
            resizeObserver.observe(this.container);
        }
        else {
            addEventListener('resize', handleResize);
        }
        this.accessibilityControlArea.addEventListener('input', () => {
            this.accessibilityControlArea.value = '';
        });
        const copyHandler = new ClipboardHandler_1.default(this);
        document.addEventListener('copy', async (evt) => {
            if (!this.isEventSink(document.querySelector(':focus'))) {
                return;
            }
            copyHandler.copy(evt);
        });
        document.addEventListener('paste', (evt) => {
            this.handlePaste(evt);
        });
    }
    updateEditorSizeVariables() {
        // Add CSS variables so that absolutely-positioned children of the editor can
        // still fill the screen.
        this.container.style.setProperty('--editor-current-width-px', `${this.container.clientWidth}px`);
        this.container.style.setProperty('--editor-current-height-px', `${this.container.clientHeight}px`);
        this.container.style.setProperty('--editor-current-display-width-px', `${this.renderingRegion.clientWidth}px`);
        this.container.style.setProperty('--editor-current-display-height-px', `${this.renderingRegion.clientHeight}px`);
    }
    /** @internal */
    handleHTMLWheelEvent(event) {
        let delta = math_1.Vec3.of(event.deltaX, event.deltaY, event.deltaZ);
        // Process wheel events if the ctrl key is down, even if disabled -- we do want to handle
        // pinch-zooming.
        if (!event.ctrlKey && !event.metaKey) {
            if (!this.settings.wheelEventsEnabled) {
                return;
            }
            else if (this.settings.wheelEventsEnabled === 'only-if-focused') {
                const focusedChild = this.container.querySelector(':focus');
                if (!focusedChild) {
                    return;
                }
            }
        }
        if (event.deltaMode === WheelEvent.DOM_DELTA_LINE) {
            delta = delta.times(15);
        }
        else if (event.deltaMode === WheelEvent.DOM_DELTA_PAGE) {
            delta = delta.times(100);
        }
        if (event.ctrlKey || event.metaKey) {
            delta = math_1.Vec3.of(0, 0, event.deltaY);
        }
        // Ensure that `pos` is relative to `this.renderingRegion`
        const bbox = this.getOutputBBoxInDOM();
        const pos = math_1.Vec2.of(event.clientX, event.clientY).minus(bbox.topLeft);
        if (this.toolController.dispatchInputEvent({
            kind: inputEvents_1.InputEvtType.WheelEvt,
            delta,
            screenPos: pos,
        })) {
            event.preventDefault();
            return true;
        }
        return false;
    }
    getPointerList() {
        const nowTime = performance.now();
        const res = [];
        for (const id in this.pointers) {
            const maxUnupdatedTime = 2000; // Maximum time without a pointer update (ms)
            if (this.pointers[id] && nowTime - this.pointers[id].timeStamp < maxUnupdatedTime) {
                res.push(this.pointers[id]);
            }
        }
        return res;
    }
    /**
     * A protected method that can override setPointerCapture in environments where it may fail
     * (e.g. with synthetic events). @internal
     */
    setPointerCapture(target, pointerId) {
        try {
            target.setPointerCapture(pointerId);
        }
        catch (error) {
            console.warn('Failed to setPointerCapture', error);
        }
    }
    /** Can be overridden in a testing environment to handle synthetic events. @internal */
    releasePointerCapture(target, pointerId) {
        try {
            target.releasePointerCapture(pointerId);
        }
        catch (error) {
            console.warn('Failed to releasePointerCapture', error);
        }
    }
    /**
     * Dispatches a `PointerEvent` to the editor. The target element for `evt` must have the same top left
     * as the content of the editor.
     */
    handleHTMLPointerEvent(eventType, evt) {
        const eventsRelativeTo = this.renderingRegion;
        const eventTarget = evt.target ?? this.renderingRegion;
        if (eventType === 'pointerdown') {
            const pointer = Pointer_1.default.ofEvent(evt, true, this.viewport, eventsRelativeTo);
            this.pointers[pointer.id] = pointer;
            this.setPointerCapture(eventTarget, pointer.id);
            const event = {
                kind: inputEvents_1.InputEvtType.PointerDownEvt,
                current: pointer,
                allPointers: this.getPointerList(),
            };
            this.toolController.dispatchInputEvent(event);
            return true;
        }
        else if (eventType === 'pointermove') {
            const pointer = Pointer_1.default.ofEvent(evt, this.pointers[evt.pointerId]?.down ?? false, this.viewport, eventsRelativeTo);
            if (pointer.down) {
                const prevData = this.pointers[pointer.id];
                if (prevData) {
                    const distanceMoved = pointer.screenPos.distanceTo(prevData.screenPos);
                    // If the pointer moved less than two pixels, don't send a new event.
                    if (distanceMoved < 2) {
                        return false;
                    }
                }
                this.pointers[pointer.id] = pointer;
                if (this.toolController.dispatchInputEvent({
                    kind: inputEvents_1.InputEvtType.PointerMoveEvt,
                    current: pointer,
                    allPointers: this.getPointerList(),
                })) {
                    evt.preventDefault();
                }
            }
            return true;
        }
        else if (eventType === 'pointercancel' || eventType === 'pointerup') {
            const pointer = Pointer_1.default.ofEvent(evt, false, this.viewport, eventsRelativeTo);
            if (!this.pointers[pointer.id]) {
                return false;
            }
            this.pointers[pointer.id] = pointer;
            this.releasePointerCapture(eventTarget, pointer.id);
            if (this.toolController.dispatchInputEvent({
                kind: inputEvents_1.InputEvtType.PointerUpEvt,
                current: pointer,
                allPointers: this.getPointerList(),
            })) {
                evt.preventDefault();
            }
            delete this.pointers[pointer.id];
            return true;
        }
        return eventType;
    }
    isEventSink(evtTarget) {
        let currentElem = evtTarget;
        while (currentElem !== null) {
            for (const elem of this.eventListenerTargets) {
                if (elem === currentElem) {
                    return true;
                }
            }
            currentElem = currentElem.parentElement;
        }
        return false;
    }
    /** @internal */
    async handleDrop(evt) {
        evt.preventDefault();
        await this.handlePaste(evt);
    }
    /** @internal */
    async handlePaste(evt) {
        const target = document.querySelector(':focus') ?? evt.target;
        if (!this.isEventSink(target)) {
            return;
        }
        return await new ClipboardHandler_1.default(this).paste(evt);
    }
    /**
     * Forward pointer events from `elem` to this editor. Such that right-click/right-click drag
     * events are also forwarded, `elem`'s contextmenu is disabled.
     *
     * `filter` is called once per pointer event, before doing any other processing. If `filter` returns `true` the event is
     * forwarded to the editor.
     *
     * **Note**: `otherEventsFilter` is like `filter`, but is called for other pointer-related
     * events that could also be forwarded to the editor. To forward just pointer events,
     * for example, `otherEventsFilter` could be given as `()=>false`.
     *
     * @example
     * ```ts
     * const overlay = document.createElement('div');
     * editor.createHTMLOverlay(overlay);
     *
     * // Send all pointer events that don't have the control key pressed
     * // to the editor.
     * editor.handlePointerEventsFrom(overlay, (event) => {
     *   if (event.ctrlKey) {
     *     return false;
     *   }
     *   return true;
     * });
     * ```
     */
    handlePointerEventsFrom(elem, filter, otherEventsFilter) {
        // May be required to prevent text selection on iOS/Safari:
        // See https://stackoverflow.com/a/70992717/17055750
        const touchstartListener = (evt) => {
            if (otherEventsFilter && !otherEventsFilter('touchstart', evt)) {
                return;
            }
            evt.preventDefault();
        };
        const contextmenuListener = (evt) => {
            if (otherEventsFilter && !otherEventsFilter('contextmenu', evt)) {
                return;
            }
            // Don't show a context menu
            evt.preventDefault();
        };
        const listeners = {
            touchstart: touchstartListener,
            contextmenu: contextmenuListener,
        };
        const eventNames = [
            'pointerdown',
            'pointermove',
            'pointerup',
            'pointercancel',
        ];
        for (const eventName of eventNames) {
            listeners[eventName] = (evt) => {
                // This listener will only be called in the context of PointerEvents.
                const event = evt;
                if (filter && !filter(eventName, event)) {
                    return undefined;
                }
                return this.handleHTMLPointerEvent(eventName, event);
            };
        }
        // Add all listeners.
        for (const eventName in listeners) {
            elem.addEventListener(eventName, listeners[eventName]);
        }
        return {
            /** Remove all event listeners registered by this function. */
            remove: () => {
                for (const eventName in listeners) {
                    elem.removeEventListener(eventName, listeners[eventName]);
                }
            },
        };
    }
    /**
     * Like {@link handlePointerEventsFrom} except ignores short input gestures like clicks.
     *
     * `filter` is called once per event, before doing any other processing. If `filter` returns `true` the event is
     * forwarded to the editor.
     *
     * `otherEventsFilter` is passed unmodified to `handlePointerEventsFrom`.
     */
    handlePointerEventsExceptClicksFrom(elem, filter, otherEventsFilter) {
        // Maps pointer IDs to gesture start points
        const gestureData = Object.create(null);
        return this.handlePointerEventsFrom(elem, (eventName, event) => {
            if (filter && !filter(eventName, event)) {
                return false;
            }
            // Position of the current event.
            // jsdom doesn't seem to support pageX/pageY -- use clientX/clientY if unavailable
            const currentPos = math_1.Vec2.of(event.pageX ?? event.clientX, event.pageY ?? event.clientY);
            const pointerId = event.pointerId ?? 0;
            // Whether to send the current event to the editor
            let sendToEditor = true;
            if (eventName === 'pointerdown') {
                // Buffer the event, but don't send it to the editor yet.
                // We don't want to send single-click events, but we do want to send full strokes.
                gestureData[pointerId] = {
                    eventBuffer: [[eventName, event]],
                    startPoint: currentPos,
                    hasMovedSignificantly: false,
                };
                // Capture the pointer so we receive future events even if the overlay is hidden.
                this.setPointerCapture(elem, event.pointerId);
                // Don't send to the editor.
                sendToEditor = false;
            }
            else if (eventName === 'pointermove' && gestureData[pointerId]) {
                const gestureStartPos = gestureData[pointerId].startPoint;
                const eventBuffer = gestureData[pointerId].eventBuffer;
                // Skip if the pointer hasn't moved enough to not be a "click".
                const strokeStartThreshold = 10;
                const isWithinClickThreshold = gestureStartPos && currentPos.distanceTo(gestureStartPos) < strokeStartThreshold;
                if (isWithinClickThreshold && !gestureData[pointerId].hasMovedSignificantly) {
                    eventBuffer.push([eventName, event]);
                    sendToEditor = false;
                }
                else {
                    // Send all buffered events to the editor -- start the stroke.
                    for (const [eventName, event] of eventBuffer) {
                        this.handleHTMLPointerEvent(eventName, event);
                    }
                    gestureData[pointerId].eventBuffer = [];
                    gestureData[pointerId].hasMovedSignificantly = true;
                    sendToEditor = true;
                }
            }
            // Pointers that aren't down -- send to the editor.
            else if (eventName === 'pointermove') {
                sendToEditor = true;
            }
            // Otherwise, if we received a pointerup/pointercancel without flushing all pointerevents from the
            // buffer, the gesture wasn't recognised as a stroke. Thus, the editor isn't expecting a pointerup/
            // pointercancel event.
            else if ((eventName === 'pointerup' || eventName === 'pointercancel') &&
                gestureData[pointerId] &&
                gestureData[pointerId].eventBuffer.length > 0) {
                this.releasePointerCapture(elem, event.pointerId);
                // Don't send to the editor.
                sendToEditor = false;
                delete gestureData[pointerId];
            }
            // Forward all other events to the editor.
            return sendToEditor;
        }, otherEventsFilter);
    }
    /** @internal */
    handleHTMLKeyDownEvent(htmlEvent) {
        console.assert(htmlEvent.type === 'keydown', `handling a keydown event with type ${htmlEvent.type}`);
        const event = (0, inputEvents_1.keyPressEventFromHTMLEvent)(htmlEvent);
        if (this.toolController.dispatchInputEvent(event)) {
            htmlEvent.preventDefault();
            return true;
        }
        else if (event.key === 't' || event.key === 'T') {
            htmlEvent.preventDefault();
            this.display.rerenderAsText();
            return true;
        }
        else if (event.key === 'Escape') {
            this.renderingRegion.blur();
            return true;
        }
        return false;
    }
    /** @internal */
    handleHTMLKeyUpEvent(htmlEvent) {
        console.assert(htmlEvent.type === 'keyup', `Handling a keyup event with type ${htmlEvent.type}`);
        const event = (0, inputEvents_1.keyUpEventFromHTMLEvent)(htmlEvent);
        if (this.toolController.dispatchInputEvent(event)) {
            htmlEvent.preventDefault();
            return true;
        }
        return false;
    }
    /**
     * Adds event listners for keypresses (and drop events) on `elem` and forwards those
     * events to the editor.
     *
     * If the given `filter` returns `false` for an event, the event is ignored and not
     * passed to the editor.
     */
    handleKeyEventsFrom(elem, filter = () => true) {
        (0, listenForKeyboardEventsFrom_1.default)(elem, {
            filter,
            handleKeyDown: (htmlEvent) => {
                this.handleHTMLKeyDownEvent(htmlEvent);
            },
            handleKeyUp: (htmlEvent) => {
                this.handleHTMLKeyUpEvent(htmlEvent);
            },
            getHandlesKeyEventsFrom: (element) => {
                return this.eventListenerTargets.includes(element);
            },
        });
        // Allow drop.
        elem.ondragover = (evt) => {
            evt.preventDefault();
        };
        elem.ondrop = (evt) => {
            this.handleDrop(evt);
        };
        this.eventListenerTargets.push(elem);
    }
    /**
     * Attempts to prevent **user-triggered** events from modifying
     * the content of the image.
     */
    setReadOnly(readOnly) {
        if (readOnly !== this.readOnly.get()) {
            this.readOnly.set(readOnly);
            this.notifier.dispatch(types_1.EditorEventType.ReadOnlyModeToggled, {
                kind: types_1.EditorEventType.ReadOnlyModeToggled,
                editorIsReadOnly: readOnly,
            });
        }
    }
    // @internal
    isReadOnlyReactiveValue() {
        return this.readOnly;
    }
    isReadOnly() {
        return this.readOnly;
    }
    /**
     * `apply` a command. `command` will be announced for accessibility.
     *
     * **Example**:
     * [[include:doc-pages/inline-examples/adding-a-stroke.md]]
     */
    dispatch(command, addToHistory = true) {
        const dispatchResult = this.dispatchNoAnnounce(command, addToHistory);
        const commandDescription = command.description(this, this.localization);
        this.announceForAccessibility(commandDescription);
        return dispatchResult;
    }
    /**
     * Dispatches a command without announcing it. By default, does not add to history.
     * Use this to show finalized commands that don't need to have `announceForAccessibility`
     * called.
     *
     * If `addToHistory` is `false`, this is equivalent to `command.apply(editor)`.
     *
     * @example
     * ```
     * const addToHistory = false;
     * editor.dispatchNoAnnounce(editor.viewport.zoomTo(someRectangle), addToHistory);
     * ```
     */
    dispatchNoAnnounce(command, addToHistory = false) {
        const result = command.apply(this);
        if (addToHistory) {
            const apply = false; // Don't double-apply
            this.history.push(command, apply);
        }
        return result;
    }
    /**
     * Apply a large transformation in chunks.
     * If `apply` is `false`, the commands are unapplied.
     * Triggers a re-render after each `updateChunkSize`-sized group of commands
     * has been applied.
     */
    async asyncApplyOrUnapplyCommands(commands, apply, updateChunkSize) {
        console.assert(updateChunkSize > 0);
        this.display.setDraftMode(true);
        for (let i = 0; i < commands.length; i += updateChunkSize) {
            this.showLoadingWarning(i / commands.length);
            for (let j = i; j < commands.length && j < i + updateChunkSize; j++) {
                const cmd = commands[j];
                if (apply) {
                    cmd.apply(this);
                }
                else {
                    cmd.unapply(this);
                }
            }
            // Re-render to show progress, but only if we're not done.
            if (i + updateChunkSize < commands.length) {
                await new Promise((resolve) => {
                    this.rerender();
                    requestAnimationFrame(resolve);
                });
            }
        }
        this.display.setDraftMode(false);
        this.hideLoadingWarning();
    }
    /** @see {@link asyncApplyOrUnapplyCommands } */
    asyncApplyCommands(commands, chunkSize) {
        return this.asyncApplyOrUnapplyCommands(commands, true, chunkSize);
    }
    /**
     * @see {@link asyncApplyOrUnapplyCommands}
     *
     * If `unapplyInReverseOrder`, commands are reversed before unapplying.
     */
    asyncUnapplyCommands(commands, chunkSize, unapplyInReverseOrder = false) {
        if (unapplyInReverseOrder) {
            commands = [...commands]; // copy
            commands.reverse();
        }
        return this.asyncApplyOrUnapplyCommands(commands, false, chunkSize);
    }
    /**
     * Schedule a re-render for some time in the near future. Does not schedule an additional
     * re-render if a re-render is already queued.
     *
     * @returns a promise that resolves when re-rendering has completed.
     */
    queueRerender() {
        if (!this.rerenderQueued) {
            this.rerenderQueued = true;
            requestAnimationFrame(() => {
                // If .rerender was called manually, we might not need to
                // re-render.
                if (this.rerenderQueued) {
                    this.rerender();
                    this.rerenderQueued = false;
                }
            });
        }
        return new Promise((resolve) => {
            this.nextRerenderListeners.push(() => resolve());
        });
    }
    // @internal
    isRerenderQueued() {
        return this.rerenderQueued;
    }
    /**
     * Re-renders the entire image.
     *
     * @see {@link Editor.queueRerender}
     */
    rerender(showImageBounds = true) {
        this.display.startRerender();
        // Don't render if the display has zero size.
        if (this.display.width === 0 || this.display.height === 0) {
            return;
        }
        const renderer = this.display.getDryInkRenderer();
        this.image.renderWithCache(renderer, this.display.getCache(), this.viewport);
        // Draw a rectangle around the region that will be visible on save
        if (showImageBounds && !this.image.getAutoresizeEnabled()) {
            const exportRectFill = { fill: math_1.Color4.fromHex('#44444455') };
            const exportRectStrokeWidth = 5 * this.viewport.getSizeOfPixelOnCanvas();
            renderer.drawRect(this.getImportExportRect(), exportRectStrokeWidth, exportRectFill);
        }
        this.rerenderQueued = false;
        this.nextRerenderListeners.forEach((listener) => listener());
        this.nextRerenderListeners = [];
    }
    /**
     * Draws the given path onto the wet ink renderer. The given path will
     * be displayed on top of the main image.
     *
     * @see {@link Display.getWetInkRenderer} {@link Display.flatten}
     */
    drawWetInk(...path) {
        for (const part of path) {
            this.display.getWetInkRenderer().drawPath(part);
        }
    }
    /**
     * Clears the wet ink display.
     *
     * The wet ink display can be used by the currently active tool to display a preview
     * of an in-progress action.
     *
     * @see {@link Display.getWetInkRenderer}
     */
    clearWetInk() {
        this.display.getWetInkRenderer().clear();
    }
    /**
     * Focuses the region used for text input/key commands.
     */
    focus() {
        this.renderingRegion.focus();
    }
    /**
     * Creates an element that will be positioned on top of the dry/wet ink
     * renderers.
     *
     * So as not to change the position of other overlays, `overlay` should either
     * be styled to have 0 height or have `position: absolute`.
     *
     * This is useful for displaying content on top of the rendered content
     * (e.g. a selection box).
     */
    createHTMLOverlay(overlay) {
        // TODO(v2): Fix conflict with toolbars that have been added to the editor.
        overlay.classList.add('overlay', 'js-draw-editor-overlay');
        this.container.appendChild(overlay);
        return {
            remove: () => overlay.remove(),
        };
    }
    /**
     * Anchors the given `element` to the canvas with a given position/transformation in canvas space.
     */
    anchorElementToCanvas(element, canvasTransform) {
        if (canvasTransform instanceof math_1.Mat33) {
            canvasTransform = ReactiveValue_1.default.fromImmutable(canvasTransform);
        }
        // The element hierarchy looks like this:
        //   overlay > contentWrapper > content
        //
        // Both contentWrapper and overlay are present to:
        // 1. overlay: Positions the content at the top left of the viewport. The overlay
        //    has `height: 0` to allow other overlays to also be aligned with the viewport's
        //    top left.
        // 2. contentWrapper: Has the same width/height as the editor's visible region and
        //    has `overflow: hidden`. This prevents the anchored element from being visible
        //    when not in the visible region of the canvas.
        const overlay = document.createElement('div');
        overlay.classList.add('anchored-element-overlay');
        const contentWrapper = document.createElement('div');
        contentWrapper.classList.add('content-wrapper');
        element.classList.add('content');
        // Updates CSS variables that specify the position/rotation/scale of the content.
        const updateElementPositioning = () => {
            const transform = canvasTransform.get();
            const canvasRotation = transform.transformVec3(math_1.Vec2.unitX).angle();
            const screenRotation = canvasRotation + this.viewport.getRotationAngle();
            const screenTransform = this.viewport.canvasToScreenTransform.rightMul(canvasTransform.get());
            overlay.style.setProperty('--full-transform', screenTransform.toCSSMatrix());
            const translation = screenTransform.transformVec2(math_1.Vec2.zero);
            overlay.style.setProperty('--position-x', `${translation.x}px`);
            overlay.style.setProperty('--position-y', `${translation.y}px`);
            overlay.style.setProperty('--rotation', `${(screenRotation * 180) / Math.PI}deg`);
            overlay.style.setProperty('--scale', `${screenTransform.getScaleFactor()}`);
        };
        updateElementPositioning();
        // The anchored element needs to be updated both when the user moves the canvas
        // and when the anchored element's transform changes.
        const updateListener = canvasTransform.onUpdate(updateElementPositioning);
        const viewportListener = this.notifier.on(types_1.EditorEventType.ViewportChanged, updateElementPositioning);
        contentWrapper.appendChild(element);
        overlay.appendChild(contentWrapper);
        overlay.classList.add('overlay', 'js-draw-editor-overlay');
        this.renderingRegion.insertAdjacentElement('afterend', overlay);
        return {
            remove: () => {
                overlay.remove();
                updateListener.remove();
                viewportListener.remove();
            },
        };
    }
    /**
     * Creates a CSS stylesheet with `content` and applies it to the document
     * (and thus, to this editor).
     */
    addStyleSheet(content) {
        const styleSheet = document.createElement('style');
        styleSheet.innerText = content;
        this.container.appendChild(styleSheet);
        return styleSheet;
    }
    /**
     * Dispatch a keyboard event to the currently selected tool.
     * Intended for unit testing.
     *
     * If `shiftKey` is undefined, it is guessed from `key`.
     *
     * At present, the **key code** dispatched is guessed from the given key and,
     * while this works for ASCII alphanumeric characters, this does not work for
     * most non-alphanumeric keys.
     *
     * Because guessing the key code from `key` is problematic, **only use this for testing**.
     */
    sendKeyboardEvent(eventType, key, ctrlKey = false, altKey = false, shiftKey = undefined) {
        shiftKey ??= key.toUpperCase() === key && key.toLowerCase() !== key;
        this.toolController.dispatchInputEvent({
            kind: eventType,
            key,
            code: (0, guessKeyCodeFromKey_1.default)(key),
            ctrlKey,
            altKey,
            shiftKey,
        });
    }
    /**
     * Dispatch a pen event to the currently selected tool.
     * Intended primarially for unit tests.
     *
     * @deprecated
     * @see {@link sendPenEvent} {@link sendTouchEvent}
     */
    sendPenEvent(eventType, point, 
    // @deprecated
    allPointers) {
        (0, sendPenEvent_1.default)(this, eventType, point, allPointers);
    }
    /**
     * Adds all components in `components` such that they are in the center of the screen.
     * This is a convenience method that creates **and applies** a single command.
     *
     * If `selectComponents` is true (the default), the components are selected.
     *
     * `actionDescription`, if given, should be a screenreader-friendly description of the
     * reason components were added (e.g. "pasted").
     */
    async addAndCenterComponents(components, selectComponents = true, actionDescription) {
        let bbox = null;
        for (const component of components) {
            if (bbox) {
                bbox = bbox.union(component.getBBox());
            }
            else {
                bbox = component.getBBox();
            }
        }
        if (!bbox) {
            return;
        }
        // Find a transform that scales/moves bbox onto the screen.
        const visibleRect = this.viewport.visibleRect;
        const scaleRatioX = visibleRect.width / bbox.width;
        const scaleRatioY = visibleRect.height / bbox.height;
        let scaleRatio = scaleRatioX;
        if (bbox.width * scaleRatio > visibleRect.width ||
            bbox.height * scaleRatio > visibleRect.height) {
            scaleRatio = scaleRatioY;
        }
        scaleRatio *= 2 / 3;
        scaleRatio = Viewport_1.default.roundScaleRatio(scaleRatio);
        const transfm = math_1.Mat33.translation(visibleRect.center.minus(bbox.center)).rightMul(math_1.Mat33.scaling2D(scaleRatio, bbox.center));
        const commands = [];
        for (const component of components) {
            // To allow deserialization, we need to add first, then transform.
            commands.push(EditorImage_1.default.addComponent(component));
            commands.push(component.transformBy(transfm));
        }
        const applyChunkSize = 100;
        await this.dispatch((0, uniteCommands_1.default)(commands, { applyChunkSize, description: actionDescription }), true);
        if (selectComponents) {
            for (const selectionTool of this.toolController.getMatchingTools(SelectionTool_1.default)) {
                selectionTool.setEnabled(true);
                selectionTool.setSelection(components);
            }
        }
    }
    /**
     * Get a data URL (e.g. as produced by `HTMLCanvasElement::toDataURL`).
     * If `format` is not `image/png`, a PNG image URL may still be returned (as in the
     * case of `HTMLCanvasElement::toDataURL`).
     *
     * The export resolution is the same as the size of the drawing canvas, unless `outputSize`
     * is given.
     *
     * **Example**:
     * [[include:doc-pages/inline-examples/adding-an-image-and-data-urls.md]]
     */
    toDataURL(format = 'image/png', outputSize) {
        const { element: canvas, renderer } = CanvasRenderer_1.default.fromViewport(this.image.getImportExportViewport(), { canvasSize: outputSize });
        this.image.renderAll(renderer);
        const dataURL = canvas.toDataURL(format);
        return dataURL;
    }
    /**
     * Converts the editor's content into an SVG image.
     *
     * If the output SVG has width or height less than `options.minDimension`, its size
     * will be increased.
     *
     * @see
     * {@link SVGRenderer}
     */
    toSVG(options) {
        return (0, editorImageToSVG_1.editorImageToSVGSync)(this.image, options ?? {});
    }
    /**
     * Converts the editor's content into an SVG image in an asynchronous,
     * but **potentially lossy** way.
     *
     * **Warning**: If the image is being edited during an async rendering, edited components
     * may not be rendered.
     *
     * Like {@link toSVG}, but can be configured to briefly pause after processing every
     * `pauseAfterCount` items. This can prevent the editor from becoming unresponsive
     * when saving very large images.
     */
    async toSVGAsync(options = {}) {
        const pauseAfterCount = options.pauseAfterCount ?? 100;
        return await (0, editorImageToSVG_1.editorImageToSVGAsync)(this.image, async (_component, processedCount, totalComponents) => {
            if (options.onProgress) {
                const shouldContinue = await options.onProgress(processedCount, totalComponents);
                if (shouldContinue === false) {
                    return false;
                }
            }
            if (processedCount % pauseAfterCount === 0) {
                await (0, untilNextAnimationFrame_1.default)();
            }
            return true;
        }, {
            minDimension: options.minDimension,
        });
    }
    /**
     * Load editor data from an `ImageLoader` (e.g. an {@link SVGLoader}).
     *
     * @see loadFromSVG
     */
    async loadFrom(loader) {
        this.showLoadingWarning(0);
        this.display.setDraftMode(true);
        const originalBackgrounds = this.image.getBackgroundComponents();
        const eraseBackgroundCommand = new Erase_1.default(originalBackgrounds);
        await loader.start(async (component) => {
            await this.dispatchNoAnnounce(EditorImage_1.default.addComponent(component));
        }, (countProcessed, totalToProcess) => {
            if (countProcessed % 500 === 0) {
                this.showLoadingWarning(countProcessed / totalToProcess);
                this.rerender();
                return (0, untilNextAnimationFrame_1.default)();
            }
            return null;
        }, (importExportRect, options) => {
            this.dispatchNoAnnounce(this.setImportExportRect(importExportRect), false);
            this.dispatchNoAnnounce(this.viewport.zoomTo(importExportRect), false);
            if (options) {
                this.dispatchNoAnnounce(this.image.setAutoresizeEnabled(options.autoresize), false);
            }
        });
        // Ensure that we don't have multiple overlapping BackgroundComponents. Remove
        // old BackgroundComponents.
        // Overlapping BackgroundComponents may cause changing the background color to
        // not work properly.
        if (this.image.getBackgroundComponents().length !== originalBackgrounds.length) {
            await this.dispatchNoAnnounce(eraseBackgroundCommand);
        }
        this.hideLoadingWarning();
        this.display.setDraftMode(false);
        this.queueRerender();
    }
    getTopmostBackgroundComponent() {
        let background = null;
        // Find a background component, if one exists.
        // Use the last (topmost) background component if there are multiple.
        for (const component of this.image.getBackgroundComponents()) {
            if (component instanceof BackgroundComponent_1.default) {
                background = component;
            }
        }
        return background;
    }
    /**
     * This is a convenience method for adding or updating the {@link BackgroundComponent}
     * and {@link EditorImage.setAutoresizeEnabled} for the current image.
     *
     * If there are multiple {@link BackgroundComponent}s in the image, this only modifies
     * the topmost such element.
     *
     * **Example**:
     * ```ts,runnable
     * import { Editor, Color4, BackgroundComponentBackgroundType } from 'js-draw';
     * const editor = new Editor(document.body);
     * editor.dispatch(editor.setBackgroundStyle({
     *     color: Color4.orange,
     *     type: BackgroundComponentBackgroundType.Grid,
     *     autoresize: true,
     * }));
     * ```
     *
     * To change the background size, see {@link EditorImage.setImportExportRect}.
     */
    setBackgroundStyle(style) {
        const originalBackground = this.getTopmostBackgroundComponent();
        const commands = [];
        if (originalBackground) {
            commands.push(new Erase_1.default([originalBackground]));
        }
        const originalType = originalBackground?.getBackgroundType?.() ?? BackgroundComponent_1.BackgroundType.None;
        const originalColor = originalBackground?.getStyle?.().color ?? math_1.Color4.transparent;
        const originalFillsScreen = this.image.getAutoresizeEnabled();
        const defaultType = style.color && originalType === BackgroundComponent_1.BackgroundType.None
            ? BackgroundComponent_1.BackgroundType.SolidColor
            : originalType;
        const backgroundType = style.type ?? defaultType;
        const backgroundColor = style.color ?? originalColor;
        const fillsScreen = style.autoresize ?? originalFillsScreen;
        if (backgroundType !== BackgroundComponent_1.BackgroundType.None) {
            const newBackground = new BackgroundComponent_1.default(backgroundType, backgroundColor);
            commands.push(EditorImage_1.default.addComponent(newBackground));
        }
        if (fillsScreen !== originalFillsScreen) {
            commands.push(this.image.setAutoresizeEnabled(fillsScreen));
            // Avoid 0x0 backgrounds
            if (!fillsScreen && this.image.getImportExportRect().maxDimension === 0) {
                commands.push(this.image.setImportExportRect(this.image.getImportExportRect().resizedTo(math_1.Vec2.of(500, 500))));
            }
        }
        return (0, uniteCommands_1.default)(commands);
    }
    /**
     * Set the background color of the image.
     *
     * This is a convenience method for adding or updating the {@link BackgroundComponent}
     * for the current image.
     *
     * @see {@link setBackgroundStyle}
     */
    setBackgroundColor(color) {
        let background = this.getTopmostBackgroundComponent();
        if (!background) {
            const backgroundType = color.eq(math_1.Color4.transparent)
                ? BackgroundComponent_1.BackgroundType.None
                : BackgroundComponent_1.BackgroundType.SolidColor;
            background = new BackgroundComponent_1.default(backgroundType, color);
            return this.image.addComponent(background);
        }
        else {
            return background.updateStyle({ color });
        }
    }
    /**
     * @returns the average of the colors of all background components. Use this to get the current background
     * color.
     */
    estimateBackgroundColor() {
        const backgroundColors = [];
        for (const component of this.image.getBackgroundComponents()) {
            if (component instanceof BackgroundComponent_1.default) {
                backgroundColors.push(component.getStyle().color ?? math_1.Color4.transparent);
            }
        }
        return math_1.Color4.average(backgroundColors);
    }
    // Returns the size of the visible region of the output SVG
    getImportExportRect() {
        return this.image.getImportExportViewport().visibleRect;
    }
    // Resize the output SVG to match `imageRect`.
    setImportExportRect(imageRect) {
        return this.image.setImportExportRect(imageRect);
    }
    /**
     * Alias for `loadFrom(SVGLoader.fromString)`.
     *
     * @example
     * ```ts,runnable
     * import {Editor} from 'js-draw';
     * const editor = new Editor(document.body);
     *
     * ---visible---
     * await editor.loadFromSVG(`
     *   <svg viewBox="5 23 52 30" width="52" height="16" version="1.1" baseProfile="full" xmlns="http://www.w3.org/2000/svg">
     *     <text style="
     *       transform: matrix(0.181846, 0.1, 0, 0.181846, 11.4, 33.2);
     *       font-family: serif;
     *       font-size: 32px;
     *       fill: rgb(100, 140, 61);
     *     ">An SVG image!</text>
     *   </svg>
     * `);
     * ```
     */
    async loadFromSVG(svgData, sanitize = false) {
        const loader = SVGLoader_1.default.fromString(svgData, {
            sanitize,
            plugins: this.getCurrentSettings().svg?.loaderPlugins,
        });
        await this.loadFrom(loader);
    }
    /**
     * Shows an information dialog with legal notices.
     */
    showAboutDialog() {
        const iconLicenseText = this.icons.licenseInfo();
        const notices = [];
        if (this.settings.appInfo) {
            const descriptionLines = [];
            if (this.settings.appInfo.version) {
                descriptionLines.push(`v${this.settings.appInfo.version}`, '');
            }
            if (this.settings.appInfo.description) {
                descriptionLines.push(this.settings.appInfo.description + '\n');
            }
            else {
                descriptionLines.push(`js-draw v${version_1.default.number}`);
            }
            notices.push({
                heading: `${this.settings.appInfo.name}`,
                text: descriptionLines.join('\n'),
            });
        }
        else {
            notices.push({
                heading: 'js-draw',
                text: `v${version_1.default.number}`,
            });
        }
        const screenSize = this.viewport.getScreenRectSize();
        notices.push({
            heading: this.localization.developerInformation,
            text: [
                'Image debug information (from when this dialog was opened):',
                `    ${this.viewport.getScaleFactor()}x zoom, ${(180 / Math.PI) * this.viewport.getRotationAngle()}° rotation`,
                `    ${this.image.estimateNumElements()} components`,
                `    auto-resize: ${this.image.getAutoresizeEnabled() ? 'enabled' : 'disabled'}`,
                `    image size: ${this.getImportExportRect().w}x${this.getImportExportRect().h}`,
                `    screen size: ${screenSize.x}x${screenSize.y}`,
                `    device pixel ratio: ${this.display.getDevicePixelRatio()}`,
                '    cache:',
                `        ${this.display
                    .getCache()
                    .getDebugInfo()
                    // Indent
                    .replace(/([\n])/g, '\n        ')}`,
            ].join('\n'),
            minimized: true,
        });
        notices.push({
            heading: this.localization.softwareLibraries,
            text: [
                `This image editor is powered by js-draw v${version_1.default.number}.`,
                '',
                'At runtime, js-draw uses',
                ' - The Coloris color picker: https://github.com/mdbassit/Coloris',
                ' - The bezier.js Bézier curve library: https://github.com/Pomax/bezierjs',
                '',
                'Both are licensed under the MIT license:',
                '',
                '',
                '== Coloris ==',
                (0, mitLicenseAttribution_1.default)('2021 Mohammed Bassit'),
                '',
                '',
                '== Bezier.js ==',
                (0, mitLicenseAttribution_1.default)('2023 Mike "Pomax" Kamermans'),
                '',
                '',
                '== js-draw ==',
                (0, mitLicenseAttribution_1.default)('2023-2025 Henry Heino'),
                '',
            ].join('\n'),
            minimized: true,
        });
        if (iconLicenseText) {
            notices.push({
                heading: 'Icon Pack',
                text: iconLicenseText,
                minimized: true,
            });
        }
        notices.push(...this.settings.notices);
        this.closeAboutDialog?.();
        this.closeAboutDialog = (0, makeAboutDialog_1.default)(this, notices).close;
    }
    /**
     * Removes and **destroys** the editor. The editor cannot be added to a parent
     * again after calling this method.
     */
    remove() {
        this.container.remove();
        // TODO: Is additional cleanup necessary here?
        this.toolController.onEditorDestroyed();
    }
}
exports.Editor = Editor;
exports.default = Editor;