UNPKG

js-draw

Version:

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

194 lines (193 loc) 8.19 kB
import { EditorEventType } from '../../../types.mjs'; import EventDispatcher from '../../../EventDispatcher.mjs'; import { toolbarCSSPrefix } from '../../constants.mjs'; import { ReactiveValue } from '../../../util/ReactiveValue.mjs'; var DropdownEventType; (function (DropdownEventType) { DropdownEventType[DropdownEventType["DropdownShown"] = 0] = "DropdownShown"; DropdownEventType[DropdownEventType["DropdownHidden"] = 1] = "DropdownHidden"; })(DropdownEventType || (DropdownEventType = {})); class Dropdown { constructor(parent, notifier, onDestroy) { this.parent = parent; this.notifier = notifier; this.onDestroy = onDestroy; this.dropdownToggleListener = null; this.hideDropdownTimeout = null; this.visible = ReactiveValue.fromInitialValue(false); this.dropdownContainer = document.createElement('div'); this.dropdownContainer.classList.add(`${toolbarCSSPrefix}dropdown`); this.dropdownContainer.classList.add('hidden'); parent.target.insertAdjacentElement('afterend', this.dropdownContainer); // When another dropdown is shown, this.dropdownToggleListener = this.notifier.on(DropdownEventType.DropdownShown, (evt) => { if (evt.dropdown !== this && // Don't hide if a submenu was shown (it might be a submenu of // the current menu). evt.fromToplevelDropdown) { this.setVisible(false); } }); } onActivated() { // Do nothing. } repositionDropdown() { const dropdownBBox = this.dropdownContainer.getBoundingClientRect(); const screenWidth = document.scrollingElement?.clientWidth ?? document.body.clientHeight; const screenHeight = document.scrollingElement?.clientHeight ?? document.body.clientHeight; let translateX = undefined; let translateY = undefined; if (dropdownBBox.left > screenWidth / 2) { const targetElem = this.parent.target; translateX = `calc(${targetElem.clientWidth + 'px'} - 100%)`; } // Shift the dropdown if it's off the screen, but only if doing so moves it on to the screen // (prevents dropdowns from going almost completely offscreen on small screens). if (dropdownBBox.bottom > screenHeight && dropdownBBox.top - dropdownBBox.height > 0) { const targetElem = this.parent.target; translateY = `calc(-${targetElem.clientHeight}px - 100%)`; } // Use .translate so as not to conflict with CSS animating the // transform property. if (translateX || translateY) { this.dropdownContainer.style.translate = `${translateX ?? '0'} ${translateY ?? '0'}`; } else { this.dropdownContainer.style.translate = ''; } } setVisible(visible) { const currentlyVisible = this.visible.get(); if (currentlyVisible === visible) { return; } // If waiting to hide the dropdown, cancel it. if (this.hideDropdownTimeout) { clearTimeout(this.hideDropdownTimeout); this.hideDropdownTimeout = null; this.dropdownContainer.classList.remove('hiding'); this.repositionDropdown(); } const animationDuration = 150; // ms this.visible.set(visible); if (visible) { this.dropdownContainer.classList.remove('hidden'); this.notifier.dispatch(DropdownEventType.DropdownShown, { dropdown: this, fromToplevelDropdown: this.parent.isToplevel(), }); this.repositionDropdown(); } else { this.notifier.dispatch(DropdownEventType.DropdownHidden, { dropdown: this, fromToplevelDropdown: this.parent.isToplevel(), }); this.dropdownContainer.classList.add('hiding'); // Hide the dropdown *slightly* before the animation finishes. This // prevents flickering in some browsers. const hideDelay = animationDuration * 0.95; this.hideDropdownTimeout = setTimeout(() => { this.dropdownContainer.classList.add('hidden'); this.dropdownContainer.classList.remove('hiding'); this.repositionDropdown(); }, hideDelay); } // Animate const animationName = `var(--dropdown-${visible ? 'show' : 'hide'}-animation)`; this.dropdownContainer.style.animation = `${animationDuration}ms ease ${animationName}`; } requestShow() { this.setVisible(true); } requestHide() { this.setVisible(false); } appendChild(item) { this.dropdownContainer.appendChild(item); } clearChildren() { this.dropdownContainer.replaceChildren(); } destroy() { this.setVisible(false); this.dropdownContainer.remove(); this.dropdownToggleListener?.remove(); // Allow children to be added to other parents this.clearChildren(); this.onDestroy(); } } export default class DropdownLayoutManager { constructor(announceForAccessibility, localization) { this.localization = localization; this.dropdowns = new Set(); this.listeners = []; this.connectedNotifiers = []; this.notifier = new EventDispatcher(); this.notifier.on(DropdownEventType.DropdownShown, ({ dropdown, fromToplevelDropdown }) => { if (!dropdown) return; announceForAccessibility(this.localization.dropdownShown(dropdown.parent.getTitle())); // Share the event with other connected notifiers this.connectedNotifiers.forEach((notifier) => { notifier.dispatch(EditorEventType.ToolbarDropdownShown, { kind: EditorEventType.ToolbarDropdownShown, fromToplevelDropdown, layoutManager: this, }); }); }); this.notifier.on(DropdownEventType.DropdownHidden, ({ dropdown }) => { if (!dropdown) return; announceForAccessibility(this.localization.dropdownHidden(dropdown.parent.getTitle())); }); } connectToEditorNotifier(notifier) { this.connectedNotifiers.push(notifier); this.refreshListeners(); } /** Creates a dropdown within `parent`. */ createToolMenu(parent) { const dropdown = new Dropdown(parent, this.notifier, () => { this.dropdowns.delete(dropdown); this.refreshListeners(); }); this.dropdowns.add(dropdown); this.refreshListeners(); return dropdown; } /** * Adds/removes listeners based on whether we have any managed dropdowns. * * We attempt to clean up all resources when `dropdowns.size == 0`, at which * point, an instance of this could be safely garbage collected. */ refreshListeners() { const clearListeners = () => { // Remove all listeners & resources that won't be garbage collected. this.listeners.forEach((l) => l.remove()); this.listeners = []; }; if (this.dropdowns.size === 0) { clearListeners(); } else if (this.listeners.length !== this.connectedNotifiers.length) { clearListeners(); this.listeners = this.connectedNotifiers.map((notifier) => { return notifier.on(EditorEventType.ToolbarDropdownShown, (evt) => { if (evt.kind !== EditorEventType.ToolbarDropdownShown || // Don't forward to ourselves events that we originally triggered. evt.layoutManager === this) { return; } this.notifier.dispatch(DropdownEventType.DropdownShown, { fromToplevelDropdown: evt.fromToplevelDropdown, }); }); }); } } }