diff --git a/apps/builder/app/builder/features/navigator/navigator-tree.tsx b/apps/builder/app/builder/features/navigator/navigator-tree.tsx index 517dc4c177fd..682a89b2aa55 100644 --- a/apps/builder/app/builder/features/navigator/navigator-tree.tsx +++ b/apps/builder/app/builder/features/navigator/navigator-tree.tsx @@ -27,6 +27,8 @@ import { rootComponent, blockTemplateComponent, descendantComponent, + getSlotContentRoot, + customSlotComponent, type Instance, } from "@webstudio-is/sdk"; import { animationCanPlayOnCanvasProperty } from "@webstudio-is/sdk/runtime"; @@ -91,6 +93,34 @@ type TreeItem = { dropTarget?: TreeDropTarget; }; +const hasVisibleNavigatorChildren = ( + instances: Map, + instance: Instance +): boolean => { + const children = + instance.component === customSlotComponent + ? (getSlotContentRoot(instances, instance.id)?.children ?? []) + : instance.children; + + for (const child of children) { + if (child.type !== "id") { + continue; + } + const childInstance = instances.get(child.value); + if (childInstance === undefined) { + continue; + } + if (childInstance.component !== "Fragment") { + return true; + } + if (hasVisibleNavigatorChildren(instances, childInstance)) { + return true; + } + } + + return false; +}; + const $expandedItems = atom(new Set()); const $dropTarget = computed( @@ -140,7 +170,10 @@ export const $flatTree = computed( const isHidden = isParentHidden || false === Boolean(propValues?.get(showAttribute) ?? true); - const isReusable = isParentReusable || instance.component === "Slot"; + const isReusable = + isParentReusable || + instance.component === "Slot" || + instance.component === customSlotComponent; const treeItem: TreeItem = { selector, visibleAncestors, @@ -151,7 +184,7 @@ export const $flatTree = computed( isReusable, }; let isVisible = true; - // slot fragment component is not rendered in navigator tree + // internal structure nodes are not rendered in navigator tree // so should be always expanded if (instance.component === "Fragment") { isVisible = false; @@ -184,7 +217,7 @@ export const $flatTree = computed( flatTree.push(treeItem); } const level = treeItem.visibleAncestors.length - 1; - if (level > 0 && instance.children.some((child) => child.type === "id")) { + if (level > 0 && hasVisibleNavigatorChildren(instances, instance)) { treeItem.isExpanded = expandedItems.has(selector.join()); } // always expand invisible items diff --git a/apps/builder/app/builder/features/pages/page-utils.ts b/apps/builder/app/builder/features/pages/page-utils.ts index 6e9515b6231f..f673f0f2aa29 100644 --- a/apps/builder/app/builder/features/pages/page-utils.ts +++ b/apps/builder/app/builder/features/pages/page-utils.ts @@ -13,6 +13,7 @@ import { ROOT_FOLDER_ID, isRootFolder, ROOT_INSTANCE_ID, + isCustomSlotInternalVariableName, systemParameter, SYSTEM_VARIABLE_ID, } from "@webstudio-is/sdk"; @@ -278,6 +279,9 @@ export const $pageRootScope = computed( if (dataSource === undefined) { continue; } + if (isCustomSlotInternalVariableName(dataSource.name)) { + continue; + } const name = encodeDataSourceVariable(dataSourceId); scope[name] = value; aliases.set(name, dataSource.name); diff --git a/apps/builder/app/builder/features/settings-panel/custom-slot-fields-section.tsx b/apps/builder/app/builder/features/settings-panel/custom-slot-fields-section.tsx new file mode 100644 index 000000000000..525873f19281 --- /dev/null +++ b/apps/builder/app/builder/features/settings-panel/custom-slot-fields-section.tsx @@ -0,0 +1,550 @@ +import { useMemo, useState } from "react"; +import { useStore } from "@nanostores/react"; +import { nanoid } from "nanoid"; +import { + Box, + Button, + ContextMenu, + ContextMenuContent, + ContextMenuItem, + ContextMenuTrigger, + Flex, + InputField, + Text, + theme, +} from "@webstudio-is/design-system"; +import { + decodeDataSourceVariable, + isCustomSlotComponentDataSource, + type DataSource, + type Instance, +} from "@webstudio-is/sdk"; +import { + findCustomSlotSchemaDataSource, + findCustomSlotValuesDataSource, + getSlotContentRootId, + customSlotComponent, + customSlotSchemaVariable, + customSlotValuesVariable, +} from "@webstudio-is/sdk"; +import { + BindingControl, + BindingPopover, + evaluateExpressionWithinScope, +} from "~/builder/shared/binding-popover"; +import { formatValue } from "~/builder/shared/expression-editor"; +import { CodeEditor } from "~/shared/code-editor"; +import { $dataSources, $instances } from "~/shared/nano-states"; +import { + createCustomSlotLiteralValue, + evaluateCustomSlotFieldValue, + parseCustomSlotFieldEditorValue, + parseCustomSlotFieldValues, + parseCustomSlotSchema, + type CustomSlotFieldSchema, + type CustomSlotFieldValue, + type CustomSlotFieldValues, +} from "~/shared/custom-slot-field-values"; +import { updateWebstudioData } from "~/shared/instance-utils"; +import { + $selectedInstanceScope, + updateExpressionValue, + useBindingState, + useLocalValue, +} from "./shared"; + +const normalizeFieldName = (value: string) => { + const trimmed = value.trim(); + + const normalized = trimmed + .replace(/[^a-zA-Z0-9_$]+/g, "_") + .replace(/^[^a-zA-Z_$]+/g, ""); + + return normalized.length > 0 ? normalized : "field"; +}; + +const ensureUniqueFieldName = ( + schema: CustomSlotFieldSchema[], + fieldId: string, + rawName: string +) => { + const base = normalizeFieldName(rawName); + let candidate = base; + let index = 2; + + while ( + schema.some( + (field) => + field.id !== fieldId && + field.name.toLowerCase() === candidate.toLowerCase() + ) + ) { + candidate = `${base}${index}`; + index += 1; + } + + return candidate; +}; + +const setJsonVariable = ({ + scopeInstanceId, + name, + value, +}: { + scopeInstanceId: string; + name: string; + value: unknown; +}) => { + updateWebstudioData((data) => { + let existing: DataSource | undefined; + + for (const dataSource of data.dataSources.values()) { + if ( + dataSource.type === "variable" && + dataSource.scopeInstanceId === scopeInstanceId && + dataSource.name === name + ) { + existing = dataSource; + break; + } + } + + if (existing?.type === "variable") { + data.dataSources.set(existing.id, { + ...existing, + value: { + type: "json", + value, + }, + }); + return; + } + + const id = nanoid(); + data.dataSources.set(id, { + id, + type: "variable", + scopeInstanceId, + name, + value: { + type: "json", + value, + }, + }); + }); +}; + +const formatFieldEditorValue = (value: unknown) => { + if (typeof value === "string") { + return value; + } + + if (value === undefined) { + return ""; + } + + return formatValue(value) ?? ""; +}; + +const findAllCustomSlotInstancesSharingFragment = ( + instances: Map, + fragmentId: string +) => { + const result: Instance[] = []; + + for (const instance of instances.values()) { + if ( + instance.component === customSlotComponent && + getSlotContentRootId(instances, instance.id) === fragmentId + ) { + result.push(instance); + } + } + + return result; +}; + +const getSchemaTargetFragmentIds = ( + instances: Map, + selectedInstance: Instance +) => { + const sharedFragmentId = getSlotContentRootId(instances, selectedInstance.id); + + if (sharedFragmentId === undefined) { + return []; + } + + return Array.from( + new Set( + findAllCustomSlotInstancesSharingFragment( + instances, + sharedFragmentId + ).flatMap( + (instance) => getSlotContentRootId(instances, instance.id) ?? [] + ) + ) + ); +}; + +const updateSchemaAcrossTargets = ({ + targetFragmentIds, + nextSchema, +}: { + targetFragmentIds: string[]; + nextSchema: CustomSlotFieldSchema[]; +}) => { + updateWebstudioData((data) => { + for (const fragmentId of targetFragmentIds) { + const existingSchema = findCustomSlotSchemaDataSource( + data.dataSources, + fragmentId + ); + + if (existingSchema?.type === "variable") { + data.dataSources.set(existingSchema.id, { + ...existingSchema, + value: { + type: "json", + value: nextSchema, + }, + }); + } else { + const id = nanoid(); + data.dataSources.set(id, { + id, + type: "variable", + scopeInstanceId: fragmentId, + name: customSlotSchemaVariable, + value: { + type: "json", + value: nextSchema, + }, + }); + } + } + }); +}; + +const CustomSlotFieldRow = ({ + field, + schema, + scope, + aliases, + value, + isRenaming, + onRename, + onStartRenaming, + onStopRenaming, + onValueChange, + onDelete, +}: { + field: CustomSlotFieldSchema; + schema: CustomSlotFieldSchema[]; + scope: Record; + aliases: Map; + value: undefined | CustomSlotFieldValue; + isRenaming: boolean; + onRename: (name: string) => void; + onStartRenaming: () => void; + onStopRenaming: () => void; + onValueChange: (value: CustomSlotFieldValue) => void; + onDelete: () => void; +}) => { + const nameLocal = useLocalValue(field.name, (nextName) => { + onRename(ensureUniqueFieldName(schema, field.id, nextName)); + }); + + const evaluatedValue = evaluateCustomSlotFieldValue({ + fieldValue: value, + evaluateExpression: (expression) => + evaluateExpressionWithinScope(expression, scope), + }); + const editorValue = formatFieldEditorValue(evaluatedValue); + const expression = + value?.type === "expression" + ? value.value + : (formatValue(value?.type === "literal" ? value.value : "") ?? `""`); + const { overwritable, variant } = useBindingState( + value?.type === "expression" ? value.value : undefined + ); + const valueLocal = useLocalValue(editorValue, (nextValue) => { + const nextLiteral = parseCustomSlotFieldEditorValue(nextValue); + + if (value?.type === "expression") { + if (overwritable) { + updateExpressionValue(value.value, nextLiteral.value); + } + return; + } + + onValueChange(nextLiteral); + }); + + return ( + + + + + {isRenaming ? ( + { + nameLocal.set(event.target.value); + }} + onBlur={() => { + nameLocal.save(); + onStopRenaming(); + }} + onKeyDown={(event) => { + if (event.key === "Enter") { + nameLocal.save(); + onStopRenaming(); + } + }} + /> + ) : ( + {field.name} + )} + + + { + onValueChange({ + type: "expression", + value: newExpression, + }); + }} + onRemove={(evaluatedValue) => { + onValueChange(createCustomSlotLiteralValue(evaluatedValue)); + }} + /> + + + + + + Rename + + Delete + + + + ); +}; + +export const CustomSlotFieldsSection = ({ + selectedInstance, +}: { + selectedInstance: Instance; +}) => { + const dataSources = useStore($dataSources); + const instances = useStore($instances); + const selectedInstanceScope = useStore($selectedInstanceScope); + const [editingFieldId, setEditingFieldId] = useState(); + const fragmentRootId = getSlotContentRootId(instances, selectedInstance.id); + + const schemaDataSource = + fragmentRootId === undefined + ? undefined + : findCustomSlotSchemaDataSource(dataSources, fragmentRootId); + + const valuesDataSource = findCustomSlotValuesDataSource( + dataSources, + selectedInstance.id + ); + + const schema = parseCustomSlotSchema( + schemaDataSource?.type === "variable" && + schemaDataSource.value.type === "json" + ? schemaDataSource.value.value + : undefined + ); + + const values = parseCustomSlotFieldValues( + valuesDataSource?.type === "variable" && + valuesDataSource.value.type === "json" + ? valuesDataSource.value.value + : undefined + ); + + const { scope, aliases } = useMemo(() => { + const nextScope: Record = {}; + const nextAliases = new Map(); + + for (const [identifier, name] of selectedInstanceScope.aliases) { + const dataSourceId = decodeDataSourceVariable(identifier); + const dataSource = + dataSourceId === undefined ? undefined : dataSources.get(dataSourceId); + + if (isCustomSlotComponentDataSource(instances, dataSource)) { + continue; + } + + nextAliases.set(identifier, name); + nextScope[identifier] = selectedInstanceScope.scope[identifier]; + } + + return { scope: nextScope, aliases: nextAliases }; + }, [dataSources, instances, selectedInstanceScope]); + + const schemaTargetFragmentIds = getSchemaTargetFragmentIds( + instances, + selectedInstance + ); + + const updateSchema = ( + updater: (schema: CustomSlotFieldSchema[]) => CustomSlotFieldSchema[] + ) => { + updateSchemaAcrossTargets({ + targetFragmentIds: schemaTargetFragmentIds, + nextSchema: updater(schema), + }); + }; + + const updateCurrentValues = ( + updater: (values: CustomSlotFieldValues) => CustomSlotFieldValues + ) => { + setJsonVariable({ + scopeInstanceId: selectedInstance.id, + name: customSlotValuesVariable, + value: updater(values), + }); + }; + + const handleAddField = () => { + const fieldId = nanoid(); + const fieldName = ensureUniqueFieldName(schema, fieldId, "field"); + + updateSchema((current) => [...current, { id: fieldId, name: fieldName }]); + + updateCurrentValues((current) => ({ + ...current, + [fieldId]: createCustomSlotLiteralValue(""), + })); + setEditingFieldId(fieldId); + }; + + return ( + + + Component fields + + {schema.length === 0 && ( + No fields yet. Add one below. + )} + + + {schema.map((field) => ( + { + updateSchema((current) => + current.map((item) => + item.id === field.id ? { ...item, name: nextName } : item + ) + ); + }} + onStartRenaming={() => { + setEditingFieldId(field.id); + }} + onStopRenaming={() => { + setEditingFieldId((current) => + current === field.id ? undefined : current + ); + }} + onValueChange={(nextValue) => { + updateCurrentValues((current) => ({ + ...current, + [field.id]: nextValue, + })); + }} + onDelete={() => { + updateSchema((current) => + current.filter((item) => item.id !== field.id) + ); + + const valueTargetInstances = + fragmentRootId === undefined + ? [selectedInstance] + : findAllCustomSlotInstancesSharingFragment( + instances, + fragmentRootId + ); + + updateWebstudioData((data) => { + for (const customSlotInstance of valueTargetInstances) { + let existing: DataSource | undefined; + + for (const dataSource of data.dataSources.values()) { + if ( + dataSource.type === "variable" && + dataSource.scopeInstanceId === customSlotInstance.id && + dataSource.name === customSlotValuesVariable + ) { + existing = dataSource; + break; + } + } + + if ( + existing?.type === "variable" && + existing.value.type === "json" + ) { + const nextValues = parseCustomSlotFieldValues( + existing.value.value + ); + delete nextValues[field.id]; + + data.dataSources.set(existing.id, { + ...existing, + value: { + type: "json", + value: nextValues, + }, + }); + } + } + }); + }} + /> + ))} + + + + + + ); +}; diff --git a/apps/builder/app/builder/features/settings-panel/resource-panel.tsx b/apps/builder/app/builder/features/settings-panel/resource-panel.tsx index 4e0c90f87eaf..7a03839e75d8 100644 --- a/apps/builder/app/builder/features/settings-panel/resource-panel.tsx +++ b/apps/builder/app/builder/features/settings-panel/resource-panel.tsx @@ -20,6 +20,7 @@ import { import { encodeDataVariableId, generateObjectExpression, + isCustomSlotInternalVariableName, isLiteralExpression, parseObjectExpression, SYSTEM_VARIABLE_ID, @@ -545,6 +546,9 @@ export const getResourceScopeForInstance = ({ if (dataSource.type === "resource") { hiddenDataSourceIds.add(dataSource.id); } + if (isCustomSlotInternalVariableName(dataSource.name)) { + hiddenDataSourceIds.add(dataSource.id); + } } if (page?.systemDataSourceId) { hiddenDataSourceIds.delete(page.systemDataSourceId); diff --git a/apps/builder/app/builder/features/settings-panel/settings-panel.tsx b/apps/builder/app/builder/features/settings-panel/settings-panel.tsx index 2d314a474646..94322c2d8355 100644 --- a/apps/builder/app/builder/features/settings-panel/settings-panel.tsx +++ b/apps/builder/app/builder/features/settings-panel/settings-panel.tsx @@ -15,6 +15,8 @@ import { UpgradeIcon } from "@webstudio-is/icons"; import { useStore } from "@nanostores/react"; import cmsUpgradeBanner from "~/shared/cms-upgrade-banner.svg?url"; import { $isDesignMode, $userPlanFeatures } from "~/shared/nano-states"; +import { customSlotComponent } from "@webstudio-is/sdk"; +import { CustomSlotFieldsSection } from "./custom-slot-fields-section"; export const SettingsPanel = ({ selectedInstance, @@ -62,6 +64,10 @@ export const SettingsPanel = ({ )} + + {selectedInstance.component === customSlotComponent && ( + + )} ); }; diff --git a/apps/builder/app/builder/features/settings-panel/shared.tsx b/apps/builder/app/builder/features/settings-panel/shared.tsx index 9e6f9b2f6c15..18a50589a2e3 100644 --- a/apps/builder/app/builder/features/settings-panel/shared.tsx +++ b/apps/builder/app/builder/features/settings-panel/shared.tsx @@ -24,6 +24,7 @@ import { import { decodeDataSourceVariable, encodeDataSourceVariable, + isCustomSlotInternalVariableName, SYSTEM_VARIABLE_ID, systemParameter, } from "@webstudio-is/sdk"; @@ -350,6 +351,9 @@ export const $selectedInstanceScope = computed( if (dataSource === undefined) { continue; } + if (isCustomSlotInternalVariableName(dataSource.name)) { + continue; + } const name = encodeDataSourceVariable(dataSourceId); scope[name] = value; aliases.set(name, dataSource.name); diff --git a/apps/builder/app/builder/features/settings-panel/variable-popover.tsx b/apps/builder/app/builder/features/settings-panel/variable-popover.tsx index 71eb9e8dddb2..bc6d4fa7f95d 100644 --- a/apps/builder/app/builder/features/settings-panel/variable-popover.tsx +++ b/apps/builder/app/builder/features/settings-panel/variable-popover.tsx @@ -44,6 +44,7 @@ import { } from "@webstudio-is/design-system"; import { type DataSource, + isCustomSlotComponentDataSource, transpileExpression, lintExpression, SYSTEM_VARIABLE_ID, @@ -752,8 +753,13 @@ const VariablePopoverContent = ({ onClose: () => void; }) => { const hasPendingResources = useStore($hasPendingResources); + const instances = useStore($instances); const panelRef = useRef(undefined); const isSystemVariable = variable?.id === SYSTEM_VARIABLE_ID; + const isCustomSlotVariable = isCustomSlotComponentDataSource( + instances, + variable + ); const [value, setValue] = useState(() => { if (variable?.type === "variable") { if (variable.value.type === "json") { @@ -856,7 +862,7 @@ const VariablePopoverContent = ({ style={{ display: "contents" }} onSubmit={(event) => { event.preventDefault(); - if (isSystemVariable) { + if (isSystemVariable || isCustomSlotVariable) { return; } const nameElement = @@ -882,7 +888,7 @@ const VariablePopoverContent = ({
; + aliases: Map; +}) => { + const entries: Array<{ + identifier: string; + name: string; + value: unknown; + }> = []; + + for (const [identifier, name] of aliases) { + entries.push({ + identifier, + name, + value: scope[identifier], + }); + } + + return entries; +}; + const BindingPanel = ({ scope, aliases, @@ -112,7 +136,10 @@ const BindingPanel = ({ ); const [errorsCount, setErrorsCount] = useState(0); const [touched, setTouched] = useState(false); - const scopeEntries = Object.entries(scope); + const scopeEntries = useMemo( + () => getBindableScopeEntries({ scope, aliases }), + [scope, aliases] + ); const validate = (expression: string) => { const diagnostics = lintExpression({ @@ -158,8 +185,7 @@ const BindingPanel = ({ )} - {scopeEntries.map(([identifier, value], index) => { - const name = aliases.get(identifier); + {scopeEntries.map(({ identifier, name, value }, index) => { const label = value === undefined ? name @@ -174,10 +200,8 @@ const BindingPanel = ({ active={usedIdentifiers.has(identifier)} // convert variable to expression onClick={() => { - if (name) { - const nameIdentifier = encodeDataVariableName(name); - editorApiRef.current?.replaceSelection(nameIdentifier); - } + const nameIdentifier = encodeDataVariableName(name); + editorApiRef.current?.replaceSelection(nameIdentifier); }} // expression editor blur is fired after pointer down even // preventing it allows to not trigger validation diff --git a/apps/builder/app/canvas/features/webstudio-component/webstudio-component.tsx b/apps/builder/app/canvas/features/webstudio-component/webstudio-component.tsx index d00e47ea9475..a29e31c6ed06 100644 --- a/apps/builder/app/canvas/features/webstudio-component/webstudio-component.tsx +++ b/apps/builder/app/canvas/features/webstudio-component/webstudio-component.tsx @@ -28,6 +28,8 @@ import { blockTemplateComponent, getIndexesWithinAncestors, elementComponent, + getSlotContentRoot, + customSlotComponent, } from "@webstudio-is/sdk"; import { indexProperty, tagProperty } from "@webstudio-is/sdk/runtime"; import { @@ -254,6 +256,45 @@ const DroppableComponentStub = forwardRef< }); DroppableComponentStub.displayName = "DroppableComponentStub"; +const CustomSlotComponentStub = forwardRef< + HTMLDivElement, + { children?: ReactNode } +>((props, ref) => { + return ( +
+ {props.children} +
+ ); +}); +CustomSlotComponentStub.displayName = "CustomSlotComponentStub"; + +const getRenderableChildren = ({ + instance, + instances, + instanceSelector, +}: { + instance: Instance; + instances: Instances; + instanceSelector: InstanceSelector; +}) => { + if (instance.component !== customSlotComponent) { + return { + children: instance.children, + instanceSelector, + }; + } + + const contentRoot = getSlotContentRoot(instances, instance.id); + + return { + children: contentRoot?.children ?? [], + instanceSelector: + contentRoot === undefined + ? instanceSelector + : [contentRoot.id, ...instanceSelector], + }; +}; + // 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 @@ -449,13 +490,18 @@ export const WebstudioComponentCanvas = forwardRef< const { [showAttribute]: show = true, ...instanceProps } = useInstanceProps(instanceSelector); + const renderableChildren = getRenderableChildren({ + instance, + instances, + instanceSelector, + }); const children = getTextContent(instanceProps) ?? createInstanceChildrenElements({ instances, - instanceSelector, - children: instance.children, + instanceSelector: renderableChildren.instanceSelector, + children: renderableChildren.children, Component: WebstudioComponentCanvas, components, }); @@ -528,6 +574,10 @@ export const WebstudioComponentCanvas = forwardRef< Component = DroppableComponentStub as AnyComponent; } + if (instance.component === customSlotComponent) { + Component = CustomSlotComponentStub as AnyComponent; + } + if (instance.component === descendantComponent) { return <>; } @@ -642,6 +692,11 @@ export const WebstudioComponentPreview = forwardRef< const instances = useStore($instances); const { [showAttribute]: show = true, ...instanceProps } = useInstanceProps(instanceSelector); + const renderableChildren = getRenderableChildren({ + instance, + instances, + instanceSelector, + }); const props = { ...mergeProps(restProps, instanceProps, "merge"), [idAttribute]: instance.id, @@ -697,6 +752,10 @@ export const WebstudioComponentPreview = forwardRef< } } + if (instance.component === customSlotComponent) { + Component = CustomSlotComponentStub as AnyComponent; + } + if (instance.component === blockComponent) { Component = Block; } @@ -713,8 +772,8 @@ export const WebstudioComponentPreview = forwardRef< {getTextContent(instanceProps) ?? createInstanceChildrenElements({ instances, - instanceSelector, - children: instance.children, + instanceSelector: renderableChildren.instanceSelector, + children: renderableChildren.children, Component: WebstudioComponentPreview, components, })} diff --git a/apps/builder/app/shared/copy-paste/plugin-instance.ts b/apps/builder/app/shared/copy-paste/plugin-instance.ts index f440dbd23ee7..af5607011367 100644 --- a/apps/builder/app/shared/copy-paste/plugin-instance.ts +++ b/apps/builder/app/shared/copy-paste/plugin-instance.ts @@ -7,7 +7,7 @@ import { WebstudioFragment, findTreeInstanceIdsExcludingSlotDescendants, isComponentDetachable, - portalComponent, + isPortalLikeComponent, } from "@webstudio-is/sdk"; import { $selectedInstanceSelector, @@ -82,7 +82,8 @@ const getPortalFragmentSelector = ( ) => { const instance = instances.get(instanceSelector[0]); if ( - instance?.component !== portalComponent || + instance === undefined || + isPortalLikeComponent(instance.component) === false || instance.children.length === 0 || instance.children[0].type !== "id" ) { diff --git a/apps/builder/app/shared/custom-slot-field-values.ts b/apps/builder/app/shared/custom-slot-field-values.ts new file mode 100644 index 000000000000..0b30f5c948e0 --- /dev/null +++ b/apps/builder/app/shared/custom-slot-field-values.ts @@ -0,0 +1,141 @@ +import { isLiteralExpression } from "@webstudio-is/sdk"; + +export type CustomSlotFieldSchema = { + id: string; + name: string; +}; + +export type CustomSlotFieldValue = + | { + type: "literal"; + value: unknown; + } + | { + type: "expression"; + value: string; + }; + +export type CustomSlotFieldValues = Record; + +export const parseCustomSlotSchema = ( + value: unknown +): CustomSlotFieldSchema[] => { + if (Array.isArray(value) === false) { + return []; + } + + return value.flatMap((item) => { + if ( + typeof item === "object" && + item !== null && + "id" in item && + "name" in item + ) { + return [{ id: String(item.id), name: String(item.name) }]; + } + return []; + }); +}; + +const parseCustomSlotFieldValue = ( + value: unknown +): undefined | CustomSlotFieldValue => { + if (typeof value !== "object" || value === null || Array.isArray(value)) { + return; + } + + const record = value as Record; + + if (record.type === "expression" && typeof record.value === "string") { + return { + type: "expression", + value: record.value, + }; + } + + if (record.type === "literal" && "value" in record) { + return { + type: "literal", + value: record.value, + }; + } +}; + +export const parseCustomSlotFieldValues = ( + value: unknown +): CustomSlotFieldValues => { + if (typeof value !== "object" || value === null || Array.isArray(value)) { + return {}; + } + + const values: CustomSlotFieldValues = {}; + + for (const [fieldId, fieldValue] of Object.entries(value)) { + const parsedValue = parseCustomSlotFieldValue(fieldValue); + if (parsedValue !== undefined) { + values[fieldId] = parsedValue; + } + } + + return values; +}; + +export const createCustomSlotLiteralValue = ( + value: unknown +): CustomSlotFieldValue => ({ + type: "literal", + value, +}); + +export const parseCustomSlotFieldEditorValue = ( + value: string +): CustomSlotFieldValue => { + if (value.length > 0 && isLiteralExpression(value)) { + try { + return createCustomSlotLiteralValue(new Function(`return (${value})`)()); + } catch { + // fall back to plain string below + } + } + + return createCustomSlotLiteralValue(value); +}; + +export const evaluateCustomSlotFieldValue = ({ + fieldValue, + evaluateExpression, +}: { + fieldValue: undefined | CustomSlotFieldValue; + evaluateExpression: (expression: string) => unknown; +}) => { + if (fieldValue === undefined) { + return ""; + } + + if (fieldValue.type === "literal") { + return fieldValue.value; + } + + return evaluateExpression(fieldValue.value); +}; + +export const buildCustomSlotComponentValue = ({ + schema, + values, + evaluateExpression, +}: { + schema: CustomSlotFieldSchema[]; + values: CustomSlotFieldValues; + evaluateExpression: (expression: string) => unknown; +}) => { + const result: Record = {}; + + for (const field of schema) { + result[field.name] = evaluateCustomSlotFieldValue({ + fieldValue: values[field.id], + evaluateExpression, + }); + } + + return result; +}; diff --git a/apps/builder/app/shared/data-variables.ts b/apps/builder/app/shared/data-variables.ts index ab0acc0e9f51..1b729f1e5b61 100644 --- a/apps/builder/app/shared/data-variables.ts +++ b/apps/builder/app/shared/data-variables.ts @@ -11,6 +11,12 @@ import { ROOT_INSTANCE_ID, SYSTEM_VARIABLE_ID, collectionComponent, + customSlotComponent, + customSlotComponentVariable, + customSlotValuesVariable, + findCustomSlotComponentDataSource, + getSlotContentRootId, + isCustomSlotInternalVariableName, decodeDataVariableId, encodeDataVariableId, findTreeInstanceIds, @@ -18,15 +24,23 @@ import { getExpressionIdentifiers, systemParameter, transpileExpression, + portalComponent, } from "@webstudio-is/sdk"; import { createJsonStringifyProxy, isPlainObject, } from "@webstudio-is/sdk/runtime"; +import { parseCustomSlotFieldValues } from "./custom-slot-field-values"; import { setUnion } from "./shim"; const allowedJsChars = /[A-Za-z_]/; +const isUserFacingDataSource = ( + dataSource: undefined | DataSource +): dataSource is DataSource => + dataSource !== undefined && + isCustomSlotInternalVariableName(dataSource.name) === false; + /** * variable names can contain any characters and * this utility encodes data variable name into valid js identifier @@ -194,7 +208,7 @@ const getParentInstanceById = (instances: Instances) => { const parentInstanceById = new Map(); for (const instance of instances.values()) { // interrupt lookup because slot variables cannot be passed to slot content - if (instance.component === "Slot") { + if (instance.component === portalComponent) { continue; } for (const child of instance.children) { @@ -220,7 +234,18 @@ const findMaskedVariablesByInstanceId = ({ let currentId: undefined | string = startingInstanceId; const instanceIdsPath: Instance["id"][] = []; while (currentId) { + const instance = instances.get(currentId); instanceIdsPath.push(currentId); + + // children inside customSlot may see customSlot-local variables, + // but should not see variables above customSlot + if ( + currentId !== startingInstanceId && + instance?.component === customSlotComponent + ) { + break; + } + currentId = parentInstanceById.get(currentId); } // allow accessing global variables everywhere @@ -234,9 +259,10 @@ const findMaskedVariablesByInstanceId = ({ const instance = instances.get(instanceId); for (const dataSource of dataSources.values()) { if (dataSource.scopeInstanceId === instanceId) { - // when current instance is collection - // ignore its collection item parameter - // when rebind variables + if (isCustomSlotInternalVariableName(dataSource.name)) { + continue; + } + if ( instanceId === startingInstanceId && instance?.component === collectionComponent && @@ -244,10 +270,28 @@ const findMaskedVariablesByInstanceId = ({ ) { continue; } + maskedVariables.set(dataSource.name, dataSource.id); } } } + const startingInstance = instances.get(startingInstanceId); + + if (startingInstance?.component === customSlotComponent) { + const fragmentRootId = getSlotContentRootId(instances, startingInstance.id); + if (fragmentRootId !== undefined) { + const componentDataSource = findCustomSlotComponentDataSource( + dataSources, + fragmentRootId + ); + if (componentDataSource) { + maskedVariables.set( + customSlotComponentVariable, + componentDataSource.id + ); + } + } + } return maskedVariables; }; @@ -392,6 +436,37 @@ const traverseExpressions = ({ for (const dataSource of dataSources.values()) { const instanceId = dataSource.scopeInstanceId ?? ""; + if ( + instanceIds.has(instanceId) && + dataSource.type === "variable" && + dataSource.name === customSlotValuesVariable && + dataSource.value.type === "json" + ) { + const nextFieldValues = parseCustomSlotFieldValues( + dataSource.value.value + ); + let hasChanged = false; + + for (const fieldValue of Object.values(nextFieldValues)) { + if (fieldValue.type !== "expression") { + continue; + } + + const nextExpression = update(fieldValue.value, instanceId); + if ( + nextExpression !== undefined && + nextExpression !== fieldValue.value + ) { + fieldValue.value = nextExpression; + hasChanged = true; + } + } + + if (hasChanged) { + dataSource.value.value = nextFieldValues; + } + } + if (instanceIds.has(instanceId) && dataSource.type === "resource") { instanceIdByResourceId.set(dataSource.resourceId, instanceId); } @@ -445,7 +520,10 @@ export const findUnsetVariableNames = ({ replaceVariable: (identifier) => { const id = decodeDataVariableId(identifier); if (id === undefined && args.includes(identifier) === false) { - unsetVariables.add(decodeDataVariableName(identifier)); + const name = decodeDataVariableName(identifier); + if (isCustomSlotInternalVariableName(name) === false) { + unsetVariables.add(name); + } } }, }); @@ -548,7 +626,9 @@ export const rebindTreeVariablesMutable = ({ // unset all variables const unsetNameById = new Map(); for (const dataSource of dataSources.values()) { - unsetNameById.set(dataSource.id, dataSource.name); + if (isUserFacingDataSource(dataSource)) { + unsetNameById.set(dataSource.id, dataSource.name); + } } // precompute parent instances outside of traverse const parentInstanceById = getParentInstanceById(instances); @@ -600,7 +680,9 @@ export const deleteVariableMutable = ( data.resources.delete(dataSource.resourceId); } const unsetNameById = new Map(); - unsetNameById.set(dataSource.id, dataSource.name); + if (isUserFacingDataSource(dataSource)) { + unsetNameById.set(dataSource.id, dataSource.name); + } const startingInstanceId = dataSource.scopeInstanceId ?? ""; const maskedIdByName = findMaskedVariablesByInstanceId({ startingInstanceId, diff --git a/apps/builder/app/shared/instance-utils.ts b/apps/builder/app/shared/instance-utils.ts index 7d028d6ccf29..03bb18a10061 100644 --- a/apps/builder/app/shared/instance-utils.ts +++ b/apps/builder/app/shared/instance-utils.ts @@ -24,7 +24,6 @@ import { encodeDataSourceVariable, transpileExpression, ROOT_INSTANCE_ID, - portalComponent, collectionComponent, Prop, Props, @@ -32,6 +31,12 @@ import { tags, blockTemplateComponent, isComponentDetachable, + customSlotComponent, + customSlotSchemaVariable, + customSlotValuesVariable, + customSlotComponentVariable, + isPortalLikeComponent, + getSlotContentRootId, } from "@webstudio-is/sdk"; import { detectTokenConflicts } from "./style-source-utils"; import { type ConflictResolution } from "./token-conflict-dialog"; @@ -156,9 +161,8 @@ export const updateWebstudioData = (mutate: (data: WebstudioData) => void) => { if (cycles.length > 0) { toast.info("Detected and fixed cycles in the instance tree."); - breakCyclesMutable( - instances.values(), - (node) => node.component === "Slot" + breakCyclesMutable(instances.values(), (node) => + isPortalLikeComponent(node.component) ); } } @@ -225,11 +229,34 @@ export const findAllEditableInstanceSelector = ({ } }; +const resolvePortalLikeContentParentSelector = ( + instances: Instances, + parentSelector: InstanceSelector +): InstanceSelector => { + const parentInstance = instances.get(parentSelector[0]); + const fragmentId = + parentInstance?.component === customSlotComponent + ? getSlotContentRootId(instances, parentInstance.id) + : parentInstance?.children[0]?.type === "id" + ? parentInstance.children[0].value + : undefined; + + if ( + parentInstance && + isPortalLikeComponent(parentInstance.component) && + fragmentId !== undefined + ) { + return [fragmentId, ...parentSelector]; + } + + return parentSelector; +}; + export const insertInstanceChildrenMutable = ( data: Omit, children: Instance["children"], insertTarget: Insertable -) => { +): undefined | InstanceSelector => { const dropTarget: DroppableTarget = { parentSelector: insertTarget.parentSelector, position: insertTarget.position === "after" ? "end" : insertTarget.position, @@ -245,7 +272,12 @@ export const insertInstanceChildrenMutable = ( metas, dropTarget ) ?? insertTarget; - const [parentInstanceId] = insertTarget.parentSelector; + const resolvedParentSelector = resolvePortalLikeContentParentSelector( + data.instances, + insertTarget.parentSelector + ); + + const [parentInstanceId] = resolvedParentSelector; const parentInstance = data.instances.get(parentInstanceId); if (parentInstance === undefined) { return; @@ -255,6 +287,7 @@ export const insertInstanceChildrenMutable = ( } else { parentInstance.children.splice(dropTarget.position, 0, ...children); } + return resolvedParentSelector; }; export const insertWebstudioElementAt = (insertable?: Insertable) => { @@ -322,12 +355,27 @@ export const insertWebstudioElementAt = (insertable?: Insertable) => { return false; } // insert element + let newInstanceSelector: undefined | InstanceSelector; + updateWebstudioData((data) => { data.instances.set(element.id, element); const children: Instance["children"] = [{ type: "id", value: element.id }]; - insertInstanceChildrenMutable(data, children, insertable); + + const insertedParentSelector = insertInstanceChildrenMutable( + data, + children, + insertable + ); + + if (insertedParentSelector) { + newInstanceSelector = [element.id, ...insertedParentSelector]; + } }); - selectInstance([element.id, ...insertable.parentSelector]); + + if (newInstanceSelector) { + selectInstance(newInstanceSelector); + } + return true; }; @@ -409,11 +457,19 @@ export const insertWebstudioFragmentAt = ( parentSelector = insertable.parentSelector; position = insertable.position; } - insertInstanceChildrenMutable(data, children, { - parentSelector, - position, - }); - newInstanceSelector = [children[0].value, ...parentSelector]; + const insertedParentSelector = insertInstanceChildrenMutable( + data, + children, + { + parentSelector, + position, + } + ); + + const firstChild = children[0]; + if (insertedParentSelector && firstChild?.type === "id") { + newInstanceSelector = [firstChild.value, ...insertedParentSelector]; + } }); if (newInstanceSelector) { selectInstance(newInstanceSelector); @@ -421,9 +477,73 @@ export const insertWebstudioFragmentAt = ( return true; }; +const createCustomSlotFragment = (): WebstudioFragment => { + const customSlotId = nanoid(); + const fragmentId = nanoid(); + const schemaId = nanoid(); + const valuesId = nanoid(); + const componentId = nanoid(); + + return { + children: [{ type: "id", value: customSlotId }], + instances: [ + { + id: customSlotId, + type: "instance", + component: customSlotComponent, + children: [{ type: "id", value: fragmentId }], + }, + { + id: fragmentId, + type: "instance", + component: "Fragment", + children: [], + }, + ], + props: [], + dataSources: [ + { + id: schemaId, + scopeInstanceId: fragmentId, + name: customSlotSchemaVariable, + type: "variable", + value: { + type: "json", + value: [], + }, + }, + { + id: valuesId, + scopeInstanceId: customSlotId, + name: customSlotValuesVariable, + type: "variable", + value: { + type: "json", + value: {}, + }, + }, + { + id: componentId, + scopeInstanceId: fragmentId, + name: customSlotComponentVariable, + type: "parameter", + }, + ], + styleSourceSelections: [], + styleSources: [], + styles: [], + breakpoints: [], + assets: [], + resources: [], + }; +}; + export const getComponentTemplateData = ( componentOrTemplate: string ): WebstudioFragment => { + if (componentOrTemplate === customSlotComponent) { + return createCustomSlotFragment(); + } const templates = $registeredTemplates.get(); const templateMeta = templates.get(componentOrTemplate); if (templateMeta) { @@ -472,12 +592,17 @@ export const reparentInstanceMutable = ( } // try to use slot fragment as target instead of slot itself const parentInstance = data.instances.get(dropTarget.parentSelector[0]); + const fragmentId = + parentInstance?.component === customSlotComponent + ? getSlotContentRootId(data.instances, parentInstance.id) + : parentInstance?.children[0]?.type === "id" + ? parentInstance.children[0].value + : undefined; if ( - parentInstance?.component === portalComponent && - parentInstance.children.length > 0 && - parentInstance.children[0].type === "id" + parentInstance && + isPortalLikeComponent(parentInstance.component) && + fragmentId !== undefined ) { - const fragmentId = parentInstance.children[0].value; dropTarget = { parentSelector: [fragmentId, ...dropTarget.parentSelector], position: dropTarget.position, @@ -1362,7 +1487,7 @@ export const insertWebstudioFragmentCopy = ({ const portalContentRootIds = new Set(); for (const instance of fragment.instances) { fragmentInstances.set(instance.id, instance); - if (instance.component === portalComponent) { + if (isPortalLikeComponent(instance.component)) { for (const child of instance.children) { if (child.type === "id") { portalContentRootIds.add(child.value); @@ -1687,7 +1812,7 @@ export const findClosestSlot = ( ) => { for (const instanceId of instanceSelector) { const instance = instances.get(instanceId); - if (instance?.component === "Slot") { + if (isPortalLikeComponent(instance?.component ?? "")) { return instance; } } diff --git a/apps/builder/app/shared/nano-states/props.ts b/apps/builder/app/shared/nano-states/props.ts index a70aa9e01f65..2ea323d6a795 100644 --- a/apps/builder/app/shared/nano-states/props.ts +++ b/apps/builder/app/shared/nano-states/props.ts @@ -15,6 +15,12 @@ import { ROOT_INSTANCE_ID, SYSTEM_VARIABLE_ID, findTreeInstanceIds, + customSlotComponent, + customSlotComponentVariable, + findCustomSlotComponentDataSource, + findCustomSlotSchemaDataSource, + findCustomSlotValuesDataSource, + getSlotContentRootId, } from "@webstudio-is/sdk"; import { normalizeProps, @@ -22,6 +28,11 @@ import { getCollectionEntries, } from "@webstudio-is/react-sdk"; import { mapGroupBy } from "~/shared/shim"; +import { + buildCustomSlotComponentValue, + parseCustomSlotFieldValues, + parseCustomSlotSchema, +} from "~/shared/custom-slot-field-values"; import { $instances } from "./instances"; import { $dataSources, @@ -230,6 +241,7 @@ export const $propValuesByInstanceSelector = computed( $pages, $assets, $uploadingFilesDataStore, + $dataSources, ], ( instances, @@ -238,11 +250,9 @@ export const $propValuesByInstanceSelector = computed( unscopedVariableValues, pages, assets, - uploadingFilesDataStore + uploadingFilesDataStore, + dataSources ) => { - // already includes global variables - const variableValues = new Map(unscopedVariableValues); - let propsList = Array.from(props.values()); // ignore asset and page props when params is not provided @@ -273,7 +283,25 @@ export const $propValuesByInstanceSelector = computed( if (page === undefined) { return propValuesByInstanceSelector; } - const traverseInstances = (instanceSelector: InstanceSelector) => { + + const globalVariableValues = new Map(); + globalVariableValues.set( + SYSTEM_VARIABLE_ID, + unscopedVariableValues.get(SYSTEM_VARIABLE_ID) + ); + for (const [dataSourceId, dataSource] of dataSources) { + if (dataSource.scopeInstanceId === ROOT_INSTANCE_ID) { + globalVariableValues.set( + dataSourceId, + unscopedVariableValues.get(dataSourceId) + ); + } + } + + const traverseInstances = ( + instanceSelector: InstanceSelector, + currentVariableValues = unscopedVariableValues + ) => { const [instanceId] = instanceSelector; const instance = instances.get(instanceId); if (instance === undefined) { @@ -292,14 +320,14 @@ export const $propValuesByInstanceSelector = computed( continue; } if (prop.type === "expression") { - const value = computeExpression(prop.value, variableValues); + const value = computeExpression(prop.value, currentVariableValues); if (value !== undefined) { propValues.set(prop.name, value); } continue; } if (prop.type === "action") { - const action = getAction(prop, variableValues); + const action = getAction(prop, currentVariableValues); if (typeof action === "function") { propValues.set(prop.name, action); } @@ -324,33 +352,116 @@ export const $propValuesByInstanceSelector = computed( const itemKeyVariableId = parameters.get("itemKey"); if (itemVariableId !== undefined && originalData) { for (const [key, value] of getCollectionEntries(originalData)) { - variableValues.set(itemVariableId, value); + const itemVariableValues = new Map(currentVariableValues); + itemVariableValues.set(itemVariableId, value); if (itemKeyVariableId !== undefined) { - variableValues.set(itemKeyVariableId, key); + itemVariableValues.set(itemKeyVariableId, key); } for (const child of instance.children) { if (child.type === "id") { const indexId = getIndexedInstanceId(instanceId, key); - traverseInstances([child.value, indexId, ...instanceSelector]); + traverseInstances( + [child.value, indexId, ...instanceSelector], + itemVariableValues + ); } } } } return; } + + if (instance.component === customSlotComponent) { + const fragmentRootId = getSlotContentRootId(instances, instanceId); + if (fragmentRootId === undefined) { + return; + } + + const componentDataSource = findCustomSlotComponentDataSource( + dataSources, + fragmentRootId + ); + + const schemaDataSource = findCustomSlotSchemaDataSource( + dataSources, + fragmentRootId + ); + + const valuesDataSource = findCustomSlotValuesDataSource( + dataSources, + instanceId + ); + + const customSlotVariableValues = new Map(globalVariableValues); + + if (componentDataSource?.type === "parameter") { + const schema = + schemaDataSource?.type === "variable" && + schemaDataSource.value.type === "json" + ? parseCustomSlotSchema(schemaDataSource.value.value) + : []; + + const values = + valuesDataSource?.type === "variable" && + valuesDataSource.value.type === "json" + ? parseCustomSlotFieldValues(valuesDataSource.value.value) + : {}; + + customSlotVariableValues.set( + componentDataSource.id, + buildCustomSlotComponentValue({ + schema, + values, + evaluateExpression: (expression) => + computeExpression(expression, currentVariableValues), + }) + ); + } + + traverseInstances( + [fragmentRootId, ...instanceSelector], + customSlotVariableValues + ); + return; + } + + if (instance.component === portalComponent) { + for (const child of instance.children) { + if (child.type === "text" && instance.children.length === 1) { + propValues.set(textContentAttribute, child.value); + } + if (child.type === "expression") { + const value = computeExpression(child.value, globalVariableValues); + if (value !== undefined) { + propValues.set(textContentAttribute, value); + } + } + if (child.type === "id") { + traverseInstances( + [child.value, ...instanceSelector], + globalVariableValues + ); + } + } + return; + } + for (const child of instance.children) { // plain text can be edited from props panel if (child.type === "text" && instance.children.length === 1) { propValues.set(textContentAttribute, child.value); } if (child.type === "expression") { - const value = computeExpression(child.value, variableValues); + const value = computeExpression(child.value, currentVariableValues); if (value !== undefined) { propValues.set(textContentAttribute, value); } } if (child.type === "id") { - traverseInstances([child.value, ...instanceSelector]); + traverseInstances( + [child.value, ...instanceSelector], + currentVariableValues + ); } } }; @@ -444,22 +555,46 @@ export const $variableValuesByInstanceSelector = computed( } if (variables) { for (const variable of variables) { + const previousVariableId = variableNames.get(variable.name); + const inheritedValue = + previousVariableId !== undefined + ? variableValues.get(previousVariableId) + : undefined; + // delete previous variable with the same name // because it is masked and no longer available - variableValues.delete(variableNames.get(variable.name) ?? ""); + if ( + previousVariableId !== undefined && + previousVariableId !== variable.id + ) { + variableValues.delete(previousVariableId); + } + variableNames.set(variable.name, variable.id); + if (variable.type === "variable") { const value = dataSourceVariables.get(variable.id); variableValues.set(variable.id, value ?? variable.value.value); + continue; } + if (variable.type === "parameter") { - const value = dataSourceVariables.get(variable.id); + let value = dataSourceVariables.get(variable.id); + + // keep inherited runtime value for scope-carried parameters like customSlot component + if (value === undefined && inheritedValue !== undefined) { + value = inheritedValue; + } + variableValues.set(variable.id, value); + // set page system value if (variable.id === page.systemDataSourceId) { variableValues.set(variable.id, system); } + continue; } + if (variable.type === "resource") { const resource = resources.get(variable.resourceId); if (resource) { @@ -554,6 +689,73 @@ export const $variableValuesByInstanceSelector = computed( } return; } + + if (instance.component === customSlotComponent) { + const fragmentRootId = getSlotContentRootId(instances, instanceId); + if (fragmentRootId === undefined) { + return; + } + + const componentDataSource = findCustomSlotComponentDataSource( + dataSources, + fragmentRootId + ); + + const schemaDataSource = findCustomSlotSchemaDataSource( + dataSources, + fragmentRootId + ); + + const valuesDataSource = findCustomSlotValuesDataSource( + dataSources, + instanceId + ); + + const ownerVariableValues = new Map(variableValues); + const customSlotVariableValues = new Map(globalVariableValues); + const customSlotVariableNames = new Map(globalVariableNames); + + if (componentDataSource?.type === "parameter") { + const schema = + schemaDataSource?.type === "variable" && + schemaDataSource.value.type === "json" + ? parseCustomSlotSchema(schemaDataSource.value.value) + : []; + + const values = + valuesDataSource?.type === "variable" && + valuesDataSource.value.type === "json" + ? parseCustomSlotFieldValues(valuesDataSource.value.value) + : {}; + + customSlotVariableNames.set( + customSlotComponentVariable, + componentDataSource.id + ); + const componentValue = buildCustomSlotComponentValue({ + schema, + values, + evaluateExpression: (expression) => + computeExpression(expression, variableValues), + }); + ownerVariableValues.set(componentDataSource.id, componentValue); + customSlotVariableValues.set(componentDataSource.id, componentValue); + } + + // component auch auf dem customSlot-owner sichtbar machen + variableValuesByInstanceSelector.set( + getInstanceKey(instanceSelector), + ownerVariableValues + ); + + traverseInstances( + [fragmentRootId, ...instanceSelector], + customSlotVariableValues, + customSlotVariableNames + ); + return; + } + // reset values for slot children to let slots behave as isolated components if (instance.component === portalComponent) { // allow accessing global variables in slots diff --git a/packages/sdk/src/core-metas.ts b/packages/sdk/src/core-metas.ts index d7e2f4816ea1..e7a8c3353a44 100644 --- a/packages/sdk/src/core-metas.ts +++ b/packages/sdk/src/core-metas.ts @@ -4,6 +4,7 @@ import { PaintBrushIcon, SettingsIcon, AddTemplateInstanceIcon, + SlotComponentIcon, } from "@webstudio-is/icons/svg"; import { html } from "./__generated__/normalize.css"; import * as normalize from "./__generated__/normalize.css"; @@ -70,6 +71,26 @@ const collectionMeta: WsComponentMeta = { }, }; +export const customSlotComponent = "ws:customSlot"; +export const customSlotSchemaVariable = "__customSlotSchema"; +export const customSlotValuesVariable = "__customSlotValues"; +export const customSlotComponentVariable = "component"; + +export const isCustomSlotInternalVariableName = (name: string) => + name === customSlotSchemaVariable || name === customSlotValuesVariable; + +const customSlotMeta: WsComponentMeta = { + label: "Custom Slot", + icon: SlotComponentIcon, + contentModel: { + category: "instance", + children: ["instance"], + }, +}; + +export const isPortalLikeComponent = (component: string) => + component === portalComponent || component === customSlotComponent; + export const descendantComponent = "ws:descendant"; const descendantMeta: WsComponentMeta = { @@ -134,6 +155,7 @@ export const coreMetas = { [rootComponent]: rootMeta, [elementComponent]: elementMeta, [collectionComponent]: collectionMeta, + [customSlotComponent]: customSlotMeta, [descendantComponent]: descendantMeta, [blockComponent]: blockMeta, [blockTemplateComponent]: blockTemplateMeta, @@ -145,6 +167,7 @@ export const isCoreComponent = (component: Instance["component"]) => component === rootComponent || component === elementComponent || component === collectionComponent || + component === customSlotComponent || component === descendantComponent || component === blockComponent || component === blockTemplateComponent; diff --git a/packages/sdk/src/core-templates.tsx b/packages/sdk/src/core-templates.tsx index 0727bc632a97..7e6679e70d86 100644 --- a/packages/sdk/src/core-templates.tsx +++ b/packages/sdk/src/core-templates.tsx @@ -17,6 +17,7 @@ import { collectionComponent, descendantComponent, elementComponent, + customSlotComponent, } from "./core-metas"; const elementMeta: TemplateMeta = { @@ -66,6 +67,16 @@ const descendantMeta: TemplateMeta = { template: , }; +const CustomSlot = ws["customSlot"]; + +const customSlotMeta: TemplateMeta = { + category: "general", + order: 5, + description: + "A reusable container that stays synced across the project and supports fields for flexible content customization.", + template: , +}; + const BlockTemplate = ws["block-template"]; const blockMeta: TemplateMeta = { @@ -427,6 +438,7 @@ export const coreTemplates = { [collectionComponent]: collectionMeta, [descendantComponent]: descendantMeta, [blockComponent]: blockMeta, + [customSlotComponent]: customSlotMeta, ...typography, ...forms, builtWithWebstudio: builtWithWebstudioMeta, diff --git a/packages/sdk/src/custom-slot-utils.ts b/packages/sdk/src/custom-slot-utils.ts new file mode 100644 index 000000000000..a3be2351cf5b --- /dev/null +++ b/packages/sdk/src/custom-slot-utils.ts @@ -0,0 +1,108 @@ +import type { DataSource, DataSources } from "./schema/data-sources"; +import type { Instance, Instances } from "./schema/instances"; +import { + customSlotComponent, + customSlotComponentVariable, + customSlotSchemaVariable, + customSlotValuesVariable, +} from "./core-metas"; + +const findScopedDataSourceByName = ( + dataSources: DataSources, + scopeInstanceId: Instance["id"], + name: DataSource["name"] +) => { + for (const dataSource of dataSources.values()) { + if ( + dataSource.scopeInstanceId === scopeInstanceId && + dataSource.name === name + ) { + return dataSource; + } + } +}; + +export const getSlotContentRootId = ( + instances: Instances, + slotId: Instance["id"] +) => { + const slot = instances.get(slotId); + if (slot?.component !== customSlotComponent) { + return; + } + if (slot.children.length !== 1) { + return; + } + const [child] = slot.children; + if (child?.type !== "id") { + return; + } + const root = instances.get(child.value); + if (root?.component !== "Fragment") { + return; + } + return root.id; +}; + +export const getSlotContentRoot = ( + instances: Instances, + slotId: Instance["id"] +) => { + const rootId = getSlotContentRootId(instances, slotId); + if (rootId === undefined) { + return; + } + return instances.get(rootId); +}; + +export const findCustomSlotSchemaDataSource = ( + dataSources: DataSources, + fragmentRootId: Instance["id"] +) => + findScopedDataSourceByName( + dataSources, + fragmentRootId, + customSlotSchemaVariable + ); + +export const findCustomSlotValuesDataSource = ( + dataSources: DataSources, + slotId: Instance["id"] +) => findScopedDataSourceByName(dataSources, slotId, customSlotValuesVariable); + +export const findCustomSlotComponentDataSource = ( + dataSources: DataSources, + fragmentRootId: Instance["id"] +) => + findScopedDataSourceByName( + dataSources, + fragmentRootId, + customSlotComponentVariable + ); + +export const isCustomSlotComponentDataSource = ( + instances: Instances, + dataSource: undefined | DataSource +) => { + if ( + dataSource?.type !== "parameter" || + dataSource.name !== customSlotComponentVariable || + dataSource.scopeInstanceId === undefined + ) { + return false; + } + + for (const instance of instances.values()) { + if (instance.component !== customSlotComponent) { + continue; + } + if ( + getSlotContentRootId(instances, instance.id) === + dataSource.scopeInstanceId + ) { + return true; + } + } + + return false; +}; diff --git a/packages/sdk/src/index.ts b/packages/sdk/src/index.ts index 35d0b1dde26f..eea30c9b941f 100644 --- a/packages/sdk/src/index.ts +++ b/packages/sdk/src/index.ts @@ -15,6 +15,7 @@ export * from "./schema/component-meta"; export * from "./assets"; export * from "./core-metas"; +export * from "./custom-slot-utils"; export * from "./instances-utils"; export * from "./page-utils"; export * from "./scope"; diff --git a/packages/sdk/src/instances-utils.ts b/packages/sdk/src/instances-utils.ts index 4c9969d96c46..6eb34dad7b15 100644 --- a/packages/sdk/src/instances-utils.ts +++ b/packages/sdk/src/instances-utils.ts @@ -1,6 +1,6 @@ import type { WsComponentMeta } from "./schema/component-meta"; import type { Instance, Instances } from "./schema/instances"; -import { blockTemplateComponent } from "./core-metas"; +import { blockTemplateComponent, isPortalLikeComponent } from "./core-metas"; export const ROOT_INSTANCE_ID = ":root"; @@ -42,7 +42,7 @@ export const findTreeInstanceIdsExcludingSlotDescendants = ( const ids = new Set([rootInstanceId]); traverseInstances(instances, rootInstanceId, (instance) => { ids.add(instance.id); - if (instance.component === "Slot") { + if (isPortalLikeComponent(instance.component)) { return false; } });