UNPKG

js-draw

Version:

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

447 lines (446 loc) 21.2 kB
"use strict"; 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 __importDefault = (this && this.__importDefault) || function (mod) { return (mod && mod.__esModule) ? mod : { "default": mod }; }; var _HelpDisplay_helpData; Object.defineProperty(exports, "__esModule", { value: true }); const math_1 = require("@js-draw/math"); const makeDraggable_1 = __importDefault(require("./makeDraggable")); const ReactiveValue_1 = require("../../util/ReactiveValue"); const cloneElementWithStyles_1 = __importDefault(require("../../util/cloneElementWithStyles")); const addLongPressOrHoverCssClasses_1 = __importDefault(require("../../util/addLongPressOrHoverCssClasses")); /** * 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 math_1.Rect2.empty; } const itemBoundingBoxes = currentItem.targetElements.map((element) => math_1.Rect2.of(element.getBoundingClientRect())); return math_1.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 = math_1.Rect2.of(textLabel.getBoundingClientRect()); const combinedBBox = getCombinedBBox(); if (labelBBox.intersects(combinedBBox)) { const containerBBox = math_1.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 math_1.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 = math_1.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 = (0, cloneElementWithStyles_1.default)(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); (0, addLongPressOrHoverCssClasses_1.default)(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 = ReactiveValue_1.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 = (0, makeDraggable_1.default)(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(); exports.default = HelpDisplay;