js-draw
Version: 
Draw pictures using a pen, touchscreen, or mouse! JS-draw is a drawing library for JavaScript and TypeScript.
282 lines (281 loc) • 11.1 kB
JavaScript
import  { EditorEventType }  from '../types.mjs';
import { Color4 } from '@js-draw/math';
import  PanZoom, { PanZoomMode }  from './PanZoom.mjs';
import  Pen  from './Pen.mjs';
import  ToolEnabledGroup  from './ToolEnabledGroup.mjs';
import  Eraser  from './Eraser.mjs';
import  SelectionTool  from './SelectionTool/SelectionTool.mjs';
import  UndoRedoShortcut  from './UndoRedoShortcut.mjs';
import  TextTool  from './TextTool.mjs';
import  PipetteTool  from './PipetteTool.mjs';
import  ToolSwitcherShortcut  from './ToolSwitcherShortcut.mjs';
import  PasteHandler  from './PasteHandler.mjs';
import  ToolbarShortcutHandler  from './ToolbarShortcutHandler.mjs';
import  { makePressureSensitiveFreehandLineBuilder }  from '../components/builders/PressureSensitiveFreehandLineBuilder.mjs';
import  FindTool  from './FindTool.mjs';
import  SelectAllShortcutHandler  from './SelectionTool/SelectAllShortcutHandler.mjs';
import  SoundUITool  from './SoundUITool.mjs';
import  { InputEvtType }  from '../inputEvents.mjs';
import  InputPipeline  from './InputFilter/InputPipeline.mjs';
import  InputStabilizer  from './InputFilter/InputStabilizer.mjs';
import  ScrollbarTool  from './ScrollbarTool.mjs';
export default class ToolController {
    /** @internal */
    constructor(editor, localization) {
        this.activeTool = null;
        this.isEditorReadOnly = editor.isReadOnlyReactiveValue();
        this.inputPipeline = new InputPipeline();
        this.inputPipeline.setEmitListener((event) => this.onEventInternal(event));
        const primaryToolGroup = new ToolEnabledGroup();
        this.primaryToolGroup = primaryToolGroup;
        const panZoomTool = new PanZoom(editor, PanZoomMode.TwoFingerTouchGestures | PanZoomMode.RightClickDrags, localization.touchPanTool);
        const keyboardPanZoomTool = new PanZoom(editor, PanZoomMode.Keyboard, localization.keyboardPanZoom);
        const primaryPenTool = new Pen(editor, localization.penTool(1), {
            color: Color4.purple,
            thickness: 8,
        });
        const secondaryPenTool = new Pen(editor, localization.penTool(2), {
            color: Color4.clay,
            thickness: 4,
        });
        // Stabilize the secondary pen tool.
        secondaryPenTool.setInputMapper(new InputStabilizer(editor.viewport));
        const eraser = new Eraser(editor, localization.eraserTool);
        const primaryTools = [
            // Three pens
            primaryPenTool,
            secondaryPenTool,
            // Highlighter-like pen with width=40
            new Pen(editor, localization.penTool(3), {
                color: Color4.ofRGBA(1, 1, 0, 0.5),
                thickness: 40,
                factory: makePressureSensitiveFreehandLineBuilder,
            }),
            eraser,
            new SelectionTool(editor, localization.selectionTool),
            new TextTool(editor, localization.textTool, localization),
            new PanZoom(editor, PanZoomMode.SinglePointerGestures, localization.anyDevicePanning),
        ];
        // Accessibility tools
        const soundExplorer = new SoundUITool(editor, localization.soundExplorer);
        soundExplorer.setEnabled(false);
        this.tools = [
            new ScrollbarTool(editor),
            new PipetteTool(editor, localization.pipetteTool),
            soundExplorer,
            panZoomTool,
            ...primaryTools,
            keyboardPanZoomTool,
            new UndoRedoShortcut(editor),
            new ToolbarShortcutHandler(editor),
            new ToolSwitcherShortcut(editor),
            eraser.makeEraserSwitcherTool(),
            new FindTool(editor),
            new PasteHandler(editor),
            new SelectAllShortcutHandler(editor),
        ];
        primaryTools.forEach((tool) => tool.setToolGroup(primaryToolGroup));
        panZoomTool.setEnabled(true);
        primaryPenTool.setEnabled(true);
        editor.notifier.on(EditorEventType.ToolEnabled, (event) => {
            if (event.kind === EditorEventType.ToolEnabled) {
                editor.announceForAccessibility(localization.toolEnabledAnnouncement(event.tool.description));
            }
        });
        editor.notifier.on(EditorEventType.ToolDisabled, (event) => {
            if (event.kind === EditorEventType.ToolDisabled) {
                editor.announceForAccessibility(localization.toolDisabledAnnouncement(event.tool.description));
            }
        });
        this.activeTool = null;
    }
    /**
     * Replaces the current set of tools with `tools`. This should only be done before
     * the creation of the app's toolbar (if using `AbstractToolbar`).
     *
     * If no `primaryToolGroup` is given, an empty one will be created.
     */
    setTools(tools, primaryToolGroup) {
        this.tools = tools;
        this.primaryToolGroup = primaryToolGroup ?? new ToolEnabledGroup();
    }
    /**
     * Add a tool that acts like one of the primary tools (only one primary tool can be enabled at a time).
     *
     * If the tool is already added to this, the tool is converted to a primary tool.
     *
     * This should be called before creating the app's toolbar.
     */
    addPrimaryTool(tool) {
        tool.setToolGroup(this.primaryToolGroup);
        if (tool.isEnabled()) {
            this.primaryToolGroup.notifyEnabled(tool);
        }
        if (!this.tools.includes(tool)) {
            this.addTool(tool);
        }
    }
    getPrimaryTools() {
        return this.tools.filter((tool) => {
            return tool.getToolGroup() === this.primaryToolGroup;
        });
    }
    /**
     * Add a tool to the end of this' tool list (the added tool receives events after tools already added to this).
     * This should be called before creating the app's toolbar.
     *
     * If `options.addToFront`, the tool is added to the beginning of this' tool list.
     *
     * Does nothing if the tool is already present.
     */
    addTool(tool, options) {
        // Only add if not already present.
        if (!this.tools.includes(tool)) {
            if (options?.addToFront) {
                this.tools.splice(0, 0, tool);
            }
            else {
                this.tools.push(tool);
            }
        }
    }
    /**
     * Removes **and destroys** all tools in `tools` from this.
     */
    removeAndDestroyTools(tools) {
        const newTools = [];
        for (const tool of this.tools) {
            if (tools.includes(tool)) {
                if (this.activeTool === tool) {
                    this.activeTool = null;
                }
                tool.onDestroy();
            }
            else {
                newTools.push(tool);
            }
        }
        this.tools = newTools;
    }
    insertTools(insertNear, toolsToInsert, mode) {
        this.tools = this.tools.filter((tool) => !toolsToInsert.includes(tool));
        const newTools = [];
        for (const tool of this.tools) {
            if (mode === 'after') {
                newTools.push(tool);
            }
            if (tool === insertNear) {
                newTools.push(...toolsToInsert);
            }
            if (mode === 'before') {
                newTools.push(tool);
            }
        }
        this.tools = newTools;
    }
    /**
     * Removes a tool from this' tool list and replaces it with `replaceWith`.
     *
     * If any of `toolsToInsert` have already been added to this, the tools are
     * moved.
     *
     * This should be called before creating the editor's toolbar.
     */
    insertToolsAfter(insertAfter, toolsToInsert) {
        this.insertTools(insertAfter, toolsToInsert, 'after');
    }
    /** @see {@link insertToolsAfter} */
    insertToolsBefore(insertBefore, toolsToInsert) {
        this.insertTools(insertBefore, toolsToInsert, 'before');
    }
    // @internal use `dispatchEvent` rather than calling `onEvent` directly.
    onEventInternal(event) {
        const isEditorReadOnly = this.isEditorReadOnly.get();
        const canToolReceiveInput = (tool) => {
            return tool.isEnabled() && (!isEditorReadOnly || tool.canReceiveInputInReadOnlyEditor());
        };
        let handled = false;
        if (event.kind === InputEvtType.PointerDownEvt) {
            let canOnlySendToActiveTool = false;
            if (this.activeTool && !this.activeTool.eventCanBeDeliveredToNonActiveTool(event)) {
                canOnlySendToActiveTool = true;
            }
            for (const tool of this.tools) {
                if (canOnlySendToActiveTool && tool !== this.activeTool) {
                    continue;
                }
                if (canToolReceiveInput(tool) && tool.onEvent(event)) {
                    if (this.activeTool !== tool) {
                        this.activeTool?.onEvent({ kind: InputEvtType.GestureCancelEvt });
                    }
                    this.activeTool = tool;
                    handled = true;
                    break;
                }
            }
        }
        else if (event.kind === InputEvtType.PointerUpEvt) {
            const upResult = this.activeTool?.onEvent(event);
            const continueHandlingEvents = upResult && event.allPointers.length > 1;
            // Should the active tool continue handling events (without an additional pointer down?)
            if (!continueHandlingEvents) {
                // No -- Remove the current tool
                this.activeTool = null;
            }
            handled = true;
        }
        else if (event.kind === InputEvtType.PointerMoveEvt) {
            if (this.activeTool !== null) {
                this.activeTool.onEvent(event);
                handled = true;
            }
        }
        else if (event.kind === InputEvtType.GestureCancelEvt) {
            if (this.activeTool !== null) {
                this.activeTool.onEvent(event);
                this.activeTool = null;
            }
        }
        else {
            for (const tool of this.tools) {
                if (!canToolReceiveInput(tool)) {
                    continue;
                }
                handled = tool.onEvent(event);
                if (handled) {
                    break;
                }
            }
        }
        return handled;
    }
    /** Alias for {@link dispatchInputEvent}. */
    onEvent(event) {
        return this.dispatchInputEvent(event);
    }
    // Returns true if the event was handled.
    dispatchInputEvent(event) {
        // Feed the event through the input pipeline
        return this.inputPipeline.onEvent(event);
    }
    /**
     * Adds a new `InputMapper` to this' input pipeline.
     *
     * A `mapper` is really a relation that maps each event to no, one,
     * or many other events.
     *
     * @see {@link InputMapper}.
     */
    addInputMapper(mapper) {
        this.inputPipeline.addToTail(mapper);
    }
    getMatchingTools(type) {
        return this.tools.filter((tool) => tool instanceof type);
    }
    // @internal
    onEditorDestroyed() {
        for (const tool of this.tools) {
            tool.onDestroy();
        }
    }
}