@commonpub/layer 0.24.0 → 0.25.1
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 +11 -8
- 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,366 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* useLayoutHotkeys — keyboard shortcuts for the layout editor.
|
|
3
|
+
*
|
|
4
|
+
* Phase 3b/B (3b.8): Cmd/Ctrl+Z = undo, Cmd/Ctrl+Shift+Z = redo.
|
|
5
|
+
* Phase 3d.1: Backspace / Delete = remove the selected section.
|
|
6
|
+
* Phase 3d.2: Cmd/Ctrl+D = duplicate the selected section.
|
|
7
|
+
* Phase 3d.3: ? (Shift+/) = show the help overlay.
|
|
8
|
+
*
|
|
9
|
+
* Deliberate non-binding: Cmd/Ctrl+Y. Notion, Linear, Figma, VS Code,
|
|
10
|
+
* Google Docs all settled on Shift+Z for redo on Mac (where Cmd+Y is
|
|
11
|
+
* "Show History"). Windows users may still expect Ctrl+Y, but matching
|
|
12
|
+
* the modern editor convention (Shift+Z BOTH places) is what the
|
|
13
|
+
* `feedback-match-established-pattern` memory points at.
|
|
14
|
+
*
|
|
15
|
+
* Input-field skip: when the focused element is `input`, `textarea`,
|
|
16
|
+
* or `[contenteditable]`, the browser's native undo handles per-field
|
|
17
|
+
* text undo + plain typing (so `?` in a search field stays a `?`).
|
|
18
|
+
* Stealing those keystrokes for layout commands would be user-hostile.
|
|
19
|
+
* We check `e.target` and short-circuit on every binding.
|
|
20
|
+
*
|
|
21
|
+
* SSR: the `typeof window` guard prevents the addEventListener call
|
|
22
|
+
* on server-render. Per `feedback-vitest-import-meta-client-undefined`
|
|
23
|
+
* — `import.meta.client` is a Nuxt build-time replacement that doesn't
|
|
24
|
+
* exist in vitest; `typeof window` is portable.
|
|
25
|
+
*
|
|
26
|
+
* Lifecycle: caller invokes inside `<script setup>` (the editor page);
|
|
27
|
+
* we attach on mount + detach on unmount via the lifecycle hooks. The
|
|
28
|
+
* editor unmount also clears history elsewhere — these are independent
|
|
29
|
+
* cleanups; both must happen.
|
|
30
|
+
*/
|
|
31
|
+
import { onBeforeUnmount, onMounted } from 'vue';
|
|
32
|
+
import type { LayoutRecord } from '@commonpub/server';
|
|
33
|
+
import type { LayoutSection } from './useLayout';
|
|
34
|
+
import type { EditorSelection } from './useLayoutEditor';
|
|
35
|
+
import {
|
|
36
|
+
useLayoutHistory,
|
|
37
|
+
removeSectionCommand,
|
|
38
|
+
duplicateSectionCommand,
|
|
39
|
+
findSectionLocation,
|
|
40
|
+
} from './useLayoutHistory';
|
|
41
|
+
import {
|
|
42
|
+
useLayoutAnnouncer,
|
|
43
|
+
narrateUndo,
|
|
44
|
+
narrateRedo,
|
|
45
|
+
narrateUndoEmpty,
|
|
46
|
+
narrateRedoEmpty,
|
|
47
|
+
narrateSectionRemoved,
|
|
48
|
+
narrateSectionDuplicated,
|
|
49
|
+
} from './useLayoutAnnouncer';
|
|
50
|
+
import { useLayoutResize } from './useLayoutResize';
|
|
51
|
+
|
|
52
|
+
export interface UseLayoutHotkeysOptions {
|
|
53
|
+
/** Read the live draft at hotkey-time. Closure so the hotkey handler
|
|
54
|
+
* always sees the current draft even after refresh/discard reassigns
|
|
55
|
+
* the ref. Returns null when no draft is loaded; the hotkey is a
|
|
56
|
+
* silent noop then. */
|
|
57
|
+
getDraft: () => LayoutRecord | null;
|
|
58
|
+
/** Read the current selection at hotkey-time. Optional so the existing
|
|
59
|
+
* Cmd+Z/Cmd+Shift+Z bindings continue to work in tests + call sites
|
|
60
|
+
* that don't care about selection. Returns null when nothing selected;
|
|
61
|
+
* selection-gated hotkeys (Backspace, Cmd+D) become silent noops. */
|
|
62
|
+
getSelection?: () => EditorSelection;
|
|
63
|
+
/** Mutate selection. Backspace removes the section + clears selection
|
|
64
|
+
* (the section is gone). Cmd+D moves selection to the clone so the
|
|
65
|
+
* user can immediately Cmd+D again or arrow-move it. Optional so the
|
|
66
|
+
* composable degrades gracefully when not provided. */
|
|
67
|
+
setSelection?: (sel: EditorSelection) => void;
|
|
68
|
+
/** Toggled by `?` (Shift+/). The editor page mounts the help overlay
|
|
69
|
+
* modal + binds its `open` state to this callback. Optional — when
|
|
70
|
+
* absent, `?` is a silent noop. */
|
|
71
|
+
onShowHelp?: () => void;
|
|
72
|
+
/**
|
|
73
|
+
* Phase 3c — closure resolving per-section resize bounds from the
|
|
74
|
+
* section registry. Returns `null` when the section is not resizable
|
|
75
|
+
* (registry's `resizable: false`) OR not registered — in which case
|
|
76
|
+
* the Shift+Arrow binding is a silent noop. The closure also returns
|
|
77
|
+
* the right neighbour's id + bounds when one exists.
|
|
78
|
+
*
|
|
79
|
+
* Why a closure, not a registry import: useLayoutHotkeys runs in
|
|
80
|
+
* setup() but its handlers fire AT KEYSTROKE TIME, possibly long
|
|
81
|
+
* after setup. The registry instance is mounted as a Vue plugin —
|
|
82
|
+
* its `useSectionRegistry` calls inside a window handler would NOT
|
|
83
|
+
* inject correctly. Passing in a closure that the editor page builds
|
|
84
|
+
* (where the registry IS injected) keeps the import clean.
|
|
85
|
+
*
|
|
86
|
+
* When absent, Shift+Arrow is a no-op (degrade gracefully — same
|
|
87
|
+
* shape as the other selection-gated hotkeys).
|
|
88
|
+
*/
|
|
89
|
+
lookupResizeBounds?: (sectionId: string) => {
|
|
90
|
+
sectionType: string;
|
|
91
|
+
rowId: string;
|
|
92
|
+
sectionMin: number;
|
|
93
|
+
sectionMax: number;
|
|
94
|
+
/** Null when the section is LAST in its row OR no neighbour
|
|
95
|
+
* registered. Same shape `applyKeyboardResize` expects. */
|
|
96
|
+
neighbour: { sectionId: string; min: number; max: number } | null;
|
|
97
|
+
} | null;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
export interface UseLayoutHotkeysResult {
|
|
101
|
+
/** Detach the handler imperatively (tests, or manual pause). The
|
|
102
|
+
* default lifecycle hook also detaches on unmount. */
|
|
103
|
+
detach: () => void;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/** Is the event's target a text-input surface that owns its own undo
|
|
107
|
+
* + native typing semantics? */
|
|
108
|
+
function isTextInputTarget(target: EventTarget | null): boolean {
|
|
109
|
+
if (!target) return false;
|
|
110
|
+
const el = target as HTMLElement;
|
|
111
|
+
if (typeof el.matches !== 'function') return false;
|
|
112
|
+
// `[contenteditable]` matches both `contenteditable` and
|
|
113
|
+
// `contenteditable="true"`; `[contenteditable="false"]` would be a
|
|
114
|
+
// read-only block, so it's safe to skip via plain attr presence.
|
|
115
|
+
return el.matches('input, textarea, [contenteditable]:not([contenteditable="false"])');
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/** Cmd on Mac, Ctrl elsewhere. We test BOTH so a user on Mac who
|
|
119
|
+
* remapped Ctrl→Cmd still gets the binding. */
|
|
120
|
+
function isUndoLike(e: KeyboardEvent): boolean {
|
|
121
|
+
return (e.metaKey || e.ctrlKey) && (e.key === 'z' || e.key === 'Z');
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/** Backspace OR Delete with no modifiers. macOS sends 'Backspace' for the
|
|
125
|
+
* big delete key + 'Delete' for fn+Backspace; Windows sends 'Delete' for
|
|
126
|
+
* the standalone Del key. Both should fire the same intent — "remove
|
|
127
|
+
* the selected thing". Strictly modifier-free so:
|
|
128
|
+
* - Cmd+Backspace (URL-bar clear in Safari) stays user-controlled
|
|
129
|
+
* - Shift+Backspace (browser back-nav fallback in some browsers, or
|
|
130
|
+
* "delete word" in some editors) doesn't trigger a section remove
|
|
131
|
+
* under the user's elbow (session 165 deep audit R2-A) */
|
|
132
|
+
function isRemoveLike(e: KeyboardEvent): boolean {
|
|
133
|
+
if (e.metaKey || e.ctrlKey || e.altKey || e.shiftKey) return false;
|
|
134
|
+
return e.key === 'Backspace' || e.key === 'Delete';
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* Is any modal dialog currently open in the document? Used to suspend
|
|
139
|
+
* global section-mutating hotkeys (Backspace, Cmd+D, Cmd+Z) while a
|
|
140
|
+
* modal owns the user's focus. Without this, pressing Backspace while
|
|
141
|
+
* focused on the HelpOverlay's Close button or the ConflictModal's
|
|
142
|
+
* "Keep editing" button would silently remove the section behind the
|
|
143
|
+
* modal — the user can't even see the change happen.
|
|
144
|
+
*
|
|
145
|
+
* Detects any element with `role="dialog"` or `role="alertdialog"` in
|
|
146
|
+
* the DOM. Vue's `v-if` removes the element when closed, so presence
|
|
147
|
+
* = open. Doesn't require a wired-up callback — the modal's own ARIA
|
|
148
|
+
* attributes are the source of truth. This means new modals added later
|
|
149
|
+
* automatically participate without touching `useLayoutHotkeys`.
|
|
150
|
+
*
|
|
151
|
+
* Session 165 deep audit R3-A.
|
|
152
|
+
*/
|
|
153
|
+
function isAnyDialogOpen(): boolean {
|
|
154
|
+
if (typeof document === 'undefined') return false;
|
|
155
|
+
return document.querySelector('[role="dialog"], [role="alertdialog"]') !== null;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/** Cmd/Ctrl+D — duplicate. Shift modifier explicitly excluded so
|
|
159
|
+
* Cmd+Shift+D (often "duplicate WITHOUT formatting" in editors,
|
|
160
|
+
* unbound here) doesn't accidentally fire. Browsers bind Cmd+D to
|
|
161
|
+
* "add bookmark" — we preventDefault when we handle, otherwise the
|
|
162
|
+
* user can still bookmark. */
|
|
163
|
+
function isDuplicateLike(e: KeyboardEvent): boolean {
|
|
164
|
+
if (e.shiftKey || e.altKey) return false;
|
|
165
|
+
if (!(e.metaKey || e.ctrlKey)) return false;
|
|
166
|
+
return e.key === 'd' || e.key === 'D';
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/** `?` (Shift+/ on US keyboards). Cross-keyboard portable because we
|
|
170
|
+
* test the produced character, not the physical key. `key === '?'`
|
|
171
|
+
* fires for any layout that produces a literal question mark. */
|
|
172
|
+
function isHelpLike(e: KeyboardEvent): boolean {
|
|
173
|
+
if (e.metaKey || e.ctrlKey || e.altKey) return false;
|
|
174
|
+
return e.key === '?';
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/** Shift+ArrowLeft / Shift+ArrowRight = keyboard resize (Phase 3c).
|
|
178
|
+
* Strict-Shift-only so unmodified arrows stay free for future drag-
|
|
179
|
+
* mode arrow navigation (plan §7.8). Cmd/Ctrl excluded so neither
|
|
180
|
+
* "switch tabs" (browser default) nor "move section" (intended for
|
|
181
|
+
* cross-row arrow nav per §7.8) collides with resize semantics. */
|
|
182
|
+
function isResizeLike(e: KeyboardEvent): 'shrink' | 'grow' | null {
|
|
183
|
+
if (e.metaKey || e.ctrlKey || e.altKey) return null;
|
|
184
|
+
if (!e.shiftKey) return null;
|
|
185
|
+
if (e.key === 'ArrowLeft') return 'shrink';
|
|
186
|
+
if (e.key === 'ArrowRight') return 'grow';
|
|
187
|
+
return null;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/**
|
|
191
|
+
* Should removing a section require a confirm? Heuristic: any
|
|
192
|
+
* authored config makes the section "rich" — the keystroke could
|
|
193
|
+
* destroy real authored content. An empty config (defaults only)
|
|
194
|
+
* is the just-dragged-in placeholder; removing it is recovery, not
|
|
195
|
+
* destruction. Mirrors `onRemoveRow`'s "confirm only when there's
|
|
196
|
+
* content" pattern in the editor page.
|
|
197
|
+
*
|
|
198
|
+
* Cmd+Z restores either way (within the session), so the confirm is
|
|
199
|
+
* a soft guard, not a contract. We still bypass when the section
|
|
200
|
+
* has no config — the keystroke is fast, the undo is fast, and the
|
|
201
|
+
* confirm dialog interrupts an empty-section sweep flow.
|
|
202
|
+
*/
|
|
203
|
+
function isRichSection(section: LayoutSection): boolean {
|
|
204
|
+
const cfg = section.config;
|
|
205
|
+
if (!cfg || typeof cfg !== 'object') return false;
|
|
206
|
+
return Object.keys(cfg).length > 0;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
export function useLayoutHotkeys(opts: UseLayoutHotkeysOptions): UseLayoutHotkeysResult {
|
|
210
|
+
const history = useLayoutHistory();
|
|
211
|
+
const announcer = useLayoutAnnouncer();
|
|
212
|
+
const resize = useLayoutResize();
|
|
213
|
+
let attached = false;
|
|
214
|
+
|
|
215
|
+
function onKeyDown(e: KeyboardEvent): void {
|
|
216
|
+
if (isTextInputTarget(e.target)) return; // text fields own the keystroke
|
|
217
|
+
// Modal-open suspends global hotkeys (session 165 deep audit R3-A).
|
|
218
|
+
// The modal owns the keystroke until dismissed; Esc + the modal's own
|
|
219
|
+
// dismiss-buttons still fire because they're handled in the modal's
|
|
220
|
+
// local listener, not this global one.
|
|
221
|
+
if (isAnyDialogOpen()) return;
|
|
222
|
+
|
|
223
|
+
// --- Undo / Redo (Phase 3b/B) ---
|
|
224
|
+
if (isUndoLike(e)) {
|
|
225
|
+
const draft = opts.getDraft();
|
|
226
|
+
if (!draft) return;
|
|
227
|
+
// Shift modifier reverses direction. Order matters: check Shift
|
|
228
|
+
// FIRST so Cmd+Shift+Z doesn't fall into the undo branch.
|
|
229
|
+
if (e.shiftKey) {
|
|
230
|
+
const cmd = history.redo(draft);
|
|
231
|
+
e.preventDefault();
|
|
232
|
+
announcer.announcePolite(cmd ? narrateRedo(cmd.label) : narrateRedoEmpty());
|
|
233
|
+
return;
|
|
234
|
+
}
|
|
235
|
+
const cmd = history.undo(draft);
|
|
236
|
+
e.preventDefault();
|
|
237
|
+
announcer.announcePolite(cmd ? narrateUndo(cmd.label) : narrateUndoEmpty());
|
|
238
|
+
return;
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
// --- Help overlay (Phase 3d.3) ---
|
|
242
|
+
if (isHelpLike(e)) {
|
|
243
|
+
if (!opts.onShowHelp) return;
|
|
244
|
+
e.preventDefault();
|
|
245
|
+
opts.onShowHelp();
|
|
246
|
+
return;
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
// The remaining bindings (Backspace, Cmd+D, Shift+Arrow) require a
|
|
250
|
+
// section selection. Read once + short-circuit if not applicable.
|
|
251
|
+
const sel = opts.getSelection?.();
|
|
252
|
+
if (!sel || sel.kind !== 'section') return;
|
|
253
|
+
const draft = opts.getDraft();
|
|
254
|
+
if (!draft) return;
|
|
255
|
+
const loc = findSectionLocation(draft, sel.id);
|
|
256
|
+
if (!loc) return; // stale selection — section vanished mid-keydown
|
|
257
|
+
|
|
258
|
+
// --- Keyboard resize (Phase 3c) ---
|
|
259
|
+
// Run BEFORE Backspace/Cmd+D so Shift+ArrowRight doesn't fall through.
|
|
260
|
+
// Modal-open guard already handled at top of handler.
|
|
261
|
+
const resizeDir = isResizeLike(e);
|
|
262
|
+
if (resizeDir !== null) {
|
|
263
|
+
e.preventDefault();
|
|
264
|
+
if (!opts.lookupResizeBounds) return;
|
|
265
|
+
const bounds = opts.lookupResizeBounds(sel.id);
|
|
266
|
+
if (!bounds) return; // not resizable / not registered
|
|
267
|
+
// applyKeyboardResize handles bounds checking + history record +
|
|
268
|
+
// narration; the return value is just for caller's follow-up.
|
|
269
|
+
resize.applyKeyboardResize({
|
|
270
|
+
rowId: bounds.rowId,
|
|
271
|
+
sectionId: sel.id,
|
|
272
|
+
direction: resizeDir,
|
|
273
|
+
getDraft: opts.getDraft,
|
|
274
|
+
sectionMin: bounds.sectionMin,
|
|
275
|
+
sectionMax: bounds.sectionMax,
|
|
276
|
+
sectionType: bounds.sectionType,
|
|
277
|
+
neighbour: bounds.neighbour,
|
|
278
|
+
});
|
|
279
|
+
return;
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
// --- Remove (Phase 3d.1) ---
|
|
283
|
+
if (isRemoveLike(e)) {
|
|
284
|
+
e.preventDefault();
|
|
285
|
+
// Soft confirm only when there's authored content. Empty sections
|
|
286
|
+
// (just-dropped placeholders) skip — the keystroke + Cmd+Z roundtrip
|
|
287
|
+
// is faster than a confirm dialog for the sweep case.
|
|
288
|
+
if (isRichSection(loc.section)) {
|
|
289
|
+
// typeof guard so SSR / jsdom-without-window doesn't blow up.
|
|
290
|
+
const confirmFn = typeof window !== 'undefined' ? window.confirm : () => true;
|
|
291
|
+
const ok = confirmFn(
|
|
292
|
+
`Remove this ${loc.section.type} section? Press Command+Z within this session to restore it.`,
|
|
293
|
+
);
|
|
294
|
+
if (!ok) return;
|
|
295
|
+
}
|
|
296
|
+
// Capture position BEFORE splice so the command's invert can
|
|
297
|
+
// restore at the original index (clamped by the factory).
|
|
298
|
+
const sectionClone = JSON.parse(JSON.stringify(loc.section)) as LayoutSection;
|
|
299
|
+
loc.row.sections.splice(loc.idx, 1);
|
|
300
|
+
announcer.announce(narrateSectionRemoved(loc.section.type, loc.zoneSlug));
|
|
301
|
+
history.record(removeSectionCommand({
|
|
302
|
+
rowId: loc.row.id,
|
|
303
|
+
position: loc.idx,
|
|
304
|
+
section: sectionClone,
|
|
305
|
+
label: `remove ${loc.section.type}`,
|
|
306
|
+
}));
|
|
307
|
+
// Selection points at a section that's no longer in the draft —
|
|
308
|
+
// clear it so the inspector falls back to page-meta + keyboard
|
|
309
|
+
// focus follows along when the page re-renders.
|
|
310
|
+
opts.setSelection?.(null);
|
|
311
|
+
return;
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
// --- Duplicate (Phase 3d.2) ---
|
|
315
|
+
if (isDuplicateLike(e)) {
|
|
316
|
+
e.preventDefault();
|
|
317
|
+
// Mint a fresh id BEFORE building the command so apply + invert
|
|
318
|
+
// both reference the same instance across undo/redo cycles. Using
|
|
319
|
+
// crypto.randomUUID() so the id is globally unique (collision
|
|
320
|
+
// resistance: 122 bits of entropy >> v1 layout sizes).
|
|
321
|
+
const cloneId = typeof crypto !== 'undefined' && crypto.randomUUID
|
|
322
|
+
? crypto.randomUUID()
|
|
323
|
+
: `${loc.section.id}-copy-${Date.now()}`;
|
|
324
|
+
const clone: LayoutSection = {
|
|
325
|
+
...JSON.parse(JSON.stringify(loc.section)),
|
|
326
|
+
id: cloneId,
|
|
327
|
+
};
|
|
328
|
+
// Land directly after the source so the visual + ordering both
|
|
329
|
+
// match "duplicate" semantics. (Notion / Figma / Linear all
|
|
330
|
+
// converge on insert-after-source.)
|
|
331
|
+
const at = loc.idx + 1;
|
|
332
|
+
loc.row.sections.splice(at, 0, JSON.parse(JSON.stringify(clone)));
|
|
333
|
+
announcer.announce(narrateSectionDuplicated(loc.section.type, at, loc.row.sections.length));
|
|
334
|
+
history.record(duplicateSectionCommand({
|
|
335
|
+
rowId: loc.row.id,
|
|
336
|
+
at,
|
|
337
|
+
clone,
|
|
338
|
+
label: `duplicate ${loc.section.type}`,
|
|
339
|
+
}));
|
|
340
|
+
// Move selection to the clone so a second Cmd+D duplicates the
|
|
341
|
+
// duplicate (not the original) — matches Figma + Notion sequence
|
|
342
|
+
// semantics. Arrow keys / Move Up now operate on the new copy.
|
|
343
|
+
opts.setSelection?.({ kind: 'section', id: cloneId });
|
|
344
|
+
return;
|
|
345
|
+
}
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
function detach(): void {
|
|
349
|
+
if (!attached) return;
|
|
350
|
+
if (typeof window === 'undefined') return;
|
|
351
|
+
window.removeEventListener('keydown', onKeyDown);
|
|
352
|
+
attached = false;
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
onMounted(() => {
|
|
356
|
+
if (typeof window === 'undefined') return;
|
|
357
|
+
window.addEventListener('keydown', onKeyDown);
|
|
358
|
+
attached = true;
|
|
359
|
+
});
|
|
360
|
+
|
|
361
|
+
onBeforeUnmount(() => {
|
|
362
|
+
detach();
|
|
363
|
+
});
|
|
364
|
+
|
|
365
|
+
return { detach };
|
|
366
|
+
}
|