diff --git a/experience_builder.libraries.yml b/experience_builder.libraries.yml index 406fa1a7c5c915e619811ff4d913e4e8e00ad00d..98a3f221e77c36fbf16a2e0adfc0548aa6e60bbf 100644 --- a/experience_builder.libraries.yml +++ b/experience_builder.libraries.yml @@ -97,8 +97,12 @@ astro.client: gpl-compatible: true astro.hydration: + header: true js: ui/lib/astro-hydration/dist/hydration.js: {} + css: + theme: + ui/lib/astro-hydration/dist/styles/hydration.css: {} license: name: MIT url: https://raw.githubusercontent.com/withastro/astro/refs/heads/main/LICENSE diff --git a/ui/lib/astro-hydration/README.md b/ui/lib/astro-hydration/README.md index 3b7b0064fca081c0073825242aba346f7ade8990..e98867fdf7f4ceb730cb4140ee0304e76bd3750c 100644 --- a/ui/lib/astro-hydration/README.md +++ b/ui/lib/astro-hydration/README.md @@ -11,7 +11,9 @@ This directory is an Astro project and is defined as an [npm workspace](https:// The build step `astro build` generates bundles for Preact (preact.module.js), hooks (hooks.module.js), etc. that the in-browser-editable components depend on. -`client.js` and `hydration.js` are defined as libraries in `experience_builder.libraries.yml` to make them accessible to the JS component source plugin. +`client.js`, `client.css`, and `hydration.js` are defined as libraries in `experience_builder.libraries.yml` to make them accessible to the JS component source plugin. + +The `client.css` file contains basic styling for Astro elements to ensure proper display using `display: contents`. ***The rest of the built files do not need to be defined as Drupal libraries because they are imported by `client.js`.** diff --git a/ui/lib/astro-hydration/public/styles/hydration.css b/ui/lib/astro-hydration/public/styles/hydration.css new file mode 100644 index 0000000000000000000000000000000000000000..e18bee310e515c90615de06de4cdd3de3eb4502a --- /dev/null +++ b/ui/lib/astro-hydration/public/styles/hydration.css @@ -0,0 +1 @@ +astro-island,astro-slot,astro-static-slot{display:contents} \ No newline at end of file diff --git a/ui/src/features/layout/preview/DataToHtmlMapContext.tsx b/ui/src/features/layout/preview/DataToHtmlMapContext.tsx index c9e6758689d6f089173de684ea3e33952acb5083..3c9a07e610a78a1a135711bc7fecfc7adbc0197e 100644 --- a/ui/src/features/layout/preview/DataToHtmlMapContext.tsx +++ b/ui/src/features/layout/preview/DataToHtmlMapContext.tsx @@ -1,11 +1,7 @@ import type { ReactNode } from 'react'; import { createContext, useContext, useState, useCallback } from 'react'; -import type { - ComponentsMap, - RegionsMap, - SlotsMap, -} from '@/types/AnnotationMaps'; +import type { ComponentsMap, RegionsMap, SlotsMap } from '@/types/Annotations'; interface ComponentHtmlMapProviderProps { children: ReactNode; diff --git a/ui/src/features/layout/previewOverlay/ComponentOverlay.tsx b/ui/src/features/layout/previewOverlay/ComponentOverlay.tsx index 45b796edd37b5821f1c94343e303aa6895150b40..4984f724ebf781c51f8b69cf6dad1c54a278a04a 100644 --- a/ui/src/features/layout/previewOverlay/ComponentOverlay.tsx +++ b/ui/src/features/layout/previewOverlay/ComponentOverlay.tsx @@ -27,7 +27,7 @@ import { useNavigationUtils } from '@/hooks/useNavigationUtils'; import useXbParams from '@/hooks/useXbParams'; import ComponentDropZone from '@/features/layout/previewOverlay/ComponentDropZone'; import { useDraggable } from '@dnd-kit/core'; -import type { StackDirection } from '@/types/AnnotationMaps'; +import type { StackDirection } from '@/types/Annotations'; export interface ComponentOverlayProps { component: ComponentNode; @@ -110,8 +110,7 @@ const ComponentOverlay: React.FC<ComponentOverlayProps> = (props) => { const newOffsets = { ...getDistanceBetweenElements( parentElementInsideIframe, - // @todo Potential bug: an element amongst the elementsInsideIframe array other than the first could be further to the top/left than the first element. - elementsInsideIframe.current[0], + elementsInsideIframe.current, ), }; diff --git a/ui/src/hooks/useRenderPreviewEmptyRegionPlaceholder.ts b/ui/src/hooks/useRenderPreviewEmptyRegionPlaceholder.ts index 055756cb90eabee5f38db5a11d045ab1217fec20..288ccf678572620b5c70ccb5f90ce2f6772b1510 100644 --- a/ui/src/hooks/useRenderPreviewEmptyRegionPlaceholder.ts +++ b/ui/src/hooks/useRenderPreviewEmptyRegionPlaceholder.ts @@ -1,7 +1,7 @@ import { useEffect, useCallback } from 'react'; import { useAppSelector } from '@/app/hooks'; import { selectLayout } from '@/features/layout/layoutModelSlice'; -import type { RegionsMap } from '@/types/AnnotationMaps'; +import type { RegionsMap } from '@/types/Annotations'; import { DEFAULT_REGION } from '@/features/ui/uiSlice'; /** diff --git a/ui/src/hooks/useRenderPreviewEmptySlotPlaceholders.ts b/ui/src/hooks/useRenderPreviewEmptySlotPlaceholders.ts index b23c14ea4770dd3f8e95468fa3dd5cdda51c41d3..f82ad8dc7447514343031c9777ac40f58f4ec10c 100644 --- a/ui/src/hooks/useRenderPreviewEmptySlotPlaceholders.ts +++ b/ui/src/hooks/useRenderPreviewEmptySlotPlaceholders.ts @@ -2,7 +2,7 @@ import { useEffect, useCallback } from 'react'; import { useAppSelector } from '@/app/hooks'; import { selectLayout } from '@/features/layout/layoutModelSlice'; import { findSlotById } from '@/features/layout/layoutUtils'; -import type { SlotsMap, SlotInfo } from '@/types/AnnotationMaps'; +import type { SlotsMap, SlotInfo } from '@/types/Annotations'; /** * This hook renders a placeholder div in each empty component slot on the page in the preview iFrame. diff --git a/ui/src/hooks/useSyncPreviewElementSize.ts b/ui/src/hooks/useSyncPreviewElementSize.ts index 04fa8ac7bcaed92d6c66e282e48800fc1655a610..265be7775a5abb81593b6d3c4f43db7aa64679ba 100644 --- a/ui/src/hooks/useSyncPreviewElementSize.ts +++ b/ui/src/hooks/useSyncPreviewElementSize.ts @@ -1,4 +1,5 @@ import { useEffect, useState, useCallback, useRef, useMemo } from 'react'; +import { calculateBoundingRect, elemIsVisible } from '@/utils/function-utils'; /** * This hook takes an HTML element or array of HTML elements and returns a state containing the elements' dimensions and position. @@ -13,14 +14,6 @@ interface Rect { height: number; } -function elemIsVisible(elem: HTMLElement) { - return !!( - elem.offsetWidth || - elem.offsetHeight || - elem.getClientRects().length - ); -} - function findParentBody(element: HTMLElement) { let currentElement = element; @@ -39,28 +32,14 @@ function findParentBody(element: HTMLElement) { function isElementObservable(element: HTMLElement) { const style = window.getComputedStyle(element); - // Check if the element is inline - inline elements to not fire resize events. - if (style.display === 'inline') { + // display: contents; elements (e.g. <astro-* />) do not fire resize events. + if (style.display === 'contents') { return false; } return elemIsVisible(element); } -function getMaxOfArray(numArray: number[]) { - if (numArray.length === 0) { - return 0; - } - return Math.max.apply(null, numArray); -} - -function getMinOfArray(numArray: number[]) { - if (numArray.length === 0) { - return 0; - } - return Math.min.apply(null, numArray); -} - function useSyncPreviewElementSize(input: HTMLElement[] | HTMLElement | null) { // Normalize the input to always be an array const elements = useMemo(() => { @@ -82,40 +61,12 @@ function useSyncPreviewElementSize(input: HTMLElement[] | HTMLElement | null) { const elementsRef = useRef<HTMLElement[] | null>(null); const recalculateBorder = useCallback(() => { - const tops: number[] = []; - const lefts: number[] = []; - const rights: number[] = []; - const bottoms: number[] = []; - - elementsRef.current?.forEach((el) => { - if (!el) { - return; - } - const rect = el.getBoundingClientRect(); - - // check the element is actually visible on the page - otherwise we end up incorrectly setting the minTop & minLeft to be 0 for hidden elements. - if (elemIsVisible(el)) { - tops.push(rect.top); - lefts.push(rect.left); - rights.push(rect.left + rect.width); - bottoms.push(rect.top + rect.height); - } - }); - - const minTop = getMinOfArray(tops); - const minLeft = getMinOfArray(lefts); - const newRect = { - top: minTop, - left: minLeft, - width: getMaxOfArray(rights) - minLeft, - height: getMaxOfArray(bottoms) - minTop, - }; + const elements = elementsRef.current; + const newRect = calculateBoundingRect(elements); - if (elementsRef.current) { + if (newRect && elements) { requestAnimationFrame(() => { setElementRect((prevRect) => { - // Only update if the values have changed so the hook returns the same object preventing components that use - // it from re-rendering if ( prevRect.top !== newRect.top || prevRect.left !== newRect.left || @@ -165,7 +116,7 @@ function useSyncPreviewElementSize(input: HTMLElement[] | HTMLElement | null) { elementsRef.current?.forEach((element) => { /** - * <astro-island> elements (XB Code Components) are display: inline; and that means you can't observe them with + * <astro-island> elements (XB Code Components) are display: contents; and that means you can't observe them with * resizeObserver. Here, if the element we're syncing with can't be observed we traverse up the DOM to find the * first parent that can be and watch that instead */ diff --git a/ui/src/types/AnnotationMaps.ts b/ui/src/types/Annotations.ts similarity index 85% rename from ui/src/types/AnnotationMaps.ts rename to ui/src/types/Annotations.ts index 82c8990b6043771ac0c07a26dd1c553f884117f2..64fb4c157c7ac2e4f6a24321f975c55c678c50be 100644 --- a/ui/src/types/AnnotationMaps.ts +++ b/ui/src/types/Annotations.ts @@ -2,11 +2,19 @@ export interface RegionInfo { elements: HTMLElement[]; regionId: string; } + export interface ComponentInfo { elements: HTMLElement[]; componentUuid: string; } +export interface SlotInfo { + element: HTMLElement; + componentUuid: string; + slotName: string; + stackDirection: StackDirection; +} + export type StackDirection = | 'vertical' | 'vertical-grid' @@ -14,12 +22,12 @@ export type StackDirection = | 'horizontal-flex' | 'horizontal-grid'; -export interface SlotInfo { - element: HTMLElement; - componentUuid: string; - slotName: string; - stackDirection: StackDirection; -} +export type BoundingRect = { + top: number; + left: number; + width: number; + height: number; +}; export type RegionsMap = Record<string, RegionInfo>; export type ComponentsMap = Record<string, ComponentInfo>; diff --git a/ui/src/utils/function-utils.ts b/ui/src/utils/function-utils.ts index 9c2974427f510e569ea55e39056eb00059530a27..2df54f780f6e2b1068d8fc3d49f642ff9ae3317f 100644 --- a/ui/src/utils/function-utils.ts +++ b/ui/src/utils/function-utils.ts @@ -4,7 +4,8 @@ import type { ComponentsMap, RegionsMap, StackDirection, -} from '@/types/AnnotationMaps'; + BoundingRect, +} from '@/types/Annotations'; import type { PendingChanges } from '@/services/pendingChangesApi'; export function handleNonWorkingBtn(): void { @@ -46,18 +47,25 @@ export function parseValue( } /** - * Calculates the horizontal and vertical distance between two DOM elements. + * Calculates the horizontal and vertical distance between the first and second passed element||group of elements. * - * @param el1 - The first DOM element. - * @param el2 - The second DOM element. + * @param el1 - The first element or array of elements. + * @param el2 - The second element or array of elements. * @returns An object containing the horizontal and vertical distances between the elements. */ export function getDistanceBetweenElements( - el1: Element, - el2: Element, + el1: HTMLElement | HTMLElement[], + el2: HTMLElement | HTMLElement[], ): { horizontalDistance: number; verticalDistance: number } { - const rect1 = el1.getBoundingClientRect(); - const rect2 = el2.getBoundingClientRect(); + const rect1 = calculateBoundingRect(el1); + const rect2 = calculateBoundingRect(el2); + + if (rect1 === null || rect2 === null) { + return { + horizontalDistance: 0, + verticalDistance: 0, + }; + } // Calculate the horizontal and vertical distances const dx = rect2.left - rect1.left; @@ -69,6 +77,89 @@ export function getDistanceBetweenElements( }; } +/** + * Calculates the bounding rectangle that encompasses all the provided elements. + * + * @param elements - A single DOM element or an array of DOM elements. + * @returns The bounding rectangle with properties: top, left, width, and height. + * Returns null if elements are not provided or invalid. + */ +export function calculateBoundingRect( + elements: HTMLElement | HTMLElement[] | null, +): BoundingRect | null { + if (!elements) { + return null; + } + + const elementsArray = Array.isArray(elements) ? elements : [elements]; + const expandedElements: HTMLElement[] = []; + + elementsArray.forEach((el) => { + if (!el) { + return; + } + + // elements that are display: contents; (e.g <astro-*> elements) take on the size of their children so in that case, + // we add the direct children to the array instead. + const style = window.getComputedStyle(el); + if (style.display === 'contents') { + expandedElements.push( + ...Array.from(el.children).filter( + (child): child is HTMLElement => child.nodeType === Node.ELEMENT_NODE, + ), + ); + } else { + expandedElements.push(el); + } + }); + + const tops: number[] = []; + const lefts: number[] = []; + const rights: number[] = []; + const bottoms: number[] = []; + + expandedElements.forEach((el) => { + const rect = el.getBoundingClientRect(); + if (elemIsVisible(el)) { + tops.push(rect.top); + lefts.push(rect.left); + rights.push(rect.left + rect.width); + bottoms.push(rect.top + rect.height); + } + }); + + const minTop = getMinOfArray(tops); + const minLeft = getMinOfArray(lefts); + return { + top: minTop, + left: minLeft, + width: getMaxOfArray(rights) - minLeft, + height: getMaxOfArray(bottoms) - minTop, + }; +} + +export function elemIsVisible(elem: HTMLElement) { + return !!( + elem.offsetWidth || + elem.offsetHeight || + elem.getClientRects().length + ); +} + +export function getMaxOfArray(numArray: number[]) { + if (numArray.length === 0) { + return 0; + } + return Math.max.apply(null, numArray); +} + +export function getMinOfArray(numArray: number[]) { + if (numArray.length === 0) { + return 0; + } + return Math.min.apply(null, numArray); +} + /** * Finds empty slots and inserts a placeholder div in between the xb-slot-start/xb-slot-end comments to show the user * where they can drop things. @@ -397,7 +488,10 @@ export function findInChanges( } function getStackingDirection(container: HTMLElement): StackDirection { - const style = getComputedStyle(container); + let style = getComputedStyle(container); + if (style.display === 'contents' && container.parentElement) { + style = getComputedStyle(container.parentElement); + } const display = style.display; if (display.includes('flex')) {