Files
webstudio/apps/builder/app/canvas/instance-selected.ts
T
Bogdan Chadkin 24180a2edb perf: improve instance selection performance (#5240)
We have html element mutations in a few places and each can add ~250ms
of repaint time on large projects. Here I got rid of it in two places

1. Collapsed logic enforced document height to preserve scroll position.
Instead I created an absolutely positioned div which enforced scroll
where collapsed elements are reset.
2. Scroll into view logic defined position absolute on scroll container.
Though it's not necessary on html element so just ignored it.
2025-06-01 14:42:10 +03:00

359 lines
10 KiB
TypeScript

import { shallowEqual } from "shallow-equal";
import warnOnce from "warn-once";
import type { Instance } from "@webstudio-is/sdk";
import type { CssProperty, UnitValue } from "@webstudio-is/css-engine";
import { propertiesData } from "@webstudio-is/css-data";
import { selectorIdAttribute } from "@webstudio-is/react-sdk";
import { subscribeWindowResize } from "~/shared/dom-hooks";
import {
$isResizingCanvas,
$selectedInstanceSizes,
$selectedInstanceRenderState,
$stylesIndex,
$instances,
$propValuesByInstanceSelectorWithMemoryProps,
$styles,
$selectedInstanceStates,
$styleSourceSelections,
type UnitSizes,
type PropertySizes,
} from "~/shared/nano-states";
import {
getAllElementsBoundingBox,
getVisibleElementsByInstanceSelector,
getAllElementsByInstanceSelector,
scrollIntoView,
hasDoNotTrackMutationRecord,
} from "~/shared/dom-utils";
import { subscribeScrollState } from "~/canvas/shared/scroll-state";
import { $selectedInstanceOutline } from "~/shared/nano-states";
import { setDataCollapsed } from "~/canvas/collapsed";
import type { InstanceSelector } from "~/shared/tree-utils";
import { $awareness } from "~/shared/awareness";
const setOutline = (instanceId: Instance["id"], elements: HTMLElement[]) => {
$selectedInstanceOutline.set({
instanceId,
rect: getAllElementsBoundingBox(elements),
});
};
const hideOutline = () => {
$selectedInstanceOutline.set(undefined);
};
const calculateUnitSizes = (element: HTMLElement): UnitSizes => {
// Based on this https://stackoverflow.com/questions/1248081/how-to-get-the-browser-viewport-dimensions/8876069#8876069
// this is crossbrowser way to get viewport sizes vw vh in px
const vw =
Math.max(document.documentElement.clientWidth, window.innerWidth) / 100;
const vh =
Math.max(document.documentElement.clientHeight, window.innerHeight) / 100;
// em in px is equal to current computed style for font size
const em = Number.parseFloat(getComputedStyle(element).fontSize);
// rem in px is equal to root computed style for font size
const rem = Number.parseFloat(
getComputedStyle(document.documentElement).fontSize
);
// we create a node with 1ch width, measure it in px and remove it
const node = document.createElement("div");
node.style.width = "1ch";
node.style.position = "absolute";
element.appendChild(node);
const ch = Number.parseFloat(getComputedStyle(node).width);
element.removeChild(node);
return {
ch, // 1ch in pixels
vw, // 1vw in pixels
vh, // 1vh in pixels
em, // 1em in pixels
rem, // 1rem in pixels
px: 1, // always 1, simplifies conversions and types, i.e valueTo = valueFrom * unitSizes[from] / unitSizes[to]
};
};
const calculatePropertySizes = (element: HTMLElement) => {
const computedStyle = getComputedStyle(element);
const propertySizes: PropertySizes = {};
for (const property in propertiesData) {
try {
const propertyValue = computedStyle.getPropertyValue(property);
const value = CSSStyleValue.parse(property, propertyValue);
if (value instanceof CSSUnitValue) {
propertySizes[property as CssProperty] = {
type: "unit",
// px | number | percent etc
unit:
value.unit === "percent" ? "%" : (value.unit as UnitValue["unit"]),
value: value.value,
};
}
} catch (error) {
// failed with unknown property like -moz-osx-font-smoothing
// also firefox does not support CSSStyleValue
}
}
return propertySizes;
};
const subscribeSelectedInstance = (
selectedInstanceSelector: Readonly<InstanceSelector>,
debounceEffect: (callback: () => void) => void
) => {
if (selectedInstanceSelector.length === 0) {
return;
}
const instanceId = selectedInstanceSelector[0];
let visibleElements = getVisibleElementsByInstanceSelector(
selectedInstanceSelector
);
const updateScroll = () => {
const bbox = getAllElementsBoundingBox(visibleElements);
if (visibleElements.length === 0) {
return;
}
scrollIntoView(visibleElements[0], bbox);
};
const updateElements = () => {
visibleElements = getVisibleElementsByInstanceSelector(
selectedInstanceSelector
);
};
const updateDataCollapsed = () => {
if (visibleElements.length === 0) {
return;
}
for (const element of visibleElements) {
const selectorId = element.getAttribute(selectorIdAttribute);
if (selectorId === null) {
continue;
}
const instanceSelector = selectorId.split(",");
if (instanceSelector.length === 0) {
continue;
}
setDataCollapsed(instanceSelector[0], false);
}
// Synchronously execute setDataCollapsed to calculate right outline
// This fixes an issue, when new element outline was calculated before collapsed elements calculations
setDataCollapsed(instanceId, true);
};
updateDataCollapsed();
const showOutline = () => {
if ($isResizingCanvas.get()) {
return;
}
setOutline(instanceId, visibleElements);
};
// effect close to rendered element also catches dnd remounts
// so actual state is always provided here
showOutline();
const updateStores = () => {
const elements = getAllElementsByInstanceSelector(selectedInstanceSelector);
if (elements.length === 0) {
return;
}
const [element] = elements;
// trigger style recomputing every time instance styles are changed
const unitSizes = calculateUnitSizes(element);
const propertySizes = calculatePropertySizes(element);
$selectedInstanceSizes.set({ unitSizes, propertySizes });
const availableStates = new Set<string>();
const instanceStyleSourceIds = new Set(
$styleSourceSelections.get().get(instanceId)?.values
);
const styles = $styles.get();
for (const styleDecl of styles.values()) {
if (
instanceStyleSourceIds.has(styleDecl.styleSourceId) &&
styleDecl.state
) {
availableStates.add(styleDecl.state);
}
}
const activeStates = new Set<string>();
for (const state of availableStates) {
try {
// pseudo classes like :open or :current are not supported in .matches method
if (element.matches(state)) {
activeStates.add(state);
}
} catch {
warnOnce(true, `state selector "${state}" is invalid`);
}
}
if (
!shallowEqual(activeStates.keys(), $selectedInstanceStates.get().keys())
) {
$selectedInstanceStates.set(activeStates);
}
};
let updateStoreTimeouHandle: undefined | ReturnType<typeof setTimeout>;
const update = () => {
debounceEffect(() => {
updateElements();
// Having hover etc, element can have no size because of that
// Newly created element can have 0 size
updateDataCollapsed();
// contentRect has wrong x/y values for absolutely positioned element.
// getBoundingClientRect is used instead.
showOutline();
updateStores();
// Having that elements can be changed (i.e. div => address tag change, observe again)
updateObservers();
});
};
// update scroll state
updateScroll();
// Lightweight update
const updateOutline: MutationCallback = (mutationRecords) => {
if (hasDoNotTrackMutationRecord(mutationRecords)) {
return;
}
showOutline();
};
const resizeObserver = new ResizeObserver(update);
const mutationHandler: MutationCallback = (mutationRecords) => {
if (hasDoNotTrackMutationRecord(mutationRecords)) {
return;
}
update();
};
// detect movement of the element within same parent
// React prevent remount when key stays the same
// `attributes: true` fixes issues with popups after trigger text editing
// that cause radix to incorrectly set content in a wrong position at first render
const mutationObserver = new MutationObserver(mutationHandler);
const updateObservers = () => {
for (const element of visibleElements) {
resizeObserver.observe(element);
const parent = element?.parentElement;
if (parent) {
mutationObserver.observe(parent, {
childList: true,
attributes: true,
attributeOldValue: true,
attributeFilter: ["style", "class"],
});
}
}
};
const bodyStyleMutationObserver = new MutationObserver(updateOutline);
// previewStyle variables
bodyStyleMutationObserver.observe(document.body, {
attributes: true,
attributeOldValue: true,
attributeFilter: ["style", "class"],
});
updateObservers();
const unsubscribe$stylesIndex = $stylesIndex.subscribe(update);
const unsubscribe$instances = $instances.subscribe(update);
const unsubscribePropValuesStore =
$propValuesByInstanceSelectorWithMemoryProps.subscribe(update);
const unsubscribeIsResizingCanvas = $isResizingCanvas.subscribe(
(isResizing) => {
if (isResizing && $selectedInstanceOutline.get()) {
return hideOutline();
}
showOutline();
}
);
const unsubscribeScrollState = subscribeScrollState({
onScrollStart() {
hideOutline();
},
onScrollEnd() {
showOutline();
},
});
const unsubscribeWindowResize = subscribeWindowResize({
onResizeStart() {
hideOutline();
},
onResizeEnd() {
showOutline();
},
});
$selectedInstanceRenderState.set("mounted");
return () => {
clearTimeout(updateStoreTimeouHandle);
hideOutline();
$selectedInstanceRenderState.set("notMounted");
resizeObserver.disconnect();
mutationObserver.disconnect();
bodyStyleMutationObserver.disconnect();
unsubscribeIsResizingCanvas();
unsubscribeScrollState();
unsubscribeWindowResize();
unsubscribe$stylesIndex();
unsubscribe$instances();
unsubscribePropValuesStore();
};
};
export const subscribeSelected = (
debounceEffect: (callback: () => void) => void
) => {
let previousSelectedInstance: readonly string[] | undefined = undefined;
let unsubscribeSelectedInstance = () => {};
const unsubscribe = $awareness.subscribe((awareness) => {
const instanceSelector = awareness?.instanceSelector;
if (instanceSelector !== previousSelectedInstance) {
unsubscribeSelectedInstance();
unsubscribeSelectedInstance =
subscribeSelectedInstance(instanceSelector ?? [], debounceEffect) ??
(() => {});
previousSelectedInstance = instanceSelector;
}
});
return () => {
unsubscribe();
unsubscribeSelectedInstance();
};
};