mirror of
https://github.com/webstudio-is/webstudio.git
synced 2026-10-30 08:37:56 +09:00
## 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
293 lines
8.7 KiB
TypeScript
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",
|
|
};
|
|
};
|