Files
webstudio/apps/builder/app/canvas/collapsed.ts
T
Oleg Isonen dcdac0eaa7 feat: Project settings dialog accessible from dashboard (#5514)
## Description

1. What is this PR about (link the issue and add a short description)

## 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
2025-12-15 17:23:16 +01:00

343 lines
11 KiB
TypeScript

import htmlTags, { voidHtmlTags, type HtmlTags } from "html-tags";
import { collapsedAttribute, idAttribute } from "@webstudio-is/react-sdk";
import { compareMedia, StyleValue } from "@webstudio-is/css-engine";
import {
$breakpoints,
$instances,
$registeredComponentMetas,
$stylesIndex,
} from "~/shared/nano-states";
import { $selectedBreakpoint } from "~/shared/nano-states";
import { $selectedPage } from "~/shared/awareness";
import { serverSyncStore } from "~/shared/sync/sync-stores";
import { doNotTrackMutation } from "~/shared/dom-utils";
const isHtmlTag = (tag: string): tag is HtmlTags =>
htmlTags.includes(tag as HtmlTags);
const instanceIdSet = new Set<string>();
let rafHandle: number;
// Do not add collapsed paddings for replaced elements as at the moment we add them they don't have real size
// https://developer.mozilla.org/en-US/docs/Web/CSS/Replaced_element
const replacedHtmlElements = ["iframe", "video", "embed", "img"];
// Do not add collapsed paddings for void elements
// https://developer.mozilla.org/en-US/docs/Glossary/Void_element
const skipElementsSet = new Set([...voidHtmlTags, ...replacedHtmlElements]);
const isSelectorSupported = (selector: string) => {
try {
return Boolean(document.querySelector(selector));
} catch {
return false;
}
};
const getInstanceSize = (instanceId: string, tagName: HtmlTags | undefined) => {
const metas = $registeredComponentMetas.get();
const breakpoints = $breakpoints.get();
const selectedBreakpoint = $selectedBreakpoint.get();
const { stylesByInstanceId } = $stylesIndex.get();
const instances = $instances.get();
const selectedBreakpointId = selectedBreakpoint?.id;
if (selectedBreakpointId === undefined) {
return {
width: undefined,
height: undefined,
};
}
let widthValue: undefined | StyleValue;
let heightValue: undefined | StyleValue;
const component =
instanceId === undefined ? undefined : instances.get(instanceId)?.component;
if (component && tagName) {
const presetStyles = metas.get(component)?.presetStyle?.[tagName] ?? [];
for (const styleDecl of presetStyles) {
if (styleDecl.state === undefined) {
if (styleDecl.property === "width") {
widthValue = styleDecl.value;
}
if (styleDecl.property === "height") {
heightValue = styleDecl.value;
}
}
}
}
const sortedBreakpoints = Array.from(breakpoints.values()).sort(compareMedia);
const matchingBreakpoints: string[] = [];
for (const breakpoint of sortedBreakpoints) {
matchingBreakpoints.push(breakpoint.id);
if (breakpoint.id === selectedBreakpointId) {
break;
}
}
const instanceStyles = stylesByInstanceId.get(instanceId);
if (instanceStyles) {
for (const breakpointId of matchingBreakpoints) {
for (const styleDecl of instanceStyles) {
if (
styleDecl.breakpointId === breakpointId &&
styleDecl.state === undefined
) {
if (styleDecl.property === "width") {
widthValue = styleDecl.value;
}
if (styleDecl.property === "height") {
heightValue = styleDecl.value;
}
}
}
}
}
return {
width: widthValue?.type === "unit" ? widthValue.value : undefined,
height: heightValue?.type === "unit" ? heightValue.value : undefined,
};
};
const MAX_SIZE_TO_USE_OPTIMIZATION = 50;
const findFirstNonContentsParent = (element: Element) => {
// Start with the element's parent
let parent = element.parentElement;
// Continue traversing up until we find a non-contents parent or reach the top
while (parent) {
// Get the computed style of the parent
const computedStyle = window.getComputedStyle(parent);
const isHidden = parent.getAttribute("hidden") !== null;
// Check if the display is not 'contents'
if (computedStyle.display !== "contents" && !isHidden) {
return parent;
}
// Move up to the next parent
parent = parent.parentElement;
}
// Return null if no non-contents parent is found
return null;
};
const recalculate = () => {
const rootInstanceId = $selectedPage.get()?.rootInstanceId;
// Below algorithm quickly finds the common ancestor of all elements with an instanceId.
// However, for a large number of elements, it's more efficient to calculate from the root.
// In almost all cases, if instanceIdsSet >= MAX_SIZE_TO_USE_OPTIMIZATION, it likely includes all elements.
const instanceIds =
instanceIdSet.size < MAX_SIZE_TO_USE_OPTIMIZATION
? Array.from(instanceIdSet)
: rootInstanceId !== undefined
? [rootInstanceId]
: [];
instanceIdSet.clear();
if (instanceIds.length === 0) {
return;
}
/**
* Selector to find elements common ancestors, in case of no :has selector support select body
**/
const elementSelector = isSelectorSupported(":has(body)")
? `[${idAttribute}]${instanceIds
.map((instanceId) => `:has([${idAttribute}="${instanceId}"])`)
.join("")}`
: "body";
const elements: Element[] = [];
// Element itself or last common ancestor or body
const baseElement =
Array.from(document.querySelectorAll(elementSelector)).pop() ??
document.body;
elements.push(baseElement);
const descendants = baseElement.querySelectorAll(`[${idAttribute}]`);
elements.push(...descendants);
const elementsToRecalculate: HTMLElement[] = [];
const parentsWithAbsoluteChildren = new Map<HTMLElement, number>();
for (const element of elements) {
if (!(element instanceof HTMLElement)) {
continue;
}
if (skipElementsSet.has(element.tagName.toLowerCase())) {
// Images should not collapse, and have a fallback.
// The issue that unloaded image has 0 width and height until explicitly set,
// so at the moment new Image added we detect it as collapsed.
// Skip it for now.
continue;
}
const elementStyle = window.getComputedStyle(element);
// Find all Leaf like elements
// Leaf like elements are elements that have no children or all children are absolute or fixed
// Excluding hidden elements without size
if (element.childElementCount === 0) {
if (element.offsetParent !== null) {
elementsToRecalculate.push(element);
}
if (elementStyle.position === "fixed") {
elementsToRecalculate.push(element);
}
}
const parentElement = findFirstNonContentsParent(element);
if (parentElement) {
if (
elementStyle.position === "absolute" ||
elementStyle.position === "fixed" ||
element.offsetParent == null // collapsed or none
) {
parentsWithAbsoluteChildren.set(
parentElement,
parentsWithAbsoluteChildren.get(parentElement) ?? 0
);
} else {
parentsWithAbsoluteChildren.set(parentElement, 1);
}
}
}
for (const [element, value] of parentsWithAbsoluteChildren.entries()) {
// HTML is not part of webstudio data so skip to not process
if (element.tagName === "HTML") {
continue;
}
// All children are absolute or fixed
if (value === 0) {
elementsToRecalculate.push(element);
}
}
// If most elements are collapsed at the next step, scrollHeight becomes equal to clientHeight,
// which resets the scroll position. To prevent this, we set the document's height to the current scrollHeight
// to preserve the scroll position.
// use nested element to avoid full page repaint and freeze on big projects
let collapsedElement = document.querySelector(
"#ws-collapsed"
) as null | HTMLElement;
if (!collapsedElement) {
collapsedElement = document.createElement("div") as HTMLElement;
collapsedElement.style.position = "absolute";
collapsedElement.style.top = "0px";
collapsedElement.style.left = "0px";
collapsedElement.style.right = "0px";
collapsedElement.setAttribute("id", "ws-collapsed");
collapsedElement.setAttribute("hidden", "true");
// Mark that we are in the process of recalculating collapsed elements
// to avoid infinite loop of mutations
doNotTrackMutation(collapsedElement);
document.documentElement.appendChild(collapsedElement);
}
collapsedElement.removeAttribute("hidden");
collapsedElement.style.height = `${document.documentElement.scrollHeight}px`;
// Now combine all operations in batches.
// 1. Remove all collapsed attributes
baseElement.parentElement?.removeAttribute(collapsedAttribute);
baseElement.removeAttribute(collapsedAttribute);
for (const element of baseElement.querySelectorAll(
`[${collapsedAttribute}]`
)) {
element.removeAttribute(collapsedAttribute);
}
// 2. Calculate collapsed state
const collapsedElements = new Map<HTMLElement, string>();
for (const element of elementsToRecalculate) {
const elementInstanceId = element.getAttribute(idAttribute);
if (elementInstanceId === null) {
// Not a webstudio controlled element, like popover portal
continue;
}
const tagName = element.tagName.toLowerCase();
const elementSize = getInstanceSize(
elementInstanceId,
isHtmlTag(tagName) ? tagName : undefined
);
const collapsedWidth =
element.offsetWidth === 0 && elementSize.width === undefined ? "w" : "";
const collapsedHeight =
element.offsetHeight === 0 && elementSize.height === undefined ? "h" : "";
if (collapsedHeight || collapsedWidth) {
collapsedElements.set(element, collapsedWidth + collapsedHeight);
}
}
// 3. Add collapsed attributes
for (const [element, value] of collapsedElements.entries()) {
element.setAttribute(collapsedAttribute, value);
}
collapsedElement.setAttribute("hidden", "true");
};
/**
* When we add elements or edit element styles, a situation arises where an element can collapse.
* I.e. get either 0 width or 0 height or both.
* We need the user to be able to select such elements anyway.
* We try to find the minimum set of elements that will prevent any of the canvas elements from collapsing.
* For such elements we set collapsedAttribute and then style helpers add padding to
* prevent collapsing.
**/
export const setDataCollapsed = (instanceId: string, syncExec = false) => {
instanceIdSet.add(instanceId);
cancelAnimationFrame(rafHandle);
if (syncExec) {
recalculate();
return;
}
rafHandle = requestAnimationFrame(() => {
recalculate();
});
};
/**
* For optimisation reasons try to extract instanceId of changed elements from pubsub
* In that case we just check the subtree of parent/common ancestor of changed elements to find collapsed elements
**/
export const subscribeCollapsed = () => {
return serverSyncStore.subscribe((_transactionId, changes) => {
for (const change of changes) {
if (change.namespace === "styleSourceSelections") {
for (const patch of change.patches) {
const instanceId = patch.value?.instanceId;
if (typeof instanceId === "string") {
setDataCollapsed(instanceId);
}
}
}
}
});
};