UNPKG

@atlaskit/editor-common

Version:

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

322 lines (300 loc) • 14.2 kB
import _toConsumableArray from "@babel/runtime/helpers/toConsumableArray"; function _createForOfIteratorHelper(r, e) { var t = "undefined" != typeof Symbol && r[Symbol.iterator] || r["@@iterator"]; if (!t) { if (Array.isArray(r) || (t = _unsupportedIterableToArray(r)) || e && r && "number" == typeof r.length) { t && (r = t); var _n = 0, F = function F() {}; return { s: F, n: function n() { return _n >= r.length ? { done: !0 } : { done: !1, value: r[_n++] }; }, e: function e(r) { throw r; }, f: F }; } throw new TypeError("Invalid attempt to iterate non-iterable instance.\nIn order to be iterable, non-array objects must have a [Symbol.iterator]() method."); } var o, a = !0, u = !1; return { s: function s() { t = t.call(r); }, n: function n() { var r = t.next(); return a = r.done, r; }, e: function e(r) { u = !0, o = r; }, f: function f() { try { a || null == t.return || t.return(); } finally { if (u) throw o; } } }; } function _unsupportedIterableToArray(r, a) { if (r) { if ("string" == typeof r) return _arrayLikeToArray(r, a); var t = {}.toString.call(r).slice(8, -1); return "Object" === t && r.constructor && (t = r.constructor.name), "Map" === t || "Set" === t ? Array.from(r) : "Arguments" === t || /^(?:Ui|I)nt(?:8|16|32)(?:Clamped)?Array$/.test(t) ? _arrayLikeToArray(r, a) : void 0; } } function _arrayLikeToArray(r, a) { (null == a || a > r.length) && (a = r.length); for (var e = 0, n = Array(a); e < a; e++) n[e] = r[e]; return n; } /** * 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 var 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) { var result; /** * Recursive helper to traverse the ADF document and track expand ancestors. * We use recursion to properly maintain the expand ancestor stack. */ var _traverse = function 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 || (_node$attrs = node.attrs) === null || _node$attrs === void 0 ? void 0 : _node$attrs.localId) === targetLocalId) { result = { expandParentLocalIds: _toConsumableArray(expandAncestors) // Copy the array }; return false; // Stop traversing } // Check if this node is an expand or nestedExpand var isExpand = (node === null || node === void 0 ? void 0 : node.type) === 'expand' || (node === null || node === void 0 ? void 0 : node.type) === 'nestedExpand'; var newExpandAncestors = isExpand && node !== null && node !== void 0 && (_node$attrs2 = node.attrs) !== null && _node$attrs2 !== void 0 && _node$attrs2.localId ? [].concat(_toConsumableArray(expandAncestors), [node.attrs.localId]) : expandAncestors; // Traverse children if they exist if (node !== null && node !== void 0 && node.content && Array.isArray(node.content)) { var _iterator = _createForOfIteratorHelper(node.content), _step; try { for (_iterator.s(); !(_step = _iterator.n()).done;) { var child = _step.value; if (result || !child) { break; } _traverse(child, newExpandAncestors); } } catch (err) { _iterator.e(err); } finally { _iterator.f(); } } 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 var findParentExpands = function findParentExpands(element) { var maxDepth = arguments.length > 1 && arguments[1] !== undefined ? arguments[1] : SCROLL_TO_BLOCK_TIMING.MAX_EXPAND_DEPTH; var expands = []; var currentElement = element; var depth = 0; while (currentElement && depth < maxDepth) { // Look for expand container - handles both "expand" and "nestedExpand" types var 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 var isExpandCollapsed = function isExpandCollapsed(expandContainer) { // Check for aria-expanded attribute on the toggle button var 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 var contentDiv = expandContainer.querySelector('.ak-editor-expand__content'); if (contentDiv && contentDiv instanceof HTMLElement) { var 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 var expandElement = function expandElement(expandContainer) { // Find and click the toggle button var 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). */ var _expandAllParentsThenScroll = function expandAllParentsThenScroll(element) { var attempt = arguments.length > 1 && arguments[1] !== undefined ? arguments[1] : 0; var scrollFn = arguments.length > 2 ? arguments[2] : undefined; var MAX_EXPAND_DEPTH = SCROLL_TO_BLOCK_TIMING.MAX_EXPAND_DEPTH, MAX_ATTEMPTS = SCROLL_TO_BLOCK_TIMING.MAX_ATTEMPTS, DOM_UPDATE_DELAY = SCROLL_TO_BLOCK_TIMING.DOM_UPDATE_DELAY, RETRY_DELAY = SCROLL_TO_BLOCK_TIMING.RETRY_DELAY; var doScroll = scrollFn !== null && scrollFn !== void 0 ? scrollFn : function (el) { return el.scrollIntoView({ behavior: 'smooth', block: 'center' }); }; // Store timeout ID and nested cleanup function so they can be cancelled. var timeoutId = null; var nestedCleanup = null; // Cleanup function that cancels pending timeout and any nested operations. var cleanup = function 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). var parentExpands = findParentExpands(element, MAX_EXPAND_DEPTH); // Step 2: Find any collapsed expands (filter out disconnected elements). var collapsedExpands = parentExpands.filter(function (expandContainer) { return 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. var outermostCollapsed = collapsedExpands[0]; try { var 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(function () { 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 (_unused) { // Fallback to simple scroll on error. if (element.isConnected) { doScroll(element); } } }, DOM_UPDATE_DELAY); } else { // Failed to expand, retry with longer delay. timeoutId = setTimeout(function () { if (element.isConnected) { nestedCleanup = _expandAllParentsThenScroll(element, attempt + 1, scrollFn); } }, RETRY_DELAY); } } catch (_unused2) { // Retry on error. timeoutId = setTimeout(function () { if (element.isConnected) { nestedCleanup = _expandAllParentsThenScroll(element, attempt + 1, scrollFn); } }, RETRY_DELAY); } } catch (_unused3) { // Fallback to simple scroll on error. if (element.isConnected) { doScroll(element); } } return cleanup; }; export { _expandAllParentsThenScroll as expandAllParentsThenScroll }; export var getLocalIdSelector = function getLocalIdSelector(localId, container) { // Check if the element with data-local-id exists var element = container.querySelector("[data-local-id=\"".concat(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=\"".concat(localId, "\"]")); if (element) { return element; } element = container.querySelector("[data-task-list-local-id=\"".concat(localId, "\"]")); if (element) { return element; } // Special case for tables which use data-table-local-id element = container.querySelector("[data-table-local-id=\"".concat(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=\"".concat(localId, "\"]")); if (element) { return element; } return null; };