@bygghammar/cms-platform 0.90.0-m7.9 → 0.91.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +241 -0
- package/README.md +23 -1
- package/docs/GOOGLE-SHEETS.md +145 -0
- package/docs/LAYOUT-BLOCKS.md +125 -0
- package/docs/MEDIA-FOLDERS.md +90 -0
- package/docs/NEW-SITE.md +35 -0
- package/docs/PAGES.md +452 -0
- package/docs/SCHEDULING.md +130 -0
- package/docs/TYPOGRAPHY.md +86 -0
- package/migrations/20260904090000_c54603f8-a155-40f8-a86c-54434aa5c0ae.sql +33 -0
- package/migrations/20260904140000_9f2a7c41-06d5-4d6b-8b4e-1c7a5d3e9b20.sql +383 -0
- package/migrations/20260904160000_0d565000-2839-4738-aacc-537d7090398f.sql +751 -0
- package/package.json +5 -1
- package/src/backoffice/DomainsView.tsx +152 -25
- package/src/backoffice/FormEditorView.tsx +10 -0
- package/src/backoffice/FormSettingsView.tsx +8 -0
- package/src/backoffice/MediaLibraryView.tsx +239 -21
- package/src/backoffice/NavigationView.tsx +43 -0
- package/src/backoffice/PageBuilderView.tsx +193 -36
- package/src/backoffice/PageEditorView.tsx +293 -57
- package/src/backoffice/PagesListView.tsx +294 -19
- package/src/backoffice/ScheduleFields.tsx +151 -0
- package/src/backoffice/SectionFormView.tsx +58 -0
- package/src/backoffice/StatusBadge.tsx +174 -0
- package/src/backoffice/TrackingView.tsx +247 -208
- package/src/backoffice/TypographyPanel.tsx +308 -0
- package/src/backoffice/forms/GoogleSheetsHelp.tsx +220 -0
- package/src/backoffice/inspectors/blocks.tsx +149 -0
- package/src/backoffice/inspectors/fields.tsx +81 -0
- package/src/backoffice/inspectors/index.tsx +3 -0
- package/src/backoffice/media/ConfirmDialog.tsx +64 -4
- package/src/backoffice/routes/host.ts +31 -0
- package/src/backoffice/routes/index.tsx +140 -15
- package/src/backoffice/routes/media-forms.tsx +5 -0
- package/src/backoffice/seo/PageSeoPanelView.tsx +31 -6
- package/src/backoffice/seo/page-seo-form.ts +11 -1
- package/src/backoffice/shell/domain-guard.ts +27 -14
- package/src/backoffice/shell/index.ts +5 -0
- package/src/backoffice/shell/use-resolved-config.tsx +67 -0
- package/src/backoffice/tracking-drafts.ts +150 -0
- package/src/blocks/block-children.tsx +40 -0
- package/src/blocks/block-tree.ts +378 -0
- package/src/blocks/components/IconGridBlock.tsx +76 -51
- package/src/blocks/components/LayoutBlock.tsx +100 -0
- package/src/blocks/countdown-time.ts +17 -87
- package/src/blocks/registry.tsx +21 -0
- package/src/blocks/types.ts +40 -0
- package/src/cms/page-publication.ts +377 -0
- package/src/cms/page-status.ts +152 -0
- package/src/cms/timezone.ts +181 -0
- package/src/cms/use-publication-boundary.ts +84 -0
- package/src/cms/use-schedule-clock.ts +43 -0
- package/src/cms/visibility-schedule.ts +267 -0
- package/src/config/modules.ts +34 -10
- package/src/config/site-config.ts +32 -0
- package/src/config/typography.ts +143 -0
- package/src/http/cache-policy.ts +49 -2
- package/src/media/folders.ts +183 -0
- package/src/media/use-media-upload.ts +21 -4
- package/src/navigation/index.ts +34 -2
- package/src/platform/contract.ts +52 -1
- package/src/runtime/server-entry.ts +8 -2
- package/src/server/content-sections-core.ts +16 -0
- package/src/server/domains-core.ts +124 -107
- package/src/server/forms-delivery-core.ts +61 -10
- package/src/server/forms-submissions-core.ts +47 -0
- package/src/server/google-service-account.ts +325 -0
- package/src/server/google-sheets-core.ts +463 -0
- package/src/server/http/sitemap.ts +32 -8
- package/src/server/injections-core.ts +230 -49
- package/src/server/media-library-core.ts +27 -6
- package/src/server/pages-core.ts +680 -184
- package/src/server/published-cache.ts +25 -4
- package/src/server/resolve-globals.ts +6 -1
- package/src/server/schema-drift.ts +4 -0
- package/src/server/schemas.ts +15 -0
- package/src/site/PageRenderer.tsx +45 -12
- package/src/site/SiteFooter.tsx +44 -3
- package/src/site/SiteHeader.tsx +23 -2
- package/src/site/google-fonts.ts +191 -0
- package/src/site/host.ts +172 -0
- package/src/site/locale-domains.ts +500 -0
- package/src/site/routes/public-page.tsx +12 -1
- package/src/site/routes.ts +206 -38
- package/src/site/site-url.ts +80 -17
- package/src/site/typography.tsx +75 -0
- package/src/tracking/injections.ts +52 -9
- package/src/ui/MobileCarousel.tsx +289 -0
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Draft bookkeeping for the "Code & tracking" screen.
|
|
3
|
+
*
|
|
4
|
+
* The screen edits rows locally and reloads them from the server, and those two
|
|
5
|
+
* facts fight: reloading used to replace the whole editing state, so anything
|
|
6
|
+
* not yet saved — a new profile, a duplicate, edits to one profile while
|
|
7
|
+
* another was being saved — was thrown away the moment a refetch landed.
|
|
8
|
+
*
|
|
9
|
+
* So local rows are reconciled against the server list rather than replaced.
|
|
10
|
+
* A row the user has changed keeps its local value; an untouched row takes the
|
|
11
|
+
* server's; a row the server no longer has goes away; and a row that has never
|
|
12
|
+
* been saved is never dropped by a reload, because the server cannot possibly
|
|
13
|
+
* know about it yet.
|
|
14
|
+
*
|
|
15
|
+
* Pure module — no React, so the rules are testable on their own.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
/** The fields a draft row carries. Structural, to match the host's own type. */
|
|
19
|
+
export type DraftProfile = {
|
|
20
|
+
id: string;
|
|
21
|
+
name: string;
|
|
22
|
+
scope_type: string;
|
|
23
|
+
scope_value: string;
|
|
24
|
+
enabled: boolean;
|
|
25
|
+
head_html: string;
|
|
26
|
+
body_start_html: string;
|
|
27
|
+
body_end_html: string;
|
|
28
|
+
gtm_id: string;
|
|
29
|
+
ga4_id: string;
|
|
30
|
+
meta_pixel_id: string;
|
|
31
|
+
tiktok_pixel_id: string;
|
|
32
|
+
extra_meta: { name: string; content: string }[];
|
|
33
|
+
sort_order: number;
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
export type DraftRow<T extends DraftProfile = DraftProfile> = {
|
|
37
|
+
/**
|
|
38
|
+
* Identity of the row in the editor, stable across saves and reloads.
|
|
39
|
+
*
|
|
40
|
+
* Not the server id: a row exists in the editor before it has one, and
|
|
41
|
+
* keying off `id` is what made an unsaved row and its saved counterpart look
|
|
42
|
+
* like the same row to React and like different rows to everything else.
|
|
43
|
+
*/
|
|
44
|
+
key: string;
|
|
45
|
+
/** Current edited value. */
|
|
46
|
+
value: T;
|
|
47
|
+
/**
|
|
48
|
+
* The server row this draft was last in sync with, or null for a row that
|
|
49
|
+
* has never been saved. Local edits are exactly `value` differing from this.
|
|
50
|
+
*/
|
|
51
|
+
base: T | null;
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
let counter = 0;
|
|
55
|
+
|
|
56
|
+
/** A fresh editor key. */
|
|
57
|
+
export function draftKey(): string {
|
|
58
|
+
counter += 1;
|
|
59
|
+
return `draft-${counter}`;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
const COMPARED = [
|
|
63
|
+
"name",
|
|
64
|
+
"scope_type",
|
|
65
|
+
"scope_value",
|
|
66
|
+
"enabled",
|
|
67
|
+
"head_html",
|
|
68
|
+
"body_start_html",
|
|
69
|
+
"body_end_html",
|
|
70
|
+
"gtm_id",
|
|
71
|
+
"ga4_id",
|
|
72
|
+
"meta_pixel_id",
|
|
73
|
+
"tiktok_pixel_id",
|
|
74
|
+
"sort_order",
|
|
75
|
+
] as const;
|
|
76
|
+
|
|
77
|
+
/** True when two rows hold the same content, ignoring server-only fields. */
|
|
78
|
+
export function sameProfile(a: DraftProfile, b: DraftProfile): boolean {
|
|
79
|
+
for (const field of COMPARED) {
|
|
80
|
+
if (a[field] !== b[field]) return false;
|
|
81
|
+
}
|
|
82
|
+
return JSON.stringify(a.extra_meta ?? []) === JSON.stringify(b.extra_meta ?? []);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** True when the row holds edits the server has not seen. */
|
|
86
|
+
export function isDirty(row: DraftRow): boolean {
|
|
87
|
+
return row.base === null || !sameProfile(row.value, row.base);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/** Wrap a freshly loaded server row. */
|
|
91
|
+
export function serverRow<T extends DraftProfile>(value: T): DraftRow<T> {
|
|
92
|
+
return { key: draftKey(), value, base: value };
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/** Wrap a row that exists only in the editor so far. */
|
|
96
|
+
export function newRow<T extends DraftProfile>(value: T): DraftRow<T> {
|
|
97
|
+
return { key: draftKey(), value, base: null };
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* Fold a server list into the current rows.
|
|
102
|
+
*
|
|
103
|
+
* Saved rows keep the server's order; rows that have never been saved keep
|
|
104
|
+
* their own order after them, which is where they were added.
|
|
105
|
+
*/
|
|
106
|
+
export function mergeServerProfiles<T extends DraftProfile>(
|
|
107
|
+
rows: DraftRow<T>[],
|
|
108
|
+
profiles: T[],
|
|
109
|
+
): DraftRow<T>[] {
|
|
110
|
+
const saved: DraftRow<T>[] = [];
|
|
111
|
+
for (const p of profiles) {
|
|
112
|
+
const row = rows.find((r) => r.base !== null && r.value.id === p.id);
|
|
113
|
+
if (!row) {
|
|
114
|
+
saved.push(serverRow(p));
|
|
115
|
+
continue;
|
|
116
|
+
}
|
|
117
|
+
// A row the user has edited keeps its edits and simply learns what the
|
|
118
|
+
// server now holds; an untouched row takes the server's version, so a real
|
|
119
|
+
// update still lands.
|
|
120
|
+
saved.push(isDirty(row) ? { ...row, base: p } : { ...row, value: p, base: p });
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
const unsaved = rows.filter((r) => r.base === null);
|
|
124
|
+
return [...saved, ...unsaved];
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* Record a successful save.
|
|
129
|
+
*
|
|
130
|
+
* `sent` is the value that actually went to the server, not whatever the row
|
|
131
|
+
* holds now — anything typed while the request was in flight is still unsaved
|
|
132
|
+
* and has to stay that way. `id` adopts the identity a newly created row was
|
|
133
|
+
* given, so the next reload reconciles with it instead of listing it twice.
|
|
134
|
+
*/
|
|
135
|
+
export function markSaved<T extends DraftProfile>(
|
|
136
|
+
rows: DraftRow<T>[],
|
|
137
|
+
key: string,
|
|
138
|
+
sent: T,
|
|
139
|
+
id?: string,
|
|
140
|
+
): DraftRow<T>[] {
|
|
141
|
+
return rows.map((row) => {
|
|
142
|
+
if (row.key !== key) return row;
|
|
143
|
+
const savedId = id || row.value.id;
|
|
144
|
+
return {
|
|
145
|
+
...row,
|
|
146
|
+
value: { ...row.value, id: savedId },
|
|
147
|
+
base: { ...sent, id: savedId },
|
|
148
|
+
};
|
|
149
|
+
});
|
|
150
|
+
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import { createContext, useContext, type ReactNode } from "react";
|
|
2
|
+
import type { BlockInstance } from "./types";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* How a container block renders the blocks inside it.
|
|
6
|
+
*
|
|
7
|
+
* The renderer that owns block lookup, global sections and the builder's
|
|
8
|
+
* selection wrappers installs this once per page; container blocks call it
|
|
9
|
+
* instead of importing the renderer, which keeps `src/blocks` free of any
|
|
10
|
+
* dependency on `src/site` and lets the builder and the public site share one
|
|
11
|
+
* recursion.
|
|
12
|
+
*/
|
|
13
|
+
export type BlockChildren = {
|
|
14
|
+
render: (blocks: BlockInstance[]) => ReactNode;
|
|
15
|
+
/** True inside the page builder, where empty containers still need shape. */
|
|
16
|
+
edit?: boolean;
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
const NO_CHILDREN: BlockChildren = { render: () => null };
|
|
20
|
+
|
|
21
|
+
const BlockChildrenContext = createContext<BlockChildren>(NO_CHILDREN);
|
|
22
|
+
|
|
23
|
+
export function BlockChildrenProvider({
|
|
24
|
+
value,
|
|
25
|
+
children,
|
|
26
|
+
}: {
|
|
27
|
+
value: BlockChildren;
|
|
28
|
+
children: ReactNode;
|
|
29
|
+
}) {
|
|
30
|
+
return <BlockChildrenContext.Provider value={value}>{children}</BlockChildrenContext.Provider>;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Renders child blocks. Outside a page renderer this renders nothing, so a
|
|
35
|
+
* container block used in isolation (a test, a storybook) degrades quietly
|
|
36
|
+
* rather than throwing.
|
|
37
|
+
*/
|
|
38
|
+
export function useBlockChildren(): BlockChildren {
|
|
39
|
+
return useContext(BlockChildrenContext);
|
|
40
|
+
}
|
|
@@ -0,0 +1,378 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The nested block model.
|
|
3
|
+
*
|
|
4
|
+
* A page is an ordered list of blocks. One block type — `layout` — is a
|
|
5
|
+
* container: it holds an array of columns, each an ordered list of blocks, so
|
|
6
|
+
* the page is a tree rather than a flat list. Everything that has to walk that
|
|
7
|
+
* tree (the builder's drag and drop, the renderer, page duplication, block
|
|
8
|
+
* counting) uses the operations here instead of reaching into `props.columns`,
|
|
9
|
+
* which keeps one definition of what "inside a column" means.
|
|
10
|
+
*
|
|
11
|
+
* Storage stays plain JSON: children live in the container's own `props`, so
|
|
12
|
+
* page versions, previews, the backup archive and the wire schema all keep
|
|
13
|
+
* working with no new table and no new column.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import type { BlockInstance, BlockProps, Json } from "./types";
|
|
17
|
+
|
|
18
|
+
export const LAYOUT_BLOCK_TYPE = "layout";
|
|
19
|
+
|
|
20
|
+
/** Columns a layout may have on desktop. */
|
|
21
|
+
export const LAYOUT_MIN_COLUMNS = 2;
|
|
22
|
+
export const LAYOUT_MAX_COLUMNS = 8;
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* How deep containers may nest. A layout inside a layout is genuinely useful
|
|
26
|
+
* (a two-column band whose left half splits again); five levels of it is a
|
|
27
|
+
* mistake, so the builder refuses the drop rather than letting a page become
|
|
28
|
+
* unrenderable.
|
|
29
|
+
*/
|
|
30
|
+
export const MAX_LAYOUT_DEPTH = 3;
|
|
31
|
+
|
|
32
|
+
/** Fresh block id, in the same shape the builder has always minted. */
|
|
33
|
+
export function newBlockId(prefix = "b"): string {
|
|
34
|
+
return `${prefix}_${Math.random().toString(36).slice(2, 9)}`;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export function isLayoutBlock(block: BlockInstance | null | undefined): boolean {
|
|
38
|
+
return block?.type === LAYOUT_BLOCK_TYPE;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** Desktop column count, clamped to what the block supports. */
|
|
42
|
+
export function layoutColumnCountFromProps(props: BlockProps | undefined): number {
|
|
43
|
+
const raw = Number((props as { columnsDesktop?: unknown } | undefined)?.columnsDesktop);
|
|
44
|
+
const n = Number.isFinite(raw) ? Math.round(raw) : LAYOUT_MIN_COLUMNS;
|
|
45
|
+
return Math.min(LAYOUT_MAX_COLUMNS, Math.max(LAYOUT_MIN_COLUMNS, n));
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export function layoutColumnCount(block: BlockInstance | null | undefined): number {
|
|
49
|
+
return layoutColumnCountFromProps(block?.props);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* The block's columns, always exactly `layoutColumnCount` of them.
|
|
54
|
+
*
|
|
55
|
+
* Reducing the column count in the inspector must never delete content, so
|
|
56
|
+
* blocks in trimmed columns are folded into the last remaining column instead
|
|
57
|
+
* of being dropped.
|
|
58
|
+
*/
|
|
59
|
+
export function readColumnsFromProps(props: BlockProps | undefined): BlockInstance[][] {
|
|
60
|
+
const raw = (props as { columns?: unknown } | undefined)?.columns;
|
|
61
|
+
const stored: BlockInstance[][] = Array.isArray(raw)
|
|
62
|
+
? raw.map((column) => (Array.isArray(column) ? (column as BlockInstance[]) : []))
|
|
63
|
+
: [];
|
|
64
|
+
const count = layoutColumnCountFromProps(props);
|
|
65
|
+
const columns: BlockInstance[][] = [];
|
|
66
|
+
for (let i = 0; i < count; i += 1) columns.push([...(stored[i] ?? [])]);
|
|
67
|
+
// Anything beyond the current count moves into the last column, never away.
|
|
68
|
+
const overflow = stored.slice(count).flat();
|
|
69
|
+
if (overflow.length) columns[count - 1] = [...(columns[count - 1] ?? []), ...overflow];
|
|
70
|
+
return columns;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export function readLayoutColumns(block: BlockInstance): BlockInstance[][] {
|
|
74
|
+
return readColumnsFromProps(block.props);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** A copy of `block` whose columns are `columns`. */
|
|
78
|
+
export function writeLayoutColumns(
|
|
79
|
+
block: BlockInstance,
|
|
80
|
+
columns: BlockInstance[][],
|
|
81
|
+
): BlockInstance {
|
|
82
|
+
const props: BlockProps = { ...(block.props ?? {}) };
|
|
83
|
+
props["columns"] = columns as unknown as Json;
|
|
84
|
+
return { ...block, props };
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** Direct children of a block, per column. Non-containers have none. */
|
|
88
|
+
export function blockColumns(block: BlockInstance): BlockInstance[][] {
|
|
89
|
+
return isLayoutBlock(block) ? readLayoutColumns(block) : [];
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/** Where a block sits: which container, which column, which position. */
|
|
93
|
+
export type BlockPath = {
|
|
94
|
+
/** `null` at the page root. */
|
|
95
|
+
parentId: string | null;
|
|
96
|
+
/** Column index inside the parent; always 0 at the root. */
|
|
97
|
+
column: number;
|
|
98
|
+
index: number;
|
|
99
|
+
};
|
|
100
|
+
|
|
101
|
+
/** A drop target, i.e. a `BlockPath` a block is about to occupy. */
|
|
102
|
+
export type DropTarget = BlockPath;
|
|
103
|
+
|
|
104
|
+
export const ROOT_TARGET = (index: number): DropTarget => ({
|
|
105
|
+
parentId: null,
|
|
106
|
+
column: 0,
|
|
107
|
+
index,
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
type Walked = {
|
|
111
|
+
block: BlockInstance;
|
|
112
|
+
path: BlockPath;
|
|
113
|
+
depth: number;
|
|
114
|
+
};
|
|
115
|
+
|
|
116
|
+
/** Depth-first walk over the whole tree, parents before their children. */
|
|
117
|
+
export function walkBlockTree(blocks: readonly BlockInstance[]): Walked[] {
|
|
118
|
+
const out: Walked[] = [];
|
|
119
|
+
const visit = (list: readonly BlockInstance[], parentId: string | null, column: number, depth: number) => {
|
|
120
|
+
list.forEach((block, index) => {
|
|
121
|
+
out.push({ block, path: { parentId, column, index }, depth });
|
|
122
|
+
blockColumns(block).forEach((children, columnIndex) => {
|
|
123
|
+
visit(children, block.id, columnIndex, depth + 1);
|
|
124
|
+
});
|
|
125
|
+
});
|
|
126
|
+
};
|
|
127
|
+
visit(blocks, null, 0, 0);
|
|
128
|
+
return out;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/** Every block in the tree, in document order. */
|
|
132
|
+
export function flattenBlockTree(blocks: readonly BlockInstance[]): BlockInstance[] {
|
|
133
|
+
return walkBlockTree(blocks).map((entry) => entry.block);
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/** Total block count including nested children. */
|
|
137
|
+
export function countBlocksDeep(blocks: readonly BlockInstance[]): number {
|
|
138
|
+
return walkBlockTree(blocks).length;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
export function findBlockDeep(
|
|
142
|
+
blocks: readonly BlockInstance[],
|
|
143
|
+
id: string,
|
|
144
|
+
): BlockInstance | null {
|
|
145
|
+
return walkBlockTree(blocks).find((entry) => entry.block.id === id)?.block ?? null;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
export function findBlockPath(blocks: readonly BlockInstance[], id: string): BlockPath | null {
|
|
149
|
+
return walkBlockTree(blocks).find((entry) => entry.block.id === id)?.path ?? null;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/** Nesting depth of a block, 0 at the page root. */
|
|
153
|
+
export function blockDepth(blocks: readonly BlockInstance[], id: string): number {
|
|
154
|
+
return walkBlockTree(blocks).find((entry) => entry.block.id === id)?.depth ?? 0;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/** Deepest level below `block`, 0 when it has no children. */
|
|
158
|
+
export function subtreeHeight(block: BlockInstance): number {
|
|
159
|
+
const columns = blockColumns(block);
|
|
160
|
+
let height = 0;
|
|
161
|
+
for (const column of columns) {
|
|
162
|
+
for (const child of column) height = Math.max(height, 1 + subtreeHeight(child));
|
|
163
|
+
}
|
|
164
|
+
return height;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/** True when `id` is `block` itself or anywhere inside it. */
|
|
168
|
+
export function containsBlockId(block: BlockInstance, id: string): boolean {
|
|
169
|
+
if (block.id === id) return true;
|
|
170
|
+
return blockColumns(block).some((column) => column.some((child) => containsBlockId(child, id)));
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/** Rebuild a tree with one container's columns replaced. */
|
|
174
|
+
function replaceColumns(
|
|
175
|
+
blocks: readonly BlockInstance[],
|
|
176
|
+
parentId: string,
|
|
177
|
+
update: (columns: BlockInstance[][]) => BlockInstance[][],
|
|
178
|
+
): BlockInstance[] {
|
|
179
|
+
return blocks.map((block) => {
|
|
180
|
+
if (block.id === parentId && isLayoutBlock(block)) {
|
|
181
|
+
return writeLayoutColumns(block, update(readLayoutColumns(block)));
|
|
182
|
+
}
|
|
183
|
+
if (!isLayoutBlock(block)) return block;
|
|
184
|
+
const columns = readLayoutColumns(block);
|
|
185
|
+
const next = columns.map((column) => replaceColumns(column, parentId, update));
|
|
186
|
+
const changed = next.some((column, i) => column !== columns[i]);
|
|
187
|
+
return changed ? writeLayoutColumns(block, next) : block;
|
|
188
|
+
});
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/** Remove a block from anywhere in the tree. Returns the block it removed. */
|
|
192
|
+
export function removeBlockFromTree(
|
|
193
|
+
blocks: readonly BlockInstance[],
|
|
194
|
+
id: string,
|
|
195
|
+
): { blocks: BlockInstance[]; removed: BlockInstance | null } {
|
|
196
|
+
let removed: BlockInstance | null = null;
|
|
197
|
+
const strip = (list: readonly BlockInstance[]): BlockInstance[] =>
|
|
198
|
+
list
|
|
199
|
+
.filter((block) => {
|
|
200
|
+
if (block.id !== id) return true;
|
|
201
|
+
removed = block;
|
|
202
|
+
return false;
|
|
203
|
+
})
|
|
204
|
+
.map((block) => {
|
|
205
|
+
if (!isLayoutBlock(block)) return block;
|
|
206
|
+
const columns = readLayoutColumns(block);
|
|
207
|
+
return writeLayoutColumns(
|
|
208
|
+
block,
|
|
209
|
+
columns.map((column) => strip(column)),
|
|
210
|
+
);
|
|
211
|
+
});
|
|
212
|
+
return { blocks: strip(blocks), removed };
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
/** Insert a block at an exact position. Out-of-range indexes clamp to the end. */
|
|
216
|
+
/** True when `parentId` names a Layout block that can actually hold children. */
|
|
217
|
+
function layoutParentExists(blocks: readonly BlockInstance[], parentId: string): boolean {
|
|
218
|
+
const parent = findBlockDeep(blocks, parentId);
|
|
219
|
+
return parent != null && isLayoutBlock(parent);
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
/** Why an insertion was refused, for a message the editor can show. */
|
|
223
|
+
export type InsertRefusal = "missing-parent" | "too-deep";
|
|
224
|
+
|
|
225
|
+
export type InsertResult =
|
|
226
|
+
| { ok: true; blocks: BlockInstance[] }
|
|
227
|
+
| { ok: false; reason: InsertRefusal };
|
|
228
|
+
|
|
229
|
+
/**
|
|
230
|
+
* Place a block at an exact position, or refuse.
|
|
231
|
+
*
|
|
232
|
+
* Insertion is fallible on purpose. A drop target is calculated from the tree
|
|
233
|
+
* as it looked when the drag started, and an undo, a refetch or a second
|
|
234
|
+
* editor can retire that target before the drop lands. Returning a refusal
|
|
235
|
+
* rather than a tree is what stops {@link moveBlockInTree} — which has already
|
|
236
|
+
* lifted the block out by the time it gets here — from reporting success while
|
|
237
|
+
* the block goes nowhere.
|
|
238
|
+
*/
|
|
239
|
+
export function insertBlockIntoTree(
|
|
240
|
+
blocks: readonly BlockInstance[],
|
|
241
|
+
block: BlockInstance,
|
|
242
|
+
target: DropTarget,
|
|
243
|
+
): InsertResult {
|
|
244
|
+
const depth = target.parentId == null ? 0 : blockDepth(blocks, target.parentId) + 1;
|
|
245
|
+
if (depth + subtreeHeight(block) > MAX_LAYOUT_DEPTH) {
|
|
246
|
+
return { ok: false, reason: "too-deep" };
|
|
247
|
+
}
|
|
248
|
+
if (target.parentId == null) {
|
|
249
|
+
const next = [...blocks];
|
|
250
|
+
next.splice(Math.max(0, Math.min(target.index, next.length)), 0, block);
|
|
251
|
+
return { ok: true, blocks: next };
|
|
252
|
+
}
|
|
253
|
+
if (!layoutParentExists(blocks, target.parentId)) {
|
|
254
|
+
return { ok: false, reason: "missing-parent" };
|
|
255
|
+
}
|
|
256
|
+
return {
|
|
257
|
+
ok: true,
|
|
258
|
+
blocks: replaceColumns(blocks, target.parentId, (columns) => {
|
|
259
|
+
const columnIndex = Math.max(0, Math.min(target.column, columns.length - 1));
|
|
260
|
+
const next = columns.map((column) => [...column]);
|
|
261
|
+
const column = next[columnIndex] ?? [];
|
|
262
|
+
column.splice(Math.max(0, Math.min(target.index, column.length)), 0, block);
|
|
263
|
+
next[columnIndex] = column;
|
|
264
|
+
return next;
|
|
265
|
+
}),
|
|
266
|
+
};
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
/** Why a move was refused, for a message the editor can show. */
|
|
270
|
+
export type MoveRefusal = "self" | "descendant" | "too-deep" | "missing";
|
|
271
|
+
|
|
272
|
+
export type MoveResult =
|
|
273
|
+
| { ok: true; blocks: BlockInstance[] }
|
|
274
|
+
| { ok: false; reason: MoveRefusal };
|
|
275
|
+
|
|
276
|
+
/**
|
|
277
|
+
* Move a block to an exact position anywhere in the tree.
|
|
278
|
+
*
|
|
279
|
+
* Root to column, column to column, reorder within a column and column back to
|
|
280
|
+
* root are all the same operation. A container may not be dropped into itself
|
|
281
|
+
* or into anything it contains — that would detach the subtree from the page —
|
|
282
|
+
* and nesting deeper than `MAX_LAYOUT_DEPTH` is refused rather than saved.
|
|
283
|
+
*
|
|
284
|
+
* `target.index` is a position in the destination list *after* the block has
|
|
285
|
+
* been taken out of wherever it was, which is what `arrayMove` has always done
|
|
286
|
+
* in the builder: moving the first of `[A, B, C]` to index 2 gives
|
|
287
|
+
* `[B, C, A]`.
|
|
288
|
+
*/
|
|
289
|
+
export function moveBlockInTree(
|
|
290
|
+
blocks: readonly BlockInstance[],
|
|
291
|
+
id: string,
|
|
292
|
+
target: DropTarget,
|
|
293
|
+
): MoveResult {
|
|
294
|
+
if (target.parentId === id) return { ok: false, reason: "self" };
|
|
295
|
+
const moving = findBlockDeep(blocks, id);
|
|
296
|
+
if (!moving) return { ok: false, reason: "missing" };
|
|
297
|
+
if (target.parentId && containsBlockId(moving, target.parentId)) {
|
|
298
|
+
return { ok: false, reason: "descendant" };
|
|
299
|
+
}
|
|
300
|
+
// Check the destination before lifting the block out. A target that has been
|
|
301
|
+
// retired since the drag started would otherwise leave the block with
|
|
302
|
+
// nowhere to land, and the move would report success having deleted it.
|
|
303
|
+
if (target.parentId != null && !layoutParentExists(blocks, target.parentId)) {
|
|
304
|
+
return { ok: false, reason: "missing" };
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
const targetDepth = target.parentId == null ? 0 : blockDepth(blocks, target.parentId) + 1;
|
|
308
|
+
if (targetDepth + subtreeHeight(moving) > MAX_LAYOUT_DEPTH) {
|
|
309
|
+
return { ok: false, reason: "too-deep" };
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
const { blocks: without, removed } = removeBlockFromTree(blocks, id);
|
|
313
|
+
if (!removed) return { ok: false, reason: "missing" };
|
|
314
|
+
const inserted = insertBlockIntoTree(without, removed, target);
|
|
315
|
+
// Belt and braces: never hand back a tree the block did not reach.
|
|
316
|
+
if (!inserted.ok) {
|
|
317
|
+
return { ok: false, reason: inserted.reason === "too-deep" ? "too-deep" : "missing" };
|
|
318
|
+
}
|
|
319
|
+
return { ok: true, blocks: inserted.blocks };
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
/** True when a block can legally be dropped at `target`. */
|
|
323
|
+
export function canDropBlock(
|
|
324
|
+
blocks: readonly BlockInstance[],
|
|
325
|
+
id: string,
|
|
326
|
+
target: DropTarget,
|
|
327
|
+
): boolean {
|
|
328
|
+
return moveBlockInTree(blocks, id, target).ok;
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
/** Deepest nesting a new block of this kind could add at `target`. */
|
|
332
|
+
export function canNestAt(blocks: readonly BlockInstance[], target: DropTarget, height = 0): boolean {
|
|
333
|
+
if (target.parentId != null && !layoutParentExists(blocks, target.parentId)) return false;
|
|
334
|
+
const depth = target.parentId == null ? 0 : blockDepth(blocks, target.parentId) + 1;
|
|
335
|
+
return depth + height <= MAX_LAYOUT_DEPTH;
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
/** Patch one block anywhere in the tree, leaving the rest untouched. */
|
|
339
|
+
export function patchBlockInTree(
|
|
340
|
+
blocks: readonly BlockInstance[],
|
|
341
|
+
id: string,
|
|
342
|
+
patch: (block: BlockInstance) => BlockInstance,
|
|
343
|
+
): BlockInstance[] {
|
|
344
|
+
return blocks.map((block) => {
|
|
345
|
+
if (block.id === id) return patch(block);
|
|
346
|
+
if (!isLayoutBlock(block)) return block;
|
|
347
|
+
const columns = readLayoutColumns(block);
|
|
348
|
+
return writeLayoutColumns(
|
|
349
|
+
block,
|
|
350
|
+
columns.map((column) => patchBlockInTree(column, id, patch)),
|
|
351
|
+
);
|
|
352
|
+
});
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
/**
|
|
356
|
+
* A deep copy with brand-new ids, top to bottom.
|
|
357
|
+
*
|
|
358
|
+
* Used by "duplicate block" in the builder and by page duplication on the
|
|
359
|
+
* server: two blocks sharing an id would make the outline, the selection and
|
|
360
|
+
* every drag target ambiguous.
|
|
361
|
+
*/
|
|
362
|
+
export function cloneBlockDeep(block: BlockInstance): BlockInstance {
|
|
363
|
+
const clone: BlockInstance = {
|
|
364
|
+
...block,
|
|
365
|
+
id: newBlockId(block.type),
|
|
366
|
+
props: block.props ? (structuredClone(block.props) as BlockProps) : undefined,
|
|
367
|
+
};
|
|
368
|
+
if (!isLayoutBlock(clone)) return clone;
|
|
369
|
+
return writeLayoutColumns(
|
|
370
|
+
clone,
|
|
371
|
+
readLayoutColumns(clone).map((column) => column.map((child) => cloneBlockDeep(child))),
|
|
372
|
+
);
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
/** `cloneBlockDeep` over a whole list — the page-duplication entry point. */
|
|
376
|
+
export function duplicateBlockTree(blocks: readonly BlockInstance[]): BlockInstance[] {
|
|
377
|
+
return blocks.map((block) => cloneBlockDeep(block));
|
|
378
|
+
}
|