mirror of
https://github.com/webstudio-is/webstudio.git
synced 2026-11-03 02:27:57 +09:00
## Summary Unifies frontend testing around three intentional boundaries: - Vitest Node projects for pure and server tests - Vitest Browser Mode with Playwright/Chromium for DOM and component tests - Playwright Test for Builder end-to-end workflows The Builder no longer uses jsdom. Ordinary E2E workflows use an automatically managed isolated `page` fixture; explicit contexts remain only for simultaneous identities and suite-scoped setup. ## Technical choices - Split Builder tests into one Node project and independent browser projects so each Chromium/Vite process has a bounded workload. - Added shared Browser Mode configuration for the affected SDK React packages. - Preserve the Builder's production top-window bridge ownership; Browser Mode uses explicit test-only installation seams. - Centralized Playwright context options and kept action/navigation timeouts bounded. - Validate Playwright filters before Docker, migrations, browser installation, or Builder builds start. - Preserve the first failed E2E attempt's report, traces, screenshots, and videos when CI retries a shard. - Split static checks, typechecks, CLI tests, core package tests, browser package tests, and Builder projects into parallel CI jobs with five-minute hard ceilings. - Distribute Builder E2E tests across ten isolated shards with two workers; parallel suites create unique per-worker setup data. - Reuse color contrast DOM/canvas samples so the exhaustive theme sweep takes about four seconds instead of timing out at 30 seconds. - The removed cost is jsdom behavior drift, oversized serial check jobs, and unnecessary E2E startup for empty selections. Real browser startup and E2E environment setup remain. ## Todo - [x] Split Builder pure/server tests from browser/component tests. - [x] Migrate Builder DOM tests from jsdom to Vitest Browser Mode. - [x] Migrate affected SDK React DOM tests to the shared Browser Mode setup. - [x] Remove jsdom annotations, dependencies, and compatibility setup. - [x] Replace ordinary manual E2E contexts with the isolated `page` fixture. - [x] Keep explicit contexts only for concurrent identities and `beforeAll` setup. - [x] Preserve setup authentication isolation from workflow pages. - [x] Add bounded Playwright action and navigation timeouts. - [x] Preserve first-attempt E2E failure evidence across CI retry. - [x] Reject empty E2E selections before expensive environment setup. - [x] Parallelize repository checks into bounded CI shards. - [x] Isolate parallel E2E setup data across workers. - [x] Remove the color contrast sweep bottleneck. - [x] Update E2E contributor documentation. - [x] Review product documentation impact; no user-facing product docs are affected. - [x] Run the full repository tests and typechecks. - [x] Run formatting, lint, package-boundary, generated-file, and fixture checks. - [x] Complete final correctness and simplification review. ## Verification - `pnpm --filter @webstudio-is/builder test` - `pnpm -r typecheck` - `pnpm lint` - `pnpm check:package-boundaries` - `pnpm check:generated-api` - `pnpm check:generated-docs` - `pnpm fixtures` - Design-system: 222 tests pass locally. - Builder: all seven Vitest projects pass locally. - The formerly slow E2E shard passes locally in 1m55s. - Full database-backed Builder E2E execution passes in CI. - All CI jobs pass; the slowest completed in 4m58s. - No fixture inputs/outputs or generated files changed. Ref #6270
604 lines
18 KiB
TypeScript
604 lines
18 KiB
TypeScript
import {
|
|
breakpointPasteLimitWarning,
|
|
extractWebstudioFragment,
|
|
findChildReferenceIndex,
|
|
findSafeFragmentPasteTarget,
|
|
getCommonAncestorSelector,
|
|
getInstancePath,
|
|
getPasteRootInstanceIds,
|
|
getFragmentContentModelWarnings,
|
|
mergeWebstudioFragments,
|
|
type InstanceSelector,
|
|
sortInstancePathsForChildMutation,
|
|
} from "@webstudio-is/project-build/runtime";
|
|
import {
|
|
instanceTransferDataVersion,
|
|
instancesTransferDataVersion,
|
|
parseInstanceTransferData,
|
|
type InstanceTransferData,
|
|
type InstancesTransferData,
|
|
} from "@webstudio-is/project-build/transfer";
|
|
import { findClosestInsertable } from "../instance-utils/insert";
|
|
import {
|
|
executeRuntimeMutationAsync,
|
|
getWebstudioData,
|
|
} from "../instance-utils/data";
|
|
import { type Insertable } from "../instance-utils/insert";
|
|
import { shallowEqual } from "shallow-equal";
|
|
import { toast } from "@webstudio-is/design-system";
|
|
import {
|
|
blockComponent,
|
|
contentBlockSourceProp,
|
|
getContentBlockSource,
|
|
isMdxFileAsset,
|
|
type Instance,
|
|
type WebstudioFragment,
|
|
isComponentDetachable,
|
|
} from "@webstudio-is/sdk";
|
|
import { $assetFolders, $instances, $project } from "~/shared/sync/data-stores";
|
|
import { deleteInstanceBySelector } from "../instance-utils/mutation";
|
|
import {
|
|
$allSelectedInstanceSelectors,
|
|
clearInstanceSelection,
|
|
$selectedInstancePath,
|
|
$selectedInstanceSelector,
|
|
$registeredComponentMetas,
|
|
$variableValuesByInstanceSelector,
|
|
selectInstances,
|
|
} from "~/shared/nano-states";
|
|
import { builderApi } from "../builder-api";
|
|
import { pasteHandled, pasteIgnored, type Plugin } from "./copy-paste";
|
|
import { resolveFragmentTokenConflicts } from "../resolve-token-conflicts";
|
|
import { reportFragmentContentModelWarnings } from "./fragment-utils";
|
|
import { transferFragmentAssets } from "./asset-transfer-utils";
|
|
import {
|
|
isRepeatedContentBlockOccurrence,
|
|
parseContentBlockRenderScope,
|
|
resolveContentBlockOccurrenceAssetId,
|
|
} from "../content-block-source-utils";
|
|
import { rewriteTransferredDocumentAssetReferences } from "./mdx-asset-transfer";
|
|
import {
|
|
createClipboardAssetPaths,
|
|
hasDynamicContentBlockSource,
|
|
prepareConnectedContentBlockFragment,
|
|
resolveRepeatedContentBlockSourcesForCopy,
|
|
} from "./content-block-fragment";
|
|
import {
|
|
$externalContentRoots,
|
|
getExternalContentSourceSelector,
|
|
} from "../external-content-mutations";
|
|
|
|
const invalidPasteDataMessage =
|
|
"Could not paste Webstudio instance data. The clipboard data appears to be incomplete or invalid.";
|
|
|
|
const getTreeData = (
|
|
instanceSelector: InstanceSelector,
|
|
{ showToast = true }: { showToast?: boolean } = {}
|
|
): InstanceTransferData | undefined => {
|
|
const instances = $instances.get();
|
|
const [targetInstanceId] = instanceSelector;
|
|
const instance = instances.get(targetInstanceId);
|
|
if (instance && !isComponentDetachable(instance.component)) {
|
|
if (showToast) {
|
|
toast.error(
|
|
"This instance can not be moved outside of its parent component."
|
|
);
|
|
}
|
|
return;
|
|
}
|
|
|
|
// @todo tell user they can't copy or cut root
|
|
if (instanceSelector.length === 1) {
|
|
return;
|
|
}
|
|
|
|
const data = getWebstudioData();
|
|
const project = $project.get();
|
|
const sourceInstanceSelector =
|
|
instance?.component === blockComponent
|
|
? getExternalContentSourceSelector({
|
|
selector: instanceSelector,
|
|
roots: $externalContentRoots.get(),
|
|
})?.sourceSelector
|
|
: undefined;
|
|
const copyInstanceSelector = sourceInstanceSelector ?? instanceSelector;
|
|
let fragment = extractWebstudioFragment(data, copyInstanceSelector[0]);
|
|
let renderScopes: ReadonlySet<string> | undefined;
|
|
const isRepeatedOccurrence = isRepeatedContentBlockOccurrence({
|
|
instanceSelector,
|
|
instances,
|
|
});
|
|
if (isRepeatedOccurrence) {
|
|
const resolved = resolveRepeatedContentBlockSourcesForCopy({
|
|
fragment,
|
|
selectedInstanceSelector: copyInstanceSelector,
|
|
occurrences: Array.from($externalContentRoots.get().values()).flatMap(
|
|
(root) => {
|
|
const sourceInstanceSelector = parseContentBlockRenderScope(
|
|
root.sourceRenderScope ?? root.renderScope ?? ""
|
|
);
|
|
const sourceBlockInstanceId =
|
|
root.sourceBlockInstanceId ?? root.blockInstanceId;
|
|
return root.identity === undefined ||
|
|
sourceInstanceSelector === undefined
|
|
? []
|
|
: [
|
|
{
|
|
sourceBlockInstanceId,
|
|
sourceRenderScope:
|
|
root.sourceRenderScope ?? root.renderScope ?? "",
|
|
sourceInstanceSelector,
|
|
assetId: root.identity.assetId,
|
|
},
|
|
];
|
|
}
|
|
),
|
|
});
|
|
fragment = resolved.fragment;
|
|
renderScopes = resolved.renderScopes;
|
|
}
|
|
if (instance?.component === blockComponent) {
|
|
const source = getContentBlockSource({
|
|
blockInstanceId: copyInstanceSelector[0],
|
|
props: data.props.values(),
|
|
});
|
|
const resolvedAssetId =
|
|
source?.type === "expression"
|
|
? resolveContentBlockOccurrenceAssetId({
|
|
source,
|
|
instanceSelector,
|
|
variableValuesByRenderScope:
|
|
$variableValuesByInstanceSelector.get(),
|
|
})
|
|
: undefined;
|
|
const resolvedAsset =
|
|
resolvedAssetId === undefined
|
|
? undefined
|
|
: data.assets.get(resolvedAssetId);
|
|
if (resolvedAsset !== undefined && isMdxFileAsset(resolvedAsset)) {
|
|
fragment = {
|
|
...fragment,
|
|
props: fragment.props.map((prop) =>
|
|
prop.instanceId === copyInstanceSelector[0] &&
|
|
prop.name === contentBlockSourceProp
|
|
? { ...prop, type: "asset" as const, value: resolvedAsset.id }
|
|
: prop
|
|
),
|
|
};
|
|
}
|
|
}
|
|
fragment = prepareConnectedContentBlockFragment({
|
|
fragment,
|
|
projectId: project?.id,
|
|
assets: data.assets,
|
|
renderScopes,
|
|
});
|
|
const hasDynamicContentSource = hasDynamicContentBlockSource(fragment);
|
|
if (
|
|
showToast &&
|
|
instance?.component !== blockComponent &&
|
|
hasDynamicContentSource
|
|
) {
|
|
toast.warn(
|
|
"Dynamic MDX sources are copied from the Collection items currently rendered on the canvas."
|
|
);
|
|
}
|
|
return {
|
|
sourceOrigin: window.location.origin,
|
|
assetPaths: createClipboardAssetPaths(fragment.assets, $assetFolders.get()),
|
|
instanceSelector: copyInstanceSelector,
|
|
...fragment,
|
|
};
|
|
};
|
|
|
|
const stringify = (data: InstanceTransferData) => {
|
|
return JSON.stringify({ [instanceTransferDataVersion]: data });
|
|
};
|
|
|
|
const stringifyMultiRoot = (data: InstancesTransferData) => {
|
|
return JSON.stringify({ [instancesTransferDataVersion]: data });
|
|
};
|
|
|
|
const stringifyMultiRootSelection = (selectedData: InstanceTransferData[]) => {
|
|
const firstData = selectedData[0];
|
|
if (firstData === undefined) {
|
|
return;
|
|
}
|
|
const rootInstanceIds = selectedData.map((data) => data.instanceSelector[0]);
|
|
return stringifyMultiRoot({
|
|
sourceOrigin: firstData.sourceOrigin,
|
|
assetPaths: Object.assign(
|
|
{},
|
|
...selectedData.map(({ assetPaths }) => assetPaths ?? {})
|
|
),
|
|
rootInstanceIds,
|
|
fragment: mergeWebstudioFragments(rootInstanceIds, selectedData),
|
|
});
|
|
};
|
|
|
|
const reportSkippedSelectedInstances = (operation: "copied" | "cut") => {
|
|
builderApi.toast.info(`Some selected instances could not be ${operation}.`);
|
|
};
|
|
|
|
const findMultiSelectionInsertable = (
|
|
fragment: WebstudioFragment
|
|
): undefined | Insertable => {
|
|
const selectedInstanceSelectors = $allSelectedInstanceSelectors.get();
|
|
if (selectedInstanceSelectors.length < 2) {
|
|
return;
|
|
}
|
|
const instances = $instances.get();
|
|
const selectedPaths = selectedInstanceSelectors
|
|
.map((instanceSelector) => getInstancePath(instanceSelector, instances))
|
|
.filter((path): path is NonNullable<typeof path> => path !== undefined);
|
|
if (selectedPaths.length < 2) {
|
|
return;
|
|
}
|
|
|
|
const directParentSelector = selectedPaths[0][1]?.instanceSelector;
|
|
const hasSameDirectParent =
|
|
directParentSelector !== undefined &&
|
|
selectedPaths.every((path) =>
|
|
shallowEqual(path[1]?.instanceSelector, directParentSelector)
|
|
);
|
|
if (hasSameDirectParent) {
|
|
const parentInstance = instances.get(directParentSelector[0]);
|
|
if (parentInstance === undefined) {
|
|
return;
|
|
}
|
|
const selectedSiblingIndexes = selectedPaths.map((path) =>
|
|
findChildReferenceIndex(parentInstance.children, path[0].instance.id)
|
|
);
|
|
if (selectedSiblingIndexes.includes(-1)) {
|
|
return;
|
|
}
|
|
const lastSelectedSiblingIndex = Math.max(...selectedSiblingIndexes);
|
|
return findPasteTargetForFragment(fragment, {
|
|
parentSelector: directParentSelector,
|
|
position: lastSelectedSiblingIndex + 1,
|
|
});
|
|
}
|
|
|
|
const commonAncestorSelector = getCommonAncestorSelector(
|
|
selectedPaths.map((path) => path[0].instanceSelector)
|
|
);
|
|
if (commonAncestorSelector === undefined) {
|
|
return;
|
|
}
|
|
return findPasteTargetForFragment(fragment, {
|
|
parentSelector: commonAncestorSelector,
|
|
position: "end",
|
|
});
|
|
};
|
|
|
|
const findSelectionPasteTarget = (fragment: WebstudioFragment) =>
|
|
findMultiSelectionInsertable(fragment) ??
|
|
findPasteTargetForFragment(fragment);
|
|
|
|
const findPasteTargetForFragment = (
|
|
fragment: WebstudioFragment,
|
|
insertable?: Insertable
|
|
): undefined | Insertable => {
|
|
const instances = $instances.get();
|
|
|
|
insertable = findClosestInsertable(fragment, insertable, {
|
|
allowContentModelWarnings: true,
|
|
});
|
|
if (insertable === undefined) {
|
|
return;
|
|
}
|
|
return findSafeFragmentPasteTarget({
|
|
fragment,
|
|
instances,
|
|
insertTarget: insertable,
|
|
});
|
|
};
|
|
|
|
const findPasteTarget = (
|
|
data: WebstudioFragment & { instanceSelector: string[] }
|
|
): undefined | Insertable => {
|
|
const instances = $instances.get();
|
|
|
|
const instanceSelector = $selectedInstanceSelector.get();
|
|
const selectedSourceSelector =
|
|
instanceSelector === undefined
|
|
? undefined
|
|
: getExternalContentSourceSelector({
|
|
selector: instanceSelector,
|
|
roots: $externalContentRoots.get(),
|
|
})?.sourceSelector;
|
|
|
|
// paste after selected instance
|
|
if (
|
|
instanceSelector &&
|
|
shallowEqual(
|
|
selectedSourceSelector ?? instanceSelector,
|
|
data.instanceSelector
|
|
)
|
|
) {
|
|
// body is not allowed to copy
|
|
// so clipboard always have at least two level instance selector
|
|
const [currentInstanceId, parentInstanceId] = instanceSelector;
|
|
const parentInstance = instances.get(parentInstanceId);
|
|
if (parentInstance === undefined) {
|
|
return;
|
|
}
|
|
const indexWithinChildren = parentInstance.children.findIndex(
|
|
(child) => child.type === "id" && child.value === currentInstanceId
|
|
);
|
|
return {
|
|
parentSelector: instanceSelector.slice(1),
|
|
position: indexWithinChildren + 1,
|
|
};
|
|
}
|
|
|
|
return findSelectionPasteTarget(data);
|
|
};
|
|
|
|
const insertPastedFragment = async ({
|
|
fragment,
|
|
pasteTarget,
|
|
selectRootInstances,
|
|
sourceOrigin,
|
|
assetPaths,
|
|
projectId,
|
|
}: {
|
|
fragment: WebstudioFragment;
|
|
pasteTarget: Insertable;
|
|
selectRootInstances: (rootInstanceIds: Instance["id"][]) => void;
|
|
sourceOrigin: string | undefined;
|
|
assetPaths?: Readonly<Record<string, string>>;
|
|
projectId: string;
|
|
}) => {
|
|
try {
|
|
const contentModelWarnings = getFragmentContentModelWarnings({
|
|
fragment,
|
|
metas: $registeredComponentMetas.get(),
|
|
});
|
|
const conflictResolution = await resolveFragmentTokenConflicts(fragment);
|
|
if (conflictResolution === "cancel") {
|
|
return pasteHandled;
|
|
}
|
|
const transferred = await transferFragmentAssets({
|
|
sourceOrigin,
|
|
projectId,
|
|
fragments: [fragment],
|
|
importAssets: builderApi.importAssets,
|
|
});
|
|
if (transferred.success === false) {
|
|
return transferred;
|
|
}
|
|
if ($project.get()?.id !== projectId) {
|
|
return {
|
|
success: false,
|
|
error: "Project changed while pasting.",
|
|
} as const;
|
|
}
|
|
if (sourceOrigin !== undefined) {
|
|
try {
|
|
const { skippedInvalidAssetIds } =
|
|
await rewriteTransferredDocumentAssetReferences({
|
|
sourceOrigin,
|
|
projectId,
|
|
sourceAssets: fragment.assets,
|
|
sourceAssetPaths: assetPaths,
|
|
importedAssets: transferred.assets,
|
|
});
|
|
if (skippedInvalidAssetIds.length > 0) {
|
|
toast.warn(
|
|
"Some invalid content files were copied unchanged. Open them to review their diagnostics."
|
|
);
|
|
}
|
|
} catch {
|
|
return {
|
|
success: false,
|
|
error:
|
|
"Could not update Asset references in the copied content files.",
|
|
} as const;
|
|
}
|
|
}
|
|
if ($project.get()?.id !== projectId) {
|
|
return {
|
|
success: false,
|
|
error: "Project changed while pasting.",
|
|
} as const;
|
|
}
|
|
const transferredFragment = transferred.fragments.get(fragment);
|
|
if (transferredFragment === undefined) {
|
|
return pasteHandled;
|
|
}
|
|
const result = await executeRuntimeMutationAsync({
|
|
id: "instances.insertFragment",
|
|
input: {
|
|
parentInstanceId: pasteTarget.parentSelector[0],
|
|
fragment: transferredFragment,
|
|
conflictResolution,
|
|
insertIndex:
|
|
typeof pasteTarget.position === "number"
|
|
? pasteTarget.position
|
|
: undefined,
|
|
},
|
|
context: { allowLegacyContentModelWarnings: true },
|
|
});
|
|
const rootInstanceIds = result?.result.rootInstanceIds;
|
|
if (rootInstanceIds === undefined || rootInstanceIds.length === 0) {
|
|
return pasteHandled;
|
|
}
|
|
if (result?.result.didMergeBreakpointsDueToLimit === true) {
|
|
toast.warn(breakpointPasteLimitWarning);
|
|
}
|
|
reportFragmentContentModelWarnings(contentModelWarnings);
|
|
selectRootInstances(rootInstanceIds);
|
|
return pasteHandled;
|
|
} catch {
|
|
return pasteHandled;
|
|
}
|
|
};
|
|
|
|
const handlePasteInstance = async (clipboardData: string) => {
|
|
const transferData = parseInstanceTransferData(clipboardData);
|
|
if (transferData.owned === false) {
|
|
return pasteIgnored;
|
|
}
|
|
if (transferData.valid === false) {
|
|
return { success: false, error: invalidPasteDataMessage } as const;
|
|
}
|
|
const projectId = $project.get()?.id;
|
|
if (projectId === undefined) {
|
|
return pasteHandled;
|
|
}
|
|
if (transferData.type === "multi-root") {
|
|
const pasteRootInstanceIds = getPasteRootInstanceIds(transferData.data);
|
|
if (pasteRootInstanceIds.length === 0) {
|
|
return pasteHandled;
|
|
}
|
|
const fragment: WebstudioFragment = {
|
|
...transferData.data.fragment,
|
|
children: pasteRootInstanceIds.map((instanceId) => ({
|
|
type: "id",
|
|
value: instanceId,
|
|
})),
|
|
};
|
|
const pasteTarget = findSelectionPasteTarget(fragment);
|
|
if (pasteTarget === undefined) {
|
|
return pasteHandled;
|
|
}
|
|
return insertPastedFragment({
|
|
fragment,
|
|
pasteTarget,
|
|
sourceOrigin: transferData.data.sourceOrigin ?? window.location.origin,
|
|
assetPaths: transferData.data.assetPaths,
|
|
projectId,
|
|
selectRootInstances: (rootInstanceIds) => {
|
|
selectInstances(
|
|
rootInstanceIds.map((newRootInstanceId) => [
|
|
newRootInstanceId,
|
|
...pasteTarget.parentSelector,
|
|
])
|
|
);
|
|
},
|
|
});
|
|
}
|
|
const fragment = transferData.data;
|
|
|
|
const pasteTarget = findPasteTarget(fragment);
|
|
if (pasteTarget === undefined) {
|
|
return pasteHandled;
|
|
}
|
|
return insertPastedFragment({
|
|
fragment,
|
|
pasteTarget,
|
|
sourceOrigin: transferData.data.sourceOrigin ?? window.location.origin,
|
|
assetPaths: transferData.data.assetPaths,
|
|
projectId,
|
|
selectRootInstances: (rootInstanceIds) => {
|
|
const newRootInstanceId = rootInstanceIds[0];
|
|
if (newRootInstanceId === undefined) {
|
|
return;
|
|
}
|
|
selectInstances([[newRootInstanceId, ...pasteTarget.parentSelector]]);
|
|
},
|
|
});
|
|
};
|
|
|
|
const handleCopyInstance = () => {
|
|
const selectedInstanceSelectors = $allSelectedInstanceSelectors.get();
|
|
if (selectedInstanceSelectors.length === 0) {
|
|
return;
|
|
}
|
|
if (selectedInstanceSelectors.length === 1) {
|
|
const data = getTreeData(selectedInstanceSelectors[0]);
|
|
if (data === undefined) {
|
|
return;
|
|
}
|
|
return stringify(data);
|
|
}
|
|
|
|
const selectedData = selectedInstanceSelectors
|
|
.map((instanceSelector) =>
|
|
getTreeData(instanceSelector, { showToast: false })
|
|
)
|
|
.filter((data): data is InstanceTransferData => data !== undefined);
|
|
if (selectedData.length === 0) {
|
|
return;
|
|
}
|
|
if (selectedData.length < selectedInstanceSelectors.length) {
|
|
reportSkippedSelectedInstances("copied");
|
|
}
|
|
return stringifyMultiRootSelection(selectedData);
|
|
};
|
|
|
|
const handleCutInstance = () => {
|
|
const selectedInstanceSelectors = $allSelectedInstanceSelectors.get();
|
|
if (selectedInstanceSelectors.length > 1) {
|
|
const instances = $instances.get();
|
|
const selectedPaths = selectedInstanceSelectors
|
|
.map((instanceSelector) => {
|
|
const data = getTreeData(instanceSelector, { showToast: false });
|
|
const instancePath =
|
|
data === undefined
|
|
? undefined
|
|
: getInstancePath(data.instanceSelector, instances);
|
|
if (data === undefined || instancePath === undefined) {
|
|
return;
|
|
}
|
|
return { data, instancePath };
|
|
})
|
|
.filter(
|
|
(
|
|
item
|
|
): item is {
|
|
data: InstanceTransferData;
|
|
instancePath: NonNullable<ReturnType<typeof getInstancePath>>;
|
|
} => item !== undefined
|
|
);
|
|
if (selectedPaths.length === 0) {
|
|
return;
|
|
}
|
|
const selectedPathData = selectedPaths.map(({ data }) => data);
|
|
if (selectedPathData.length < selectedInstanceSelectors.length) {
|
|
reportSkippedSelectedInstances("cut");
|
|
}
|
|
const clipboardData = stringifyMultiRootSelection(selectedPathData);
|
|
for (const { instancePath } of sortInstancePathsForChildMutation(
|
|
selectedPaths
|
|
)) {
|
|
deleteInstanceBySelector(instancePath[0].instanceSelector);
|
|
}
|
|
clearInstanceSelection();
|
|
return clipboardData;
|
|
}
|
|
|
|
const instancePath = $selectedInstancePath.get();
|
|
if (instancePath === undefined) {
|
|
return;
|
|
}
|
|
// @todo tell user they can't delete root
|
|
if (instancePath.length === 1) {
|
|
return;
|
|
}
|
|
const data = getTreeData(instancePath[0].instanceSelector);
|
|
if (data === undefined) {
|
|
return;
|
|
}
|
|
deleteInstanceBySelector(instancePath[0].instanceSelector);
|
|
if (data === undefined) {
|
|
return;
|
|
}
|
|
return stringify(data);
|
|
};
|
|
|
|
export const instanceText = {
|
|
name: "instance-text",
|
|
mimeType: "text/plain",
|
|
onCopy: handleCopyInstance,
|
|
onCut: handleCutInstance,
|
|
onPaste: handlePasteInstance,
|
|
} satisfies Plugin;
|
|
|
|
export const instanceJson = {
|
|
name: "instance-json",
|
|
mimeType: "application/json",
|
|
onPaste: handlePasteInstance,
|
|
} satisfies Plugin;
|