// Insert utilities own deciding where newly created or pasted instance content // should be placed. Put insert target resolution and insertion commands here, // while fragment cloning stays in fragment.ts and existing-instance moves stay // in mutation.ts. import { toast } from "@webstudio-is/design-system"; import invariant from "tiny-invariant"; import { type Prop, type WebstudioFragment, elementComponent, } from "@webstudio-is/sdk"; import { builderRuntimeContext, BuilderRuntimeError, createComponentTemplateFragment, getFragmentContentModelWarnings, type FragmentContentModelWarning, type ConflictResolution, resolveComponentInsertTarget, resolveFragmentInsertTarget, type InstanceSelector, type InsertTarget, } from "@webstudio-is/project-build/runtime"; import { $registeredComponentMetas, $registeredTemplates, $selectedInstanceSelector, $selectedPage, selectInstance, } from "../nano-states"; import { $instances, $project, $props } from "../sync/data-stores"; import { getInstanceLabel } from "~/builder/shared/instance-label"; import { executeRuntimeMutation, executeRuntimeMutationSequence } from "./data"; const getRootInstanceId = () => $selectedPage.get()?.rootInstanceId; const onMissingTarget = () => { toast.error("Cannot insert: the target no longer exists."); }; const onRootTarget = () => { toast.error(`Cannot insert into Global root`); }; const onNoInsertMatch = (fragment: Pick) => { return (message: string) => { const component = fragment.instances[0].component; const label = getInstanceLabel({ component }); toast.warn(message || `"${label}" has no place here`); }; }; const onNoComponentInsertMatch = (component: string) => { return (message: string) => { const label = getInstanceLabel({ component }); toast.warn(message || `"${label}" has no place here`); }; }; const getFragmentInsertTarget = ( fragment: WebstudioFragment, from?: Insertable, options?: { allowContentModelWarnings?: boolean } ) => { const rootInstanceId = getRootInstanceId(); if (rootInstanceId === undefined) { return; } const instances = $instances.get(); return resolveFragmentInsertTarget({ fragment, instances, props: $props.get(), metas: $registeredComponentMetas.get(), rootInstanceId, selectedInstanceSelector: $selectedInstanceSelector.get(), from, onRootTarget, onMissingTarget, onNoMatch: onNoInsertMatch(fragment), allowFragmentContentModelWarnings: options?.allowContentModelWarnings, }); }; export const insertWebstudioElementAt = (insertable?: Insertable) => { const target = getComponentInsertTarget(elementComponent, insertable); if (target === undefined) { return false; } const result = executeRuntimeMutation({ id: "instances.insertComponent", input: { parentInstanceId: target.parentInstanceId, component: elementComponent, tag: target.tag, insertIndex: target.insertIndex, }, }); const newInstanceId = result?.result.rootInstanceIds[0]; if (newInstanceId === undefined) { return false; } const parentSelector = result?.result.parentInstanceId === undefined || result.result.parentInstanceId === target.parentInstanceId ? target.parentSelector : [result.result.parentInstanceId, ...target.parentSelector]; selectInstance([newInstanceId, ...parentSelector]); return true; }; const getComponentInsertTarget = (component: string, from?: Insertable) => { const rootInstanceId = getRootInstanceId(); if (rootInstanceId === undefined) { return; } return resolveComponentInsertTarget({ component, templates: $registeredTemplates.get(), context: builderRuntimeContext, instances: $instances.get(), props: $props.get(), metas: $registeredComponentMetas.get(), rootInstanceId, selectedInstanceSelector: $selectedInstanceSelector.get(), from, onRootTarget, onMissingTarget, onNoMatch: onNoComponentInsertMatch(component), }); }; export const insertWebstudioComponentAt = ( component: string, insertable?: Insertable ) => { const target = getComponentInsertTarget(component, insertable); if (target === undefined) { return false; } let result: ReturnType< typeof executeRuntimeMutation<"instances.insertComponent"> >; try { result = executeRuntimeMutation({ id: "instances.insertComponent", input: { parentInstanceId: target.parentInstanceId, component, tag: target.tag, insertIndex: target.insertIndex, }, }); } catch (error) { if (error instanceof BuilderRuntimeError) { toast.error(error.message); return false; } throw error; } const newInstanceId = result?.result.rootInstanceIds[0]; if (result !== undefined && newInstanceId !== undefined) { const parentSelector = result.result.parentInstanceId === target.parentInstanceId ? target.parentSelector : [result.result.parentInstanceId, ...target.parentSelector]; selectInstance([newInstanceId, ...parentSelector]); } if (result?.result.didMergeBreakpointsDueToLimit === true) { toast.info( "Some breakpoints were merged because the project reached the breakpoint limit." ); } return result !== undefined; }; export const insertWebstudioFragmentAt = ( fragment: WebstudioFragment, insertable?: Insertable, conflictResolution?: ConflictResolution, options?: { contentMode?: boolean; replaceInstanceSelector?: InstanceSelector; onBreakpointLimitMerge?: () => void; allowContentModelWarnings?: boolean; onContentModelWarnings?: (warnings: FragmentContentModelWarning[]) => void; } ): boolean => { const hasChildren = fragment.children.length > 0; const hasTokens = fragment.styleSources.length > 0; if (!hasChildren && !hasTokens) { return false; } // Tokens-only fragment: insert tokens/breakpoints/styles without instances if (!hasChildren && hasTokens) { const projectId = $project.get()?.id; if (projectId === undefined) { return false; } const result = executeRuntimeMutation({ id: "instances.insertFragment", input: { fragment, conflictResolution: conflictResolution ?? "theirs", contentMode: options?.contentMode, }, }); if (result?.result.didMergeBreakpointsDueToLimit === true) { options?.onBreakpointLimitMerge?.(); } return result !== undefined; } const target = getFragmentInsertTarget(fragment, insertable, options); if ($project.get() === undefined || target === undefined) { return false; } const insertOperation = { id: "instances.insertFragment", input: { parentInstanceId: target.parentInstanceId, fragment, conflictResolution: conflictResolution ?? "theirs", contentMode: options?.contentMode, insertIndex: target.insertIndex, }, context: { allowLegacyContentModelWarnings: options?.allowContentModelWarnings, }, } as const; const insertionResult = options?.replaceInstanceSelector === undefined ? executeRuntimeMutation(insertOperation)?.result : executeRuntimeMutationSequence([ insertOperation, { id: "instances.deleteBySelector", input: { instanceSelector: options.replaceInstanceSelector }, }, ] as const)?.[0]; const newInstanceId = insertionResult?.rootInstanceIds[0]; if (insertionResult !== undefined && newInstanceId !== undefined) { const resultParentInstanceId = "parentInstanceId" in insertionResult ? insertionResult.parentInstanceId : undefined; const nextParentSelector = resultParentInstanceId === undefined || resultParentInstanceId === target.parentInstanceId ? target.parentSelector : [resultParentInstanceId, ...target.parentSelector]; selectInstance([newInstanceId, ...nextParentSelector]); } if (insertionResult?.didMergeBreakpointsDueToLimit === true) { options?.onBreakpointLimitMerge?.(); } if ( insertionResult !== undefined && options?.allowContentModelWarnings === true ) { const warnings = getFragmentContentModelWarnings({ fragment, metas: $registeredComponentMetas.get(), }); if (warnings.length > 0) { options.onContentModelWarnings?.(warnings); } } return insertionResult !== undefined; }; export const getComponentTemplateData = ( componentOrTemplate: string ): WebstudioFragment => { return createComponentTemplateFragment({ component: componentOrTemplate, templates: $registeredTemplates.get(), createId: builderRuntimeContext.createId, }); }; export const getImageAssetFragment = (assetId: string): WebstudioFragment => { const fragment = getComponentTemplateData("Image"); const imageInstance = fragment.instances.find( (instance) => instance.component === "Image" ); invariant(imageInstance, "Expected the Image template to contain an Image"); const sourceIndex = fragment.props.findIndex( (prop) => prop.instanceId === imageInstance.id && prop.name === "src" ); const source: Prop = { ...(sourceIndex === -1 ? { id: builderRuntimeContext.createId() } : fragment.props[sourceIndex]), instanceId: imageInstance.id, name: "src", type: "asset", value: assetId, }; const props = [...fragment.props]; if (sourceIndex === -1) { props.push(source); } else { props[sourceIndex] = source; } return { ...fragment, props }; }; export const insertImageAssetAt = (assetId: string, insertable?: Insertable) => insertWebstudioFragmentAt(getImageAssetFragment(assetId), insertable); export type Insertable = InsertTarget; export const findClosestInsertable = ( fragment: WebstudioFragment, from?: Insertable, options?: { allowContentModelWarnings?: boolean } ): undefined | Insertable => { const rootInstanceId = getRootInstanceId(); if (rootInstanceId === undefined) { return; } const target = resolveFragmentInsertTarget({ fragment, instances: $instances.get(), props: $props.get(), metas: $registeredComponentMetas.get(), rootInstanceId, selectedInstanceSelector: $selectedInstanceSelector.get(), from, onRootTarget, onMissingTarget, onNoMatch: onNoInsertMatch(fragment), allowFragmentContentModelWarnings: options?.allowContentModelWarnings, }); if (target === undefined) { return; } return { parentSelector: target.parentSelector, position: target.insertIndex ?? "end", }; };