mirror of
https://github.com/webstudio-is/webstudio.git
synced 2026-11-09 05:26:24 +09:00
## 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
538 lines
16 KiB
TypeScript
538 lines
16 KiB
TypeScript
import {
|
|
useEffect,
|
|
forwardRef,
|
|
type ForwardedRef,
|
|
useRef,
|
|
useLayoutEffect,
|
|
useMemo,
|
|
Fragment,
|
|
type ReactNode,
|
|
} from "react";
|
|
import { Suspense, lazy } from "react";
|
|
import { computed } from "nanostores";
|
|
import { useStore } from "@nanostores/react";
|
|
import { useLexicalComposerContext } from "@lexical/react/LexicalComposerContext";
|
|
import { mergeRefs } from "@react-aria/utils";
|
|
import type { Instance, Instances, Prop } from "@webstudio-is/sdk";
|
|
import { findTreeInstanceIds } from "@webstudio-is/sdk";
|
|
import {
|
|
type WebstudioComponentProps,
|
|
idAttribute,
|
|
componentAttribute,
|
|
showAttribute,
|
|
selectorIdAttribute,
|
|
indexAttribute,
|
|
getIndexesWithinAncestors,
|
|
createInstanceChildrenElements,
|
|
collectionComponent,
|
|
type AnyComponent,
|
|
textContentAttribute,
|
|
descendantComponent,
|
|
} from "@webstudio-is/react-sdk";
|
|
import { rawTheme } from "@webstudio-is/design-system";
|
|
import {
|
|
$propValuesByInstanceSelectorWithMemoryProps,
|
|
getIndexedInstanceId,
|
|
$instances,
|
|
$registeredComponentMetas,
|
|
$selectedInstanceRenderState,
|
|
$selectedInstanceSelector,
|
|
$selectedPage,
|
|
} from "~/shared/nano-states";
|
|
import { $textEditingInstanceSelector } from "~/shared/nano-states";
|
|
import {
|
|
type InstanceSelector,
|
|
areInstanceSelectorsEqual,
|
|
} from "~/shared/tree-utils";
|
|
import { setDataCollapsed } from "~/canvas/collapsed";
|
|
import { getIsVisuallyHidden } from "~/shared/visually-hidden";
|
|
import { serverSyncStore } from "~/shared/sync";
|
|
|
|
const TextEditor = lazy(async () => {
|
|
const { TextEditor } = await import("../text-editor");
|
|
return { default: TextEditor };
|
|
});
|
|
|
|
const ContentEditable = ({
|
|
renderComponentWithRef,
|
|
}: {
|
|
renderComponentWithRef: (
|
|
elementRef: ForwardedRef<HTMLElement>
|
|
) => JSX.Element;
|
|
}) => {
|
|
const [editor] = useLexicalComposerContext();
|
|
|
|
const ref = useRef<HTMLElement>(null);
|
|
|
|
/**
|
|
* useLayoutEffect to be sure that editor plugins on useEffect would have access to rootElement
|
|
*/
|
|
useLayoutEffect(() => {
|
|
let rootElement = ref.current;
|
|
|
|
if (rootElement == null) {
|
|
return;
|
|
}
|
|
|
|
if (getIsVisuallyHidden(rootElement)) {
|
|
return;
|
|
}
|
|
|
|
if (rootElement?.tagName === "BUTTON" || rootElement.tagName === "A") {
|
|
// <button> with contentEditable does not let to press space
|
|
// <a> stops working with inline-flex when only 1 character left
|
|
// so add span inside and use it as editor element in lexical
|
|
const span = document.createElement("span");
|
|
for (const child of rootElement.childNodes) {
|
|
rootElement.removeChild(child);
|
|
span.appendChild(child);
|
|
}
|
|
rootElement.appendChild(span);
|
|
|
|
rootElement = span;
|
|
}
|
|
if (rootElement) {
|
|
rootElement.contentEditable = "true";
|
|
}
|
|
|
|
editor.setRootElement(rootElement);
|
|
}, [editor]);
|
|
|
|
return renderComponentWithRef(ref);
|
|
};
|
|
|
|
const ErrorStub = forwardRef<
|
|
HTMLDivElement,
|
|
{
|
|
children?: ReactNode;
|
|
}
|
|
>((props, ref) => {
|
|
return (
|
|
<div
|
|
{...props}
|
|
ref={ref}
|
|
style={{
|
|
padding: rawTheme.spacing[5],
|
|
border: `1px solid ${rawTheme.colors.borderDestructiveMain}`,
|
|
color: rawTheme.colors.foregroundDestructive,
|
|
}}
|
|
/>
|
|
);
|
|
});
|
|
ErrorStub.displayName = "ErrorStub";
|
|
|
|
const MissingComponentStub = forwardRef<
|
|
HTMLDivElement,
|
|
{ children?: ReactNode }
|
|
>((props, ref) => {
|
|
return (
|
|
<ErrorStub ref={ref} {...props}>
|
|
Component {props[componentAttribute as never]} does not exist
|
|
</ErrorStub>
|
|
);
|
|
});
|
|
MissingComponentStub.displayName = "MissingComponentStub";
|
|
|
|
const InvalidCollectionDataStub = forwardRef<
|
|
HTMLDivElement,
|
|
{ children?: ReactNode }
|
|
>((props, ref) => {
|
|
return (
|
|
<ErrorStub ref={ref} {...props}>
|
|
The Collection component requires an array in the data property. When
|
|
binding external data, it is likely that the array is nested somewhere
|
|
within, and you need to provide the correct path in the binding.{" "}
|
|
<a
|
|
style={{ color: "inherit" }}
|
|
target="_blank"
|
|
href="https://docs.webstudio.is/university/core-components/collection.md#whats-an-array"
|
|
// avoid preventing click by events interceptor
|
|
onClickCapture={(event) => event.stopPropagation()}
|
|
>
|
|
Learn more
|
|
</a>
|
|
</ErrorStub>
|
|
);
|
|
});
|
|
InvalidCollectionDataStub.displayName = "InvalidCollectionDataStub";
|
|
|
|
const DroppableComponentStub = forwardRef<
|
|
HTMLDivElement,
|
|
{ children?: ReactNode }
|
|
>((props, ref) => {
|
|
return (
|
|
<div {...props} ref={ref} style={{ display: "block" }}>
|
|
{/* explicitly specify undefined to override passed children */}
|
|
{undefined}
|
|
</div>
|
|
);
|
|
});
|
|
DroppableComponentStub.displayName = "DroppableComponentStub";
|
|
|
|
// this utility is temporary solution to compute instance selectors
|
|
// for rich text subtree which cannot have slots so its safe to traverse ancestors
|
|
// until editor instance is reached
|
|
//
|
|
// once all lexical formats are replaced with elmenents it should be
|
|
// straightforward to compute selectors from lexical tree
|
|
const getInstanceSelector = (
|
|
instances: Instances,
|
|
rootInstanceSelector: InstanceSelector,
|
|
instanceId: Instance["id"]
|
|
) => {
|
|
const parentInstancesById = new Map<Instance["id"], Instance["id"]>();
|
|
for (const instance of instances.values()) {
|
|
for (const child of instance.children) {
|
|
if (child.type === "id") {
|
|
parentInstancesById.set(child.value, instance.id);
|
|
}
|
|
}
|
|
}
|
|
const selector: InstanceSelector = [];
|
|
let currentInstanceId: undefined | Instance["id"] = instanceId;
|
|
while (currentInstanceId) {
|
|
selector.push(currentInstanceId);
|
|
currentInstanceId = parentInstancesById.get(currentInstanceId);
|
|
if (currentInstanceId === rootInstanceSelector[0]) {
|
|
return [...selector, ...rootInstanceSelector];
|
|
}
|
|
}
|
|
return undefined;
|
|
};
|
|
|
|
const $indexesWithinAncestors = computed(
|
|
[$registeredComponentMetas, $instances, $selectedPage],
|
|
(metas, instances, page) => {
|
|
return getIndexesWithinAncestors(
|
|
metas,
|
|
instances,
|
|
page ? [page.rootInstanceId] : []
|
|
);
|
|
}
|
|
);
|
|
|
|
const useInstanceProps = (instanceSelector: InstanceSelector) => {
|
|
const instanceSelectorKey = JSON.stringify(instanceSelector);
|
|
const [instanceId] = instanceSelector;
|
|
const $instancePropsObject = useMemo(() => {
|
|
return computed(
|
|
[$propValuesByInstanceSelectorWithMemoryProps, $indexesWithinAncestors],
|
|
(propValuesByInstanceSelector, indexesWithinAncestors) => {
|
|
const instancePropsObject: Record<Prop["name"], unknown> = {};
|
|
const index = indexesWithinAncestors.get(instanceId);
|
|
if (index !== undefined) {
|
|
instancePropsObject[indexAttribute] = index.toString();
|
|
}
|
|
const instanceProps =
|
|
propValuesByInstanceSelector.get(instanceSelectorKey);
|
|
if (instanceProps) {
|
|
for (const [name, value] of instanceProps) {
|
|
instancePropsObject[name] = value;
|
|
}
|
|
}
|
|
return instancePropsObject;
|
|
}
|
|
);
|
|
}, [instanceSelectorKey, instanceId]);
|
|
const instancePropsObject = useStore($instancePropsObject);
|
|
return instancePropsObject;
|
|
};
|
|
|
|
const existingElements = new Set<string>();
|
|
|
|
/**
|
|
* We are identifying newly created instances like Tooltips and ensuring the calculation of 'collapsed' elements.
|
|
*/
|
|
const useCollapsedOnNewElement = (instanceId: Instance["id"]) => {
|
|
useEffect(() => {
|
|
if (existingElements.has(instanceId) === false) {
|
|
setDataCollapsed(instanceId);
|
|
}
|
|
|
|
existingElements.add(instanceId);
|
|
return () => {
|
|
existingElements.delete(instanceId);
|
|
};
|
|
}, [instanceId]);
|
|
};
|
|
|
|
/**
|
|
* We combine Radix's implicit event handlers with user-defined ones,
|
|
* such as onClick or onSubmit. For instance, a Button within
|
|
* a TooltipTrigger receives an onClick handler from the TooltipTrigger.
|
|
* We might also need an additional onClick handler on the Button for other
|
|
* purposes (setting variable).
|
|
**/
|
|
const mergeProps = (
|
|
// here we assume all on* props are callbacks
|
|
// cast to avoid extra checks
|
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
restProps: Record<string, any>,
|
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
instanceProps: Record<string, any>,
|
|
callbackStrategy: "merge" | "delete"
|
|
) => {
|
|
// merge props into single object
|
|
const props = { ...restProps, ...instanceProps };
|
|
for (const propName of Object.keys(props)) {
|
|
const restPropValue = restProps[propName];
|
|
const instancePropValue = instanceProps[propName];
|
|
|
|
const isHandler = /^on[A-Z]/.test(propName);
|
|
if (isHandler === false) {
|
|
continue;
|
|
}
|
|
// combine handlers for preview
|
|
if (callbackStrategy === "merge") {
|
|
props[propName] = (...args: unknown[]) => {
|
|
restPropValue?.(...args);
|
|
instancePropValue?.(...args);
|
|
};
|
|
}
|
|
// delete all handlers from canvas mode
|
|
if (callbackStrategy === "delete") {
|
|
delete props[propName];
|
|
}
|
|
}
|
|
return props;
|
|
};
|
|
|
|
const getTextContent = (instanceProps: Record<string, unknown>) => {
|
|
const value = instanceProps[textContentAttribute];
|
|
// serialize objects and let react render literal types
|
|
if (typeof value === "object" && value !== null) {
|
|
return String(value);
|
|
}
|
|
return value as ReactNode;
|
|
};
|
|
|
|
export const WebstudioComponentCanvas = forwardRef<
|
|
HTMLElement,
|
|
WebstudioComponentProps
|
|
>(({ instance, instanceSelector, components, ...restProps }, ref) => {
|
|
const instanceId = instance.id;
|
|
const instances = useStore($instances);
|
|
|
|
const textEditingInstanceSelector = useStore($textEditingInstanceSelector);
|
|
|
|
const { [showAttribute]: show = true, ...instanceProps } =
|
|
useInstanceProps(instanceSelector);
|
|
|
|
const children =
|
|
getTextContent(instanceProps) ??
|
|
createInstanceChildrenElements({
|
|
instances,
|
|
instanceSelector,
|
|
children: instance.children,
|
|
Component: WebstudioComponentCanvas,
|
|
components,
|
|
});
|
|
/**
|
|
* Prevents edited element from having a size of 0 on the first render.
|
|
* Directly using `children` in Text Edit
|
|
* conflicts with React due to lexical node changes.
|
|
*/
|
|
const initialContentEditableContent = useRef(children);
|
|
|
|
useCollapsedOnNewElement(instanceId);
|
|
|
|
// this assumes presence of `useStore($selectedInstanceSelector)` above
|
|
// we rely on root re-rendering after selected instance changes
|
|
useEffect(() => {
|
|
// 1 means root
|
|
if (instanceSelector.length === 1) {
|
|
// If by the time root is rendered,
|
|
// no selected instance renders and sets state to "mounted",
|
|
// then it's clear that selected instance will not render at all, so we set it to "notMounted"
|
|
if ($selectedInstanceRenderState.get() === "pending") {
|
|
$selectedInstanceRenderState.set("notMounted");
|
|
}
|
|
}
|
|
});
|
|
|
|
if (show === false) {
|
|
return <></>;
|
|
}
|
|
|
|
let Component =
|
|
components.get(instance.component) ??
|
|
(MissingComponentStub as AnyComponent);
|
|
|
|
if (instance.component === collectionComponent) {
|
|
const data = instanceProps.data;
|
|
if (data && Array.isArray(data) === false) {
|
|
Component = InvalidCollectionDataStub as AnyComponent;
|
|
} else if (
|
|
// render stub component when no data or children
|
|
Array.isArray(data) &&
|
|
data.length > 0 &&
|
|
instance.children.length > 0
|
|
) {
|
|
return data.map((_item, index) => {
|
|
return (
|
|
<Fragment key={index}>
|
|
{createInstanceChildrenElements({
|
|
instances,
|
|
// create fake indexed id to distinct items for select and hover
|
|
instanceSelector: [
|
|
getIndexedInstanceId(instance.id, index),
|
|
...instanceSelector,
|
|
],
|
|
children: instance.children,
|
|
Component: WebstudioComponentCanvas,
|
|
components,
|
|
})}
|
|
</Fragment>
|
|
);
|
|
});
|
|
} else {
|
|
Component = DroppableComponentStub as AnyComponent;
|
|
}
|
|
}
|
|
|
|
if (instance.component === descendantComponent) {
|
|
return <></>;
|
|
}
|
|
|
|
const props: {
|
|
[componentAttribute]: string;
|
|
[idAttribute]: string;
|
|
} & Record<string, unknown> = {
|
|
...mergeProps(restProps, instanceProps, "delete"),
|
|
// current props should override bypassed from parent
|
|
// important for data-ws-* props
|
|
tabIndex: 0,
|
|
[selectorIdAttribute]: instanceSelector.join(","),
|
|
[componentAttribute]: instance.component,
|
|
[idAttribute]: instance.id,
|
|
};
|
|
|
|
// React ignores defaultValue changes after first render.
|
|
// Key prop forces re-creation to reflect updates on canvas.
|
|
const key =
|
|
props.defaultValue != null ? props.defaultValue.toString() : undefined;
|
|
|
|
const instanceElement = (
|
|
<>
|
|
<Component key={key} {...props} ref={ref}>
|
|
{children}
|
|
</Component>
|
|
</>
|
|
);
|
|
|
|
if (
|
|
areInstanceSelectorsEqual(textEditingInstanceSelector, instanceSelector) ===
|
|
false
|
|
) {
|
|
initialContentEditableContent.current = children;
|
|
return instanceElement;
|
|
}
|
|
|
|
return (
|
|
<Suspense fallback={instanceElement}>
|
|
<TextEditor
|
|
rootInstanceSelector={instanceSelector}
|
|
instances={instances}
|
|
contentEditable={
|
|
<ContentEditable
|
|
renderComponentWithRef={(elementRef) => (
|
|
<Component {...props} ref={mergeRefs(ref, elementRef)}>
|
|
{initialContentEditableContent.current}
|
|
</Component>
|
|
)}
|
|
/>
|
|
}
|
|
onChange={(instancesList) => {
|
|
serverSyncStore.createTransaction([$instances], (instances) => {
|
|
const deletedTreeIds = findTreeInstanceIds(instances, instance.id);
|
|
for (const updatedInstance of instancesList) {
|
|
instances.set(updatedInstance.id, updatedInstance);
|
|
// exclude reused instances
|
|
deletedTreeIds.delete(updatedInstance.id);
|
|
}
|
|
for (const instanceId of deletedTreeIds) {
|
|
instances.delete(instanceId);
|
|
}
|
|
});
|
|
}}
|
|
onSelectInstance={(instanceId) => {
|
|
const instances = $instances.get();
|
|
const newSelectedSelector = getInstanceSelector(
|
|
instances,
|
|
instanceSelector,
|
|
instanceId
|
|
);
|
|
$textEditingInstanceSelector.set(undefined);
|
|
$selectedInstanceSelector.set(newSelectedSelector);
|
|
}}
|
|
/>
|
|
</Suspense>
|
|
);
|
|
});
|
|
|
|
export const WebstudioComponentPreview = forwardRef<
|
|
HTMLElement,
|
|
WebstudioComponentProps
|
|
>(({ instance, instanceSelector, components, ...restProps }, ref) => {
|
|
const instances = useStore($instances);
|
|
const { [showAttribute]: show = true, ...instanceProps } =
|
|
useInstanceProps(instanceSelector);
|
|
const props = {
|
|
...mergeProps(restProps, instanceProps, "merge"),
|
|
[idAttribute]: instance.id,
|
|
[componentAttribute]: instance.component,
|
|
};
|
|
if (show === false) {
|
|
return <></>;
|
|
}
|
|
|
|
if (instance.component === collectionComponent) {
|
|
const data = instanceProps.data;
|
|
// render nothing when no data or children
|
|
if (
|
|
Array.isArray(data) &&
|
|
data.length > 0 &&
|
|
instance.children.length > 0
|
|
) {
|
|
return data.map((_item, index) => {
|
|
return (
|
|
<Fragment key={index}>
|
|
{createInstanceChildrenElements({
|
|
instances,
|
|
// create fake indexed id to distinct items for select and hover
|
|
instanceSelector: [
|
|
getIndexedInstanceId(instance.id, index),
|
|
...instanceSelector,
|
|
],
|
|
children: instance.children,
|
|
Component: WebstudioComponentPreview,
|
|
components,
|
|
})}
|
|
</Fragment>
|
|
);
|
|
});
|
|
}
|
|
}
|
|
|
|
if (instance.component === descendantComponent) {
|
|
return <></>;
|
|
}
|
|
|
|
const Component = components.get(instance.component);
|
|
if (Component === undefined) {
|
|
return <></>;
|
|
}
|
|
return (
|
|
<Component {...props} ref={ref}>
|
|
{getTextContent(instanceProps) ??
|
|
createInstanceChildrenElements({
|
|
instances,
|
|
instanceSelector,
|
|
children: instance.children,
|
|
Component: WebstudioComponentPreview,
|
|
components,
|
|
})}
|
|
</Component>
|
|
);
|
|
});
|