mirror of
https://github.com/webstudio-is/webstudio.git
synced 2026-11-09 05:26:24 +09:00
## 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
243 lines
6.9 KiB
TypeScript
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),
|
|
};
|