mirror of
https://github.com/webstudio-is/webstudio.git
synced 2026-11-09 14:03:33 +09:00
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:
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;
|
||||
}
|
||||
);
|
||||
@@ -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} />;
|
||||
}
|
||||
|
||||
|
||||
Generated
-3
@@ -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)
|
||||
|
||||
Reference in new issue
Block a user