@commonpub/layer 0.24.0 → 0.25.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/README.md +41 -12
- package/components/LayoutRow.vue +944 -0
- package/components/LayoutSection.vue +1028 -0
- package/components/LayoutSlot.vue +104 -162
- package/components/PageFrame.vue +116 -0
- package/components/admin/layouts/AdminLayoutsAnnouncer.vue +53 -0
- package/components/admin/layouts/AdminLayoutsAutoForm.vue +419 -0
- package/components/admin/layouts/AdminLayoutsCanvas.vue +332 -0
- package/components/admin/layouts/AdminLayoutsConflictModal.vue +266 -0
- package/components/admin/layouts/AdminLayoutsHelpOverlay.vue +346 -0
- package/components/admin/layouts/AdminLayoutsInspector.vue +157 -0
- package/components/admin/layouts/AdminLayoutsInspectorPage.vue +266 -0
- package/components/admin/layouts/AdminLayoutsInspectorRow.vue +80 -0
- package/components/admin/layouts/AdminLayoutsInspectorSection.vue +175 -0
- package/components/admin/layouts/AdminLayoutsPalette.vue +117 -0
- package/components/admin/layouts/AdminLayoutsPaletteTile.vue +149 -0
- package/components/admin/layouts/AdminLayoutsToolbar.vue +483 -0
- package/components/blocks/BlockDividerView.vue +52 -2
- package/components/homepage/ContentGridSection.vue +23 -1
- package/components/homepage/HeroSection.vue +69 -8
- package/components/sections/SectionCta.vue +175 -0
- package/composables/autoFormSchema.ts +319 -0
- package/composables/useAdminSidebar.ts +116 -0
- package/composables/useEditorChrome.ts +56 -0
- package/composables/useLayout.ts +34 -41
- package/composables/useLayoutAnnouncer.ts +332 -0
- package/composables/useLayoutAutoSave.ts +117 -0
- package/composables/useLayoutDrag.ts +290 -0
- package/composables/useLayoutEditor.ts +593 -0
- package/composables/useLayoutHistory.ts +583 -0
- package/composables/useLayoutHotkeys.ts +366 -0
- package/composables/useLayoutResize.ts +783 -0
- package/layouts/admin.vue +137 -24
- package/middleware/admin-layouts.ts +29 -0
- package/package.json +10 -7
- package/pages/[...customPath].vue +154 -0
- package/pages/admin/homepage.vue +46 -0
- package/pages/admin/index.vue +16 -0
- package/pages/admin/layouts/[id].vue +1110 -0
- package/pages/admin/layouts/index.vue +356 -0
- package/pages/explore.vue +16 -6
- package/sections/builtin/content-feed.ts +18 -29
- package/sections/builtin/contests.ts +11 -19
- package/sections/builtin/cta.ts +46 -0
- package/sections/builtin/custom-html.ts +16 -30
- package/sections/builtin/divider.ts +15 -17
- package/sections/builtin/editorial.ts +11 -21
- package/sections/builtin/embed.ts +31 -0
- package/sections/builtin/gallery.ts +29 -0
- package/sections/builtin/heading.ts +14 -19
- package/sections/builtin/hero.ts +16 -51
- package/sections/builtin/hubs.ts +11 -26
- package/sections/builtin/image.ts +12 -49
- package/sections/builtin/learning.ts +5 -13
- package/sections/builtin/markdown.ts +29 -0
- package/sections/builtin/paragraph.ts +14 -17
- package/sections/builtin/stats.ts +17 -18
- package/sections/builtin/video.ts +30 -0
- package/sections/registry.ts +11 -0
- package/server/api/admin/homepage/sections.put.ts +52 -1
- package/server/api/admin/layouts/[id]/publish.post.ts +12 -0
- package/server/api/admin/layouts/[id]/versions/[versionId]/revert.post.ts +11 -0
- package/server/api/admin/layouts/[id].delete.ts +33 -1
- package/server/api/admin/layouts/[id].put.ts +78 -0
- package/server/api/admin/layouts/index.post.ts +60 -4
- package/server/api/admin/layouts/migrate-homepage.post.ts +12 -0
- package/server/api/admin/layouts/seed-homepage.post.ts +9 -0
- package/server/api/layouts/by-route.get.ts +64 -12
- package/server/utils/layoutCache.ts +37 -1
- package/server/utils/validateSectionConfigs.ts +123 -0
- package/theme/base.css +1 -0
- package/components/sections/SectionContentFeed.vue +0 -160
- package/components/sections/SectionContests.vue +0 -193
- package/components/sections/SectionCustomHtml.vue +0 -70
- package/components/sections/SectionDivider.vue +0 -55
- package/components/sections/SectionEditorial.vue +0 -138
- package/components/sections/SectionHeading.vue +0 -78
- package/components/sections/SectionHero.vue +0 -164
- package/components/sections/SectionHubs.vue +0 -247
- package/components/sections/SectionImage.vue +0 -104
- package/components/sections/SectionParagraph.vue +0 -55
- package/components/sections/SectionStats.vue +0 -151
|
@@ -0,0 +1,583 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* useLayoutHistory — undo / redo for the layout editor.
|
|
3
|
+
*
|
|
4
|
+
* Phase 3b/B. Plan §7.14: in-memory stack of LayoutOps, each operation
|
|
5
|
+
* captures its inverse, cap 50, cleared on Save (saved draft = new
|
|
6
|
+
* baseline; redo across save is hostile) AND on refresh (the local
|
|
7
|
+
* draft was replaced — old commands reference vanished section ids).
|
|
8
|
+
*
|
|
9
|
+
* Module-scoped singleton matching `useLayoutAnnouncer` +
|
|
10
|
+
* `useEditorChrome`. The kickoff prompt named "Pinia store" but the
|
|
11
|
+
* monorepo has zero Pinia surfaces; adding `pinia` + `@pinia/nuxt` +
|
|
12
|
+
* provider plumbing + new test patterns for ONE store creates the
|
|
13
|
+
* cruft the user has explicitly flagged. The semantic — "command
|
|
14
|
+
* pattern undo/redo with the documented contract" — is satisfied
|
|
15
|
+
* cleanly by a module-scoped reactive ref. Same lifetime (editor
|
|
16
|
+
* mounts → unmounts → next mount calls `clear()` to reset). See
|
|
17
|
+
* session 164 log for the deviation rationale.
|
|
18
|
+
*
|
|
19
|
+
* Command pattern:
|
|
20
|
+
* `apply(draft)` — performs the operation; called on the original
|
|
21
|
+
* mutation's redo (the dispatcher does it once at
|
|
22
|
+
* record time; the command stores a CLOSURE so the
|
|
23
|
+
* same op can replay on Cmd+Shift+Z).
|
|
24
|
+
* `invert(draft)` — performs the operation's reverse; called on undo.
|
|
25
|
+
* `label` — human copy for narration + tooltips ("move hero").
|
|
26
|
+
* `timestamp` — when recorded (for diagnostics / future "undo
|
|
27
|
+
* history" UI; not load-bearing today).
|
|
28
|
+
*
|
|
29
|
+
* Both apply + invert are idempotent in the sense that a re-find by
|
|
30
|
+
* `section.id` survives the previous command's mutations. The stack
|
|
31
|
+
* is strict LIFO — between record and the matching undo, no other
|
|
32
|
+
* command can have shifted the state in a way that breaks the find.
|
|
33
|
+
*
|
|
34
|
+
* Stack semantics (Notion / Linear / Figma convention):
|
|
35
|
+
* - `record(cmd)` pushes to `past` AND clears `future` (a new action
|
|
36
|
+
* after undo invalidates the prior redo branch — same rule mature
|
|
37
|
+
* editors converged on).
|
|
38
|
+
* - `undo(draft)` pops `past`, runs `cmd.invert(draft)`, pushes the
|
|
39
|
+
* same cmd to `future`. Returns the cmd so the caller can announce.
|
|
40
|
+
* - `redo(draft)` pops `future`, runs `cmd.apply(draft)`, pushes back
|
|
41
|
+
* to `past`. Symmetrical.
|
|
42
|
+
* - `clear()` empties both stacks; called on save success + refresh.
|
|
43
|
+
*
|
|
44
|
+
* Cap: 50 commands in `past`. When recording past[50], shift the oldest
|
|
45
|
+
* out. `future` is not capped — only built by undo, which is bounded by
|
|
46
|
+
* past.
|
|
47
|
+
*/
|
|
48
|
+
import { computed, markRaw, ref, type ComputedRef, type Ref } from 'vue';
|
|
49
|
+
import type { LayoutRecord } from '@commonpub/server';
|
|
50
|
+
import type { LayoutSection, LayoutRow } from './useLayout';
|
|
51
|
+
|
|
52
|
+
/** One reversible operation. `apply` + `invert` are pure with respect
|
|
53
|
+
* to the rest of the system — they mutate ONLY the passed draft. */
|
|
54
|
+
export interface LayoutCommand {
|
|
55
|
+
apply: (draft: LayoutRecord) => void;
|
|
56
|
+
invert: (draft: LayoutRecord) => void;
|
|
57
|
+
/** Human-readable label for narration + toolbar tooltips. */
|
|
58
|
+
label: string;
|
|
59
|
+
/** Recorded at — Date.now() at record-time. */
|
|
60
|
+
timestamp: number;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** Cap chosen to match plan §7.14. Mature editors converge on 50–100;
|
|
64
|
+
* 50 keeps memory bounded (~50 × small JSON ≈ tens of KB at most). */
|
|
65
|
+
const STACK_CAP = 50;
|
|
66
|
+
|
|
67
|
+
const past = ref<LayoutCommand[]>([]);
|
|
68
|
+
const future = ref<LayoutCommand[]>([]);
|
|
69
|
+
|
|
70
|
+
export interface LayoutHistory {
|
|
71
|
+
past: Ref<LayoutCommand[]>;
|
|
72
|
+
future: Ref<LayoutCommand[]>;
|
|
73
|
+
canUndo: ComputedRef<boolean>;
|
|
74
|
+
canRedo: ComputedRef<boolean>;
|
|
75
|
+
/** Top of `past` — the next thing `undo()` will revert. Drives the
|
|
76
|
+
* toolbar's undo button tooltip ("Undo: move hero"). */
|
|
77
|
+
lastLabel: ComputedRef<string | null>;
|
|
78
|
+
/** Top of `future` — the next thing `redo()` will replay. Drives the
|
|
79
|
+
* redo button tooltip. */
|
|
80
|
+
nextLabel: ComputedRef<string | null>;
|
|
81
|
+
/** Record a command AFTER mutating the draft. The mutation already
|
|
82
|
+
* happened (the dispatcher / Move Up handler did it); this just
|
|
83
|
+
* remembers how to invert + how to replay. Clears `future` (new
|
|
84
|
+
* branch invalidates redo). */
|
|
85
|
+
record: (cmd: LayoutCommand) => void;
|
|
86
|
+
/** Undo the most recent command. Returns it so the caller can
|
|
87
|
+
* narrate. Noop + returns null when `past` is empty. */
|
|
88
|
+
undo: (draft: LayoutRecord) => LayoutCommand | null;
|
|
89
|
+
/** Redo the most recently undone command. Returns it for narration.
|
|
90
|
+
* Noop + returns null when `future` is empty. */
|
|
91
|
+
redo: (draft: LayoutRecord) => LayoutCommand | null;
|
|
92
|
+
/** Empty both stacks. Called on save success + refresh. */
|
|
93
|
+
clear: () => void;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
export function useLayoutHistory(): LayoutHistory {
|
|
97
|
+
const canUndo = computed<boolean>(() => past.value.length > 0);
|
|
98
|
+
const canRedo = computed<boolean>(() => future.value.length > 0);
|
|
99
|
+
const lastLabel = computed<string | null>(() => {
|
|
100
|
+
const last = past.value[past.value.length - 1];
|
|
101
|
+
return last ? last.label : null;
|
|
102
|
+
});
|
|
103
|
+
const nextLabel = computed<string | null>(() => {
|
|
104
|
+
const next = future.value[future.value.length - 1];
|
|
105
|
+
return next ? next.label : null;
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
function record(cmd: LayoutCommand): void {
|
|
109
|
+
// markRaw so Vue's reactive proxy doesn't wrap the command. The
|
|
110
|
+
// command contains closures (`apply` + `invert`); we don't want
|
|
111
|
+
// the array's proxy to re-wrap the cmd on every read, which would
|
|
112
|
+
// (a) break reference-identity assertions (`undo()` returning the
|
|
113
|
+
// exact command the caller recorded — useful for telemetry +
|
|
114
|
+
// tests), and (b) waste a Proxy per command. Only the array
|
|
115
|
+
// length needs to be reactive — that comes from the outer ref().
|
|
116
|
+
const rawCmd = markRaw(cmd);
|
|
117
|
+
// Cap enforcement: shift the oldest out so memory stays bounded.
|
|
118
|
+
// Done BEFORE push so the resulting length is exactly STACK_CAP.
|
|
119
|
+
// Mutate in place so reactive readers (toolbar buttons) re-evaluate.
|
|
120
|
+
if (past.value.length >= STACK_CAP) past.value.shift();
|
|
121
|
+
past.value.push(rawCmd);
|
|
122
|
+
// New action invalidates the redo branch — Notion/Linear/Figma
|
|
123
|
+
// convention. Without this, redo would replay stale commands
|
|
124
|
+
// against a state that no longer matches their expectations.
|
|
125
|
+
if (future.value.length > 0) future.value = [];
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
function undo(draft: LayoutRecord): LayoutCommand | null {
|
|
129
|
+
const cmd = past.value.pop();
|
|
130
|
+
if (!cmd) return null;
|
|
131
|
+
cmd.invert(draft);
|
|
132
|
+
future.value.push(cmd);
|
|
133
|
+
return cmd;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
function redo(draft: LayoutRecord): LayoutCommand | null {
|
|
137
|
+
const cmd = future.value.pop();
|
|
138
|
+
if (!cmd) return null;
|
|
139
|
+
cmd.apply(draft);
|
|
140
|
+
past.value.push(cmd);
|
|
141
|
+
return cmd;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
function clear(): void {
|
|
145
|
+
past.value = [];
|
|
146
|
+
future.value = [];
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
return { past, future, canUndo, canRedo, lastLabel, nextLabel, record, undo, redo, clear };
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/* ------------------------------------------------------------------ */
|
|
153
|
+
/* Command factories — one per layout op. */
|
|
154
|
+
/* ------------------------------------------------------------------ */
|
|
155
|
+
/*
|
|
156
|
+
* Each factory closes over the parameters needed for both apply +
|
|
157
|
+
* invert. The factories live with the history (vs co-located with the
|
|
158
|
+
* drag dispatcher) so that:
|
|
159
|
+
* 1. The undo invariant is centralised. Wrong apply/invert is the
|
|
160
|
+
* easiest mistake; one file makes the symmetry visually obvious.
|
|
161
|
+
* 2. Tests for the factories exercise apply + invert as a pair
|
|
162
|
+
* against a fixture draft — independent of the dispatcher /
|
|
163
|
+
* drag-drop UI.
|
|
164
|
+
*
|
|
165
|
+
* The factories DO mutate the draft on apply/invert. They do NOT call
|
|
166
|
+
* history.record(); the caller decides whether to record (test code
|
|
167
|
+
* may want apply-without-record for fixture setup).
|
|
168
|
+
*/
|
|
169
|
+
|
|
170
|
+
/** Deep-clone a section so the invert can re-insert the SAME-shape
|
|
171
|
+
* payload even if the live one is later edited. */
|
|
172
|
+
function cloneSection(s: LayoutSection): LayoutSection {
|
|
173
|
+
return JSON.parse(JSON.stringify(s)) as LayoutSection;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/** Walk zones → rows to find a row by id. Returns null if vanished. */
|
|
177
|
+
export function findRowInDraft(draft: LayoutRecord, rowId: string): LayoutRow | null {
|
|
178
|
+
for (const zone of draft.zones) {
|
|
179
|
+
for (const row of zone.rows) {
|
|
180
|
+
if (row.id === rowId) return row as LayoutRow;
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
return null;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/** Walk zones → rows to find the zone slug containing a given row. */
|
|
187
|
+
export function findZoneOfRow(draft: LayoutRecord, rowId: string): string | null {
|
|
188
|
+
for (const zone of draft.zones) {
|
|
189
|
+
for (const row of zone.rows) {
|
|
190
|
+
if (row.id === rowId) return zone.zone;
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
return null;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
/**
|
|
197
|
+
* Walk zones → rows → sections to locate a section by id. Phase 3d
|
|
198
|
+
* keystrokes (Backspace, Cmd+D) work from an EditorSelection that
|
|
199
|
+
* carries only the section id — finding the host row + zone + index
|
|
200
|
+
* needs a search. O(zones × rows × sections); fine at v1 N=10s.
|
|
201
|
+
*
|
|
202
|
+
* Returns null when the section is no longer in the draft (e.g. the
|
|
203
|
+
* selection went stale because a drag mid-keydown removed it).
|
|
204
|
+
*/
|
|
205
|
+
export interface SectionLocation {
|
|
206
|
+
zoneSlug: string;
|
|
207
|
+
row: LayoutRow;
|
|
208
|
+
idx: number;
|
|
209
|
+
section: LayoutSection;
|
|
210
|
+
}
|
|
211
|
+
export function findSectionLocation(
|
|
212
|
+
draft: LayoutRecord,
|
|
213
|
+
sectionId: string,
|
|
214
|
+
): SectionLocation | null {
|
|
215
|
+
for (const zone of draft.zones) {
|
|
216
|
+
for (const row of zone.rows) {
|
|
217
|
+
const idx = (row.sections as LayoutSection[]).findIndex((s) => s.id === sectionId);
|
|
218
|
+
if (idx !== -1) {
|
|
219
|
+
const section = (row.sections as LayoutSection[])[idx]!;
|
|
220
|
+
return { zoneSlug: zone.zone, row: row as LayoutRow, idx, section };
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
return null;
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
/**
|
|
228
|
+
* Palette → row insert.
|
|
229
|
+
*
|
|
230
|
+
* apply: splice the section into row at `at`
|
|
231
|
+
* invert: remove the section from the row by id
|
|
232
|
+
*/
|
|
233
|
+
export function insertSectionCommand(params: {
|
|
234
|
+
rowId: string;
|
|
235
|
+
at: number;
|
|
236
|
+
section: LayoutSection;
|
|
237
|
+
label?: string;
|
|
238
|
+
}): LayoutCommand {
|
|
239
|
+
const sectionClone = cloneSection(params.section);
|
|
240
|
+
const label = params.label ?? `insert ${params.section.type}`;
|
|
241
|
+
return {
|
|
242
|
+
label,
|
|
243
|
+
timestamp: Date.now(),
|
|
244
|
+
apply(draft) {
|
|
245
|
+
const row = findRowInDraft(draft, params.rowId);
|
|
246
|
+
if (!row) return;
|
|
247
|
+
// Use a fresh clone each apply so undo→redo cycles don't share a
|
|
248
|
+
// mutated object. (Vue's reactivity proxies the live tree; the
|
|
249
|
+
// clone stays independent.)
|
|
250
|
+
row.sections.splice(params.at, 0, cloneSection(sectionClone));
|
|
251
|
+
},
|
|
252
|
+
invert(draft) {
|
|
253
|
+
const row = findRowInDraft(draft, params.rowId);
|
|
254
|
+
if (!row) return;
|
|
255
|
+
const idx = row.sections.findIndex((s) => s.id === sectionClone.id);
|
|
256
|
+
if (idx === -1) return;
|
|
257
|
+
row.sections.splice(idx, 1);
|
|
258
|
+
},
|
|
259
|
+
};
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
/**
|
|
263
|
+
* Within-row reorder.
|
|
264
|
+
*
|
|
265
|
+
* apply: remove from `from`, insert at `to`
|
|
266
|
+
* invert: reverse — remove from `to`, insert at `from`
|
|
267
|
+
*/
|
|
268
|
+
export function reorderSectionCommand(params: {
|
|
269
|
+
rowId: string;
|
|
270
|
+
sectionId: string;
|
|
271
|
+
from: number;
|
|
272
|
+
to: number;
|
|
273
|
+
label?: string;
|
|
274
|
+
}): LayoutCommand {
|
|
275
|
+
const label = params.label ?? `reorder section`;
|
|
276
|
+
return {
|
|
277
|
+
label,
|
|
278
|
+
timestamp: Date.now(),
|
|
279
|
+
apply(draft) {
|
|
280
|
+
const row = findRowInDraft(draft, params.rowId);
|
|
281
|
+
if (!row) return;
|
|
282
|
+
const idx = row.sections.findIndex((s) => s.id === params.sectionId);
|
|
283
|
+
if (idx === -1) return;
|
|
284
|
+
const [moved] = row.sections.splice(idx, 1);
|
|
285
|
+
if (!moved) return;
|
|
286
|
+
row.sections.splice(params.to, 0, moved);
|
|
287
|
+
},
|
|
288
|
+
invert(draft) {
|
|
289
|
+
const row = findRowInDraft(draft, params.rowId);
|
|
290
|
+
if (!row) return;
|
|
291
|
+
const idx = row.sections.findIndex((s) => s.id === params.sectionId);
|
|
292
|
+
if (idx === -1) return;
|
|
293
|
+
const [moved] = row.sections.splice(idx, 1);
|
|
294
|
+
if (!moved) return;
|
|
295
|
+
row.sections.splice(params.from, 0, moved);
|
|
296
|
+
},
|
|
297
|
+
};
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
/**
|
|
301
|
+
* Add a new row to a zone.
|
|
302
|
+
*
|
|
303
|
+
* apply: splice the row into zone.rows at `position`
|
|
304
|
+
* invert: remove the row by id
|
|
305
|
+
*
|
|
306
|
+
* Used by the "+ Add row" canvas button (plan §7.2). The row's sections
|
|
307
|
+
* are typically empty at add-time, but the command stores a deep clone
|
|
308
|
+
* so future "duplicate row" callers (which pre-populate sections) work
|
|
309
|
+
* with the same factory. Undo of an add-row removes the WHOLE row +
|
|
310
|
+
* any sections that landed in it after the add.
|
|
311
|
+
*/
|
|
312
|
+
export function addRowCommand(params: {
|
|
313
|
+
zoneSlug: string;
|
|
314
|
+
position: number;
|
|
315
|
+
row: LayoutRow;
|
|
316
|
+
label?: string;
|
|
317
|
+
}): LayoutCommand {
|
|
318
|
+
const rowClone = JSON.parse(JSON.stringify(params.row)) as LayoutRow;
|
|
319
|
+
const label = params.label ?? `add row`;
|
|
320
|
+
return {
|
|
321
|
+
label,
|
|
322
|
+
timestamp: Date.now(),
|
|
323
|
+
apply(draft) {
|
|
324
|
+
const zone = draft.zones.find((z) => z.zone === params.zoneSlug);
|
|
325
|
+
if (!zone) return;
|
|
326
|
+
// Fresh clone each apply so undo→redo doesn't share a mutated object.
|
|
327
|
+
zone.rows.splice(params.position, 0, JSON.parse(JSON.stringify(rowClone)));
|
|
328
|
+
},
|
|
329
|
+
invert(draft) {
|
|
330
|
+
const zone = draft.zones.find((z) => z.zone === params.zoneSlug);
|
|
331
|
+
if (!zone) return;
|
|
332
|
+
const idx = zone.rows.findIndex((r) => r.id === rowClone.id);
|
|
333
|
+
if (idx === -1) return;
|
|
334
|
+
zone.rows.splice(idx, 1);
|
|
335
|
+
},
|
|
336
|
+
};
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
/**
|
|
340
|
+
* Remove a row from a zone.
|
|
341
|
+
*
|
|
342
|
+
* apply: remove the row by id from zone.rows
|
|
343
|
+
* invert: restore the row at `position` (clamped if the zone now has
|
|
344
|
+
* fewer rows)
|
|
345
|
+
*
|
|
346
|
+
* Symmetric pair with `addRowCommand`. The full row (including any
|
|
347
|
+
* sections it contained at remove-time) is deep-cloned + stored on the
|
|
348
|
+
* command so the invert restores the row's contents — Cmd+Z after a
|
|
349
|
+
* row removal brings back the sections too.
|
|
350
|
+
*/
|
|
351
|
+
export function removeRowCommand(params: {
|
|
352
|
+
zoneSlug: string;
|
|
353
|
+
position: number;
|
|
354
|
+
row: LayoutRow;
|
|
355
|
+
label?: string;
|
|
356
|
+
}): LayoutCommand {
|
|
357
|
+
const rowClone = JSON.parse(JSON.stringify(params.row)) as LayoutRow;
|
|
358
|
+
const label = params.label ?? `remove row`;
|
|
359
|
+
return {
|
|
360
|
+
label,
|
|
361
|
+
timestamp: Date.now(),
|
|
362
|
+
apply(draft) {
|
|
363
|
+
const zone = draft.zones.find((z) => z.zone === params.zoneSlug);
|
|
364
|
+
if (!zone) return;
|
|
365
|
+
const idx = zone.rows.findIndex((r) => r.id === rowClone.id);
|
|
366
|
+
if (idx === -1) return;
|
|
367
|
+
zone.rows.splice(idx, 1);
|
|
368
|
+
},
|
|
369
|
+
invert(draft) {
|
|
370
|
+
const zone = draft.zones.find((z) => z.zone === params.zoneSlug);
|
|
371
|
+
if (!zone) return;
|
|
372
|
+
// Clamp to the current length — intervening commands may have
|
|
373
|
+
// removed other rows, putting our captured position out of range.
|
|
374
|
+
const pos = Math.min(params.position, zone.rows.length);
|
|
375
|
+
zone.rows.splice(pos, 0, JSON.parse(JSON.stringify(rowClone)));
|
|
376
|
+
},
|
|
377
|
+
};
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
/**
|
|
381
|
+
* Remove a section from a row.
|
|
382
|
+
*
|
|
383
|
+
* apply: remove the section by id from row.sections
|
|
384
|
+
* invert: restore the section at `position` (clamped if the row now has
|
|
385
|
+
* fewer sections)
|
|
386
|
+
*
|
|
387
|
+
* Symmetric pair with `insertSectionCommand`. Phase 3d.1 (Backspace /
|
|
388
|
+
* Delete) records this. The full section (config + responsive + every
|
|
389
|
+
* authored field) is deep-cloned + stored on the command so the invert
|
|
390
|
+
* restores complete content — Cmd+Z after a section removal brings the
|
|
391
|
+
* authored copy back, not just the type stub.
|
|
392
|
+
*/
|
|
393
|
+
export function removeSectionCommand(params: {
|
|
394
|
+
rowId: string;
|
|
395
|
+
position: number;
|
|
396
|
+
section: LayoutSection;
|
|
397
|
+
label?: string;
|
|
398
|
+
}): LayoutCommand {
|
|
399
|
+
const sectionClone = cloneSection(params.section);
|
|
400
|
+
const label = params.label ?? `remove ${params.section.type}`;
|
|
401
|
+
return {
|
|
402
|
+
label,
|
|
403
|
+
timestamp: Date.now(),
|
|
404
|
+
apply(draft) {
|
|
405
|
+
const row = findRowInDraft(draft, params.rowId);
|
|
406
|
+
if (!row) return;
|
|
407
|
+
const idx = row.sections.findIndex((s) => s.id === sectionClone.id);
|
|
408
|
+
if (idx === -1) return;
|
|
409
|
+
row.sections.splice(idx, 1);
|
|
410
|
+
},
|
|
411
|
+
invert(draft) {
|
|
412
|
+
const row = findRowInDraft(draft, params.rowId);
|
|
413
|
+
if (!row) return;
|
|
414
|
+
// Clamp to current length — intervening commands may have removed
|
|
415
|
+
// other sections, pushing our captured position out of range.
|
|
416
|
+
const pos = Math.min(params.position, row.sections.length);
|
|
417
|
+
row.sections.splice(pos, 0, cloneSection(sectionClone));
|
|
418
|
+
},
|
|
419
|
+
};
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
/**
|
|
423
|
+
* Duplicate a section in place — clones the source, inserts directly
|
|
424
|
+
* after it. Phase 3d.2 (Cmd/Ctrl+D).
|
|
425
|
+
*
|
|
426
|
+
* apply: splice `clone` into row at `at`
|
|
427
|
+
* invert: remove `clone` by id
|
|
428
|
+
*
|
|
429
|
+
* The clone's id MUST be unique within the layout — the caller mints
|
|
430
|
+
* it via `crypto.randomUUID()` BEFORE building the command, so apply +
|
|
431
|
+
* invert can both find the same instance across undo/redo cycles.
|
|
432
|
+
*
|
|
433
|
+
* Practically equivalent to `insertSectionCommand` for purposes of
|
|
434
|
+
* apply/invert, but kept as a separate factory because:
|
|
435
|
+
* 1. The label defaults differ (`duplicate hero` vs `insert hero`),
|
|
436
|
+
* which surfaces in the toolbar tooltip + screen-reader narration.
|
|
437
|
+
* Telling the user "you can undo the insert" when the action was a
|
|
438
|
+
* duplicate is jarring.
|
|
439
|
+
* 2. Tests + audit lenses can target duplicate semantics specifically
|
|
440
|
+
* (e.g. "does the new id collide with the source?"). One factory
|
|
441
|
+
* per intent matches the convention the row commands already set.
|
|
442
|
+
*/
|
|
443
|
+
export function duplicateSectionCommand(params: {
|
|
444
|
+
rowId: string;
|
|
445
|
+
at: number;
|
|
446
|
+
clone: LayoutSection;
|
|
447
|
+
label?: string;
|
|
448
|
+
}): LayoutCommand {
|
|
449
|
+
const clone = cloneSection(params.clone);
|
|
450
|
+
const label = params.label ?? `duplicate ${clone.type}`;
|
|
451
|
+
return {
|
|
452
|
+
label,
|
|
453
|
+
timestamp: Date.now(),
|
|
454
|
+
apply(draft) {
|
|
455
|
+
const row = findRowInDraft(draft, params.rowId);
|
|
456
|
+
if (!row) return;
|
|
457
|
+
// Fresh clone on each apply so undo→redo cycles don't share a
|
|
458
|
+
// mutated object — same rule the other factories follow.
|
|
459
|
+
row.sections.splice(params.at, 0, cloneSection(clone));
|
|
460
|
+
},
|
|
461
|
+
invert(draft) {
|
|
462
|
+
const row = findRowInDraft(draft, params.rowId);
|
|
463
|
+
if (!row) return;
|
|
464
|
+
const idx = row.sections.findIndex((s) => s.id === clone.id);
|
|
465
|
+
if (idx === -1) return;
|
|
466
|
+
row.sections.splice(idx, 1);
|
|
467
|
+
},
|
|
468
|
+
};
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
/**
|
|
472
|
+
* Resize a section + (optionally) its right neighbour. Phase 3c.
|
|
473
|
+
*
|
|
474
|
+
* apply: set section.colSpan to `toColSpan`; if neighbourId present,
|
|
475
|
+
* set neighbour.colSpan to `neighbourToColSpan`
|
|
476
|
+
* invert: restore both to their `*FromColSpan`
|
|
477
|
+
*
|
|
478
|
+
* The pointer-drag handler in `useLayoutResize` mutates the live draft
|
|
479
|
+
* during the gesture (for live preview); on pointer-release it commits
|
|
480
|
+
* ONE command capturing the start + end values. A drag-back-to-original
|
|
481
|
+
* (toColSpan === fromColSpan AND no neighbour change) is a no-op the
|
|
482
|
+
* caller filters before recording — keeps the stack from filling with
|
|
483
|
+
* self-equal entries (mirrors the reorder dispatcher's `from===to` skip).
|
|
484
|
+
*
|
|
485
|
+
* Neighbour semantics:
|
|
486
|
+
* - `neighbourId === null` → section is LAST in its row; neighbour
|
|
487
|
+
* fields are unused. Plain colSpan swap.
|
|
488
|
+
* - `neighbourId` set → both sections were in the same row at command-
|
|
489
|
+
* record time; the apply/invert restores both. The `findSectionLocation`
|
|
490
|
+
* walks zones → rows → sections by id, so neither cares about the
|
|
491
|
+
* specific row index — robust against intervening reorders.
|
|
492
|
+
*
|
|
493
|
+
* Idempotence + intervening commands: if either section was deleted
|
|
494
|
+
* between record + replay, the corresponding find returns null and
|
|
495
|
+
* that side is silently skipped — same defensive shape as the other
|
|
496
|
+
* factories. Cmd+Z on a deleted section's resize is a quiet noop.
|
|
497
|
+
*
|
|
498
|
+
* Symmetric pair design (no separate factory for keyboard vs pointer):
|
|
499
|
+
* Shift+Arrow keyboard resize records the same command shape; the only
|
|
500
|
+
* difference is `label` ("resize hero (keyboard)" vs default "resize
|
|
501
|
+
* section") so narration tells SR users which input drove it.
|
|
502
|
+
*/
|
|
503
|
+
export function resizeSectionCommand(params: {
|
|
504
|
+
rowId: string;
|
|
505
|
+
sectionId: string;
|
|
506
|
+
fromColSpan: number;
|
|
507
|
+
toColSpan: number;
|
|
508
|
+
/** Right neighbour at resize-start. Null when the resized section
|
|
509
|
+
* was LAST in its row (no absorption — extends to row edge). */
|
|
510
|
+
neighbourId: string | null;
|
|
511
|
+
neighbourFromColSpan?: number;
|
|
512
|
+
neighbourToColSpan?: number;
|
|
513
|
+
label?: string;
|
|
514
|
+
}): LayoutCommand {
|
|
515
|
+
const label = params.label ?? `resize section`;
|
|
516
|
+
return {
|
|
517
|
+
label,
|
|
518
|
+
timestamp: Date.now(),
|
|
519
|
+
apply(draft) {
|
|
520
|
+
const loc = findSectionLocation(draft, params.sectionId);
|
|
521
|
+
if (loc) loc.section.colSpan = params.toColSpan;
|
|
522
|
+
if (params.neighbourId && params.neighbourToColSpan !== undefined) {
|
|
523
|
+
const nLoc = findSectionLocation(draft, params.neighbourId);
|
|
524
|
+
if (nLoc) nLoc.section.colSpan = params.neighbourToColSpan;
|
|
525
|
+
}
|
|
526
|
+
},
|
|
527
|
+
invert(draft) {
|
|
528
|
+
const loc = findSectionLocation(draft, params.sectionId);
|
|
529
|
+
if (loc) loc.section.colSpan = params.fromColSpan;
|
|
530
|
+
if (params.neighbourId && params.neighbourFromColSpan !== undefined) {
|
|
531
|
+
const nLoc = findSectionLocation(draft, params.neighbourId);
|
|
532
|
+
if (nLoc) nLoc.section.colSpan = params.neighbourFromColSpan;
|
|
533
|
+
}
|
|
534
|
+
},
|
|
535
|
+
};
|
|
536
|
+
}
|
|
537
|
+
|
|
538
|
+
/**
|
|
539
|
+
* Cross-row / cross-zone move.
|
|
540
|
+
*
|
|
541
|
+
* apply: remove from sourceRow at fromIdx, insert into destRow at toIdx
|
|
542
|
+
* invert: reverse — remove from destRow, insert back into sourceRow
|
|
543
|
+
*
|
|
544
|
+
* If either row vanishes between record + replay, the command short-
|
|
545
|
+
* circuits silently (the section vanished too, so there's nothing
|
|
546
|
+
* useful to do). The history's strict LIFO ordering makes this
|
|
547
|
+
* scenario rare: it only happens if a concurrent refresh replaced
|
|
548
|
+
* draft mid-stack, in which case `clear()` should have fired.
|
|
549
|
+
*/
|
|
550
|
+
export function moveSectionCommand(params: {
|
|
551
|
+
fromRowId: string;
|
|
552
|
+
toRowId: string;
|
|
553
|
+
sectionId: string;
|
|
554
|
+
fromIdx: number;
|
|
555
|
+
toIdx: number;
|
|
556
|
+
label?: string;
|
|
557
|
+
}): LayoutCommand {
|
|
558
|
+
const label = params.label ?? `move section`;
|
|
559
|
+
return {
|
|
560
|
+
label,
|
|
561
|
+
timestamp: Date.now(),
|
|
562
|
+
apply(draft) {
|
|
563
|
+
const from = findRowInDraft(draft, params.fromRowId);
|
|
564
|
+
const to = findRowInDraft(draft, params.toRowId);
|
|
565
|
+
if (!from || !to) return;
|
|
566
|
+
const idx = from.sections.findIndex((s) => s.id === params.sectionId);
|
|
567
|
+
if (idx === -1) return;
|
|
568
|
+
const [moved] = from.sections.splice(idx, 1);
|
|
569
|
+
if (!moved) return;
|
|
570
|
+
to.sections.splice(params.toIdx, 0, moved);
|
|
571
|
+
},
|
|
572
|
+
invert(draft) {
|
|
573
|
+
const from = findRowInDraft(draft, params.fromRowId);
|
|
574
|
+
const to = findRowInDraft(draft, params.toRowId);
|
|
575
|
+
if (!from || !to) return;
|
|
576
|
+
const idx = to.sections.findIndex((s) => s.id === params.sectionId);
|
|
577
|
+
if (idx === -1) return;
|
|
578
|
+
const [moved] = to.sections.splice(idx, 1);
|
|
579
|
+
if (!moved) return;
|
|
580
|
+
from.sections.splice(params.fromIdx, 0, moved);
|
|
581
|
+
},
|
|
582
|
+
};
|
|
583
|
+
}
|