Files
webstudio/apps/builder/app/canvas/instance-selected.ts
T
Ivan Starkov 38d49f9f69 feat: Add native select control (#3616)
## Description

rename of #3609

## New

- Native Select

## Changes

- Improved detection algorithm to include invisible elements (like
`option` or `display:none`).
- Added interceptor to handle pointer down events, preventing select
from opening.
- Enhanced element selection on canvas to support `display:none` and
option selection.
- Fixed prop types generation (previously, the value property was
broken).
- Memory props has effect on canvas only
- Recreate component on Canvas in case of defaultValue has changed


## Steps for reproduction

Add `Select` see it works

## 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:
5de6)
- [ ] 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
2024-06-26 07:07:37 +03:00

323 lines
9.5 KiB
TypeScript

import type { Instance } from "@webstudio-is/sdk";
import { idAttribute, selectorIdAttribute } from "@webstudio-is/react-sdk";
import { subscribeWindowResize } from "~/shared/dom-hooks";
import {
$isResizingCanvas,
$selectedInstanceBrowserStyle,
$selectedInstanceIntanceToTag,
$selectedInstanceUnitSizes,
$selectedInstanceRenderState,
$stylesIndex,
$instances,
$selectedInstanceSelector,
$propValuesByInstanceSelectorWithMemoryProps,
$styles,
$selectedInstanceStates,
$styleSourceSelections,
} from "~/shared/nano-states";
import htmlTags, { type htmlTags as HtmlTags } from "html-tags";
import {
getAllElementsBoundingBox,
getElementsByInstanceSelector,
} from "~/shared/dom-utils";
import { subscribeScrollState } from "~/canvas/shared/scroll-state";
import { $selectedInstanceOutline } from "~/shared/nano-states";
import type { UnitSizes } from "~/builder/features/style-panel/shared/css-value-input/convert-units";
import { setDataCollapsed } from "~/canvas/collapsed";
import { getBrowserStyle } from "./features/webstudio-component/get-browser-style";
import type { InstanceSelector } from "~/shared/tree-utils";
const isHtmlTag = (tag: string): tag is HtmlTags =>
htmlTags.includes(tag as HtmlTags);
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 subscribeSelectedInstance = (
selectedInstanceSelector: Readonly<InstanceSelector>,
debounceEffect: (callback: () => void) => void
) => {
if (selectedInstanceSelector.length === 0) {
return;
}
const instanceId = selectedInstanceSelector[0];
// setDataCollapsed
let elements = getElementsByInstanceSelector(selectedInstanceSelector);
elements[0]?.scrollIntoView({
behavior: "smooth",
block: "nearest",
});
const updateElements = () => {
elements = getElementsByInstanceSelector(selectedInstanceSelector);
};
const updateDataCollapsed = () => {
if (elements.length === 0) {
return;
}
for (const element of elements) {
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, elements);
};
// effect close to rendered element also catches dnd remounts
// so actual state is always provided here
showOutline();
const updateStores = () => {
if (elements.length === 0) {
return;
}
const [element] = elements;
// trigger style recomputing every time instance styles are changed
$selectedInstanceBrowserStyle.set(getBrowserStyle(element));
// Map self and ancestor instance ids to tag names
const instanceToTag = new Map<Instance["id"], HtmlTags>();
for (
let ancestorOrSelf: HTMLElement | null = element;
ancestorOrSelf !== null;
ancestorOrSelf = ancestorOrSelf.parentElement
) {
const tagName = ancestorOrSelf.tagName.toLowerCase();
const instanceId = ancestorOrSelf.getAttribute(idAttribute);
if (isHtmlTag(tagName) && instanceId !== null) {
instanceToTag.set(instanceId, tagName);
}
}
$selectedInstanceIntanceToTag.set(instanceToTag);
const unitSizes = calculateUnitSizes(element);
$selectedInstanceUnitSizes.set(unitSizes);
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) {
if (element.matches(state)) {
activeStates.add(state);
}
}
$selectedInstanceStates.set(activeStates);
};
let updateStoreTimeouHandle: undefined | ReturnType<typeof setTimeout>;
const updateStoresDebounced = () => {
clearTimeout(updateStoreTimeouHandle);
updateStoreTimeouHandle = setTimeout(updateStores, 100);
};
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();
// Cause serious performance issues, use debounced version
// The result of stores is not needed immediately
updateStoresDebounced();
// Having that elements can be changed (i.e. div => address tag change, observe again)
updateObservers();
});
};
// Lightweight update
const updateOutline = () => {
showOutline();
};
const resizeObserver = new ResizeObserver(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(update);
const updateObservers = () => {
for (const element of elements) {
resizeObserver.observe(element);
const parent = element?.parentElement;
if (parent) {
mutationObserver.observe(parent, {
childList: true,
attributes: true,
attributeFilter: ["style", "class"],
});
}
}
};
const bodyStyleMutationObserver = new MutationObserver(updateOutline);
// previewStyle variables
bodyStyleMutationObserver.observe(document.body, {
attributes: 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("pending");
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 = $selectedInstanceSelector.subscribe(
(instanceSelector) => {
if (instanceSelector !== previousSelectedInstance) {
unsubscribeSelectedInstance();
unsubscribeSelectedInstance =
subscribeSelectedInstance(instanceSelector ?? [], debounceEffect) ??
(() => {});
previousSelectedInstance = instanceSelector;
}
}
);
return () => {
unsubscribe();
unsubscribeSelectedInstance();
};
};