UNPKG

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
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(); } } }