Files
Oleg Isonen ef94d85ccf feat: drag image assets onto canvas (#5912)
## Summary

- allow dragging a single image asset from the Assets panel onto the
canvas
- insert an Image component with its source bound to the dragged asset
in one undoable mutation
- preserve asset-folder drops and canceled drags
- build the Builder once in CI and reuse the compact build artifact
across all E2E shards

## E2E timing

Recent CI showed shard test runtimes of roughly 101–145 seconds, while
every shard also repeated a roughly two-minute Builder build. The four
shards are already reasonably balanced, so a fifth shard would not
address the primary bottleneck.

The shared artifact is about 30 MB uncompressed and roughly 5 MB
compressed because checked-out public assets are excluded. This should
return individual shard jobs to the 2–3 minute range.

## Validation

- 45 focused unit tests
- image asset to canvas E2E using the prebuilt-artifact path
- image asset to folder regression E2E
- Builder typecheck
- lint
- package-boundary validation
- workflow YAML validation
- shell syntax validation
2026-07-18 09:15:02 +02:00

293 lines
8.7 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,
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
) => {
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),
});
};
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 }
): 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,
contentMode: options?.contentMode,
},
});
if (result?.result.didMergeBreakpointsDueToLimit === true) {
options?.onBreakpointLimitMerge?.();
}
return result !== undefined;
}
const target = getFragmentInsertTarget(fragment, insertable);
if ($project.get() === undefined || target === undefined) {
return false;
}
const result = executeRuntimeMutation({
id: "instances.insertFragment",
input: {
parentInstanceId: target.parentInstanceId,
fragment,
conflictResolution,
contentMode: options?.contentMode,
insertIndex: target.insertIndex,
},
});
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?.();
}
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
): 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),
});
if (target === undefined) {
return;
}
return {
parentSelector: target.parentSelector,
position: target.insertIndex ?? "end",
};
};