feat: switch to expression edtior for expressions (#2707)

Ref https://github.com/webstudio-is/webstudio/issues/2532

- switched to expression editor for expression field in variables panel
- removed legacy code editor
- preview actual (not initial) values in variable list
- show actual values in variable list for parameters
- render autocomplete tooltip in body to prevent variables popover
scroll overflow
- wrap lines expression editor to avoid horizontal scroll
- fix html embed failing when non string value is specified

<img width="503" alt="Screenshot 2023-12-20 at 11 17 22"
src="https://github.com/webstudio-is/webstudio/assets/5635476/09a4ae36-2b02-416f-b290-3a82cda79851">
<img width="493" alt="Screenshot 2023-12-20 at 11 15 14"
src="https://github.com/webstudio-is/webstudio/assets/5635476/409c08e5-200f-45aa-908c-be5d0a2ba520">
This commit is contained in:
Bogdan Chadkin authored and GitHub committed 2023-12-21 12:14:00 +07:00
1 parent 60dcb68776
commit 0688dfdab7
9 files changed
+406 -487

No files matched your search

@@ -1,39 +0,0 @@
import type { Meta, StoryObj } from "@storybook/react";
import { CodeEditor as CodeEditorComponent } from "./code-editor";
export default {
title: "Builder/CodeEditor",
component: CodeEditorComponent,
} satisfies Meta;
const variables = new Map([
["#1", "formState"],
["#2", "formInitial"],
["#3", "count"],
["#4", "something"],
["#5", "else"],
["#6", "hello"],
]);
export const CodeEditor: StoryObj = {
render: () => (
<>
<p>Type $ to trigger variables completion</p>
<label>Editor with variable in the middle</label>
<CodeEditorComponent
variables={variables}
defaultValue={"Default variable is #3"}
// eslint-disable-next-line no-console
onChange={(newCode) => console.info(newCode)}
/>
<div style={{ height: 32 }}></div>
<label>Editor with variable at the beginning</label>
<CodeEditorComponent
variables={variables}
defaultValue={"#1 comes first and #2 comes second, and last same #1"}
// eslint-disable-next-line no-console
onChange={(newCode) => console.info(newCode)}
/>
</>
),
};
@@ -1,386 +0,0 @@
import { useCallback, useEffect, useState } from "react";
import { createPortal } from "react-dom";
import {
type DOMConversionMap,
type DOMConversionOutput,
type DOMExportOutput,
type EditorConfig,
type LexicalNode,
type NodeKey,
type SerializedTextNode,
$applyNodeReplacement,
TextNode,
$getRoot,
$isElementNode,
createEditor,
type LexicalEditor,
} from "lexical";
import { $generateNodesFromDOM } from "@lexical/html";
import { LexicalComposer } from "@lexical/react/LexicalComposer";
import { PlainTextPlugin } from "@lexical/react/LexicalPlainTextPlugin";
import { ContentEditable } from "@lexical/react/LexicalContentEditable";
import { HistoryPlugin } from "@lexical/react/LexicalHistoryPlugin";
import LexicalErrorBoundary from "@lexical/react/LexicalErrorBoundary";
import {
LexicalTypeaheadMenuPlugin,
MenuOption,
useBasicTypeaheadTriggerMatch,
} from "@lexical/react/LexicalTypeaheadMenuPlugin";
import { useLexicalComposerContext } from "@lexical/react/LexicalComposerContext";
import { OnChangePlugin } from "@lexical/react/LexicalOnChangePlugin";
import {
MenuItemButton,
MenuList,
css,
textVariants,
theme,
} from "@webstudio-is/design-system";
// mention node is based on
// https://github.com/facebook/lexical/blob/f15a17564b6267531732ba5cc824b8aaf45f5b54/packages/lexical-playground/src/nodes/MentionNode.ts
const convertMentionElement = (
domNode: HTMLElement
): DOMConversionOutput | null => {
const mentionText = domNode.getAttribute("data-mention-text");
const mentionId = domNode.getAttribute("data-mention-id");
if (mentionText !== null && mentionId !== null) {
const node = $createMentionNode(mentionText, mentionId);
return {
node,
};
}
return null;
};
const mentionStyle = "background-color: rgba(24, 119, 232, 0.2)";
class MentionNode extends TextNode {
mentionText: string;
mentionId: string;
static getType(): string {
return "mention";
}
static clone(node: MentionNode): MentionNode {
return new MentionNode(node.mentionText, node.mentionId, node.__key);
}
static importJSON(): MentionNode {
throw Error("Unimplemented");
}
static importDOM(): DOMConversionMap | null {
return {
span: (domNode: HTMLElement) => {
if (!domNode.hasAttribute("data-lexical-mention")) {
return null;
}
return {
conversion: convertMentionElement,
priority: 1,
};
},
};
}
constructor(mentionText: string, mentionId: string, key?: NodeKey) {
super(mentionText, key);
this.mentionText = mentionText;
this.mentionId = mentionId;
}
exportJSON(): SerializedTextNode {
throw Error("Unimplemented");
}
createDOM(config: EditorConfig): HTMLElement {
const dom = super.createDOM(config);
dom.style.cssText = mentionStyle;
dom.className = "mention";
return dom;
}
exportDOM(): DOMExportOutput {
const element = document.createElement("span");
element.setAttribute("data-lexical-mention", "true");
element.setAttribute("data-mention-text", this.mentionText);
element.setAttribute("data-mention-id", this.mentionId);
element.textContent = this.__text;
return { element };
}
isTextEntity(): true {
return true;
}
canInsertTextBefore(): boolean {
return false;
}
canInsertTextAfter(): boolean {
return false;
}
}
const $createMentionNode = (
mentionText: string,
mentionId: string
): MentionNode => {
const mentionNode = new MentionNode(mentionText, mentionId);
mentionNode.setMode("segmented").toggleDirectionless();
return $applyNodeReplacement(mentionNode);
};
const $isMentionNode = (
node: LexicalNode | null | undefined
): node is MentionNode => {
return node instanceof MentionNode;
};
const findAllSubstrings = (text: string, substrings: string[]) => {
const offsets = new Set<number>();
for (const substring of substrings) {
let index = text.indexOf(substring);
while (index !== -1) {
// add start and end offsets of each match
offsets.add(index);
offsets.add(index + substring.length);
index = text.indexOf(substring, index + 1);
}
}
return Array.from(offsets);
};
const VariableSuggestionsPlugin = ({
variables,
}: {
variables: Map<string, string>;
}) => {
const [editor] = useLexicalComposerContext();
const checkForMentionMatch = useBasicTypeaheadTriggerMatch("$", {
minLength: 0,
});
const [options, setOptions] = useState<MenuOption[]>([]);
const onQueryChange = useCallback(
(matchingString: string | null) => {
let matchedIds: string[] = [];
if (matchingString == null) {
matchedIds = Array.from(variables.keys());
} else {
matchedIds = Array.from(variables)
.filter(([_id, text]) =>
// make search case insensitive
text.toLowerCase().includes(matchingString.toLowerCase())
)
.map(([id]) => id);
}
setOptions(matchedIds.map((id) => new MenuOption(id)));
},
[variables]
);
const onSelectOption = useCallback(
(
selectedOption: MenuOption,
nodeToReplace: TextNode | null,
closeMenu: () => void
) => {
editor.update(() => {
const mentionId = selectedOption.key;
const mentionText = variables.get(mentionId);
if (mentionText !== undefined) {
const mentionNode = $createMentionNode(mentionText, mentionId);
if (nodeToReplace) {
nodeToReplace.replace(mentionNode);
}
mentionNode.select();
}
closeMenu();
});
},
[editor, variables]
);
useEffect(() => {
return editor.registerNodeTransform(TextNode, (node) => {
const offsets = findAllSubstrings(
node.getTextContent(),
Array.from(variables.keys())
);
// stop before calling any lexical updates to avoid infinite loop
if (offsets.length === 0) {
return;
}
const nodes = node.splitText(...offsets);
for (node of nodes) {
const mentionId = node.getTextContent();
const mentionText = variables.get(mentionId);
if (mentionText !== undefined) {
const mentionNode = $createMentionNode(mentionText, mentionId);
node.replace(mentionNode);
}
}
});
}, [editor, variables]);
return (
<LexicalTypeaheadMenuPlugin<MenuOption>
triggerFn={checkForMentionMatch}
onQueryChange={onQueryChange}
onSelectOption={onSelectOption}
options={options}
menuRenderFn={(
anchorElementRef,
{ selectedIndex, selectOptionAndCleanUp, setHighlightedIndex }
) => {
if (anchorElementRef.current === null || options.length === 0) {
return null;
}
return createPortal(
<MenuList
// put menu below cursor
css={{
minWidth: 160,
width: "max-content",
position: "absolute",
top: "100%",
}}
>
{options.map((option, index) => (
<MenuItemButton
key={option.key}
// show variable names without changes
css={{ textTransform: "none" }}
tabIndex={-1}
ref={option.setRefElement}
role="option"
aria-selected={selectedIndex === index}
onMouseEnter={() => {
setHighlightedIndex(index);
}}
onClick={() => {
setHighlightedIndex(index);
selectOptionAndCleanUp(option);
}}
>
{variables.get(option.key)}
</MenuItemButton>
))}
</MenuList>,
anchorElementRef.current
);
}}
/>
);
};
const traverseNode = (
node: LexicalNode,
callback: (node: LexicalNode) => void
) => {
callback(node);
if ($isElementNode(node)) {
for (const child of node.getChildren()) {
traverseNode(child, callback);
}
}
};
const onError = (error: unknown) => {
// eslint-disable-next-line no-console
console.error(error);
};
const rootStyle = css({
...textVariants.mono,
boxSizing: "border-box",
color: theme.colors.foregroundMain,
borderRadius: theme.borderRadius[4],
border: `1px solid ${theme.colors.borderMain}`,
background: theme.colors.backgroundControls,
paddingTop: 6,
paddingBottom: 4,
paddingRight: theme.spacing[2],
paddingLeft: theme.spacing[3],
"&:focus": {
borderColor: theme.colors.borderFocus,
outline: `1px solid ${theme.colors.borderFocus}`,
},
});
const paragraphStyle = css({
marginTop: 0,
marginBottom: 0,
});
/**
* The main feature of code editor is to provide completion
* with specified variables
*
* Could be used to edit css as well in the future.
*/
export const CodeEditor = ({
readOnly = false,
variables,
defaultValue,
onChange,
onBlur,
}: {
readOnly?: boolean;
variables: Map<string, string>;
defaultValue: string;
onChange: (newCode: string) => void;
onBlur?: () => void;
}) => {
const initialConfig = {
namespace: "CodeEditor",
editable: readOnly === false,
nodes: [MentionNode],
theme: {
root: rootStyle.toString(),
paragraph: paragraphStyle.toString(),
},
editorState: (editor: LexicalEditor) => {
const root = $getRoot();
const parser = new DOMParser();
const dom = parser.parseFromString(`<p>${defaultValue}</p>`, "text/html");
const nodes = $generateNodesFromDOM(editor, dom);
root.append(...nodes);
},
onError,
};
return (
<LexicalComposer initialConfig={initialConfig}>
<PlainTextPlugin
contentEditable={<ContentEditable spellCheck={false} onBlur={onBlur} />}
placeholder={null}
ErrorBoundary={LexicalErrorBoundary}
/>
<HistoryPlugin />
<VariableSuggestionsPlugin variables={variables} />
<OnChangePlugin
onChange={(editorState) => {
const { editorState: _editorState, ...config } = initialConfig;
const updater = createEditor(config);
updater.setEditorState(editorState.clone());
updater.update(() => {
const root = $getRoot();
traverseNode(root, (node) => {
if ($isMentionNode(node)) {
node.setTextContent(node.mentionId);
}
});
onChange(root.getTextContent());
});
}}
/>
</LexicalComposer>
);
};
@@ -43,9 +43,9 @@ import {
$instances,
$props,
$selectedInstanceSelector,
$variableValuesByInstanceSelector,
} from "~/shared/nano-states";
import { serverSyncStore } from "~/shared/sync";
import { CodeEditor } from "./code-editor";
import type { PropValue } from "./shared";
import { getStartingValue } from "./props-section/use-props-logic";
import {
@@ -265,6 +265,14 @@ const $selectedInstanceVariables = computed(
}
);
const $selectedInstanceVariableValues = computed(
[$selectedInstanceSelector, $variableValuesByInstanceSelector],
(instanceSelector, variableValuesByInstanceSelector) => {
const key = JSON.stringify(instanceSelector);
return variableValuesByInstanceSelector.get(key) ?? new Map();
}
);
const EmptyList = ({ onAdd }: { onAdd: () => void }) => {
return (
<Flex direction="column" css={{ gap: theme.spacing[5] }}>
@@ -331,6 +339,7 @@ const ListItem = ({
selected,
deletable,
variable,
value,
onSelect,
onEdit,
}: {
@@ -338,6 +347,7 @@ const ListItem = ({
selected: boolean;
deletable: boolean;
variable: DataSource;
value: unknown;
onSelect: (variableId: DataSource["id"]) => void;
onEdit: (variable: DataSource) => void;
}) => {
@@ -345,9 +355,9 @@ const ListItem = ({
<CssValueListItem
label={
<Label truncate>
{variable.type === "variable"
? `${variable.name}: ${formatValuePreview(variable.value.value)}`
: variable.name}
{value === undefined
? variable.name
: `${variable.name}: ${formatValuePreview(value)}`}
</Label>
}
id={variable.id}
@@ -436,13 +446,6 @@ const ListPanel = ({
onChange: (value: undefined | PropValue) => void;
}) => {
const matchedVariables = useStore($selectedInstanceVariables);
const editorVariables = useMemo(() => {
const variables = new Map<string, string>();
for (const variable of matchedVariables) {
variables.set(encodeDataSourceVariable(variable.id), variable.name);
}
return variables;
}, [matchedVariables]);
const propExpression = prop?.type === "expression" ? prop?.value ?? "" : "";
const exoressionVariables = useMemo(
() => getExpressionVariables(propExpression),
@@ -451,6 +454,22 @@ const ListPanel = ({
const usedVariables = useStore($usedVariables);
const [expression, setExpression] = useState<undefined | string>();
const variableValues = useStore($selectedInstanceVariableValues);
const editorScope = useMemo(() => {
const scope: Record<string, unknown> = {};
for (const [variableId, variableValue] of variableValues) {
scope[encodeDataSourceVariable(variableId)] = variableValue;
}
return scope;
}, [variableValues]);
const editorAliases = useMemo(() => {
const aliases = new Map<string, string>();
for (const variable of matchedVariables) {
aliases.set(encodeDataSourceVariable(variable.id), variable.name);
}
return aliases;
}, [matchedVariables]);
return (
<ScrollArea
css={{
@@ -470,6 +489,7 @@ const ListPanel = ({
key={variable.id}
index={index}
variable={variable}
value={variableValues.get(variable.id)}
// mark all variables used in expression as selected
selected={exoressionVariables.has(variable.id)}
deletable={
@@ -496,11 +516,10 @@ const ListPanel = ({
}}
>
<Label>Expression</Label>
<CodeEditor
// reset uncontrolled editor when saved expression is changed
key={propExpression}
variables={editorVariables}
defaultValue={expression ?? propExpression}
<ExpressionEditor
scope={editorScope}
aliases={editorAliases}
value={expression ?? propExpression}
onChange={setExpression}
onBlur={() => {
// skip when expression is not changed
@@ -515,7 +534,14 @@ const ListPanel = ({
}
try {
validateExpression(expression);
validateExpression(expression, {
transformIdentifier: (id) => {
if (editorAliases.has(id) === false) {
throw Error(`Unknown variable "${id}"`);
}
return id;
},
});
} catch (error) {
// @todo show errors
(error as Error).message;
@@ -12,6 +12,7 @@ import {
drawSelection,
dropCursor,
EditorView,
tooltips,
} from "@codemirror/view";
import { defaultKeymap, history, historyKeymap } from "@codemirror/commands";
import {
@@ -80,6 +81,9 @@ const VariablesData = Facet.define<{
aliases: Aliases;
}>();
// completion based on
// https://github.com/codemirror/lang-javascript/blob/4dcee95aee9386fd2c8ad55f93e587b39d968489/src/complete.ts
// Defines a completion source that completes from the given scope
// object (for example `globalThis`). Will enter properties
// of the object when completing properties on a directly-named path.
@@ -118,6 +122,8 @@ const scopeCompletionSource: CompletionSource = (context) => {
/**
* Highlight variables and replace their $ws$dataSource$name like labels
* with user names
*
* https://codemirror.net/examples/decoration/#atomic-ranges
*/
class VariableWidget extends WidgetType {
@@ -186,46 +192,45 @@ const rootStyle = css({
"& .cm-line": {
padding: 0,
},
"& .cm-tooltip": {
});
const autocompletionStyle = css({
"&.cm-tooltip.cm-tooltip-autocomplete": {
...textVariants.mono,
border: "none",
backgroundColor: "transparent",
},
"& .cm-tooltip.cm-tooltip-autocomplete ul": {
minWidth: 160,
maxWidth: 260,
width: "max-content",
boxSizing: "border-box",
borderRadius: theme.borderRadius[6],
backgroundColor: theme.colors.backgroundMenu,
border: `1px solid ${theme.colors.borderMain}`,
boxShadow: `${theme.shadows.menuDropShadow}, inset 0 0 0 1px ${theme.colors.borderMenuInner}`,
padding: theme.spacing[3],
"& li": {
...textVariants.labelsTitleCase,
textTransform: "none",
// outline: "none",
// cursor: "default",
position: "relative",
display: "flex",
alignItems: "center",
color: theme.colors.foregroundMain,
"& ul": {
minWidth: 160,
maxWidth: 260,
width: "max-content",
boxSizing: "border-box",
borderRadius: theme.borderRadius[6],
backgroundColor: theme.colors.backgroundMenu,
border: `1px solid ${theme.colors.borderMain}`,
boxShadow: `${theme.shadows.menuDropShadow}, inset 0 0 0 1px ${theme.colors.borderMenuInner}`,
padding: theme.spacing[3],
borderRadius: theme.borderRadius[3],
"&[aria-selected]": {
"& li": {
...textVariants.labelsTitleCase,
textTransform: "none",
position: "relative",
display: "flex",
alignItems: "center",
color: theme.colors.foregroundMain,
backgroundColor: theme.colors.backgroundItemMenuItemHover,
},
"& .cm-completionIcon": {
display: "none",
},
"& .cm-completionLabel": {
flexGrow: 1,
},
"& .cm-completionDetail": {
overflow: "hidden",
textOverflow: "ellipsis",
fontStyle: "normal",
color: theme.colors.hint,
padding: theme.spacing[3],
borderRadius: theme.borderRadius[3],
"&[aria-selected]": {
color: theme.colors.foregroundMain,
backgroundColor: theme.colors.backgroundItemMenuItemHover,
},
"& .cm-completionLabel": {
flexGrow: 1,
},
"& .cm-completionDetail": {
overflow: "hidden",
textOverflow: "ellipsis",
fontStyle: "normal",
color: theme.colors.hint,
},
},
},
},
@@ -293,10 +298,18 @@ export const ExpressionEditor = ({
dropCursor(),
bracketMatching(),
closeBrackets(),
EditorView.lineWrapping,
syntaxHighlighting(defaultHighlightStyle, { fallback: true }),
javascript({}),
VariablesData.of({ scope, aliases }),
autocompletion({ override: [scopeCompletionSource] }),
// render autocomplete in body
// to prevent popover scroll overflow
tooltips({ parent: document.body }),
autocompletion({
override: [scopeCompletionSource],
icons: false,
tooltipClass: () => autocompletionStyle.toString(),
}),
variables,
keymap.of([
...closeBracketsKeymap,
@@ -304,10 +317,16 @@ export const ExpressionEditor = ({
...historyKeymap,
...completionKeymap,
// use tab to trigger completion
{ key: "Tab", run: startCompletion },
{
key: "Tab",
preventDefault: true,
stopPropagation: true,
run: startCompletion,
},
]),
EditorView.editable.of(readOnly === false),
EditorState.readOnly.of(readOnly === true),
// https://github.com/uiwjs/react-codemirror/blob/5d7a37245ce70e61f215b77dc42a7eaf295c46e7/core/src/useCodeMirror.ts#L57-L70
EditorView.updateListener.of((update) => {
if (
// prevent invoking callback when focus or selection is changed
@@ -330,7 +349,7 @@ export const ExpressionEditor = ({
}, [scope, aliases, readOnly]);
// update editor with react value
// https://github.com/uiwjs/react-codemirror/blob/5d7a37245ce70e61f215b77dc42a7eaf295c46e7/core/src/useCodeMirror.ts#L158-L169
useEffect(() => {
const view = viewRef.current;
if (view === undefined) {
@@ -3,7 +3,10 @@ import { cleanStores } from "nanostores";
import type { Instance, Page } from "@webstudio-is/sdk";
import { collectionComponent } from "@webstudio-is/react-sdk";
import { $instances } from "./instances";
import { $propValuesByInstanceSelector } from "./props";
import {
$propValuesByInstanceSelector,
$variableValuesByInstanceSelector,
} from "./props";
import { $pages, $selectedPageId } from "./pages";
import {
$assets,
@@ -374,3 +377,194 @@ test("access parameter value from variables values", () => {
cleanStores($propValuesByInstanceSelector);
});
test("compute variable values for root", () => {
$instances.set(
toMap([{ id: "body", type: "instance", component: "Body", children: [] }])
);
selectPageRoot("body");
$dataSources.set(
toMap([
{
id: "variableId",
scopeInstanceId: "body",
type: "variable",
name: "variableName",
value: { type: "string", value: "initial" },
},
])
);
$dataSourceVariables.set(new Map([["variableId", "success"]]));
$props.set(new Map());
expect($variableValuesByInstanceSelector.get()).toEqual(
new Map([
[
JSON.stringify(["body"]),
new Map<string, unknown>([["variableId", "success"]]),
],
])
);
cleanStores($variableValuesByInstanceSelector);
});
test("nest variable values from root to current instance", () => {
$instances.set(
toMap([
{
id: "body",
type: "instance",
component: "Body",
children: [
{ type: "id", value: "box" },
{ type: "id", value: "text" },
],
},
{ id: "box", type: "instance", component: "Box", children: [] },
{ id: "text", type: "instance", component: "Text", children: [] },
])
);
selectPageRoot("body");
$dataSources.set(
toMap([
{
id: "bodyVariableId",
scopeInstanceId: "body",
type: "variable",
name: "bodyVariable",
value: { type: "string", value: "" },
},
{
id: "boxVariableId",
scopeInstanceId: "box",
type: "variable",
name: "boxVariable",
value: { type: "string", value: "" },
},
{
id: "textVariableId",
scopeInstanceId: "text",
type: "variable",
name: "textVariable",
value: { type: "string", value: "" },
},
])
);
$dataSourceVariables.set(
new Map([
["bodyVariableId", "bodyValue"],
["boxVariableId", "boxValue"],
["textVariableId", "textValue"],
])
);
$props.set(new Map());
expect($variableValuesByInstanceSelector.get()).toEqual(
new Map([
[
JSON.stringify(["body"]),
new Map<string, unknown>([["bodyVariableId", "bodyValue"]]),
],
[
JSON.stringify(["box", "body"]),
new Map<string, unknown>([
["bodyVariableId", "bodyValue"],
["boxVariableId", "boxValue"],
]),
],
[
JSON.stringify(["text", "body"]),
new Map<string, unknown>([
["bodyVariableId", "bodyValue"],
["textVariableId", "textValue"],
]),
],
])
);
cleanStores($variableValuesByInstanceSelector);
});
test("compute item values for collection", () => {
$instances.set(
toMap([
{
id: "collection",
type: "instance",
component: collectionComponent,
children: [{ type: "id", value: "box" }],
},
{ id: "box", type: "instance", component: "Box", children: [] },
])
);
selectPageRoot("collection");
$dataSources.set(
toMap([
{
id: "dataId",
scopeInstanceId: "collection",
type: "variable",
name: "collectionItem",
value: { type: "json", value: ["apple", "banana", "orange"] },
},
{
id: "itemId",
scopeInstanceId: "collection",
type: "parameter",
name: "collectionItem",
},
])
);
$dataSourceVariables.set(new Map([]));
$props.set(
toMap([
{
id: "dataPropId",
instanceId: "collection",
name: "data",
type: "expression",
value: "$ws$dataSource$dataId",
},
{
id: "itemPropId",
instanceId: "collection",
name: "item",
type: "parameter",
value: "itemId",
},
])
);
expect($variableValuesByInstanceSelector.get()).toEqual(
new Map([
[
JSON.stringify(["box", "collection[0]", "collection"]),
new Map<string, unknown>([
["dataId", ["apple", "banana", "orange"]],
["itemId", "apple"],
]),
],
[
JSON.stringify(["box", "collection[1]", "collection"]),
new Map<string, unknown>([
["dataId", ["apple", "banana", "orange"]],
["itemId", "banana"],
]),
],
[
JSON.stringify(["box", "collection[2]", "collection"]),
new Map<string, unknown>([
["dataId", ["apple", "banana", "orange"]],
["itemId", "orange"],
]),
],
[
JSON.stringify(["collection"]),
new Map<string, unknown>([["dataId", ["apple", "banana", "orange"]]]),
],
])
);
cleanStores($variableValuesByInstanceSelector);
});
@@ -233,3 +233,109 @@ export const $propValuesByInstanceSelector = computed(
return propValuesByInstanceSelector;
}
);
export const $variableValuesByInstanceSelector = computed(
[$instances, $props, $selectedPage, $dataSources, $dataSourceVariables],
(instances, props, page, dataSources, dataSourceVariables) => {
const propsByInstanceId = groupBy(
props.values(),
(prop) => prop.instanceId
);
const variablesByInstanceId = groupBy(
dataSources.values(),
(dataSource) => dataSource.scopeInstanceId
);
// traverse instances tree and compute props within each instance
const variableValuesByInstanceSelector = new Map<
Instance["id"],
Map<Prop["name"], unknown>
>();
if (page === undefined) {
return variableValuesByInstanceSelector;
}
const traverseInstances = (
instanceSelector: InstanceSelector,
parentVariableValues: Map<string, unknown>
) => {
const [instanceId] = instanceSelector;
const instance = instances.get(instanceId);
if (instance === undefined) {
return;
}
const variableValues = new Map<string, unknown>(parentVariableValues);
variableValuesByInstanceSelector.set(
JSON.stringify(instanceSelector),
variableValues
);
const variables = variablesByInstanceId.get(instanceId);
if (variables) {
for (const variable of variables) {
const value = dataSourceVariables.get(variable.id);
if (variable.type === "variable") {
variableValues.set(variable.id, value ?? variable.value.value);
} else if (value !== undefined) {
variableValues.set(variable.id, value);
}
}
}
const propValues = new Map<Prop["name"], unknown>();
const props = propsByInstanceId.get(instanceId);
const parameters = new Map<Prop["name"], DataSource["id"]>();
if (props) {
for (const prop of props) {
if (
prop.type === "asset" ||
prop.type === "page" ||
prop.type === "action"
) {
continue;
}
if (prop.type === "expression") {
const value = computeExpression(prop.value, variableValues);
if (value !== undefined) {
propValues.set(prop.name, value);
}
continue;
}
if (prop.type === "parameter") {
parameters.set(prop.name, prop.value);
continue;
}
propValues.set(prop.name, prop.value);
}
}
if (instance.component === collectionComponent) {
const data = propValues.get("data");
const itemVariableId = parameters.get("item");
if (Array.isArray(data) && itemVariableId !== undefined) {
data.forEach((item, index) => {
const itemVariableValues = new Map(variableValues);
itemVariableValues.set(itemVariableId, item);
for (const child of instance.children) {
if (child.type === "id") {
const indexId = getIndexedInstanceId(instanceId, index);
traverseInstances(
[child.value, indexId, ...instanceSelector],
itemVariableValues
);
}
}
});
}
return;
}
for (const child of instance.children) {
if (child.type === "id") {
traverseInstances([child.value, ...instanceSelector], variableValues);
}
}
};
traverseInstances([page.rootInstanceId], new Map());
return variableValuesByInstanceSelector;
}
);
-1
View File
@@ -31,7 +31,6 @@
"@fontsource-variable/manrope": "^5.0.15",
"@fontsource/roboto-mono": "^5.0.13",
"@lexical/headless": "^0.12.2",
"@lexical/html": "^0.12.2",
"@lexical/link": "^0.12.2",
"@lexical/react": "^0.12.2",
"@lexical/selection": "^0.12.2",
@@ -83,7 +83,10 @@ export const HtmlEmbed = forwardRef<HTMLDivElement, Props>((props, ref) => {
const { code, executeScriptOnCanvas, clientOnly, ...rest } = props;
// code can be actually undefined when prop is not provided
if (code === undefined || code.trim().length === 0) {
//
// cast code to string in case non-string value is computed
// from expression
if (code === undefined || String(code).trim().length === 0) {
return <Placeholder innerRef={ref} {...rest} />;
}
-3
View File
@@ -94,9 +94,6 @@ importers:
'@lexical/headless':
specifier: ^0.12.2
version: 0.12.2(lexical@0.12.2)
'@lexical/html':
specifier: ^0.12.2
version: 0.12.2(lexical@0.12.2)
'@lexical/link':
specifier: ^0.12.2
version: 0.12.2(lexical@0.12.2)