diff --git a/apps/builder/app/canvas/instance-hovering.ts b/apps/builder/app/canvas/instance-hovering.ts index d02e04521b..f05d8779bb 100644 --- a/apps/builder/app/canvas/instance-hovering.ts +++ b/apps/builder/app/canvas/instance-hovering.ts @@ -92,10 +92,10 @@ export const subscribeInstanceHovering = ({ window.addEventListener( "mouseout", () => { - mouseOutTimeoutId = setTimeout(() => { - updateOnMouseMove = false; - hoveredElement = undefined; + updateOnMouseMove = false; + hoveredElement = undefined; + mouseOutTimeoutId = setTimeout(() => { $blockChildOutline.set(undefined); $hoveredInstanceSelector.set(undefined); $hoveredInstanceOutline.set(undefined); diff --git a/apps/builder/app/canvas/instance-selected.ts b/apps/builder/app/canvas/instance-selected.ts index e807665758..e5edf13984 100644 --- a/apps/builder/app/canvas/instance-selected.ts +++ b/apps/builder/app/canvas/instance-selected.ts @@ -20,6 +20,8 @@ import { getAllElementsBoundingBox, getVisibleElementsByInstanceSelector, getAllElementsByInstanceSelector, + scrollIntoView, + hasDoNotTrackMutationRecord, } from "~/shared/dom-utils"; import { subscribeScrollState } from "~/canvas/shared/scroll-state"; import { $selectedInstanceOutline } from "~/shared/nano-states"; @@ -127,26 +129,10 @@ const subscribeSelectedInstance = ( const updateScroll = () => { const bbox = getAllElementsBoundingBox(visibleElements); - - // Adds a small amount of space around the element after scrolling - const topScrollMargin = 16; - - if (bbox.top < 0 || bbox.bottom > window.innerHeight) { - const moveToTopDelta = bbox.top - topScrollMargin; - const moveToBottomDelta = - bbox.bottom - window.innerHeight + topScrollMargin; - - // scrollTo is used because scrollIntoView does not work with elements that have display:contents, etc. - // Here, we can be confident that if the outline can be calculated, we can scroll to it. - window.scrollTo({ - top: - window.scrollY + - (Math.abs(moveToTopDelta) < Math.abs(moveToBottomDelta) - ? moveToTopDelta - : moveToBottomDelta), - behavior: "smooth", - }); + if (visibleElements.length === 0) { + return; } + scrollIntoView(visibleElements[0], bbox); }; const updateElements = () => { @@ -285,6 +271,10 @@ const subscribeSelectedInstance = ( const resizeObserver = new ResizeObserver(update); const mutationHandler: MutationCallback = (mutationRecords) => { + if (hasDoNotTrackMutationRecord(mutationRecords)) { + return; + } + if (hasCollapsedMutationRecord(mutationRecords)) { return; } diff --git a/apps/builder/app/shared/dom-utils.stories.tsx b/apps/builder/app/shared/dom-utils.stories.tsx new file mode 100644 index 0000000000..c8d60abd52 --- /dev/null +++ b/apps/builder/app/shared/dom-utils.stories.tsx @@ -0,0 +1,63 @@ +import { scrollIntoView } from "./dom-utils"; + +export default { + title: "DomUtile/ScrollIntoView", +}; + +const handleClick = (selector: string) => { + const elements = document.querySelectorAll(selector); + + const element = elements[0]; + if (element === undefined) { + return; + } + + if (false === element instanceof HTMLElement) { + return; + } + + scrollIntoView(element, element.getBoundingClientRect()); +}; + +const ToolbarStory = () => { + return ( +
+ + +

+ Viewport Matrix Fiztures +

+
+

+ Inside scrollable +

+
+
+

Inside viewport

+
+ + +
+ ); +}; + +export { ToolbarStory as Toolbar }; diff --git a/apps/builder/app/shared/dom-utils.ts b/apps/builder/app/shared/dom-utils.ts index 8af9301ddd..9c474c294a 100644 --- a/apps/builder/app/shared/dom-utils.ts +++ b/apps/builder/app/shared/dom-utils.ts @@ -216,3 +216,177 @@ export const getAllElementsBoundingBox = ( width: right - left, }); }; + +const doNotTrackMutationAttribute = "data-ws-do-not-track-mutation"; + +export const hasDoNotTrackMutationAttribute = (element: Element) => { + return element.hasAttribute(doNotTrackMutationAttribute); +}; + +export const hasDoNotTrackMutationRecord = ( + mutationRecords: MutationRecord[] +) => { + return mutationRecords.some((record) => + record.type === "childList" + ? [...record.addedNodes.values()].some( + (node) => + node instanceof Element && + node.hasAttribute(doNotTrackMutationAttribute) + ) + : false + ); +}; + +/** + * Get a DOMMatrix mapping the container's local coords to viewport coords. + * This uses one test DIV (width=100, height=100) placed at (0,0) in container space. + */ +const getLocalToViewportMatrix = (container: Element): DOMMatrix => { + const rectSize = 100; + + const testDiv = document.createElement("div"); + Object.assign(testDiv.style, { + position: "absolute", + left: "0px", + top: "0px", + width: `${rectSize}px`, + height: `${rectSize}px`, + pointerEvents: "none", + background: "transparent", + visibility: "hidden", + }); + container.appendChild(testDiv); + testDiv.setAttribute(doNotTrackMutationAttribute, "true"); + + const { left, top, width, height } = testDiv.getBoundingClientRect(); + container.removeChild(testDiv); + + const x1 = left; + const y1 = top; + const x2 = left + width; + const y2 = top; + const x3 = left; + const y3 = top + height; + + const a = (x2 - x1) / rectSize; + const b = (y2 - y1) / rectSize; + const c = (x3 - x1) / rectSize; + const d = (y3 - y1) / rectSize; + const e = x1; + const f = y1; + + return new DOMMatrix([a, b, c, d, e, f]); +}; + +const getViewportToLocalMatrix = (container: Element): DOMMatrix => { + return getLocalToViewportMatrix(container).inverse(); +}; + +const transformDOMRect = (rect: DOMRect, matrix: DOMMatrix) => { + const topLeft = new DOMPoint(rect.x, rect.y).matrixTransform(matrix); + const topRight = new DOMPoint(rect.x + rect.width, rect.y).matrixTransform( + matrix + ); + const bottomLeft = new DOMPoint(rect.x, rect.y + rect.height).matrixTransform( + matrix + ); + const bottomRight = new DOMPoint( + rect.x + rect.width, + rect.y + rect.height + ).matrixTransform(matrix); + + const xs = [topLeft.x, topRight.x, bottomLeft.x, bottomRight.x]; + const ys = [topLeft.y, topRight.y, bottomLeft.y, bottomRight.y]; + const minX = Math.min(...xs); + const maxX = Math.max(...xs); + const minY = Math.min(...ys); + const maxY = Math.max(...ys); + + return new DOMRect(minX, minY, maxX - minX, maxY - minY); +}; + +/** + * `overflow: hidden` can be scrolled + */ +const isScrollable = ( + node: HTMLElement, + checkForOverflow: boolean +): boolean => { + if (!node) { + return false; + } + const style = window.getComputedStyle(node); + + let isScrollable = /(auto|scroll|hidden)/.test( + style.overflow + style.overflowX + style.overflowY + ); + + if (isScrollable && checkForOverflow) { + isScrollable = + node.scrollHeight !== node.clientHeight || + node.scrollWidth !== node.clientWidth; + } + + return isScrollable; +}; + +const getScrollParent = ( + node: HTMLElement, + checkForOverflow: boolean +): HTMLElement | null => { + for ( + let scrollableNode = node.parentElement; + scrollableNode !== null; + scrollableNode = scrollableNode.parentElement + ) { + if (isScrollable(scrollableNode, checkForOverflow)) { + return scrollableNode; + } + } + + return document.documentElement; +}; + +/** + * We scroll using rectangle and anchor calculations because `scrollIntoView` does not work + * reliably for certain elements, such as those with `display: contents`. + * For these elements, we display a selected or hovered outline on the canvas using the + * bounding rectangles of their children or the selection range. + * Here, we ensure scrolling works for these elements as well. + */ +export const scrollIntoView = (anchor: HTMLElement, rect: DOMRect) => { + const scrollParent = getScrollParent(anchor, true); + + if (false === scrollParent instanceof HTMLElement) { + return; + } + + requestAnimationFrame(() => { + const savedPosition = (scrollParent as HTMLElement).style.position; + (scrollParent as HTMLElement).style.position = "relative"; + + const matrix = getViewportToLocalMatrix(scrollParent); + + const transformedRect = transformDOMRect(rect, matrix); + + const scrollDiv = document.createElement("div"); + + Object.assign(scrollDiv.style, { + position: "absolute", + left: `${transformedRect.left}px`, + top: `${transformedRect.top}px`, + width: `${transformedRect.width}px`, + height: `${transformedRect.height}px`, + pointerEvents: "none", + background: "transparent", + scrollMargin: "20px", + }); + scrollParent.appendChild(scrollDiv); + + scrollDiv.scrollIntoView({ behavior: "smooth", block: "nearest" }); + + scrollParent.removeChild(scrollDiv); + + (scrollParent as HTMLElement).style.position = savedPosition; + }); +};