feat(site-builder): page duplicate/reorder/set-landing + fix cross-page node copy/paste
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -18,6 +18,28 @@ interface PageContextValue {
|
||||
addPage: (name: string, slug: string) => void;
|
||||
deletePage: (pageId: string) => void;
|
||||
renamePage: (pageId: string, name: string, slug: string) => void;
|
||||
/**
|
||||
* Duplicates `pageId`, inserting the copy immediately after the source in
|
||||
* `pages` and switching the canvas to the new copy. If `pageId` is the
|
||||
* active page, its current on-canvas state is saved first so the copy
|
||||
* (and the original) both reflect what's actually on screen. The copy
|
||||
* gets its own unique slug (never `'index'` -- it's never at index 0) and
|
||||
* a name of `"<source name> copy"`; its `seo` is copied from the source.
|
||||
*/
|
||||
duplicatePage: (pageId: string) => void;
|
||||
/**
|
||||
* Reorders `pageId` one slot `'up'` or `'down'` within `pages` (swap with
|
||||
* the adjacent page; no-op at either end). Does NOT touch the live
|
||||
* canvas -- only list order changes. Re-applies the landing-page
|
||||
* invariant afterward (see `applyLandingInvariant`) since a reorder can
|
||||
* move a different page into/out of index 0.
|
||||
*/
|
||||
movePage: (pageId: string, direction: 'up' | 'down') => void;
|
||||
/**
|
||||
* Moves `pageId` to index 0 (making it the new landing page) and
|
||||
* re-applies the landing-page invariant. Does NOT touch the live canvas.
|
||||
*/
|
||||
setLandingPage: (pageId: string) => void;
|
||||
/** Merges `seo` fields onto the target page's existing `seo` (creating it if absent). */
|
||||
updatePageSeo: (pageId: string, seo: Partial<PageSeo>) => void;
|
||||
setHeaderCraftState: (craftState: string) => void;
|
||||
@@ -188,6 +210,9 @@ const PageContext = createContext<PageContextValue>({
|
||||
addPage: () => {},
|
||||
deletePage: () => {},
|
||||
renamePage: () => {},
|
||||
duplicatePage: () => {},
|
||||
movePage: () => {},
|
||||
setLandingPage: () => {},
|
||||
updatePageSeo: () => {},
|
||||
setHeaderCraftState: () => {},
|
||||
setFooterCraftState: () => {},
|
||||
@@ -229,6 +254,52 @@ export function uniqueSlug(base: string, existingSlugs: string[]): string {
|
||||
return `${base}-${i}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Re-establishes the landing-page invariant on a REORDERED pages array: the
|
||||
* page now at index 0 is the landing page and its slug is locked to
|
||||
* `'index'` (regardless of whatever slug it held before it was moved there);
|
||||
* every other page keeps its slug UNLESS it's the page that previously held
|
||||
* `'index'` and has now been demoted to index > 0 -- that page needs a real,
|
||||
* unique slug of its own (derived from its name) since a page can no longer
|
||||
* publish to `index.html` from anywhere but index 0.
|
||||
*
|
||||
* Pure function of the array -- used by both `movePage` (swap two adjacent
|
||||
* pages) and `setLandingPage` (move an arbitrary page to index 0) as the
|
||||
* shared "fix the invariant up after reordering" step, and directly
|
||||
* unit-testable without mounting `PageProvider`.
|
||||
*
|
||||
* Assumes at most one page enters with slug `'index'` (true for any array
|
||||
* that already satisfied the invariant before the reorder that produced this
|
||||
* input) -- exactly the case both callers hand it.
|
||||
*/
|
||||
export function applyLandingInvariant(pages: PageData[]): PageData[] {
|
||||
if (pages.length === 0) return pages;
|
||||
|
||||
// Slugs that are FIXED and must not be collided into: 'index' (reserved
|
||||
// for whoever ends up at index 0) plus every non-landing page's existing
|
||||
// slug except the demoted page's (it currently holds 'index' and is about
|
||||
// to be given a new one). Computed upfront, over the WHOLE array, so the
|
||||
// demoted page's new slug is checked against every other page regardless
|
||||
// of array order -- checking only "slugs seen so far" while walking the
|
||||
// array would miss a collision against a page that appears LATER in the
|
||||
// list than the demoted one.
|
||||
const fixedSlugs: string[] = ['index'];
|
||||
for (let i = 1; i < pages.length; i++) {
|
||||
if (pages[i].slug !== 'index') fixedSlugs.push(pages[i].slug);
|
||||
}
|
||||
|
||||
return pages.map((page, i) => {
|
||||
if (i === 0) {
|
||||
return page.slug === 'index' ? page : { ...page, slug: 'index' };
|
||||
}
|
||||
if (page.slug === 'index') {
|
||||
// Demoted landing page -- give it a real, unique slug of its own.
|
||||
return { ...page, slug: uniqueSlug(slugify(page.name), fixedSlugs) };
|
||||
}
|
||||
return page;
|
||||
});
|
||||
}
|
||||
|
||||
const DEFAULT_PAGE: PageData = {
|
||||
id: 'home',
|
||||
name: 'Home',
|
||||
@@ -409,6 +480,97 @@ export const PageProvider: React.FC<{ children: ReactNode }> = ({ children }) =>
|
||||
[loadState],
|
||||
);
|
||||
|
||||
/**
|
||||
* Duplicates `pageId`: inserts a copy immediately after the source in
|
||||
* `pages` and switches the live canvas to it. See the doc comment on
|
||||
* `PageContextValue.duplicatePage`.
|
||||
*/
|
||||
const duplicatePage = useCallback(
|
||||
(pageId: string) => {
|
||||
const isActive = pageId === activePageIdRef.current;
|
||||
|
||||
// If the source is the active page, persist its current on-canvas
|
||||
// state back into `pages` first (same as switchPage/addPage do)
|
||||
// so the ORIGINAL page isn't left with a stale craftState after
|
||||
// this. `query.serialize()` below reads the live canvas directly
|
||||
// rather than waiting on this (React state update timing aside,
|
||||
// it's simplest to just ask Craft.js for the truth).
|
||||
if (isActive) {
|
||||
saveCurrentState();
|
||||
}
|
||||
|
||||
const source = pagesRef.current.find((p) => p.id === pageId);
|
||||
if (!source) return;
|
||||
|
||||
const sourceCraftState = isActive ? query.serialize() : source.craftState;
|
||||
const otherSlugs = pagesRef.current.map((p) => p.slug);
|
||||
const copyId = nextPageId();
|
||||
const copyName = `${source.name} copy`;
|
||||
// The copy is always inserted AFTER the source (index >= 1), so it
|
||||
// never needs the reserved 'index' slug -- a normal unique slug always
|
||||
// applies here regardless of whether the source itself is the landing
|
||||
// page.
|
||||
const copySlug = uniqueSlug(slugify(copyName), otherSlugs);
|
||||
const copy: PageData = {
|
||||
id: copyId,
|
||||
name: copyName,
|
||||
slug: copySlug,
|
||||
craftState: sourceCraftState,
|
||||
seo: source.seo ? { ...source.seo } : undefined,
|
||||
};
|
||||
|
||||
setPages((prev) => {
|
||||
const idx = prev.findIndex((p) => p.id === pageId);
|
||||
if (idx === -1) return prev;
|
||||
const next = [...prev];
|
||||
next.splice(idx + 1, 0, copy);
|
||||
return next;
|
||||
});
|
||||
|
||||
// Switch the canvas to the new copy so the user lands on it, same as
|
||||
// addPage switching to the freshly created page.
|
||||
loadState(copy.craftState, EMPTY_CANVAS);
|
||||
setActivePageId(copyId);
|
||||
activePageIdRef.current = copyId;
|
||||
},
|
||||
[query, saveCurrentState, loadState],
|
||||
);
|
||||
|
||||
/**
|
||||
* Reorders `pageId` one slot up or down (swap with the adjacent page).
|
||||
* Pure list-order change -- does not touch the live canvas. See the doc
|
||||
* comment on `PageContextValue.movePage`.
|
||||
*/
|
||||
const movePage = useCallback((pageId: string, direction: 'up' | 'down') => {
|
||||
setPages((prev) => {
|
||||
const idx = prev.findIndex((p) => p.id === pageId);
|
||||
if (idx === -1) return prev;
|
||||
const swapIdx = direction === 'up' ? idx - 1 : idx + 1;
|
||||
if (swapIdx < 0 || swapIdx >= prev.length) return prev; // no-op at the ends
|
||||
|
||||
const next = [...prev];
|
||||
[next[idx], next[swapIdx]] = [next[swapIdx], next[idx]];
|
||||
return applyLandingInvariant(next);
|
||||
});
|
||||
}, []);
|
||||
|
||||
/**
|
||||
* Moves `pageId` to index 0, making it the new landing page. Pure list-
|
||||
* order change -- does not touch the live canvas. See the doc comment on
|
||||
* `PageContextValue.setLandingPage`.
|
||||
*/
|
||||
const setLandingPage = useCallback((pageId: string) => {
|
||||
setPages((prev) => {
|
||||
const idx = prev.findIndex((p) => p.id === pageId);
|
||||
if (idx <= 0) return prev; // already the landing page, or not found
|
||||
|
||||
const next = [...prev];
|
||||
const [moved] = next.splice(idx, 1);
|
||||
next.unshift(moved);
|
||||
return applyLandingInvariant(next);
|
||||
});
|
||||
}, []);
|
||||
|
||||
const renamePage = useCallback((pageId: string, name: string, slug: string) => {
|
||||
setPages((prev) =>
|
||||
prev.map((p, i) => {
|
||||
@@ -556,6 +718,9 @@ export const PageProvider: React.FC<{ children: ReactNode }> = ({ children }) =>
|
||||
addPage,
|
||||
deletePage,
|
||||
renamePage,
|
||||
duplicatePage,
|
||||
movePage,
|
||||
setLandingPage,
|
||||
updatePageSeo,
|
||||
setHeaderCraftState,
|
||||
setFooterCraftState,
|
||||
|
||||
Reference in New Issue
Block a user