Files
webstudio/apps/builder/app/shared/instance-utils/insert.ts
T
Oleg Isonen 5a57234c75 revert: Store Content Block content in MDX assets
Revert the accidental merge of #6125 so the implementation can continue in draft PR #6181.
2026-08-18 13:23:32 +01:00

316 lines
9.6 KiB
TypeScript

// 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,
createComponentTemplateFragment,
getFragmentContentModelWarnings,
type FragmentContentModelWarning,
type ConflictResolution,
resolveComponentInsertTarget,
resolveFragmentInsertTarget,
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 } 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<WebstudioFragment, "instances">) => {
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;
}
const result = executeRuntimeMutation({
id: "instances.insertComponent",
input: {
parentInstanceId: target.parentInstanceId,
component,
tag: target.tag,
insertIndex: target.insertIndex,
},
});
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;
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 result = executeRuntimeMutation({
id: "instances.insertFragment",
input: {
parentInstanceId: target.parentInstanceId,
fragment,
conflictResolution: conflictResolution ?? "theirs",
contentMode: options?.contentMode,
insertIndex: target.insertIndex,
},
context: {
allowLegacyContentModelWarnings: options?.allowContentModelWarnings,
},
});
const newInstanceId = result?.result.rootInstanceIds[0];
if (result !== undefined && newInstanceId !== undefined) {
const resultParentInstanceId =
"parentInstanceId" in result.result
? result.result.parentInstanceId
: undefined;
const nextParentSelector =
resultParentInstanceId === undefined ||
resultParentInstanceId === target.parentInstanceId
? target.parentSelector
: [resultParentInstanceId, ...target.parentSelector];
selectInstance([newInstanceId, ...nextParentSelector]);
}
if (result?.result.didMergeBreakpointsDueToLimit === true) {
options?.onBreakpointLimitMerge?.();
}
if (result !== undefined && options?.allowContentModelWarnings === true) {
const warnings = getFragmentContentModelWarnings({
fragment,
metas: $registeredComponentMetas.get(),
});
if (warnings.length > 0) {
options.onContentModelWarnings?.(warnings);
}
}
return result !== 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",
};
};