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
JavaScript
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;