@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,117 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* useLayoutAutoSave — debounced auto-save for the layout editor.
|
|
3
|
+
*
|
|
4
|
+
* Phase 3a.6 + session-160 audit polish. Two complementary triggers:
|
|
5
|
+
* 1. Debounce: watch a dirty flag; on first dirt, wait `debounceMs`
|
|
6
|
+
* (default 1500 per docs/plans/layout-and-pages.md §7.13) then
|
|
7
|
+
* save. Further edits within the window reset the timer.
|
|
8
|
+
* 2. Visibility-change flush: when the tab becomes hidden (Cmd+Tab,
|
|
9
|
+
* tab close intent, minimize) and the draft is dirty, fire an
|
|
10
|
+
* immediate save. This is the safety net for users who edit
|
|
11
|
+
* then close the tab during the debounce window.
|
|
12
|
+
*
|
|
13
|
+
* Caller (the editor page) owns:
|
|
14
|
+
* - the dirty ref (from useLayoutEditor)
|
|
15
|
+
* - the save fn (from useLayoutEditor)
|
|
16
|
+
* - error/conflict handling — save() throws on 409 and the page
|
|
17
|
+
* catches it; auto-save itself just swallows + lets the
|
|
18
|
+
* editor.status reflect the result
|
|
19
|
+
*
|
|
20
|
+
* Per UX research synthesis (session 160 audit): debounce alone loses
|
|
21
|
+
* data when the user Cmd-W's during the window; blur alone misses
|
|
22
|
+
* idle-keyboard edits; both together gives a "nothing was lost"
|
|
23
|
+
* mental model.
|
|
24
|
+
*
|
|
25
|
+
* The composable returns a `cancel()` for tests + manual pause; the
|
|
26
|
+
* timer is automatically cleared on component unmount.
|
|
27
|
+
*/
|
|
28
|
+
import { onBeforeUnmount, onMounted, watch, type ComputedRef, type Ref } from 'vue';
|
|
29
|
+
|
|
30
|
+
export interface UseLayoutAutoSaveOptions {
|
|
31
|
+
/** Reactive dirty flag — when true, schedule a save. */
|
|
32
|
+
dirty: ComputedRef<boolean> | Ref<boolean>;
|
|
33
|
+
/** Save function — called on debounce-fire. */
|
|
34
|
+
save: () => Promise<void>;
|
|
35
|
+
/** Debounce window in ms. Default 1500. */
|
|
36
|
+
debounceMs?: number;
|
|
37
|
+
/** When true, skip scheduling entirely (e.g. user toggled auto-save off). */
|
|
38
|
+
paused?: Ref<boolean>;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export interface UseLayoutAutoSaveResult {
|
|
42
|
+
/** Stop the pending timer + ignore subsequent dirt until explicitly resumed. */
|
|
43
|
+
cancel: () => void;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export function useLayoutAutoSave(opts: UseLayoutAutoSaveOptions): UseLayoutAutoSaveResult {
|
|
47
|
+
const debounceMs = opts.debounceMs ?? 1500;
|
|
48
|
+
let timer: ReturnType<typeof setTimeout> | null = null;
|
|
49
|
+
|
|
50
|
+
function cancel(): void {
|
|
51
|
+
if (timer !== null) {
|
|
52
|
+
clearTimeout(timer);
|
|
53
|
+
timer = null;
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
watch(
|
|
58
|
+
opts.dirty,
|
|
59
|
+
(isDirty) => {
|
|
60
|
+
cancel();
|
|
61
|
+
if (!isDirty) return;
|
|
62
|
+
if (opts.paused?.value) return;
|
|
63
|
+
timer = setTimeout(() => {
|
|
64
|
+
timer = null;
|
|
65
|
+
// Errors are surfaced via the save() side-effects (editor.status,
|
|
66
|
+
// toasts). Swallow here so the watcher doesn't reject.
|
|
67
|
+
void opts.save().catch(() => {
|
|
68
|
+
/* handled by save()'s status setter */
|
|
69
|
+
});
|
|
70
|
+
}, debounceMs);
|
|
71
|
+
},
|
|
72
|
+
{ flush: 'post' },
|
|
73
|
+
);
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Visibility-change flush: when the tab is being hidden AND the draft
|
|
77
|
+
* is dirty, cancel the pending debounce and save IMMEDIATELY. This
|
|
78
|
+
* protects against data loss when the user Cmd+Tab's or closes the
|
|
79
|
+
* tab during the debounce window.
|
|
80
|
+
*
|
|
81
|
+
* `document.visibilityState === 'hidden'` fires reliably across modern
|
|
82
|
+
* browsers (per CanIUse: 100% support). The save() call is async and
|
|
83
|
+
* returns a promise that we don't await — the browser may not give
|
|
84
|
+
* us time to finish, but firing the request is better than not.
|
|
85
|
+
*
|
|
86
|
+
* The "REAL safety" path (request that survives page teardown) is
|
|
87
|
+
* now wired separately: session 162 P2.3 added `editor.flushBeacon()`
|
|
88
|
+
* (fetch with `keepalive:true`) which the editor page calls from a
|
|
89
|
+
* `pagehide` listener. visibilitychange is the fast path; pagehide-
|
|
90
|
+
* +-beacon is the safety net for the actual teardown.
|
|
91
|
+
*/
|
|
92
|
+
function onVisibilityChange(): void {
|
|
93
|
+
if (typeof document === 'undefined') return;
|
|
94
|
+
if (document.visibilityState !== 'hidden') return;
|
|
95
|
+
if (opts.paused?.value) return;
|
|
96
|
+
// Only flush if there's a pending dirty save
|
|
97
|
+
const isDirty = (opts.dirty as { value: boolean }).value;
|
|
98
|
+
if (!isDirty) return;
|
|
99
|
+
cancel();
|
|
100
|
+
void opts.save().catch(() => { /* handled */ });
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
onMounted(() => {
|
|
104
|
+
if (typeof document !== 'undefined') {
|
|
105
|
+
document.addEventListener('visibilitychange', onVisibilityChange);
|
|
106
|
+
}
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
onBeforeUnmount(() => {
|
|
110
|
+
cancel();
|
|
111
|
+
if (typeof document !== 'undefined') {
|
|
112
|
+
document.removeEventListener('visibilitychange', onVisibilityChange);
|
|
113
|
+
}
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
return { cancel };
|
|
117
|
+
}
|
|
@@ -0,0 +1,290 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* useLayoutDrag — pure drag-drop dispatcher logic.
|
|
3
|
+
*
|
|
4
|
+
* Phase 3b/A. Owns the "what to do when X drops on Y" semantics so the
|
|
5
|
+
* component wiring (`<LayoutRow>` + palette tile in upcoming commits)
|
|
6
|
+
* stays a thin shell over these pure functions. Two motivations:
|
|
7
|
+
*
|
|
8
|
+
* 1. **Testability** — dispatcher behavior is unit-tested with plain
|
|
9
|
+
* objects + no dnd-kit infrastructure. The component layer only
|
|
10
|
+
* proves `makeDroppable` is called with the right options.
|
|
11
|
+
* 2. **One source of truth** — when 3b/B adds cross-zone drag + when
|
|
12
|
+
* 3f adds drag-from-inspector, the same dispatcher routes them.
|
|
13
|
+
* No "every component reinvents the drop semantics" drift.
|
|
14
|
+
*
|
|
15
|
+
* Drag payload shape: every drag carries `{ kind, ...details }` so the
|
|
16
|
+
* row's onDrop handler can branch on `kind` without sniffing arbitrary
|
|
17
|
+
* data. Palette tiles carry the section's registry def; section
|
|
18
|
+
* instances carry the section + source row id.
|
|
19
|
+
*
|
|
20
|
+
* Mutations are applied DIRECTLY to the row.sections array. The editor's
|
|
21
|
+
* deep watcher on `editor.draft.value` picks them up + bumps dirty +
|
|
22
|
+
* the existing auto-save composable schedules a save within 1.5s. No
|
|
23
|
+
* parallel save path — Phase 3b/A kickoff rule.
|
|
24
|
+
*/
|
|
25
|
+
import type { LayoutSection, LayoutRow } from './useLayout';
|
|
26
|
+
import type { SectionDefinition } from '@commonpub/ui';
|
|
27
|
+
import type { IDragEvent } from '@vue-dnd-kit/core';
|
|
28
|
+
|
|
29
|
+
/* ------------------------------------------------------------------ */
|
|
30
|
+
/* Drag payload taxonomy */
|
|
31
|
+
/* ------------------------------------------------------------------ */
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Discriminator on every drag payload. Matched by a fast switch in
|
|
35
|
+
* `dispatchSectionDrop` — adding a new kind is one literal + one case.
|
|
36
|
+
*/
|
|
37
|
+
export type DragPayloadKind = 'palette-section-spec' | 'section-instance';
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Payload carried by a palette tile drag. The tile knows the section's
|
|
41
|
+
* registry def; the drop handler asks the registry to mint a new
|
|
42
|
+
* `LayoutSection` from the def's `defaultConfig` + `defaultColSpan`.
|
|
43
|
+
*
|
|
44
|
+
* We pass the FULL def (not just `type`) because the registry isn't
|
|
45
|
+
* trivially accessible from every drop handler — the row may not have
|
|
46
|
+
* the registry plumbed in. Passing the def avoids an extra dependency.
|
|
47
|
+
*/
|
|
48
|
+
export interface PaletteSectionDragPayload {
|
|
49
|
+
kind: 'palette-section-spec';
|
|
50
|
+
/** Section type slug — matches the def's `type`. */
|
|
51
|
+
sectionType: string;
|
|
52
|
+
/** Default config + colSpan to mint the new section from. */
|
|
53
|
+
defaultConfig: Record<string, unknown>;
|
|
54
|
+
defaultColSpan: number;
|
|
55
|
+
schemaVersion: number;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Payload carried by a section-instance drag (drag a section from one
|
|
60
|
+
* row to reorder or move to another row). 3b/A scope is within-row
|
|
61
|
+
* reorder; 3b/B adds cross-row + cross-zone.
|
|
62
|
+
*
|
|
63
|
+
* **The dispatcher MUST look up by `section.id`, not by `section`
|
|
64
|
+
* reference identity.** The envelope holds a reference at drag-start;
|
|
65
|
+
* if a concurrent `editor.refresh()` happens mid-drag (another admin's
|
|
66
|
+
* save + the user clicks "Reload their version" in the conflict modal),
|
|
67
|
+
* the referenced `section` becomes a phantom. Id-based lookup against
|
|
68
|
+
* the LIVE `row.sections` array tolerates the swap. Tests cover the
|
|
69
|
+
* 'section-not-found' noop branch.
|
|
70
|
+
*/
|
|
71
|
+
export interface SectionInstanceDragPayload {
|
|
72
|
+
kind: 'section-instance';
|
|
73
|
+
/** The section being dragged. Held by REFERENCE — the dispatcher
|
|
74
|
+
* uses `.id` to look up the live position in `row.sections`,
|
|
75
|
+
* never the reference directly. See class-comment above. */
|
|
76
|
+
section: LayoutSection;
|
|
77
|
+
/** The row this section currently lives in — needed for cross-row
|
|
78
|
+
* moves so the dispatcher can remove it from its source. */
|
|
79
|
+
fromRowId: string;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export type DragPayload = PaletteSectionDragPayload | SectionInstanceDragPayload;
|
|
83
|
+
|
|
84
|
+
/* ------------------------------------------------------------------ */
|
|
85
|
+
/* Section factory */
|
|
86
|
+
/* ------------------------------------------------------------------ */
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Mint a new `LayoutSection` from a registry definition. Uses the
|
|
90
|
+
* project's `crypto.randomUUID()` idiom (matches packages/server,
|
|
91
|
+
* layers/base/pages/learn/.../edit.vue). `enabled: true` so a fresh
|
|
92
|
+
* drop is immediately visible; `responsive: null` defers per-breakpoint
|
|
93
|
+
* tuning to Phase 3f's inspector.
|
|
94
|
+
*
|
|
95
|
+
* Pure — takes a def + returns a section. No mutation of inputs.
|
|
96
|
+
*/
|
|
97
|
+
export function createSectionFromSpec(def: PaletteSectionDragPayload): LayoutSection {
|
|
98
|
+
return {
|
|
99
|
+
id: crypto.randomUUID(),
|
|
100
|
+
order: 0, // server-side write handler renumbers; client value isn't authoritative
|
|
101
|
+
type: def.sectionType,
|
|
102
|
+
config: { ...def.defaultConfig },
|
|
103
|
+
colSpan: def.defaultColSpan,
|
|
104
|
+
responsive: null,
|
|
105
|
+
enabled: true,
|
|
106
|
+
visibility: null,
|
|
107
|
+
schemaVersion: def.schemaVersion,
|
|
108
|
+
};
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* Build a palette drag payload from a registry section def. Called by
|
|
113
|
+
* `<AdminLayoutsPalette>` when wiring `makeDraggable` on each tile.
|
|
114
|
+
* Lives here so the drop handler's expectation + the drag source's
|
|
115
|
+
* production stay in lockstep.
|
|
116
|
+
*/
|
|
117
|
+
export function paletteDragPayload(def: SectionDefinition): PaletteSectionDragPayload {
|
|
118
|
+
return {
|
|
119
|
+
kind: 'palette-section-spec',
|
|
120
|
+
sectionType: def.type,
|
|
121
|
+
defaultConfig: { ...def.defaultConfig },
|
|
122
|
+
defaultColSpan: def.defaultColSpan,
|
|
123
|
+
schemaVersion: def.schemaVersion,
|
|
124
|
+
};
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/* ------------------------------------------------------------------ */
|
|
128
|
+
/* Insert-index computation */
|
|
129
|
+
/* ------------------------------------------------------------------ */
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* Where in `row.sections` to insert the dropped item. Read from
|
|
133
|
+
* dnd-kit's `event.hoveredDraggable` (the section currently under the
|
|
134
|
+
* cursor inside the row):
|
|
135
|
+
*
|
|
136
|
+
* - No hovered draggable → append to the end (drop on empty area).
|
|
137
|
+
* - Hovered draggable with placement.left/top → insert BEFORE it.
|
|
138
|
+
* - Hovered draggable with placement.right/bottom → insert AFTER it.
|
|
139
|
+
*
|
|
140
|
+
* Rows are horizontal; `left`/`right` are the primary signals.
|
|
141
|
+
* `top`/`bottom` are checked as fallbacks for vertical-list mode (which
|
|
142
|
+
* we'll use for cross-row reordering in 3b/B's stacked-zones layout —
|
|
143
|
+
* the computation is reused there).
|
|
144
|
+
*
|
|
145
|
+
* Pure. Takes the event + the fallback length. Returns an integer in
|
|
146
|
+
* `[0, fallbackLen]` (the half-open range that `Array.prototype.splice`
|
|
147
|
+
* accepts as an insert position).
|
|
148
|
+
*/
|
|
149
|
+
export function computeInsertIndex(event: IDragEvent, fallbackLen: number): number {
|
|
150
|
+
const hovered = event.hoveredDraggable;
|
|
151
|
+
if (!hovered) return fallbackLen; // append
|
|
152
|
+
const insertBefore = hovered.placement.left || hovered.placement.top;
|
|
153
|
+
return insertBefore ? hovered.index : hovered.index + 1;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/* ------------------------------------------------------------------ */
|
|
157
|
+
/* Drop dispatcher */
|
|
158
|
+
/* ------------------------------------------------------------------ */
|
|
159
|
+
|
|
160
|
+
/**
|
|
161
|
+
* Outcome of a dispatched drop — useful for callers that want to
|
|
162
|
+
* narrate the result (ARIA live region, audit log, telemetry) without
|
|
163
|
+
* sniffing the row's array.
|
|
164
|
+
*
|
|
165
|
+
* Phase 3b/B adds `'moved'` for cross-row + cross-zone. The caller
|
|
166
|
+
* uses `fromRowId` + `toRowId` to build a moveSectionCommand for the
|
|
167
|
+
* undo stack + to look up zone slugs for narration.
|
|
168
|
+
*/
|
|
169
|
+
export type DropOutcome =
|
|
170
|
+
| { kind: 'inserted'; section: LayoutSection; at: number }
|
|
171
|
+
| { kind: 'reordered'; section: LayoutSection; from: number; to: number }
|
|
172
|
+
| {
|
|
173
|
+
kind: 'moved';
|
|
174
|
+
section: LayoutSection;
|
|
175
|
+
fromRowId: string;
|
|
176
|
+
fromIdx: number;
|
|
177
|
+
fromTotal: number;
|
|
178
|
+
toRowId: string;
|
|
179
|
+
toIdx: number;
|
|
180
|
+
toTotal: number;
|
|
181
|
+
}
|
|
182
|
+
| { kind: 'noop'; reason: string };
|
|
183
|
+
|
|
184
|
+
/**
|
|
185
|
+
* Context the dispatcher needs for CROSS-ROW operations. Optional so
|
|
186
|
+
* within-row + palette callers (which don't need to look up other
|
|
187
|
+
* rows) can still call the dispatcher with a 2-arg signature.
|
|
188
|
+
*
|
|
189
|
+
* When `findRow` is omitted OR returns null for the source row, a
|
|
190
|
+
* cross-row drop falls back to noop (with reason `'no-find-row'` or
|
|
191
|
+
* `'source-row-not-found'`). This makes the cross-zone wiring an
|
|
192
|
+
* opt-in: callers in old contexts (single-row tests) still work.
|
|
193
|
+
*/
|
|
194
|
+
export interface DispatchContext {
|
|
195
|
+
/** Look up a row anywhere in the draft by id. Returns null if the
|
|
196
|
+
* row doesn't exist (or the caller chose not to support cross-row). */
|
|
197
|
+
findRow?: (rowId: string) => LayoutRow | null;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/**
|
|
201
|
+
* Apply a drop to a row. The row's `sections` array is mutated in
|
|
202
|
+
* place — the editor's deep watcher picks it up + auto-save fires.
|
|
203
|
+
*
|
|
204
|
+
* Phase 3b/B scope:
|
|
205
|
+
* - palette-section-spec → splice in a fresh section at the computed
|
|
206
|
+
* insert index.
|
|
207
|
+
* - section-instance dragged FROM THIS SAME ROW → reorder in place
|
|
208
|
+
* via splice-remove + splice-insert (sameList per dnd-kit's model).
|
|
209
|
+
* - section-instance dragged FROM A DIFFERENT ROW + ctx.findRow
|
|
210
|
+
* provides the source → remove from source, insert into destination
|
|
211
|
+
* row. Cross-row + cross-zone share this branch (a "different
|
|
212
|
+
* row" may be in another zone).
|
|
213
|
+
*
|
|
214
|
+
* Returns a DropOutcome describing what happened. `noop` outcomes have
|
|
215
|
+
* a `reason` for diagnostics — surfaced in tests + (optionally) audit
|
|
216
|
+
* logs.
|
|
217
|
+
*/
|
|
218
|
+
export function dispatchSectionDrop(
|
|
219
|
+
event: IDragEvent,
|
|
220
|
+
row: LayoutRow,
|
|
221
|
+
ctx: DispatchContext = {},
|
|
222
|
+
): DropOutcome {
|
|
223
|
+
const item = event.draggedItems[0]?.item as DragPayload | undefined;
|
|
224
|
+
if (!item) return { kind: 'noop', reason: 'no-dragged-item' };
|
|
225
|
+
|
|
226
|
+
if (item.kind === 'palette-section-spec') {
|
|
227
|
+
const newSection = createSectionFromSpec(item);
|
|
228
|
+
const insertAt = computeInsertIndex(event, row.sections.length);
|
|
229
|
+
row.sections.splice(insertAt, 0, newSection);
|
|
230
|
+
return { kind: 'inserted', section: newSection, at: insertAt };
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
if (item.kind === 'section-instance') {
|
|
234
|
+
if (item.fromRowId !== row.id) {
|
|
235
|
+
// Cross-row drag. Needs the source row to splice out of.
|
|
236
|
+
// Without ctx.findRow, we can't reach it — fall back to noop.
|
|
237
|
+
if (!ctx.findRow) {
|
|
238
|
+
return { kind: 'noop', reason: 'no-find-row' };
|
|
239
|
+
}
|
|
240
|
+
const sourceRow = ctx.findRow(item.fromRowId);
|
|
241
|
+
if (!sourceRow) {
|
|
242
|
+
return { kind: 'noop', reason: 'source-row-not-found' };
|
|
243
|
+
}
|
|
244
|
+
const fromIdx = sourceRow.sections.findIndex((s) => s.id === item.section.id);
|
|
245
|
+
if (fromIdx === -1) {
|
|
246
|
+
// The dragged section isn't in the source row — defensive
|
|
247
|
+
// against a stale payload or concurrent edit.
|
|
248
|
+
return { kind: 'noop', reason: 'section-not-found' };
|
|
249
|
+
}
|
|
250
|
+
const fromTotal = sourceRow.sections.length;
|
|
251
|
+
// Target index in the DESTINATION row (which is `row`, separate
|
|
252
|
+
// from source). No adjustment needed — the splice on source
|
|
253
|
+
// doesn't shift indices in the destination.
|
|
254
|
+
const targetIdx = computeInsertIndex(event, row.sections.length);
|
|
255
|
+
const [moved] = sourceRow.sections.splice(fromIdx, 1);
|
|
256
|
+
if (!moved) return { kind: 'noop', reason: 'section-vanished-mid-splice' };
|
|
257
|
+
row.sections.splice(targetIdx, 0, moved);
|
|
258
|
+
return {
|
|
259
|
+
kind: 'moved',
|
|
260
|
+
section: moved,
|
|
261
|
+
fromRowId: sourceRow.id,
|
|
262
|
+
fromIdx,
|
|
263
|
+
fromTotal,
|
|
264
|
+
toRowId: row.id,
|
|
265
|
+
toIdx: targetIdx,
|
|
266
|
+
toTotal: row.sections.length,
|
|
267
|
+
};
|
|
268
|
+
}
|
|
269
|
+
const fromIdx = row.sections.findIndex((s) => s.id === item.section.id);
|
|
270
|
+
if (fromIdx === -1) {
|
|
271
|
+
// The dragged section isn't in this row's array — defensive
|
|
272
|
+
// against a stale payload or a concurrent edit that removed it.
|
|
273
|
+
return { kind: 'noop', reason: 'section-not-found' };
|
|
274
|
+
}
|
|
275
|
+
// Compute the target index using the row's CURRENT sections (the
|
|
276
|
+
// dragged section is still there). After splice-remove the indices
|
|
277
|
+
// shift down by 1 for positions after fromIdx — adjust on insert.
|
|
278
|
+
const targetIdx = computeInsertIndex(event, row.sections.length);
|
|
279
|
+
const [moved] = row.sections.splice(fromIdx, 1);
|
|
280
|
+
// Adjust if removing the source shifted the target.
|
|
281
|
+
const adjustedTarget = targetIdx > fromIdx ? targetIdx - 1 : targetIdx;
|
|
282
|
+
if (!moved) return { kind: 'noop', reason: 'section-vanished-mid-splice' };
|
|
283
|
+
row.sections.splice(adjustedTarget, 0, moved);
|
|
284
|
+
return { kind: 'reordered', section: moved, from: fromIdx, to: adjustedTarget };
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
// Exhaustive switch: TS narrows `item` to `never` here. Any new kind
|
|
288
|
+
// will surface as a compile error → forces an explicit case.
|
|
289
|
+
return { kind: 'noop', reason: 'unknown-drag-kind' };
|
|
290
|
+
}
|