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
JavaScript
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,
});
});
});
}
}
}