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

## Description

closes  #4631

tested in Safari, FF, Chrome

## TODO:

- [x] - Menu content cause
<img width="914" alt="image"
src="https://github.com/user-attachments/assets/45567d6b-b9b4-493d-80b8-596f5c0ac650"
/>


## Steps for reproduction

1. click button
2. expect xyz

## Code Review

- [ ] hi @kof, I need you to do
  - conceptual review (architecture, feature-correctness)
  - detailed review (read every line)
  - test it on preview

## Before requesting a review

- [ ] made a self-review
- [ ] added inline comments where things may be not obvious (the "why",
not "what")

## Before merging

- [ ] tested locally and on preview environment (preview dev login:
0000)
- [ ] updated [test
cases](https://github.com/webstudio-is/webstudio/blob/main/apps/builder/docs/test-cases.md)
document
- [ ] added tests
- [ ] if any new env variables are added, added them to `.env` file
This commit is contained in:
Ivan Starkov authored and GitHub committed 2024-12-30 23:42:34 +03:00
1 parent c385f6c3a7
commit 2c7cfb70ff
4 files changed
+249 -22

No files matched your search

+3 -3
View File
@@ -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);
+9 -19
View File
@@ -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;
}
@@ -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 (
<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 };
+174
View File
@@ -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;
});
};