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