Files
webstudio/apps/builder/app/shared/copy-paste/plugin-page.ts
T
Oleg Isonen ca08919b13 fix: Confirm conflicting root styles on page paste (#5971)
## Summary

Prevent page, template, folder, and marketplace page imports from
silently replacing local declarations on the shared global root. When
incoming root styles differ at the same property, breakpoint, and state,
Builder now asks whether to preserve the project values or use the
incoming values. Cancellation happens before the runtime mutation.

## Technical choices

- Compare CSS values by their serialized meaning so equivalent keyword
and unparsed values do not create false conflicts.
- Plan breakpoint remapping with the same insertion path used by
fragment paste, including ID collisions and breakpoint limits.
- Keep named-token conflict resolution separate because token conflicts
support suffixing and merging, while a shared root-local declaration has
only an existing or incoming value.
- Default the root-style dialog to **Keep existing** because global root
styles affect every page, and show the breakpoint plus current and
incoming values for each conflict.
- Re-read current project data after the asynchronous token dialog
before detecting root conflicts.
- Leave no-conflict resolution undefined so the runtime performs a final
conflict check instead of treating it as an explicit **Use incoming**
choice.
- Resolve root conflicts at the page-copy boundary before using the
generic fragment/style insertion path.
- Reuse one root-conflict UI resolver for clipboard paste and
marketplace imports, one page-transfer traversal helper in the UI and
runtime, and one presentational shell for token and root-style conflict
dialogs.
- Require an explicit `rootStyleConflictResolution` for conflicting
`pageTransfer.insert` and `pages.copy` runtime operations so non-UI
callers cannot overwrite global values accidentally.
- Document that `ours` keeps target-project values and `theirs` uses
incoming values in both generated runtime contracts.

## Todo

- [x] Detect conflicting root-local declarations by property,
breakpoint, and state
- [x] Add keep-existing, use-incoming, and cancel behavior
- [x] Cover page, template, nested-folder, marketplace, and direct
runtime transfer paths
- [x] Preserve non-conflicting incoming root declarations with either
resolution
- [x] Avoid false conflicts for equivalent CSS values and colliding
breakpoint IDs
- [x] Revalidate root styles after asynchronous token resolution
- [x] Preserve existing named-token conflict behavior
- [x] Remove duplicated transfer traversal, conflict resolution mapping,
and dialog layout
- [x] Regenerate the runtime operation contract
- [x] Verify regression tests fail when their corresponding behavior is
disabled
- [x] Run targeted tests, typechecks, formatting, and lint

## Verification

- `pnpm --filter @webstudio-is/project-build test --
src/runtime/style-copy.test.ts src/runtime/fragment.test.tsx
src/runtime/page-copy.test.tsx` — 80 passed
- `pnpm --filter @webstudio-is/builder test --
app/shared/copy-paste/plugin-page.test.ts
app/shared/root-style-conflict-dialog.test.tsx
app/shared/token-conflict-dialog.test.tsx` — 17 passed
- Retrospective fail/pass audit covered semantic CSS equality,
breakpoint ID collisions, both runtime guards, both resolution
directions, folder/template paths, conflict detail rendering, and stale
target data after token resolution
- `pnpm --filter @webstudio-is/project-build typecheck` — passed
- `pnpm --filter @webstudio-is/builder typecheck` — passed
- `pnpm lint` — passed
- Runtime contract regeneration was repeated and produced identical
output
- Fixture pipeline not run locally: no fixture inputs or outputs are
affected; GitHub fixture checks passed on the previous revision
- Agent evaluations not run: repository guidance requires separate
explicit approval before running them

## Compatibility and limitations

The new runtime inputs are optional for non-conflicting imports.
Existing imports continue unchanged; only imports that would overwrite a
conflicting shared root-local declaration require an explicit
resolution. No migrations or persistence changes are required.

Closes #5970
2026-08-02 12:33:02 +02:00

243 lines
6.9 KiB
TypeScript

import {
executeRuntimeMutation,
getWebstudioData,
} from "../instance-utils/data";
import { toast } from "@webstudio-is/design-system";
import {
type WebstudioFragment,
findPageByIdOrPath,
findParentFolderByChildId,
type Folder,
type Page,
type PageTemplate,
} from "@webstudio-is/sdk";
import { $pages, $project } from "~/shared/sync/data-stores";
import { detectFragmentTokenConflicts } from "@webstudio-is/project-build/runtime";
import { resolveTokenConflicts } from "../resolve-token-conflicts";
import { resolveRootStyleConflicts } from "../resolve-root-style-conflicts";
import {
createFolderCopyData,
createPageCopyData,
createTemplateCopyData,
} from "@webstudio-is/project-build/runtime";
import {
pageTransferDataVersion,
parsePageTransferData,
collectPageTransferItems,
type FolderTransferData,
type PageTransferData,
type PageTransferItem,
type TemplateTransferData,
} from "@webstudio-is/project-build/transfer";
import { $selectedPage } from "../nano-states";
import { getPageActionTarget } from "../page-action-target";
import { pasteHandled, pasteIgnored, type Plugin } from "./copy-paste";
import { breakpointPasteLimitWarning } from "@webstudio-is/project-build/runtime";
const invalidPasteDataMessage =
"Could not paste Webstudio page data. The clipboard data appears to be incomplete or invalid.";
const stringify = (data: PageTransferItem) => {
return JSON.stringify({ [pageTransferDataVersion]: data });
};
const getDefaultTargetFolderId = () => {
const pages = $pages.get();
if (pages === undefined) {
return;
}
const selectedPage = $selectedPage.get();
if (selectedPage) {
return (
findParentFolderByChildId(selectedPage.id, pages.folders)?.id ??
pages.rootFolderId
);
}
return pages.rootFolderId;
};
const mergeWebstudioFragments = (
...fragments: WebstudioFragment[]
): WebstudioFragment => {
return {
children: fragments.flatMap((fragment) => fragment.children),
instances: fragments.flatMap((fragment) => fragment.instances),
assets: fragments.flatMap((fragment) => fragment.assets),
dataSources: fragments.flatMap((fragment) => fragment.dataSources),
resources: fragments.flatMap((fragment) => fragment.resources),
props: fragments.flatMap((fragment) => fragment.props),
breakpoints: fragments.flatMap((fragment) => fragment.breakpoints),
styleSourceSelections: fragments.flatMap(
(fragment) => fragment.styleSourceSelections
),
styleSources: fragments.flatMap((fragment) => fragment.styleSources),
styles: fragments.flatMap((fragment) => fragment.styles),
} satisfies WebstudioFragment;
};
const addPageType = (
data: ReturnType<typeof createPageCopyData>
): PageTransferData => ({
...data,
type: "page",
});
const addTemplateType = (
data: ReturnType<typeof createTemplateCopyData>
): TemplateTransferData => ({
...data,
type: "template",
});
const addFolderType = (
data: NonNullable<ReturnType<typeof createFolderCopyData>>
): FolderTransferData => ({
type: "folder",
folder: data.folder,
children: data.children.map((child) =>
"folder" in child ? addFolderType(child) : addPageType(child)
),
});
const getPageCopyData = (
data: ReturnType<typeof getWebstudioData>,
pageId: Page["id"]
): PageTransferData | undefined => {
const page = findPageByIdOrPath(pageId, data.pages);
if (page === undefined) {
return;
}
return addPageType(createPageCopyData({ data, page }));
};
const handleCopyPage = () => {
const target = getPageActionTarget();
if (target?.type === "page") {
return copyPageData(target.id);
}
if (target?.type === "folder") {
return copyFolderData(target.id);
}
if (target?.type === "template") {
return copyTemplateData(target.id);
}
};
export const copyPageData = (pageId: Page["id"]) => {
const data = getWebstudioData();
const pageData = getPageCopyData(data, pageId);
if (pageData === undefined) {
return;
}
return stringify(pageData);
};
export const copyTemplateData = (templateId: PageTemplate["id"]) => {
const data = getWebstudioData();
const template = data.pages.pageTemplates?.get(templateId);
if (template === undefined) {
return;
}
return stringify(addTemplateType(createTemplateCopyData({ data, template })));
};
export const copyFolderData = (folderId: Folder["id"]) => {
const data = getWebstudioData();
const folderData = createFolderCopyData({ data, folderId });
if (folderData === undefined) {
return;
}
return stringify(addFolderType(folderData));
};
export const handlePastePage = async (
clipboardData: string,
targetFolderId?: Folder["id"]
) => {
const transferData = parsePageTransferData(clipboardData);
if (transferData.owned === false) {
return pasteIgnored;
}
if (transferData.valid === false) {
return { success: false, error: invalidPasteDataMessage } as const;
}
const item = transferData.data;
const pages = $pages.get();
const folderId = targetFolderId ?? getDefaultTargetFolderId();
if (
pages === undefined ||
folderId === undefined ||
pages.folders.has(folderId) === false
) {
return pasteHandled;
}
try {
const targetData = getWebstudioData();
const projectId = $project.get()?.id;
if (projectId === undefined) {
return pasteHandled;
}
const pageItems = collectPageTransferItems(item);
const conflicts = pageItems.flatMap((item) =>
detectFragmentTokenConflicts({
fragment: mergeWebstudioFragments(item.rootFragment, item.bodyFragment),
targetData,
})
);
const conflictResolution = await resolveTokenConflicts(conflicts);
if (conflictResolution === "cancel") {
return pasteHandled;
}
const rootStyleTargetData = getWebstudioData();
const firstPageLikeItem = pageItems[0];
const rootStyleConflictResolution =
firstPageLikeItem === undefined
? undefined
: await resolveRootStyleConflicts({
fragment: firstPageLikeItem.rootFragment,
targetData: rootStyleTargetData,
});
if (rootStyleConflictResolution === "cancel") {
return pasteHandled;
}
const result = executeRuntimeMutation({
id: "pageTransfer.insert",
input: {
item,
targetFolderId: folderId,
projectId,
conflictResolution,
rootStyleConflictResolution,
},
});
if (result?.result.didReachBreakpointLimit) {
toast.warn(breakpointPasteLimitWarning);
}
const newId = result?.result.id;
if (newId) {
toast.success(
item.type === "page"
? "Page pasted"
: item.type === "template"
? "Template pasted"
: "Folder pasted"
);
return pasteHandled;
}
} catch {
return pasteHandled;
}
return pasteHandled;
};
export const pageText: Plugin = {
name: "page-text",
mimeType: "text/plain",
onCopy: handleCopyPage,
onPaste: (clipboardData) => handlePastePage(clipboardData),
};