UNPKG

js-draw

Version:

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

470 lines (469 loc) 20 kB
var __classPrivateFieldGet = (this && this.__classPrivateFieldGet) || function (receiver, state, kind, f) { if (kind === "a" && !f) throw new TypeError("Private accessor was defined without a getter"); if (typeof state === "function" ? receiver !== state || !f : !state.has(receiver)) throw new TypeError("Cannot read private member from an object whose class did not declare it"); return kind === "m" ? f : kind === "a" ? f.call(receiver) : f ? f.value : state.get(receiver); }; var __classPrivateFieldSet = (this && this.__classPrivateFieldSet) || function (receiver, state, value, kind, f) { if (kind === "m") throw new TypeError("Private method is not writable"); if (kind === "a" && !f) throw new TypeError("Private accessor was defined without a setter"); if (typeof state === "function" ? receiver !== state || !f : !state.has(receiver)) throw new TypeError("Cannot write private member to an object whose class did not declare it"); return (kind === "a" ? f.call(receiver, value) : f ? f.value = value : state.set(receiver, value)), value; }; var _AbstractToolbar_listeners, _AbstractToolbar_widgetsById, _AbstractToolbar_widgetList, _AbstractToolbar_updateColoris; import { EditorEventType } from '../types.mjs'; import { coloris, close as closeColoris, init as colorisInit } from '@melloware/coloris'; import SelectionTool from '../tools/SelectionTool/SelectionTool.mjs'; import PanZoomTool from '../tools/PanZoom.mjs'; import TextTool from '../tools/TextTool.mjs'; import EraserTool from '../tools/Eraser.mjs'; import PenTool from '../tools/Pen.mjs'; import PenToolWidget from './widgets/PenToolWidget.mjs'; import EraserWidget from './widgets/EraserToolWidget.mjs'; import SelectionToolWidget from './widgets/SelectionToolWidget.mjs'; import TextToolWidget from './widgets/TextToolWidget.mjs'; import HandToolWidget from './widgets/HandToolWidget.mjs'; import { ToolbarWidgetTag } from './widgets/BaseWidget.mjs'; import ActionButtonWidget from './widgets/ActionButtonWidget.mjs'; import InsertImageWidget from './widgets/InsertImageWidget/InsertImageWidget.mjs'; import DocumentPropertiesWidget from './widgets/DocumentPropertiesWidget.mjs'; import { Color4 } from '@js-draw/math'; import { toolbarCSSPrefix } from './constants.mjs'; import SaveActionWidget from './widgets/SaveActionWidget.mjs'; import ExitActionWidget from './widgets/ExitActionWidget.mjs'; import { assertIsObject, assertTruthy } from '../util/assertions.mjs'; /** * Abstract base class for js-draw editor toolbars. * * See {@link Editor.addToolbar}, {@link makeDropdownToolbar}, and {@link makeEdgeToolbar}. */ class AbstractToolbar { /** @internal */ constructor(editor, localizationTable) { this.editor = editor; _AbstractToolbar_listeners.set(this, []); _AbstractToolbar_widgetsById.set(this, {}); _AbstractToolbar_widgetList.set(this, []); _AbstractToolbar_updateColoris.set(this, null); this.closeColorPickerOverlay = null; this.localizationTable = localizationTable ?? editor.localization; if (!AbstractToolbar.colorisStarted) { colorisInit(); AbstractToolbar.colorisStarted = true; } this.setupColorPickers(); } setupCloseColorPickerOverlay() { if (this.closeColorPickerOverlay) return; this.closeColorPickerOverlay = document.createElement('div'); this.closeColorPickerOverlay.className = `${toolbarCSSPrefix}closeColorPickerOverlay`; this.editor.createHTMLOverlay(this.closeColorPickerOverlay); // Hide the color picker when attempting to draw on the overlay. __classPrivateFieldGet(this, _AbstractToolbar_listeners, "f").push(this.editor.handlePointerEventsExceptClicksFrom(this.closeColorPickerOverlay, (eventName) => { if (eventName === 'pointerdown') { closeColoris(); } // Transfer focus to the editor to allow keyboard events to be handled. if (eventName === 'pointerup') { this.editor.focus(); } // Send the event to the editor return true; })); } // @internal setupColorPickers() { // Much of the setup only needs to be done once. if (__classPrivateFieldGet(this, _AbstractToolbar_updateColoris, "f")) { __classPrivateFieldGet(this, _AbstractToolbar_updateColoris, "f").call(this); return; } this.setupCloseColorPickerOverlay(); const maxSwatchLen = 12; const swatches = [ Color4.red.toHexString(), Color4.purple.toHexString(), Color4.blue.toHexString(), Color4.clay.toHexString(), Color4.black.toHexString(), Color4.white.toHexString(), ]; const presetColorEnd = swatches.length; // Keeps track of whether a Coloris initialization is scheduled. let colorisInitScheduled = false; // (Re)init Coloris -- update the swatches list. const initColoris = () => { try { coloris({ el: '.coloris_input', format: 'hex', selectInput: false, focusInput: false, themeMode: 'auto', swatches, }); } catch (err) { console.warn('Failed to initialize Coloris. Error: ', err); // Try again --- a known issue is that Coloris fails to load if the document // isn't ready. if (!colorisInitScheduled) { colorisInitScheduled = true; // Wait to initialize after the document has loaded document.addEventListener('load', () => { initColoris(); }, { once: true }); } } }; initColoris(); __classPrivateFieldSet(this, _AbstractToolbar_updateColoris, initColoris, "f"); const addColorToSwatch = (newColor) => { let alreadyPresent = false; for (const color of swatches) { if (color === newColor) { alreadyPresent = true; } } if (!alreadyPresent) { swatches.push(newColor); if (swatches.length > maxSwatchLen) { swatches.splice(presetColorEnd, 1); } initColoris(); } }; __classPrivateFieldGet(this, _AbstractToolbar_listeners, "f").push(this.editor.notifier.on(EditorEventType.ColorPickerToggled, (event) => { if (event.kind !== EditorEventType.ColorPickerToggled) { return; } // Show/hide the overlay. Making the overlay visible gives users a surface to click // on that shows/hides the color picker. if (this.closeColorPickerOverlay) { this.closeColorPickerOverlay.style.display = event.open ? 'block' : 'none'; } })); // Add newly-selected colors to the swatch. __classPrivateFieldGet(this, _AbstractToolbar_listeners, "f").push(this.editor.notifier.on(EditorEventType.ColorPickerColorSelected, (event) => { if (event.kind === EditorEventType.ColorPickerColorSelected) { addColorToSwatch(event.color.toHexString()); } })); } closeColorPickers() { closeColoris?.(); } getWidgetUniqueId(widget) { return widget.getUniqueIdIn(__classPrivateFieldGet(this, _AbstractToolbar_widgetsById, "f")); } getWidgetFromId(id) { return __classPrivateFieldGet(this, _AbstractToolbar_widgetsById, "f")[id]; } /** Do **not** modify the return value. */ getAllWidgets() { return __classPrivateFieldGet(this, _AbstractToolbar_widgetList, "f"); } /** * Adds an `ActionButtonWidget` or `BaseToolWidget`. The widget should not have already have a parent * (i.e. its `addTo` method should not have been called). * * @example * ```ts * const toolbar = editor.addToolbar(); * const insertImageWidget = new InsertImageWidget(editor); * toolbar.addWidget(insertImageWidget); * ``` */ addWidget(widget) { // Prevent name collisions const id = widget.getUniqueIdIn(__classPrivateFieldGet(this, _AbstractToolbar_widgetsById, "f")); // Add the widget __classPrivateFieldGet(this, _AbstractToolbar_widgetsById, "f")[id] = widget; __classPrivateFieldGet(this, _AbstractToolbar_widgetList, "f").push(widget); this.addWidgetInternal(widget); this.setupColorPickers(); } /** Removes the given `widget` from this toolbar. */ removeWidget(widget) { const id = widget.getUniqueIdIn(__classPrivateFieldGet(this, _AbstractToolbar_widgetsById, "f")); this.removeWidgetInternal(widget); delete __classPrivateFieldGet(this, _AbstractToolbar_widgetsById, "f")[id]; __classPrivateFieldSet(this, _AbstractToolbar_widgetList, __classPrivateFieldGet(this, _AbstractToolbar_widgetList, "f").filter((otherWidget) => otherWidget !== widget), "f"); } /** Returns a snapshot of the state of widgets in the toolbar. */ serializeState() { const result = {}; for (const widgetId in __classPrivateFieldGet(this, _AbstractToolbar_widgetsById, "f")) { result[widgetId] = __classPrivateFieldGet(this, _AbstractToolbar_widgetsById, "f")[widgetId].serializeState(); } result[AbstractToolbar.rootToolbarId] = this.serializeInternal(); return JSON.stringify(result); } /** * Deserialize toolbar widgets from the given state. * Assumes that toolbar widgets are in the same order as when state was serialized. */ deserializeState(state) { const data = JSON.parse(state); assertIsObject(data); assertTruthy(data); const rootId = AbstractToolbar.rootToolbarId; if (rootId in data && typeof data[rootId] !== 'undefined') { this.deserializeInternal(data[rootId]); } for (const widgetId in data) { if (widgetId === rootId) { continue; } if (!(widgetId in __classPrivateFieldGet(this, _AbstractToolbar_widgetsById, "f"))) { console.warn(`Unable to deserialize widget ${widgetId} ­— no such widget.`); continue; } if (typeof data[widgetId] === 'object' && data[widgetId]) { __classPrivateFieldGet(this, _AbstractToolbar_widgetsById, "f")[widgetId].deserializeFrom(data[widgetId]); } } } /** * Called by `serializeState` to attach any additional JSONifyable data * to the serialized result. * * @returns an object that can be converted to JSON with `JSON.stringify`. */ serializeInternal() { } /** * Called by `deserializeState` with a version of the JSON outputted * previously by `serializeInternal`. */ deserializeInternal(_json) { } /** * Creates, but does not add, an action button to this container. * * @see * {@link addActionButton} */ makeActionButton(title, command, options = true) { // Parse options if (typeof options === 'boolean') { options = { mustBeToplevel: options, }; } const mustBeToplevel = options.mustBeToplevel ?? true; const autoDisableInReadOnlyEditors = options.autoDisableInReadOnlyEditors ?? true; const titleString = typeof title === 'string' ? title : title.label; const widgetId = 'action-button'; const makeIcon = () => { if (typeof title === 'string') { return null; } return title.icon; }; const widget = new ActionButtonWidget(this.editor, widgetId, makeIcon, titleString, command, this.editor.localization, mustBeToplevel, autoDisableInReadOnlyEditors); return widget; } /** * Adds an action button with `title` to this toolbar (or to the given `parent` element). * * `options` can either be an object with properties `mustBeToplevel` and/or * `autoDisableInReadOnlyEditors` or a boolean value. If a boolean, it is interpreted * as being the value of `mustBeToplevel`. * * @return The added button. * * **Example**: * ```ts,runnable * import { Editor } from 'js-draw'; * const editor = new Editor(document.body); * const toolbar = editor.addToolbar(); * * function makeTrashIcon() { * const container = document.createElement('div'); * container.textContent = '🗑️'; * return container; * } * * toolbar.addActionButton({ * icon: makeTrashIcon(), // can be any Element not in the DOM * label: 'Delete all', * }, () => { * alert('to-do!'); * }); */ addActionButton(title, command, options = true) { const widget = this.makeActionButton(title, command, options); this.addWidget(widget); return widget; } /** * Like {@link addActionButton}, except associates `tags` with the button that allow * different toolbar styles to give the button tag-dependent styles. */ addTaggedActionButton(tags, title, command, options = true) { const widget = this.makeActionButton(title, command, options); widget.setTags(tags); this.addWidget(widget); return widget; } /** * Adds a save button that, when clicked, calls `saveCallback`. * * @example * ```ts,runnable * import { Editor, makeDropdownToolbar } from 'js-draw'; * * const editor = new Editor(document.body); * const toolbar = makeDropdownToolbar(editor); * * toolbar.addDefaults(); * toolbar.addSaveButton(() => alert('save clicked!')); * ``` * * `labelOverride` can optionally be used to change the `label` or `icon` of the button. */ addSaveButton(saveCallback, labelOverride = {}) { const widget = new SaveActionWidget(this.editor, this.localizationTable, saveCallback, labelOverride); this.addWidget(widget); return widget; } /** * Adds an "Exit" button that, when clicked, calls `exitCallback`. * * **Note**: This is *roughly* equivalent to * ```ts * toolbar.addTaggedActionButton([ ToolbarWidgetTag.Exit ], { * label: this.editor.localization.exit, * icon: this.editor.icons.makeCloseIcon(), * * // labelOverride can be used to override label or icon. * ...labelOverride, * }, () => { * exitCallback(); * }); * ``` * with some additional configuration. * * @final */ addExitButton(exitCallback, labelOverride = {}) { const widget = new ExitActionWidget(this.editor, this.localizationTable, exitCallback, labelOverride); this.addWidget(widget); return widget; } /** * Adds undo and redo buttons that trigger the editor's built-in undo and redo * functionality. */ addUndoRedoButtons(undoFirst = true) { const makeUndo = () => { return this.addTaggedActionButton([ToolbarWidgetTag.Undo], { label: this.localizationTable.undo, icon: this.editor.icons.makeUndoIcon(), }, () => { this.editor.history.undo(); }); }; const makeRedo = () => { return this.addTaggedActionButton([ToolbarWidgetTag.Redo], { label: this.localizationTable.redo, icon: this.editor.icons.makeRedoIcon(), }, () => { this.editor.history.redo(); }); }; let undoButton; let redoButton; if (undoFirst) { undoButton = makeUndo(); redoButton = makeRedo(); } else { redoButton = makeRedo(); undoButton = makeUndo(); } undoButton.setDisabled(true); redoButton.setDisabled(true); this.editor.notifier.on(EditorEventType.UndoRedoStackUpdated, (event) => { if (event.kind !== EditorEventType.UndoRedoStackUpdated) { throw new Error('Wrong event type!'); } undoButton.setDisabled(event.undoStackSize === 0); redoButton.setDisabled(event.redoStackSize === 0); }); } /** * Adds widgets for pen/eraser/selection/text/pan-zoom primary tools. * * If `filter` returns `false` for a tool, no widget is added for that tool. * See {@link addDefaultToolWidgets} */ addWidgetsForPrimaryTools(filter) { for (const tool of this.editor.toolController.getPrimaryTools()) { if (filter && !filter?.(tool)) { continue; } if (tool instanceof PenTool) { const widget = new PenToolWidget(this.editor, tool, this.localizationTable); this.addWidget(widget); } else if (tool instanceof EraserTool) { this.addWidget(new EraserWidget(this.editor, tool, this.localizationTable)); } else if (tool instanceof SelectionTool) { this.addWidget(new SelectionToolWidget(this.editor, tool, this.localizationTable)); } else if (tool instanceof TextTool) { this.addWidget(new TextToolWidget(this.editor, tool, this.localizationTable)); } else if (tool instanceof PanZoomTool) { this.addWidget(new HandToolWidget(this.editor, tool, this.localizationTable)); } } } /** * Adds toolbar widgets based on the enabled tools, and additional tool-like * buttons (e.g. {@link DocumentPropertiesWidget} and {@link InsertImageWidget}). */ addDefaultToolWidgets() { this.addWidgetsForPrimaryTools(); this.addDefaultEditorControlWidgets(); } /** * Adds widgets that don't correspond to tools, but do allow the user to control * the editor in some way. * * By default, this includes {@link DocumentPropertiesWidget} and {@link InsertImageWidget}. */ addDefaultEditorControlWidgets() { this.addWidget(new DocumentPropertiesWidget(this.editor, this.localizationTable)); this.addWidget(new InsertImageWidget(this.editor, this.localizationTable)); } addDefaultActionButtons() { this.addUndoRedoButtons(); } /** * Remove this toolbar from its container and clean up listeners. * This should only be called **once** for a given toolbar. */ remove() { this.closeColorPickerOverlay?.remove(); for (const listener of __classPrivateFieldGet(this, _AbstractToolbar_listeners, "f")) { listener.remove(); } __classPrivateFieldSet(this, _AbstractToolbar_listeners, [], "f"); this.onRemove(); for (const widget of __classPrivateFieldGet(this, _AbstractToolbar_widgetList, "f")) { widget.remove(); } } /** * Removes `listener` when {@link remove} is called. */ manageListener(listener) { __classPrivateFieldGet(this, _AbstractToolbar_listeners, "f").push(listener); } } _AbstractToolbar_listeners = new WeakMap(), _AbstractToolbar_widgetsById = new WeakMap(), _AbstractToolbar_widgetList = new WeakMap(), _AbstractToolbar_updateColoris = new WeakMap(); AbstractToolbar.colorisStarted = false; AbstractToolbar.rootToolbarId = 'root-toolbar--'; export default AbstractToolbar;