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