UNPKG

js-draw

Version:

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

497 lines (496 loc) 20.7 kB
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 __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 _BaseWidget_instances, _a, _BaseWidget_hasDropdown, _BaseWidget_disabledDueToReadOnlyEditor, _BaseWidget_tags, _BaseWidget_removeEditorListeners, _BaseWidget_addEditorListeners; import ToolbarShortcutHandler from '../../tools/ToolbarShortcutHandler.mjs'; import { keyPressEventFromHTMLEvent, keyUpEventFromHTMLEvent, } from '../../inputEvents.mjs'; import { toolbarCSSPrefix } from '../constants.mjs'; import DropdownLayoutManager from './layout/DropdownLayoutManager.mjs'; import addLongPressOrHoverCssClasses from '../../util/addLongPressOrHoverCssClasses.mjs'; import HelpDisplay from '../utils/HelpDisplay.mjs'; import { assertIsObject } from '../../util/assertions.mjs'; /** * A set of labels that allow toolbar themes to treat buttons differently. */ export var ToolbarWidgetTag; (function (ToolbarWidgetTag) { ToolbarWidgetTag["Save"] = "save"; ToolbarWidgetTag["Exit"] = "exit"; ToolbarWidgetTag["Undo"] = "undo"; ToolbarWidgetTag["Redo"] = "redo"; })(ToolbarWidgetTag || (ToolbarWidgetTag = {})); /** * The `abstract` base class for items that can be shown in a `js-draw` toolbar. See also {@link AbstractToolbar.addWidget}. * * See [the custom tool example](https://github.com/personalizedrefrigerator/js-draw/blob/main/docs/examples/example-custom-tools/example.ts) * for how to create a custom toolbar widget for a tool. * * For custom action buttons, {@link AbstractToolbar.addActionButton} may be sufficient for most use cases. */ class BaseWidget { constructor(editor, id, localizationTable) { _BaseWidget_instances.add(this); this.editor = editor; this.id = id; this.dropdown = null; _BaseWidget_hasDropdown.set(this, void 0); // True iff this widget is disabled. this.disabled = false; // True iff this widget is currently disabled because the editor is read only _BaseWidget_disabledDueToReadOnlyEditor.set(this, false); _BaseWidget_tags.set(this, []); // Maps subWidget IDs to subWidgets. this.subWidgets = {}; this.toplevel = true; _BaseWidget_removeEditorListeners.set(this, null); this.localizationTable = localizationTable ?? editor.localization; // Default layout manager const defaultLayoutManager = new DropdownLayoutManager((text) => this.editor.announceForAccessibility(text), this.localizationTable); defaultLayoutManager.connectToEditorNotifier(editor.notifier); this.layoutManager = defaultLayoutManager; this.icon = null; this.container = document.createElement('div'); this.container.classList.add(`${toolbarCSSPrefix}toolContainer`, `${toolbarCSSPrefix}toolButtonContainer`, `${toolbarCSSPrefix}internalWidgetId--${id.replace(/[^a-zA-Z0-9_]/g, '-')}`); this.dropdownContent = document.createElement('div'); __classPrivateFieldSet(this, _BaseWidget_hasDropdown, false, "f"); this.button = document.createElement('div'); this.button.classList.add(`${toolbarCSSPrefix}button`); this.label = document.createElement('label'); this.button.setAttribute('role', 'button'); this.button.tabIndex = 0; // Disable the context menu. This allows long-press gestures to trigger the button's // tooltip instead. this.button.oncontextmenu = (event) => { event.preventDefault(); }; addLongPressOrHoverCssClasses(this.button); } /** * Should return a constant true or false value. If true (the default), * this widget must be automatically disabled when its editor is read-only. */ shouldAutoDisableInReadOnlyEditor() { return true; } getId() { return this.id; } /** * Note: Tags should be set *before* a tool widget is added to a toolbar. * * * Associates tags with this widget that can be used by toolbar themes * to customize the layout/appearance of this button. Prefer tags in * the `ToolbarWidgetTag` enum, where possible. * * In addition to being readable from the {@link getTags} method, tags are * added to a button's main container as CSS classes with the `toolwidget-tag--` prefix. * * For example, the `undo` tag would result in `toolwidget-tag--undo` * being added to the button's container's class list. * */ setTags(tags) { const toClassName = (tag) => { return `toolwidget-tag--${tag}`; }; // Remove CSS classes associated with old tags for (const tag of __classPrivateFieldGet(this, _BaseWidget_tags, "f")) { this.container.classList.remove(toClassName(tag)); } __classPrivateFieldSet(this, _BaseWidget_tags, [...tags], "f"); // Add new CSS classes for (const tag of __classPrivateFieldGet(this, _BaseWidget_tags, "f")) { this.container.classList.add(toClassName(tag)); } } getTags() { return [...__classPrivateFieldGet(this, _BaseWidget_tags, "f")]; } /** * Returns the ID of this widget in `container`. Adds a suffix to this' ID * if an item in `container` already has this' ID. * * For example, if `this` has ID `foo` and if * `container = { 'foo': somethingNotThis, 'foo-1': somethingElseNotThis }`, this method * returns `foo-2` because elements with IDs `foo` and `foo-1` are already present in * `container`. * * If `this` is already in `container`, returns the id given to `this` in the container. */ getUniqueIdIn(container) { let id = this.getId(); let idCounter = 0; while (id in container && container[id] !== this) { id = this.getId() + '-' + idCounter.toString(); idCounter++; } return id; } // Add content to the widget's associated dropdown menu. // Returns true if such a menu should be created, false otherwise. fillDropdown(dropdown, helpDisplay) { if (Object.keys(this.subWidgets).length === 0) { return false; } for (const widgetId in this.subWidgets) { const widget = this.subWidgets[widgetId]; const widgetElement = widget.addTo(dropdown); widget.setIsToplevel(false); // Add help information const helpText = widget.getHelpText(); if (helpText) { helpDisplay?.registerTextHelpForElement(widgetElement, helpText); } } return true; } /** * Should return a 1-2 sentence description of the widget. * * At present, this is only used if this widget has an associated dropdown. */ getHelpText() { return undefined; } /** @deprecated Renamed to `setUpButtonEventListeners`. */ setupActionBtnClickListener(button) { return this.setUpButtonEventListeners(button); } setUpButtonEventListeners(button) { const clickTriggers = { Enter: true, ' ': true }; button.onkeydown = (evt) => { let handled = false; if (evt.key in clickTriggers) { if (!this.disabled) { this.handleClick(); handled = true; } } // If we didn't do anything with the event, send it to the editor. if (!handled) { const editorEvent = keyPressEventFromHTMLEvent(evt); handled = this.editor.toolController.dispatchInputEvent(editorEvent); } if (handled) { evt.preventDefault(); } }; button.onkeyup = (htmlEvent) => { if (htmlEvent.key in clickTriggers) { return; } const event = keyUpEventFromHTMLEvent(htmlEvent); const handled = this.editor.toolController.dispatchInputEvent(event); if (handled) { htmlEvent.preventDefault(); } }; button.onclick = () => { if (!this.disabled) { this.handleClick(); } }; // Prevent double-click zoom on some devices. button.ondblclick = (event) => { event.preventDefault(); }; } // Add a listener that is triggered when a key is pressed. // Listeners will fire regardless of whether this widget is selected and require that // {@link Editor.toolController} to have an enabled {@link ToolbarShortcutHandler} tool. onKeyPress(_event) { return false; } get hasDropdown() { return __classPrivateFieldGet(this, _BaseWidget_hasDropdown, "f"); } // Add a widget to this' dropdown. Must be called before this.addTo. addSubWidget(widget) { // Generate a unique ID for the widget. const id = widget.getUniqueIdIn(this.subWidgets); this.subWidgets[id] = widget; } setLayoutManager(manager) { if (manager === this.layoutManager) { return; } this.layoutManager = manager; if (this.container.parentElement) { // Trigger a re-creation of this' content this.addTo(this.container.parentElement); } } /** * Adds this to `parent`. * Returns the element that was just added to `parent`. * @internal */ addTo(parent) { // Update title and icon this.icon = null; this.updateIcon(); this.label.innerText = this.getTitle(); const longLabelCSSClass = 'long-label'; if (this.label.innerText.length > 7) { this.label.classList.add(longLabelCSSClass); } else { this.label.classList.remove(longLabelCSSClass); } // Click functionality this.setUpButtonEventListeners(this.button); // Clear anything already in this.container. this.container.replaceChildren(); this.button.replaceChildren(this.icon, this.label); this.container.appendChild(this.button); const helpDisplay = new HelpDisplay((content) => this.editor.createHTMLOverlay(content), this.editor); const helpText = this.getHelpText(); if (helpText) { helpDisplay.registerTextHelpForElement(this.dropdownContent, [this.getTitle(), helpText].join('\n\n')); } // Clear the dropdownContainer in case this element is being moved to another // parent. this.dropdownContent.replaceChildren(); __classPrivateFieldSet(this, _BaseWidget_hasDropdown, this.fillDropdown(this.dropdownContent, helpDisplay), "f"); if (__classPrivateFieldGet(this, _BaseWidget_hasDropdown, "f")) { this.button.classList.add('has-dropdown'); // We're re-creating the dropdown. this.dropdown?.destroy(); this.dropdownIcon = this.createDropdownIcon(); this.button.appendChild(this.dropdownIcon); this.dropdown = this.layoutManager.createToolMenu({ target: this.button, getTitle: () => this.getTitle(), isToplevel: () => this.toplevel, }); this.dropdown.visible.onUpdate((visible) => { if (visible) { this.container.classList.add('dropdownVisible'); } else { this.container.classList.remove('dropdownVisible'); } // Auto-focus this component's button when the dropdown hides -- // this ensures that keyboard focus goes to a reasonable location when // the user closes a menu. if (!visible) { this.focus(); } }); if (helpDisplay.hasHelpText()) { this.dropdown.appendChild(helpDisplay.createToggleButton()); } this.dropdown.appendChild(this.dropdownContent); } this.setDropdownVisible(false); if (this.container.parentElement) { this.container.remove(); } __classPrivateFieldGet(this, _BaseWidget_instances, "m", _BaseWidget_addEditorListeners).call(this); parent.appendChild(this.container); return this.container; } /** * Remove this. This allows the widget to be added to a toolbar again * in the future using `addTo`. */ remove() { this.container.remove(); __classPrivateFieldGet(this, _BaseWidget_removeEditorListeners, "f")?.call(this); } focus() { this.button.focus(); } /** * @internal */ addCSSClassToContainer(className) { this.container.classList.add(className); } removeCSSClassFromContainer(className) { this.container.classList.remove(className); } updateIcon() { let newIcon = this.createIcon(); if (!newIcon) { newIcon = document.createElement('div'); this.container.classList.add('no-icon'); } else { this.container.classList.remove('no-icon'); } this.icon?.replaceWith(newIcon); this.icon = newIcon; this.icon.classList.add(`${toolbarCSSPrefix}icon`); } setDisabled(disabled) { this.disabled = disabled; __classPrivateFieldSet(this, _BaseWidget_disabledDueToReadOnlyEditor, false, "f"); if (this.disabled) { this.button.classList.add('disabled'); this.button.setAttribute('aria-disabled', 'true'); } else { this.button.classList.remove('disabled'); this.button.removeAttribute('aria-disabled'); } } setSelected(selected) { const currentlySelected = this.isSelected(); if (currentlySelected === selected) { return; } // Ensure that accessibility tools check and read the value of // aria-checked. // TODO: Ensure that 'role' is set to 'switch' by default for selectable // buttons. this.button.setAttribute('role', 'switch'); if (selected) { this.container.classList.add('selected'); this.button.setAttribute('aria-checked', 'true'); } else { this.container.classList.remove('selected'); this.button.setAttribute('aria-checked', 'false'); } } setDropdownVisible(visible) { if (visible) { this.dropdown?.requestShow(); } else { this.dropdown?.requestHide(); } } /** * Only used by some layout managers. * In those layout managers, makes this dropdown visible. */ activateDropdown() { this.dropdown?.onActivated(); } /** * Returns `true` if this widget must always be in a toplevel menu and not * in a scrolling/overflow menu. * * This method can be overidden to override the default of `true`. */ mustBeInToplevelMenu() { return false; } /** * Returns true iff this widget can be in a nontoplevel menu. * * @deprecated Use `!mustBeInToplevelMenu()` instead. */ canBeInOverflowMenu() { return !this.mustBeInToplevelMenu(); } getButtonWidth() { return this.button.clientWidth; } isHidden() { return this.container.style.display === 'none'; } setHidden(hidden) { this.container.style.display = hidden ? 'none' : ''; } /** Set whether the widget is contained within another. @internal */ setIsToplevel(toplevel) { this.toplevel = toplevel; } /** Returns true if the menu for this widget is open. */ isDropdownVisible() { return this.dropdown?.visible?.get() ?? false; } isSelected() { return this.container.classList.contains('selected'); } createDropdownIcon() { const icon = this.editor.icons.makeDropdownIcon(); icon.classList.add(`${toolbarCSSPrefix}showHideDropdownIcon`); return icon; } /** * Serialize state associated with this widget. * Override this method to allow saving/restoring from state on application load. * * Overriders should call `super` and include the output of `super.serializeState` in * the output dictionary. * * Clients should not rely on the output from `saveState` being in any particular * format. */ serializeState() { const subwidgetState = {}; // Save all subwidget state. for (const subwidgetId in this.subWidgets) { subwidgetState[subwidgetId] = this.subWidgets[subwidgetId].serializeState(); } return { subwidgetState, }; } /** * Restore widget state from serialized data. See also `saveState`. * * Overriders must call `super`. */ deserializeFrom(state) { if (state.subwidgetState) { assertIsObject(state.subwidgetState); // Deserialize all subwidgets. for (const subwidgetId in state.subwidgetState) { if (subwidgetId in this.subWidgets) { const serializedSubwidgetState = state.subwidgetState[subwidgetId]; if (serializedSubwidgetState) { this.subWidgets[subwidgetId].deserializeFrom(serializedSubwidgetState); } } } } } } _a = BaseWidget, _BaseWidget_hasDropdown = new WeakMap(), _BaseWidget_disabledDueToReadOnlyEditor = new WeakMap(), _BaseWidget_tags = new WeakMap(), _BaseWidget_removeEditorListeners = new WeakMap(), _BaseWidget_instances = new WeakSet(), _BaseWidget_addEditorListeners = function _BaseWidget_addEditorListeners() { __classPrivateFieldGet(this, _BaseWidget_removeEditorListeners, "f")?.call(this); const toolbarShortcutHandlers = this.editor.toolController.getMatchingTools(ToolbarShortcutHandler); let removeKeyPressListener = null; // If the onKeyPress function has been extended and the editor is configured to send keypress events to // toolbar widgets, if (toolbarShortcutHandlers.length > 0 && this.onKeyPress !== _a.prototype.onKeyPress) { const keyPressListener = (event) => this.onKeyPress(event); const handler = toolbarShortcutHandlers[0]; handler.registerListener(keyPressListener); removeKeyPressListener = () => { handler.removeListener(keyPressListener); }; } const readOnlyListener = this.editor.isReadOnlyReactiveValue().onUpdateAndNow((readOnly) => { if (readOnly && this.shouldAutoDisableInReadOnlyEditor() && !this.disabled) { this.setDisabled(true); __classPrivateFieldSet(this, _BaseWidget_disabledDueToReadOnlyEditor, true, "f"); if (__classPrivateFieldGet(this, _BaseWidget_hasDropdown, "f")) { this.dropdown?.requestHide(); } } else if (!readOnly && __classPrivateFieldGet(this, _BaseWidget_disabledDueToReadOnlyEditor, "f")) { __classPrivateFieldSet(this, _BaseWidget_disabledDueToReadOnlyEditor, false, "f"); this.setDisabled(false); } }); __classPrivateFieldSet(this, _BaseWidget_removeEditorListeners, () => { readOnlyListener.remove(); removeKeyPressListener?.(); __classPrivateFieldSet(this, _BaseWidget_removeEditorListeners, null, "f"); }, "f"); }; export default BaseWidget;