diff --git a/apps/builder/app/builder/features/settings-panel/code-editor.stories.tsx b/apps/builder/app/builder/features/settings-panel/code-editor.stories.tsx deleted file mode 100644 index a1d2449957..0000000000 --- a/apps/builder/app/builder/features/settings-panel/code-editor.stories.tsx +++ /dev/null @@ -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: () => ( - <> -

Type $ to trigger variables completion

- - console.info(newCode)} - /> -
- - console.info(newCode)} - /> - - ), -}; diff --git a/apps/builder/app/builder/features/settings-panel/code-editor.tsx b/apps/builder/app/builder/features/settings-panel/code-editor.tsx deleted file mode 100644 index 94798383d8..0000000000 --- a/apps/builder/app/builder/features/settings-panel/code-editor.tsx +++ /dev/null @@ -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(); - 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; -}) => { - const [editor] = useLexicalComposerContext(); - const checkForMentionMatch = useBasicTypeaheadTriggerMatch("$", { - minLength: 0, - }); - - const [options, setOptions] = useState([]); - - 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 ( - - triggerFn={checkForMentionMatch} - onQueryChange={onQueryChange} - onSelectOption={onSelectOption} - options={options} - menuRenderFn={( - anchorElementRef, - { selectedIndex, selectOptionAndCleanUp, setHighlightedIndex } - ) => { - if (anchorElementRef.current === null || options.length === 0) { - return null; - } - return createPortal( - - {options.map((option, index) => ( - { - setHighlightedIndex(index); - }} - onClick={() => { - setHighlightedIndex(index); - selectOptionAndCleanUp(option); - }} - > - {variables.get(option.key)} - - ))} - , - 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; - 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(`

${defaultValue}

`, "text/html"); - const nodes = $generateNodesFromDOM(editor, dom); - root.append(...nodes); - }, - onError, - }; - - return ( - - } - placeholder={null} - ErrorBoundary={LexicalErrorBoundary} - /> - - - { - 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()); - }); - }} - /> - - ); -}; diff --git a/apps/builder/app/builder/features/settings-panel/variables.tsx b/apps/builder/app/builder/features/settings-panel/variables.tsx index 8235d23abd..d4055ddc41 100644 --- a/apps/builder/app/builder/features/settings-panel/variables.tsx +++ b/apps/builder/app/builder/features/settings-panel/variables.tsx @@ -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 ( @@ -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 = ({ - {variable.type === "variable" - ? `${variable.name}: ${formatValuePreview(variable.value.value)}` - : variable.name} + {value === undefined + ? variable.name + : `${variable.name}: ${formatValuePreview(value)}`} } 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(); - 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(); + const variableValues = useStore($selectedInstanceVariableValues); + const editorScope = useMemo(() => { + const scope: Record = {}; + for (const [variableId, variableValue] of variableValues) { + scope[encodeDataSourceVariable(variableId)] = variableValue; + } + return scope; + }, [variableValues]); + const editorAliases = useMemo(() => { + const aliases = new Map(); + for (const variable of matchedVariables) { + aliases.set(encodeDataSourceVariable(variable.id), variable.name); + } + return aliases; + }, [matchedVariables]); + return ( - { // 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; diff --git a/apps/builder/app/builder/shared/expression-editor.tsx b/apps/builder/app/builder/shared/expression-editor.tsx index 75d3d77be0..b3d6957fec 100644 --- a/apps/builder/app/builder/shared/expression-editor.tsx +++ b/apps/builder/app/builder/shared/expression-editor.tsx @@ -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) { diff --git a/apps/builder/app/shared/nano-states/props.test.ts b/apps/builder/app/shared/nano-states/props.test.ts index ff72478204..7d96b33e99 100644 --- a/apps/builder/app/shared/nano-states/props.test.ts +++ b/apps/builder/app/shared/nano-states/props.test.ts @@ -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([["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([["bodyVariableId", "bodyValue"]]), + ], + [ + JSON.stringify(["box", "body"]), + new Map([ + ["bodyVariableId", "bodyValue"], + ["boxVariableId", "boxValue"], + ]), + ], + [ + JSON.stringify(["text", "body"]), + new Map([ + ["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([ + ["dataId", ["apple", "banana", "orange"]], + ["itemId", "apple"], + ]), + ], + [ + JSON.stringify(["box", "collection[1]", "collection"]), + new Map([ + ["dataId", ["apple", "banana", "orange"]], + ["itemId", "banana"], + ]), + ], + [ + JSON.stringify(["box", "collection[2]", "collection"]), + new Map([ + ["dataId", ["apple", "banana", "orange"]], + ["itemId", "orange"], + ]), + ], + [ + JSON.stringify(["collection"]), + new Map([["dataId", ["apple", "banana", "orange"]]]), + ], + ]) + ); + + cleanStores($variableValuesByInstanceSelector); +}); diff --git a/apps/builder/app/shared/nano-states/props.ts b/apps/builder/app/shared/nano-states/props.ts index fcd9a664dc..40969e5d8a 100644 --- a/apps/builder/app/shared/nano-states/props.ts +++ b/apps/builder/app/shared/nano-states/props.ts @@ -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 + >(); + if (page === undefined) { + return variableValuesByInstanceSelector; + } + const traverseInstances = ( + instanceSelector: InstanceSelector, + parentVariableValues: Map + ) => { + const [instanceId] = instanceSelector; + const instance = instances.get(instanceId); + if (instance === undefined) { + return; + } + + const variableValues = new Map(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(); + const props = propsByInstanceId.get(instanceId); + const parameters = new Map(); + 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; + } +); diff --git a/apps/builder/package.json b/apps/builder/package.json index 5b40555e0f..f20d45f7fb 100644 --- a/apps/builder/package.json +++ b/apps/builder/package.json @@ -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", diff --git a/packages/sdk-components-react/src/html-embed.tsx b/packages/sdk-components-react/src/html-embed.tsx index 0bbf6e8897..f1760e2b96 100644 --- a/packages/sdk-components-react/src/html-embed.tsx +++ b/packages/sdk-components-react/src/html-embed.tsx @@ -83,7 +83,10 @@ export const HtmlEmbed = forwardRef((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 ; } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index a6b7a06441..119413fbf6 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -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)