js-draw
Version:
Draw pictures using a pen, touchscreen, or mouse! JS-draw is a drawing library for JavaScript and TypeScript.
442 lines (441 loc) • 20.8 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 _HelpDisplay_helpData;
import { Rect2 } from '@js-draw/math';
import makeDraggable from './makeDraggable.mjs';
import { MutableReactiveValue } from '../../util/ReactiveValue.mjs';
import cloneElementWithStyles from '../../util/cloneElementWithStyles.mjs';
import addLongPressOrHoverCssClasses from '../../util/addLongPressOrHoverCssClasses.mjs';
/**
* Creates the main content of the help overlay.
*
* Shows the label for a `HelpRecord` and a highlighted copy
* of that label's `targetElements`.
*/
const createHelpPage = (helpItems, onItemClick, onBackgroundClick, context) => {
const container = document.createElement('div');
container.classList.add('help-page-container');
const textLabel = document.createElement('div');
textLabel.classList.add('label', '-space-above');
textLabel.setAttribute('aria-live', 'polite');
// The current active item in helpItems.
// (Only one item is active at a time, but each item can have multiple HTMLElements).
let currentItemIndex = 0;
let currentItem = helpItems[0] ?? null;
// Each help item can have multiple associated elements. We store clones of each
// of these elements in their own container.
//
// clonedElementContainers maps from help item indicies to **arrays** of containers.
//
// For example, clonedElementContainers would be
// [ [ Container1, Container2 ], [ Container3 ], [ Container4 ]]
// ↑ ↑ ↑
// HelpItem 1 HelpItem 2 HelpItem 3
// if the first help item had two elements (and thus two cloned element containers).
//
// We also store the original bounding box -- the bounding box of the clones can change
// while dragging to switch pages.
let clonedElementContainers = [];
// Clicking on the background of the help area should send an event (e.g. to allow the
// help container to be closed).
container.addEventListener('click', (event) => {
// If clicking directly on the container (and not on a child)
if (event.target === container) {
onBackgroundClick();
}
});
// Returns the combined bounding box of all elements associated with the currentItem
// (all active help items).
const getCombinedBBox = () => {
if (!currentItem) {
return Rect2.empty;
}
const itemBoundingBoxes = currentItem.targetElements.map((element) => Rect2.of(element.getBoundingClientRect()));
return Rect2.union(...itemBoundingBoxes);
};
// Updates each cloned element's click listener and CSS classes based on whether
// that element is the current focused element.
const updateClonedElementStates = () => {
const currentItemBBox = getCombinedBBox();
for (let index = 0; index < clonedElementContainers.length; index++) {
for (const { container, bbox: containerBBox } of clonedElementContainers[index]) {
if (index === currentItemIndex) {
container.classList.add('-active');
container.classList.remove('-clickable', '-background');
container.onclick = () => { };
}
// Otherwise, if not containing the current element
else {
if (!containerBBox.containsRect(currentItemBBox)) {
container.classList.add('-clickable');
container.classList.remove('-active', '-background');
}
else {
container.classList.add('-background');
container.classList.remove('-active', '-clickable');
}
const containerIndex = index;
container.onclick = () => {
onItemClick(containerIndex);
};
}
}
}
};
// Ensures that the item label doesn't overlap the current help item's cloned element.
const updateLabelPosition = () => {
const labelBBox = Rect2.of(textLabel.getBoundingClientRect());
const combinedBBox = getCombinedBBox();
if (labelBBox.intersects(combinedBBox)) {
const containerBBox = Rect2.of(container.getBoundingClientRect());
const spaceAboveCombined = combinedBBox.topLeft.y;
const spaceBelowCombined = containerBBox.bottomLeft.y - combinedBBox.bottomLeft.y;
if (spaceAboveCombined > spaceBelowCombined && spaceAboveCombined > labelBBox.height / 3) {
// Push to the very top
textLabel.classList.remove('-small-space-above', '-large-space-above');
textLabel.classList.add('-large-space-below');
}
if (spaceAboveCombined < spaceBelowCombined && spaceBelowCombined > labelBBox.height) {
// Push to the very bottom
textLabel.classList.add('-large-space-above');
textLabel.classList.remove('-large-space-below');
}
}
};
const refreshContent = () => {
container.replaceChildren();
// Add the text label first so that screen readers will visit it first.
textLabel.classList.remove('-large-space-above');
textLabel.classList.add('-small-space-above', '-large-space-below');
container.appendChild(textLabel);
const screenBBox = new Rect2(0, 0, window.innerWidth, window.innerHeight);
clonedElementContainers = [];
for (let itemIndex = 0; itemIndex < helpItems.length; itemIndex++) {
const item = helpItems[itemIndex];
const itemCloneContainers = [];
for (const targetElement of item.targetElements) {
let targetBBox = Rect2.of(targetElement.getBoundingClientRect());
// Move the element onto the screen if not visible
if (!screenBBox.intersects(targetBBox)) {
const screenBottomCenter = screenBBox.bottomLeft.lerp(screenBBox.bottomRight, 0.5);
const targetBottomCenter = targetBBox.bottomLeft.lerp(targetBBox.bottomRight, 0.5);
const delta = screenBottomCenter.minus(targetBottomCenter);
targetBBox = targetBBox.translatedBy(delta);
}
const clonedElement = cloneElementWithStyles(targetElement);
// Interacting with the clone won't trigger event listeners, so disable
// all inputs.
for (const input of clonedElement.querySelectorAll('input')) {
input.disabled = true;
}
clonedElement.style.margin = '0';
const clonedElementContainer = document.createElement('div');
clonedElementContainer.classList.add('cloned-element-container');
clonedElementContainer.role = 'group';
clonedElementContainer.ariaLabel = context.localization.helpControlsAccessibilityLabel;
clonedElementContainer.style.position = 'absolute';
clonedElementContainer.style.left = `${targetBBox.topLeft.x}px`;
clonedElementContainer.style.top = `${targetBBox.topLeft.y}px`;
clonedElementContainer.replaceChildren(clonedElement);
addLongPressOrHoverCssClasses(clonedElementContainer, { timeout: 0 });
itemCloneContainers.push({ container: clonedElementContainer, bbox: targetBBox });
container.appendChild(clonedElementContainer);
}
clonedElementContainers.push(itemCloneContainers);
}
updateClonedElementStates();
};
const refresh = () => {
refreshContent();
updateLabelPosition();
};
const onItemChange = () => {
const helpTextElement = document.createElement('div');
helpTextElement.textContent = currentItem?.helpText ?? '';
// For tests
helpTextElement.classList.add('current-item-help');
const navigationHelpElement = document.createElement('div');
navigationHelpElement.textContent = context.localization.helpScreenNavigationHelp;
navigationHelpElement.classList.add('navigation-help');
textLabel.replaceChildren(helpTextElement, ...(currentItemIndex === 0 ? [navigationHelpElement] : []));
updateClonedElementStates();
};
onItemChange();
return {
addToParent: (parent) => {
refreshContent();
parent.appendChild(container);
updateLabelPosition();
},
refresh,
setPageIndex: (pageIndex) => {
currentItemIndex = pageIndex;
currentItem = helpItems[pageIndex];
onItemChange();
},
};
};
/**
* Creates and manages an overlay that shows help text for a set of
* `HTMLElement`s.
*
* @see {@link BaseWidget.fillDropdown}.
*/
class HelpDisplay {
/** Constructed internally by BaseWidget. @internal */
constructor(createOverlay, context) {
this.createOverlay = createOverlay;
this.context = context;
_HelpDisplay_helpData.set(this, []);
}
/** @internal */
showHelpOverlay() {
const overlay = document.createElement('dialog');
overlay.setAttribute('autofocus', 'true');
overlay.classList.add('toolbar-help-overlay');
// Closes the overlay with a closing animation
const closing = false;
const closeOverlay = () => {
if (closing)
return;
// If changing animationDelay, be sure to also update the CSS.
const animationDelay = 250; // ms
overlay.classList.add('-hiding');
setTimeout(() => overlay.close(), animationDelay);
};
let lastDragTimestamp = 0;
const onBackgroundClick = () => {
const wasJustDragging = performance.now() - lastDragTimestamp < 100;
if (!wasJustDragging) {
closeOverlay();
}
};
const makeCloseButton = () => {
const closeButton = document.createElement('button');
closeButton.classList.add('close-button');
closeButton.appendChild(this.context.icons.makeCloseIcon());
const label = this.context.localization.close;
closeButton.setAttribute('aria-label', label);
closeButton.setAttribute('title', label);
closeButton.onclick = () => {
closeOverlay();
};
return closeButton;
};
// Wraps the label and clickable help elements
const makeNavigationContent = () => {
const currentPage = MutableReactiveValue.fromInitialValue(0);
const content = document.createElement('div');
content.classList.add('navigation-content');
const helpPage = createHelpPage(__classPrivateFieldGet(this, _HelpDisplay_helpData, "f"), (newPageIndex) => currentPage.set(newPageIndex), onBackgroundClick, this.context);
helpPage.addToParent(content);
const showPage = (pageIndex) => {
if (pageIndex >= __classPrivateFieldGet(this, _HelpDisplay_helpData, "f").length || pageIndex < 0) {
// Hide if out of bounds
console.warn('Help screen: Navigated to out-of-bounds page', pageIndex);
content.style.display = 'none';
}
else {
content.style.display = '';
helpPage.setPageIndex(pageIndex);
}
};
currentPage.onUpdateAndNow(showPage);
const navigationControl = {
content,
currentPage,
toNext: () => {
if (navigationControl.hasNext()) {
currentPage.set(currentPage.get() + 1);
}
},
toPrevious: () => {
if (navigationControl.hasPrevious()) {
currentPage.set(currentPage.get() - 1);
}
},
hasNext: () => {
return currentPage.get() + 1 < __classPrivateFieldGet(this, _HelpDisplay_helpData, "f").length;
},
hasPrevious: () => {
return currentPage.get() > 0;
},
refreshCurrent: () => {
helpPage.refresh();
},
};
return navigationControl;
};
// Creates next/previous buttons.
const makeNavigationButtons = (navigation) => {
const navigationButtonContainer = document.createElement('div');
navigationButtonContainer.classList.add('navigation-buttons');
const nextButton = document.createElement('button');
const previousButton = document.createElement('button');
nextButton.textContent = this.context.localization.next;
previousButton.textContent = this.context.localization.previous;
nextButton.classList.add('next');
previousButton.classList.add('previous');
const updateButtonVisibility = () => {
navigationButtonContainer.classList.remove('-has-next', '-has-previous');
if (navigation.hasNext()) {
navigationButtonContainer.classList.add('-has-next');
nextButton.disabled = false;
}
else {
navigationButtonContainer.classList.remove('-has-next');
nextButton.disabled = true;
}
if (navigation.hasPrevious()) {
navigationButtonContainer.classList.add('-has-previous');
previousButton.disabled = false;
}
else {
navigationButtonContainer.classList.remove('-has-previous');
previousButton.disabled = true;
}
};
navigation.currentPage.onUpdateAndNow(updateButtonVisibility);
nextButton.onclick = () => {
navigation.toNext();
};
previousButton.onclick = () => {
navigation.toPrevious();
};
navigationButtonContainer.replaceChildren(previousButton, nextButton);
return navigationButtonContainer;
};
const navigation = makeNavigationContent();
const navigationButtons = makeNavigationButtons(navigation);
overlay.replaceChildren(makeCloseButton(), navigationButtons, navigation.content);
this.createOverlay(overlay);
overlay.showModal();
const minDragOffsetToTransition = 30;
const setDragOffset = (offset) => {
if (offset > 0 && !navigation.hasPrevious()) {
offset = 0;
}
if (offset < 0 && !navigation.hasNext()) {
offset = 0;
}
// Clamp offset
if (offset > minDragOffsetToTransition || offset < -minDragOffsetToTransition) {
offset = minDragOffsetToTransition * Math.sign(offset);
}
overlay.style.transform = `translate(${offset}px, 0px)`;
if (offset >= minDragOffsetToTransition) {
navigationButtons.classList.add('-highlight-previous');
}
else {
navigationButtons.classList.remove('-highlight-previous');
}
if (offset <= -minDragOffsetToTransition) {
navigationButtons.classList.add('-highlight-next');
}
else {
navigationButtons.classList.remove('-highlight-next');
}
};
// Listeners
const dragListener = makeDraggable(overlay, {
draggableChildElements: [navigation.content],
onDrag: (_deltaX, _deltaY, totalDisplacement) => {
overlay.classList.add('-dragging');
setDragOffset(totalDisplacement.x);
},
onDragEnd: (dragStatistics) => {
overlay.classList.remove('-dragging');
setDragOffset(0);
if (!dragStatistics.roughlyClick) {
const xDisplacement = dragStatistics.displacement.x;
if (xDisplacement > minDragOffsetToTransition) {
navigation.toPrevious();
}
else if (xDisplacement < -minDragOffsetToTransition) {
navigation.toNext();
}
lastDragTimestamp = dragStatistics.endTimestamp;
}
},
});
let resizeObserver;
if (window.ResizeObserver) {
resizeObserver = new ResizeObserver(() => {
navigation.refreshCurrent();
});
resizeObserver.observe(overlay);
}
const onMediaChangeListener = () => {
// Refresh the cloned elements and their styles after a delay.
// This is necessary because styles are cloned, in addition to elements.
requestAnimationFrame(() => navigation.refreshCurrent());
};
// matchMedia is unsupported by jsdom
const mediaQueryList = window.matchMedia?.('(prefers-color-scheme: dark)');
mediaQueryList?.addEventListener('change', onMediaChangeListener);
// Close the overlay when clicking on the background (*directly* on any of the
// elements in closeOverlayTriggers).
const closeOverlayTriggers = [navigation.content, navigationButtons, overlay];
overlay.onclick = (event) => {
if (closeOverlayTriggers.includes(event.target)) {
onBackgroundClick();
}
};
overlay.onkeyup = (event) => {
if (event.code === 'Escape') {
closeOverlay();
event.preventDefault();
}
else if (event.code === 'ArrowRight') {
navigation.toNext();
event.preventDefault();
}
else if (event.code === 'ArrowLeft') {
navigation.toPrevious();
event.preventDefault();
}
};
overlay.addEventListener('close', () => {
this.context.announceForAccessibility(this.context.localization.helpHidden);
mediaQueryList?.removeEventListener('change', onMediaChangeListener);
dragListener.removeListeners();
resizeObserver?.disconnect();
overlay.remove();
});
}
/** Marks `helpText` as associated with a single `targetElement`. */
registerTextHelpForElement(targetElement, helpText) {
this.registerTextHelpForElements([targetElement], helpText);
}
/** Marks `helpText` as associated with all elements in `targetElements`. */
registerTextHelpForElements(targetElements, helpText) {
__classPrivateFieldGet(this, _HelpDisplay_helpData, "f").push({ targetElements: [...targetElements], helpText });
}
/** Returns true if any help text has been registered. */
hasHelpText() {
return __classPrivateFieldGet(this, _HelpDisplay_helpData, "f").length > 0;
}
/**
* Creates and returns a button that toggles the help display.
*/
createToggleButton() {
const buttonContainer = document.createElement('div');
buttonContainer.classList.add('toolbar-help-overlay-button');
const helpButton = document.createElement('button');
helpButton.classList.add('button');
const icon = this.context.icons.makeHelpIcon();
icon.classList.add('icon');
helpButton.appendChild(icon);
helpButton.setAttribute('aria-label', this.context.localization.help);
helpButton.onclick = () => {
this.showHelpOverlay();
};
buttonContainer.appendChild(helpButton);
return buttonContainer;
}
}
_HelpDisplay_helpData = new WeakMap();
export default HelpDisplay;