Skip to content

fix: Scroll into view doesn't work any more for scrollable divs #4674

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 9 commits into from
Dec 30, 2024
Merged
Show file tree
Hide file tree
Changes from 7 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 3 additions & 3 deletions apps/builder/app/canvas/instance-hovering.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
28 changes: 9 additions & 19 deletions apps/builder/app/canvas/instance-selected.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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 = () => {
Expand Down Expand Up @@ -285,6 +271,10 @@ const subscribeSelectedInstance = (
const resizeObserver = new ResizeObserver(update);

const mutationHandler: MutationCallback = (mutationRecords) => {
if (hasDoNotTrackMutationRecord(mutationRecords)) {
return;
}

if (hasCollapsedMutationRecord(mutationRecords)) {
return;
}
Expand Down
54 changes: 54 additions & 0 deletions apps/builder/app/shared/dom-utils.stories.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
import { scrollIntoView } from "./dom-utils";

export default {
title: "DomUtile/ScrollIntoView",
};

const handleClick = (selector: string) => {
const elements = document.querySelectorAll(selector);

scrollIntoView(elements[0], elements[0].getBoundingClientRect());
};

const ToolbarStory = () => {
return (
<div>
<button onClick={() => handleClick(".element")}>
Test Scroll Into Scrollable
</button>
<button onClick={() => handleClick(".viewport")}>
Test Scroll Into Viewport
</button>
<h1
style={{
marginTop: "1000px",
}}
>
Viewport Matrix Fiztures
</h1>
<div
style={{
marginTop: "20px",
width: "200px",
height: "200px",
overflow: "auto",
}}
>
<h1 style={{ marginTop: "2400px" }} className="element">
Inside scrollable
</h1>
<div style={{ height: "1000px" }}></div>
</div>
<h2 className="viewport">Inside viewport</h2>
<div style={{ height: "1000px" }}></div>
<button onClick={() => handleClick(".element")}>
Test Scroll Into Scrollable
</button>
<button onClick={() => handleClick(".viewport")}>
Test Scroll Into Viewport
</button>
</div>
);
};

export { ToolbarStory as Toolbar };
133 changes: 133 additions & 0 deletions apps/builder/app/shared/dom-utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import type { Instance } from "@webstudio-is/sdk";
import { idAttribute, selectorIdAttribute } from "@webstudio-is/react-sdk";
import type { InstanceSelector } from "./tree-utils";
import { getIsVisuallyHidden } from "./visually-hidden";
import { getScrollParent } from "@react-aria/utils";

export const getInstanceIdFromElement = (
element: Element
Expand Down Expand Up @@ -216,3 +217,135 @@ 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);
};

/**
* 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: Element, 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;
});
};
Loading