mirror of
https://github.com/webstudio-is/webstudio.git
synced 2026-11-08 21:15:18 +09:00
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:
1 parent
c385f6c3a7
commit
2c7cfb70ff
4 files changed
+249
-22
No files matched your search
@@ -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);
|
||||
|
||||
@@ -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 };
|
||||
@@ -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;
|
||||
});
|
||||
};
|
||||
Reference in new issue
Block a user