UNPKG

@atlaskit/editor-common

Version:

A package that contains common classes and components for editor and renderer

303 lines (281 loc) • 11.8 kB
/** * Shared utilities for scrolling to block elements with expand node support. * Used by both Confluence's useScrollOnUrlChange and platform renderer's useScrollToBlock. */ import { fg } from '@atlaskit/platform-feature-flags'; /** * Timing constants for expand animation and DOM update delays. * These values are tuned for the expand component's behavior and React's update cycle. */ export const SCROLL_TO_BLOCK_TIMING = { /** Minimal delay for DOM/React updates after expanding (no animation in expand component). */ DOM_UPDATE_DELAY: 50, /** Delay when expand operation fails and needs retry. */ RETRY_DELAY: 100, /** Maximum number of retry attempts before giving up and scrolling anyway. */ MAX_ATTEMPTS: 5, /** Maximum depth of nested expands to search (prevents infinite loops). */ MAX_EXPAND_DEPTH: 2 }; /** * Find a node by its localId in an ADF document and determine if it has expand or nestedExpand parents. * This is used to identify nodes that may be hidden inside collapsed expands. * * @param adfDoc - The ADF document to search * @param targetLocalId - The localId to search for * @returns NodeWithExpandParents if found, undefined otherwise */ export function findNodeWithExpandParents(adfDoc, targetLocalId) { let result; /** * Recursive helper to traverse the ADF document and track expand ancestors. * We use recursion to properly maintain the expand ancestor stack. */ const traverse = (node, expandAncestors) => { var _node$attrs, _node$attrs2; // If we already found the target, stop traversing if (result) { return false; } // Check if this is the target node by localId if ((node === null || node === void 0 ? void 0 : (_node$attrs = node.attrs) === null || _node$attrs === void 0 ? void 0 : _node$attrs.localId) === targetLocalId) { result = { expandParentLocalIds: [...expandAncestors] // Copy the array }; return false; // Stop traversing } // Check if this node is an expand or nestedExpand const isExpand = (node === null || node === void 0 ? void 0 : node.type) === 'expand' || (node === null || node === void 0 ? void 0 : node.type) === 'nestedExpand'; const newExpandAncestors = isExpand && node !== null && node !== void 0 && (_node$attrs2 = node.attrs) !== null && _node$attrs2 !== void 0 && _node$attrs2.localId ? [...expandAncestors, node.attrs.localId] : expandAncestors; // Traverse children if they exist if (node !== null && node !== void 0 && node.content && Array.isArray(node.content)) { for (const child of node.content) { if (result || !child) { break; } traverse(child, newExpandAncestors); } } return !result; // Continue if we haven't found it yet }; // Start traversal from the root traverse(adfDoc, []); return result; } /** * Find all parent expand elements that contain the target element. * Searches up the DOM tree to find expand or nestedExpand containers. * * This function limits the search depth to prevent infinite loops and performance issues. * The default maxDepth of 2 is chosen because: * - Most use cases have 0-2 levels of nesting * - Searching deeper can impact performance * - Users rarely nest expands more than 2 levels deep * * @param element - The target element to find parents for. * @param maxDepth - Maximum depth to search (default: 2). This is the maximum number of * expand ancestors to find, starting from the target element. * @returns Array of expand containers ordered from outermost to innermost. * For example, if element is inside expand B which is inside expand A, * this returns [expandA, expandB]. */ export const findParentExpands = (element, maxDepth = SCROLL_TO_BLOCK_TIMING.MAX_EXPAND_DEPTH) => { const expands = []; let currentElement = element; let depth = 0; while (currentElement && depth < maxDepth) { // Look for expand container - handles both "expand" and "nestedExpand" types const expandContainer = currentElement.closest('[data-node-type="expand"], [data-node-type="nestedExpand"]'); if (!expandContainer || expands.includes(expandContainer)) { break; } expands.push(expandContainer); currentElement = expandContainer.parentElement; depth++; } // Return in reverse order so we expand from outermost to innermost return expands.reverse(); }; /** * Check if an expand node is currently collapsed. * * Uses two methods to determine collapse state: * 1. First checks aria-expanded attribute on the toggle button (most reliable). * 2. Falls back to checking content div visibility via computed styles. * * @param expandContainer - The expand container element. * @returns True if the expand is collapsed, false if expanded or state cannot be determined. */ export const isExpandCollapsed = expandContainer => { // Check for aria-expanded attribute on the toggle button const toggleButton = expandContainer.querySelector('[aria-expanded]'); if (toggleButton && toggleButton instanceof HTMLElement) { return toggleButton.getAttribute('aria-expanded') === 'false'; } // Fallback: check if content div is hidden using the actual class name const contentDiv = expandContainer.querySelector('.ak-editor-expand__content'); if (contentDiv && contentDiv instanceof HTMLElement) { const computedStyle = window.getComputedStyle(contentDiv); return computedStyle.display === 'none' || computedStyle.visibility === 'hidden' || contentDiv.hidden; } return false; }; /** * Expand a collapsed expand node by clicking its toggle button. * * This function finds the toggle button with aria-expanded="false" and programmatically * clicks it to expand the node. It does not wait for the expansion to complete. * * @param expandContainer - The expand container element. * @returns True if the toggle button was found and clicked, false otherwise. */ export const expandElement = expandContainer => { // Find and click the toggle button const toggleButton = expandContainer.querySelector('[aria-expanded="false"]'); if (toggleButton && toggleButton instanceof HTMLElement) { toggleButton.click(); return true; } return false; }; /** * Expand all parent expands then scroll to the element. * * This is the main entry point for scrolling to elements that may be hidden inside collapsed expands. * It handles nested expands by expanding them one at a time from outermost to innermost, * waiting for DOM updates between each expansion. * * The function uses a recursive approach with retry logic to handle: * - Nested expands that need sequential expansion * - DOM updates that may take time to reflect * - Failed expand operations (e.g., if toggle button is temporarily unavailable) * - Disconnected elements (removed from DOM) * * After all parent expands are open (or max attempts reached), scrolls the element into view * with smooth behavior and centered in the viewport. * * @param element - The target element to scroll to. * @param attempt - Current attempt number (used internally for retry logic, starts at 0). * @returns A cleanup function that cancels any pending timeouts. Call this when the operation * should be aborted (e.g., component unmount, navigation, or new scroll request). */ export const expandAllParentsThenScroll = (element, attempt = 0, scrollFn) => { const { MAX_EXPAND_DEPTH, MAX_ATTEMPTS, DOM_UPDATE_DELAY, RETRY_DELAY } = SCROLL_TO_BLOCK_TIMING; const doScroll = scrollFn !== null && scrollFn !== void 0 ? scrollFn : el => el.scrollIntoView({ behavior: 'smooth', block: 'center' }); // Store timeout ID and nested cleanup function so they can be cancelled. let timeoutId = null; let nestedCleanup = null; // Cleanup function that cancels pending timeout and any nested operations. const cleanup = () => { if (timeoutId !== null) { clearTimeout(timeoutId); timeoutId = null; } if (nestedCleanup) { nestedCleanup(); nestedCleanup = null; } }; // Guard against element being disconnected from DOM or exceeding max attempts. if (attempt >= MAX_ATTEMPTS || !element.isConnected) { // Max attempts reached or element disconnected, scroll anyway. if (element.isConnected) { doScroll(element); } return cleanup; } try { // Step 1: Find all parent expands (outermost to innermost). const parentExpands = findParentExpands(element, MAX_EXPAND_DEPTH); // Step 2: Find any collapsed expands (filter out disconnected elements). const collapsedExpands = parentExpands.filter(expandContainer => expandContainer.isConnected && isExpandCollapsed(expandContainer)); if (collapsedExpands.length === 0) { // All expands are open (or there are no expands), scroll to element. doScroll(element); return cleanup; } // Step 3: Expand ONLY the outermost collapsed expand first. // This is critical for nested expands - we must expand parent before child. const outermostCollapsed = collapsedExpands[0]; try { const expanded = expandElement(outermostCollapsed); if (expanded) { // Successfully expanded, wait briefly for DOM update then recurse to handle any nested expands. // Note: There's no animation, but we need a minimal delay for DOM/React updates. timeoutId = setTimeout(() => { try { // Verify element is still connected before proceeding. if (!element.isConnected) { return; } // Recurse to handle any nested collapsed expands or retry if still collapsed. nestedCleanup = expandAllParentsThenScroll(element, attempt + 1, scrollFn); } catch { // Fallback to simple scroll on error. if (element.isConnected) { doScroll(element); } } }, DOM_UPDATE_DELAY); } else { // Failed to expand, retry with longer delay. timeoutId = setTimeout(() => { if (element.isConnected) { nestedCleanup = expandAllParentsThenScroll(element, attempt + 1, scrollFn); } }, RETRY_DELAY); } } catch { // Retry on error. timeoutId = setTimeout(() => { if (element.isConnected) { nestedCleanup = expandAllParentsThenScroll(element, attempt + 1, scrollFn); } }, RETRY_DELAY); } } catch { // Fallback to simple scroll on error. if (element.isConnected) { doScroll(element); } } return cleanup; }; export const getLocalIdSelector = (localId, container) => { // Check if the element with data-local-id exists let element = container.querySelector(`[data-local-id="${localId}"]`); if (element) { return element; } // Special case for decision lists and task lists which already have localId element = container.querySelector(`[data-decision-list-local-id="${localId}"]`); if (element) { return element; } element = container.querySelector(`[data-task-list-local-id="${localId}"]`); if (element) { return element; } // Special case for tables which use data-table-local-id element = container.querySelector(`[data-table-local-id="${localId}"]`); if (element) { if (fg('platform_editor_block_menu_v2_patch_4')) { return element.parentElement; // return table wrapper instead of table div, so the height calculation is correct } return element; } // Special case for extension, smart cards and media which use lowercase localid element = container.querySelector(`[localid="${localId}"]`); if (element) { return element; } return null; };