@commonpub/layer 0.23.3 → 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/components/sections/SectionLearning.vue +232 -0
- package/composables/autoFormSchema.ts +319 -0
- package/composables/useAdminSidebar.ts +116 -0
- package/composables/useEditorChrome.ts +56 -0
- package/composables/useFeatures.ts +32 -5
- package/composables/useLayout.ts +46 -43
- 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/nuxt.config.ts +14 -0
- package/package.json +8 -5
- 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 +30 -0
- package/sections/builtin/cta.ts +46 -0
- package/sections/builtin/custom-html.ts +36 -0
- package/sections/builtin/divider.ts +15 -17
- package/sections/builtin/editorial.ts +29 -0
- 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 +30 -0
- package/sections/builtin/image.ts +12 -49
- package/sections/builtin/learning.ts +30 -0
- package/sections/builtin/markdown.ts +29 -0
- package/sections/builtin/paragraph.ts +14 -17
- package/sections/builtin/stats.ts +35 -0
- package/sections/builtin/video.ts +30 -0
- package/sections/registry.ts +38 -7
- 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 +68 -0
- package/server/api/admin/layouts/seed-homepage.post.ts +9 -0
- package/server/api/layouts/by-route.get.ts +64 -12
- package/server/plugins/feature-flags-prime.ts +39 -0
- 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/SectionDivider.vue +0 -55
- package/components/sections/SectionHeading.vue +0 -78
- package/components/sections/SectionHero.vue +0 -164
- package/components/sections/SectionImage.vue +0 -104
- package/components/sections/SectionParagraph.vue +0 -55
|
@@ -0,0 +1,783 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* useLayoutResize — pointer + keyboard driven section resize.
|
|
3
|
+
*
|
|
4
|
+
* Phase 3c. Implements the resize gesture documented in
|
|
5
|
+
* `docs/plans/layout-and-pages.md` §7.5: drag the right-edge handle to
|
|
6
|
+
* change a section's `colSpan` in the 12-col grid; the right neighbour
|
|
7
|
+
* absorbs the inverse delta so the row's total stays constant; LAST-in-
|
|
8
|
+
* row sections grow into the trailing space without a neighbour.
|
|
9
|
+
*
|
|
10
|
+
* Why vanilla pointer events (NOT grid-layout-plus, despite the package
|
|
11
|
+
* being installed):
|
|
12
|
+
* - grid-layout-plus uses absolute positioning + transforms; our
|
|
13
|
+
* sections live in a CSS `display:grid` row via
|
|
14
|
+
* `grid-column: span var(--cpub-section-cols-lg)`. The two sizing
|
|
15
|
+
* models can't share a child element.
|
|
16
|
+
* - grid-layout-plus's interactjs registers pointerdown on the GridItem
|
|
17
|
+
* wrapper — the same DOM node `@vue-dnd-kit/core` already owns for
|
|
18
|
+
* the drag-section gesture (session 163's verified collision boundary).
|
|
19
|
+
* Owning a SEPARATE child element (the handle) sidesteps the conflict.
|
|
20
|
+
* - Resize from scratch is ~300 lines + tests; the library's
|
|
21
|
+
* coordinate model (x/y/w/h) doesn't match `colSpan` so we'd carry
|
|
22
|
+
* a propMap shim AND maintain the library.
|
|
23
|
+
* - Decision recorded in session 166-3c.md + the
|
|
24
|
+
* `feedback-phase-3-hybrid-libraries` memory entry.
|
|
25
|
+
*
|
|
26
|
+
* Architecture:
|
|
27
|
+
* - Module-scoped singleton state ref. Pointer + keyboard handlers
|
|
28
|
+
* read + mutate the same ref. Matches `useLayoutHistory` and
|
|
29
|
+
* `useLayoutAnnouncer`'s shape — one resize at a time across the
|
|
30
|
+
* entire editor.
|
|
31
|
+
* - Pure helpers (`computeSnappedColSpan`, `clampResize`) export
|
|
32
|
+
* separately so the composable's tests don't need pointer mocks
|
|
33
|
+
* for the math.
|
|
34
|
+
* - Live preview during drag: pointermove mutates `section.colSpan`
|
|
35
|
+
* directly so the existing CSS render path picks it up. The editor's
|
|
36
|
+
* deep watcher fires on every frame — `dirtyVersion` increments
|
|
37
|
+
* ~60×/sec but auto-save's 1.5s debounce coalesces them; the
|
|
38
|
+
* pointermove is rAF-throttled so reactivity only triggers once per
|
|
39
|
+
* frame.
|
|
40
|
+
* - On pointerup: capture the BEFORE/AFTER colSpans + commit ONE
|
|
41
|
+
* `resizeSectionCommand` to history. A pointer-up that ends at the
|
|
42
|
+
* same span as start records nothing — keeps the undo stack from
|
|
43
|
+
* filling with no-op self-equal entries (mirrors the dispatcher's
|
|
44
|
+
* `from===to` reorder skip).
|
|
45
|
+
*
|
|
46
|
+
* Wire diagram:
|
|
47
|
+
* - LayoutSection.vue — renders the right-edge handle button +
|
|
48
|
+
* calls `useLayoutResize().startResize(opts)` on its pointerdown.
|
|
49
|
+
* - useLayoutResize.ts (this file) — owns state + document handlers.
|
|
50
|
+
* - LayoutRow.vue — reads `state.value` to render the 12-col guide
|
|
51
|
+
* overlay + the constraint-snap label DURING a resize. Same row id
|
|
52
|
+
* only.
|
|
53
|
+
* - useLayoutHotkeys.ts — Shift+ArrowLeft / Shift+ArrowRight call
|
|
54
|
+
* `applyKeyboardResize(opts)` to change colSpan by ±1.
|
|
55
|
+
*
|
|
56
|
+
* **Phase 3c base-only resize (round-2 audit P2 deferral)**: this
|
|
57
|
+
* composable mutates `section.colSpan` — the BASE breakpoint value.
|
|
58
|
+
* When `section.responsive.lg` is set, the LG viewport renders that
|
|
59
|
+
* value instead of the base; mutating the base in that case is
|
|
60
|
+
* INVISIBLE at LG. v1 ships with no built-in section setting
|
|
61
|
+
* `responsive` by default + no UI to author it, so this edge is
|
|
62
|
+
* latent. Phase 3e (per-breakpoint editing) needs to route resize at
|
|
63
|
+
* the active viewport to mutate the right field
|
|
64
|
+
* (`responsive[viewport]` or `colSpan` fallback). Tracked in
|
|
65
|
+
* `docs/sessions/167-kickoff.md` Path A subtask 6.
|
|
66
|
+
*
|
|
67
|
+
* SSR: every `document` / `window` reference is guarded with
|
|
68
|
+
* `typeof window !== 'undefined'` per
|
|
69
|
+
* `feedback-vitest-import-meta-client-undefined` — vitest's jsdom env
|
|
70
|
+
* provides `window` so unit tests exercise the same path as the browser.
|
|
71
|
+
*/
|
|
72
|
+
import { ref, type Ref } from 'vue';
|
|
73
|
+
import type { LayoutRecord } from '@commonpub/server';
|
|
74
|
+
import type { LayoutSection } from './useLayout';
|
|
75
|
+
import { findSectionLocation } from './useLayoutHistory';
|
|
76
|
+
import {
|
|
77
|
+
useLayoutHistory,
|
|
78
|
+
resizeSectionCommand,
|
|
79
|
+
} from './useLayoutHistory';
|
|
80
|
+
import {
|
|
81
|
+
useLayoutAnnouncer,
|
|
82
|
+
narrateResize,
|
|
83
|
+
narrateResizeBlocked,
|
|
84
|
+
} from './useLayoutAnnouncer';
|
|
85
|
+
|
|
86
|
+
/* ------------------------------------------------------------------ */
|
|
87
|
+
/* Pure helpers — tested independently */
|
|
88
|
+
/* ------------------------------------------------------------------ */
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Snap a continuous pointer delta to an integer column count. The 12-col
|
|
92
|
+
* grid is the unit; sub-grid pointer motion rounds to the nearest column.
|
|
93
|
+
*
|
|
94
|
+
* deltaCols = round((pointerDX / containerWidth) * 12)
|
|
95
|
+
*
|
|
96
|
+
* `containerWidth` is the row's INSIDE width (no padding). Caller reads
|
|
97
|
+
* via `getBoundingClientRect().width` at gesture-start — the value is
|
|
98
|
+
* stable for the duration of a single drag (no row resize mid-drag).
|
|
99
|
+
*
|
|
100
|
+
* Returns the SIGNED integer delta (-12 to +12). Positive = grow; negative
|
|
101
|
+
* = shrink. The caller adds it to the start span + clamps in
|
|
102
|
+
* `clampResize`.
|
|
103
|
+
*
|
|
104
|
+
* Edge: containerWidth=0 (row not yet measured) → delta=0. Resize is
|
|
105
|
+
* effectively disabled until the next pointermove with a real width;
|
|
106
|
+
* pragmatically this never happens because the handle can't be clicked
|
|
107
|
+
* before the row paints.
|
|
108
|
+
*/
|
|
109
|
+
export function computeSnappedColSpan(
|
|
110
|
+
pointerDX: number,
|
|
111
|
+
containerWidth: number,
|
|
112
|
+
): number {
|
|
113
|
+
if (containerWidth <= 0) return 0;
|
|
114
|
+
return Math.round((pointerDX / containerWidth) * 12);
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/** Constraint-hit discriminator — drives narration + visual feedback.
|
|
118
|
+
* `null` = no constraint hit; the delta applied cleanly. */
|
|
119
|
+
export type ResizeConstraint = 'section-min' | 'section-max' | 'neighbour-min' | null;
|
|
120
|
+
|
|
121
|
+
/** Result of clamping a desired delta against the section's bounds AND
|
|
122
|
+
* (optionally) its right neighbour's bounds. */
|
|
123
|
+
export interface ClampResult {
|
|
124
|
+
/** The new colSpan for the resized section, clamped. */
|
|
125
|
+
newColSpan: number;
|
|
126
|
+
/** The new colSpan for the right neighbour, clamped. Equals the
|
|
127
|
+
* passed `neighbourStart` when no neighbour (LAST in row). */
|
|
128
|
+
newNeighbourColSpan: number;
|
|
129
|
+
/** Which bound the desired delta hit. */
|
|
130
|
+
constraintHit: ResizeConstraint;
|
|
131
|
+
/** The numeric bound the user pushed against (for narration). */
|
|
132
|
+
constraintBound: number;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* Clamp a desired delta against per-section + (optional) neighbour bounds.
|
|
137
|
+
*
|
|
138
|
+
* Invariant for LAST-in-row (no neighbour): just clamp the section's
|
|
139
|
+
* new span to `[sectionMin, sectionMax]`. The trailing space is the
|
|
140
|
+
* renderer's responsibility — CSS Grid flexes it to fill, or the admin
|
|
141
|
+
* drops another section there.
|
|
142
|
+
*
|
|
143
|
+
* Invariant for non-LAST: the row's total colSpan stays constant. So
|
|
144
|
+
* the neighbour absorbs the inverse of the EFFECTIVE delta (after
|
|
145
|
+
* clamping the section's own bounds). If the neighbour would violate
|
|
146
|
+
* ITS own min, we back off the effective delta further so the neighbour
|
|
147
|
+
* sticks at its bound — the resize "stops cold" at the neighbour's
|
|
148
|
+
* minimum per plan §7.5.
|
|
149
|
+
*
|
|
150
|
+
* Pure — no state mutation. Caller decides whether to apply.
|
|
151
|
+
*
|
|
152
|
+
* Pre-condition: caller has snapped + bounds-checked `desiredDelta`
|
|
153
|
+
* against the grid (0 ≤ |delta| ≤ 12). The function handles any signed
|
|
154
|
+
* integer though, for defensive testability.
|
|
155
|
+
*/
|
|
156
|
+
export function clampResize(params: {
|
|
157
|
+
startColSpan: number;
|
|
158
|
+
desiredDelta: number;
|
|
159
|
+
sectionMin: number;
|
|
160
|
+
sectionMax: number;
|
|
161
|
+
/** Null when the section is LAST in its row. */
|
|
162
|
+
neighbourStart: number | null;
|
|
163
|
+
neighbourMin: number;
|
|
164
|
+
neighbourMax: number;
|
|
165
|
+
}): ClampResult {
|
|
166
|
+
const { startColSpan, desiredDelta, sectionMin, sectionMax,
|
|
167
|
+
neighbourStart, neighbourMin, neighbourMax } = params;
|
|
168
|
+
|
|
169
|
+
// 1. Desired new span for the resized section, before any clamp.
|
|
170
|
+
const desiredNew = startColSpan + desiredDelta;
|
|
171
|
+
|
|
172
|
+
// 2. Clamp against the section's own bounds first.
|
|
173
|
+
let newColSpan = Math.max(sectionMin, Math.min(sectionMax, desiredNew));
|
|
174
|
+
let constraintHit: ResizeConstraint = null;
|
|
175
|
+
let constraintBound = 0;
|
|
176
|
+
if (desiredNew < sectionMin) {
|
|
177
|
+
constraintHit = 'section-min';
|
|
178
|
+
constraintBound = sectionMin;
|
|
179
|
+
} else if (desiredNew > sectionMax) {
|
|
180
|
+
constraintHit = 'section-max';
|
|
181
|
+
constraintBound = sectionMax;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
// 3. LAST in row: no neighbour absorption. Section grows into trailing
|
|
185
|
+
// space; shrinks leave trailing space the renderer flexes to fill.
|
|
186
|
+
if (neighbourStart === null) {
|
|
187
|
+
return {
|
|
188
|
+
newColSpan,
|
|
189
|
+
newNeighbourColSpan: 0, // sentinel — caller knows there's no neighbour
|
|
190
|
+
constraintHit,
|
|
191
|
+
constraintBound,
|
|
192
|
+
};
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
// 4. Non-LAST: neighbour absorbs the inverse of the EFFECTIVE delta.
|
|
196
|
+
const effectiveDelta = newColSpan - startColSpan;
|
|
197
|
+
const desiredNeighbour = neighbourStart - effectiveDelta;
|
|
198
|
+
|
|
199
|
+
// 5. Clamp the neighbour against ITS own bounds.
|
|
200
|
+
const clampedNeighbour = Math.max(
|
|
201
|
+
neighbourMin,
|
|
202
|
+
Math.min(neighbourMax, desiredNeighbour),
|
|
203
|
+
);
|
|
204
|
+
|
|
205
|
+
// 6. If the neighbour would have violated its min, back off the section
|
|
206
|
+
// by the same amount so the sum-invariant holds. Plan §7.5: "When the
|
|
207
|
+
// neighbour hits its minimum, the resize stops cold."
|
|
208
|
+
if (clampedNeighbour > desiredNeighbour) {
|
|
209
|
+
// Neighbour hit its MIN (couldn't shrink any more). Reduce the
|
|
210
|
+
// section's growth so neighbour stays at its min. Only override
|
|
211
|
+
// `constraintHit` if the section's own bounds weren't the limit
|
|
212
|
+
// first — neighbour-min is the binding constraint here.
|
|
213
|
+
const backedOffDelta = neighbourStart - clampedNeighbour;
|
|
214
|
+
newColSpan = startColSpan + backedOffDelta;
|
|
215
|
+
if (constraintHit === null) {
|
|
216
|
+
constraintHit = 'neighbour-min';
|
|
217
|
+
constraintBound = neighbourMin;
|
|
218
|
+
}
|
|
219
|
+
} else if (clampedNeighbour < desiredNeighbour) {
|
|
220
|
+
// Neighbour hit its MAX while shrinking the section. Rare — usually
|
|
221
|
+
// means the neighbour's maxColSpan < 12 AND the section shrunk hard.
|
|
222
|
+
// Back off the section so sum-invariant holds; constraint-hit
|
|
223
|
+
// stays section-* because the user-side action was a section shrink.
|
|
224
|
+
const backedOffDelta = neighbourStart - clampedNeighbour;
|
|
225
|
+
newColSpan = startColSpan + backedOffDelta;
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
return {
|
|
229
|
+
newColSpan,
|
|
230
|
+
newNeighbourColSpan: clampedNeighbour,
|
|
231
|
+
constraintHit,
|
|
232
|
+
constraintBound,
|
|
233
|
+
};
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
/* ------------------------------------------------------------------ */
|
|
237
|
+
/* State machine */
|
|
238
|
+
/* ------------------------------------------------------------------ */
|
|
239
|
+
|
|
240
|
+
/** State shape for the resize gesture. `idle` means no resize in flight;
|
|
241
|
+
* `resizing` carries every field the live preview + commit needs. */
|
|
242
|
+
export type ResizeState =
|
|
243
|
+
| { kind: 'idle' }
|
|
244
|
+
| {
|
|
245
|
+
kind: 'resizing';
|
|
246
|
+
/** The row containing the resized section — useful for the row's
|
|
247
|
+
* guide-line overlay (it filters by id). */
|
|
248
|
+
rowId: string;
|
|
249
|
+
/** Section being resized. */
|
|
250
|
+
sectionId: string;
|
|
251
|
+
/** Span at gesture-start — what the eventual `resizeSectionCommand`
|
|
252
|
+
* reverts to. */
|
|
253
|
+
startColSpan: number;
|
|
254
|
+
/** Live snapped span; reflects what the section's `colSpan`
|
|
255
|
+
* currently holds (the composable mutates the draft to this). */
|
|
256
|
+
currentColSpan: number;
|
|
257
|
+
/** Right neighbour's id; null when the resized section is LAST. */
|
|
258
|
+
neighbourId: string | null;
|
|
259
|
+
/** Neighbour's start + current span. 0 when no neighbour. */
|
|
260
|
+
neighbourStartColSpan: number;
|
|
261
|
+
neighbourCurrentColSpan: number;
|
|
262
|
+
/** Per-section colSpan bounds — used by `clampResize` + by the
|
|
263
|
+
* narration helpers. */
|
|
264
|
+
sectionMin: number;
|
|
265
|
+
sectionMax: number;
|
|
266
|
+
neighbourMin: number;
|
|
267
|
+
neighbourMax: number;
|
|
268
|
+
/** Section type slug — included in narration. */
|
|
269
|
+
sectionType: string;
|
|
270
|
+
/** Pointer-X at gesture-start — pointermove subtracts to get DX. */
|
|
271
|
+
startPointerX: number;
|
|
272
|
+
/** Row's inside-width at gesture-start. Stable for the duration. */
|
|
273
|
+
containerWidth: number;
|
|
274
|
+
/** Last constraint hit — drives the constraint-snap pill + label. */
|
|
275
|
+
constraintHit: ResizeConstraint;
|
|
276
|
+
constraintBound: number;
|
|
277
|
+
/** The captured pointer id — used to release setPointerCapture
|
|
278
|
+
* on end/cancel. */
|
|
279
|
+
pointerId: number;
|
|
280
|
+
};
|
|
281
|
+
|
|
282
|
+
/* ------------------------------------------------------------------ */
|
|
283
|
+
/* Singleton */
|
|
284
|
+
/* ------------------------------------------------------------------ */
|
|
285
|
+
|
|
286
|
+
const state = ref<ResizeState>({ kind: 'idle' });
|
|
287
|
+
|
|
288
|
+
/** Pending rAF id so we throttle pointermove to once per frame. */
|
|
289
|
+
let rafHandle: number | null = null;
|
|
290
|
+
/** The last pointerX seen during the throttled window — consumed by
|
|
291
|
+
* the next rAF tick. */
|
|
292
|
+
let pendingPointerX = 0;
|
|
293
|
+
|
|
294
|
+
/* ------------------------------------------------------------------ */
|
|
295
|
+
/* Document-handler lifecycle */
|
|
296
|
+
/* ------------------------------------------------------------------ */
|
|
297
|
+
|
|
298
|
+
let getDraftClosure: (() => LayoutRecord | null) | null = null;
|
|
299
|
+
|
|
300
|
+
function onDocPointerMove(e: PointerEvent): void {
|
|
301
|
+
if (state.value.kind !== 'idle' && e.pointerId === state.value.pointerId) {
|
|
302
|
+
pendingPointerX = e.clientX;
|
|
303
|
+
if (rafHandle === null && typeof window !== 'undefined') {
|
|
304
|
+
rafHandle = window.requestAnimationFrame(() => {
|
|
305
|
+
rafHandle = null;
|
|
306
|
+
applyPointerX(pendingPointerX);
|
|
307
|
+
});
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
function onDocPointerUp(e: PointerEvent): void {
|
|
313
|
+
if (state.value.kind !== 'idle' && e.pointerId === state.value.pointerId) {
|
|
314
|
+
endResize();
|
|
315
|
+
}
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
function onDocPointerCancel(e: PointerEvent): void {
|
|
319
|
+
if (state.value.kind !== 'idle' && e.pointerId === state.value.pointerId) {
|
|
320
|
+
// Pointer was lost (window blur, touch interrupted). Same path as
|
|
321
|
+
// endResize — commit the partial result. Cancelling silently could
|
|
322
|
+
// strand the user with a half-applied resize they can't undo because
|
|
323
|
+
// no command was recorded.
|
|
324
|
+
endResize();
|
|
325
|
+
}
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
function onDocKeyDown(e: KeyboardEvent): void {
|
|
329
|
+
if (state.value.kind !== 'resizing') return;
|
|
330
|
+
if (e.key === 'Escape') {
|
|
331
|
+
e.preventDefault();
|
|
332
|
+
cancelResize();
|
|
333
|
+
}
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
/**
|
|
337
|
+
* R2-4 audit fix — Alt+Tab / window blur safety. If the user switches
|
|
338
|
+
* tabs OR the document loses focus mid-drag, the document pointerup
|
|
339
|
+
* may never fire (the OS handed events to the new window). Without a
|
|
340
|
+
* watchdog, the resize state would stay `resizing` indefinitely — the
|
|
341
|
+
* next pointermove ANYWHERE on the document would keep mutating the
|
|
342
|
+
* draft until the user clicked again. Commit instead of cancel so the
|
|
343
|
+
* partial state isn't lost (mirrors pointercancel's choice).
|
|
344
|
+
*
|
|
345
|
+
* `visibilitychange` also covers iOS Safari background-app eviction,
|
|
346
|
+
* where the window doesn't lose focus traditionally but the document
|
|
347
|
+
* stops receiving pointer events. Both cover overlapping but slightly
|
|
348
|
+
* different states; cheap to register both.
|
|
349
|
+
*/
|
|
350
|
+
function onWindowBlur(): void {
|
|
351
|
+
if (state.value.kind === 'resizing') endResize();
|
|
352
|
+
}
|
|
353
|
+
function onDocVisibilityChange(): void {
|
|
354
|
+
if (typeof document === 'undefined') return;
|
|
355
|
+
if (document.visibilityState === 'hidden' && state.value.kind === 'resizing') {
|
|
356
|
+
endResize();
|
|
357
|
+
}
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
function attachDocHandlers(): void {
|
|
361
|
+
if (typeof window === 'undefined') return;
|
|
362
|
+
document.addEventListener('pointermove', onDocPointerMove);
|
|
363
|
+
document.addEventListener('pointerup', onDocPointerUp);
|
|
364
|
+
document.addEventListener('pointercancel', onDocPointerCancel);
|
|
365
|
+
document.addEventListener('keydown', onDocKeyDown);
|
|
366
|
+
window.addEventListener('blur', onWindowBlur);
|
|
367
|
+
document.addEventListener('visibilitychange', onDocVisibilityChange);
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
function detachDocHandlers(): void {
|
|
371
|
+
if (typeof window === 'undefined') return;
|
|
372
|
+
document.removeEventListener('pointermove', onDocPointerMove);
|
|
373
|
+
document.removeEventListener('pointerup', onDocPointerUp);
|
|
374
|
+
document.removeEventListener('pointercancel', onDocPointerCancel);
|
|
375
|
+
document.removeEventListener('keydown', onDocKeyDown);
|
|
376
|
+
window.removeEventListener('blur', onWindowBlur);
|
|
377
|
+
document.removeEventListener('visibilitychange', onDocVisibilityChange);
|
|
378
|
+
if (rafHandle !== null) {
|
|
379
|
+
window.cancelAnimationFrame(rafHandle);
|
|
380
|
+
rafHandle = null;
|
|
381
|
+
}
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
/* ------------------------------------------------------------------ */
|
|
385
|
+
/* Live-preview tick */
|
|
386
|
+
/* ------------------------------------------------------------------ */
|
|
387
|
+
|
|
388
|
+
/** Read a pointerX, recompute the snapped delta, mutate the draft for
|
|
389
|
+
* live preview. Called from the rAF callback so reactivity batches. */
|
|
390
|
+
function applyPointerX(pointerX: number): void {
|
|
391
|
+
if (state.value.kind !== 'resizing') return;
|
|
392
|
+
if (!getDraftClosure) return;
|
|
393
|
+
const draft = getDraftClosure();
|
|
394
|
+
if (!draft) return;
|
|
395
|
+
const s = state.value;
|
|
396
|
+
|
|
397
|
+
const desiredDelta = computeSnappedColSpan(
|
|
398
|
+
pointerX - s.startPointerX,
|
|
399
|
+
s.containerWidth,
|
|
400
|
+
);
|
|
401
|
+
|
|
402
|
+
// No-op delta means no mutation needed — short-circuit so the watcher
|
|
403
|
+
// doesn't fire spuriously on every micro-pixel pointermove.
|
|
404
|
+
if (desiredDelta === s.currentColSpan - s.startColSpan
|
|
405
|
+
&& (s.neighbourId === null
|
|
406
|
+
|| s.neighbourCurrentColSpan === s.neighbourStartColSpan - (s.currentColSpan - s.startColSpan))) {
|
|
407
|
+
return;
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
const clamped = clampResize({
|
|
411
|
+
startColSpan: s.startColSpan,
|
|
412
|
+
desiredDelta,
|
|
413
|
+
sectionMin: s.sectionMin,
|
|
414
|
+
sectionMax: s.sectionMax,
|
|
415
|
+
neighbourStart: s.neighbourId === null ? null : s.neighbourStartColSpan,
|
|
416
|
+
neighbourMin: s.neighbourMin,
|
|
417
|
+
neighbourMax: s.neighbourMax,
|
|
418
|
+
});
|
|
419
|
+
|
|
420
|
+
// Apply to the live draft so the existing CSS render path picks up.
|
|
421
|
+
const loc = findSectionLocation(draft, s.sectionId);
|
|
422
|
+
if (loc) loc.section.colSpan = clamped.newColSpan;
|
|
423
|
+
if (s.neighbourId !== null) {
|
|
424
|
+
const nLoc = findSectionLocation(draft, s.neighbourId);
|
|
425
|
+
if (nLoc) nLoc.section.colSpan = clamped.newNeighbourColSpan;
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
// Audit-of-audit: narrate the constraint hit on STATE TRANSITION
|
|
429
|
+
// (null → bound). Without this, SR users get NO audio cue when a
|
|
430
|
+
// pointer-drag pushes past a bound — `endResize`'s narrateResize fires
|
|
431
|
+
// only at pointer-release. Narrating every frame would flood SR users
|
|
432
|
+
// (60×/sec while held at the bound); narrating once-per-transition
|
|
433
|
+
// matches Linear / Figma's resize behaviour. The visual constraint
|
|
434
|
+
// label (aria-hidden) is the sighted-user signal.
|
|
435
|
+
if (s.constraintHit === null && clamped.constraintHit !== null) {
|
|
436
|
+
const ann = useLayoutAnnouncer();
|
|
437
|
+
ann.announce(narrateResizeBlocked(
|
|
438
|
+
s.sectionType,
|
|
439
|
+
clamped.constraintHit,
|
|
440
|
+
clamped.constraintBound,
|
|
441
|
+
));
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
// Update state machine — current values + constraint signal.
|
|
445
|
+
s.currentColSpan = clamped.newColSpan;
|
|
446
|
+
s.neighbourCurrentColSpan = s.neighbourId === null
|
|
447
|
+
? 0 : clamped.newNeighbourColSpan;
|
|
448
|
+
s.constraintHit = clamped.constraintHit;
|
|
449
|
+
s.constraintBound = clamped.constraintBound;
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
/* ------------------------------------------------------------------ */
|
|
453
|
+
/* Public API — start / end / cancel */
|
|
454
|
+
/* ------------------------------------------------------------------ */
|
|
455
|
+
|
|
456
|
+
export interface StartResizeOpts {
|
|
457
|
+
rowId: string;
|
|
458
|
+
sectionId: string;
|
|
459
|
+
sectionType: string;
|
|
460
|
+
startColSpan: number;
|
|
461
|
+
sectionMin: number;
|
|
462
|
+
sectionMax: number;
|
|
463
|
+
/** Null when the resized section is LAST in its row. */
|
|
464
|
+
neighbourId: string | null;
|
|
465
|
+
neighbourStartColSpan: number;
|
|
466
|
+
neighbourMin: number;
|
|
467
|
+
neighbourMax: number;
|
|
468
|
+
/** Pointer coords at gesture-start. */
|
|
469
|
+
startPointerX: number;
|
|
470
|
+
pointerId: number;
|
|
471
|
+
/** Row's inside-width at gesture-start. Stable for the duration. */
|
|
472
|
+
containerWidth: number;
|
|
473
|
+
/** Closure that returns the live draft. The composable holds it for
|
|
474
|
+
* the duration of the resize so pointermove can mutate. */
|
|
475
|
+
getDraft: () => LayoutRecord | null;
|
|
476
|
+
/** Optional handle element to setPointerCapture on. When provided,
|
|
477
|
+
* pointermove keeps firing on the handle even when the cursor leaves
|
|
478
|
+
* it (essential — without capture, dragging the handle off its 14px
|
|
479
|
+
* width loses the pointer). */
|
|
480
|
+
captureEl?: HTMLElement | null;
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
function startResize(opts: StartResizeOpts): void {
|
|
484
|
+
// If a previous resize is somehow still active (defensive), commit
|
|
485
|
+
// it before starting a new one. Prevents two resizes mutating draft
|
|
486
|
+
// simultaneously.
|
|
487
|
+
if (state.value.kind !== 'idle') endResize();
|
|
488
|
+
|
|
489
|
+
getDraftClosure = opts.getDraft;
|
|
490
|
+
state.value = {
|
|
491
|
+
kind: 'resizing',
|
|
492
|
+
rowId: opts.rowId,
|
|
493
|
+
sectionId: opts.sectionId,
|
|
494
|
+
sectionType: opts.sectionType,
|
|
495
|
+
startColSpan: opts.startColSpan,
|
|
496
|
+
currentColSpan: opts.startColSpan,
|
|
497
|
+
neighbourId: opts.neighbourId,
|
|
498
|
+
neighbourStartColSpan: opts.neighbourStartColSpan,
|
|
499
|
+
neighbourCurrentColSpan: opts.neighbourStartColSpan,
|
|
500
|
+
sectionMin: opts.sectionMin,
|
|
501
|
+
sectionMax: opts.sectionMax,
|
|
502
|
+
neighbourMin: opts.neighbourMin,
|
|
503
|
+
neighbourMax: opts.neighbourMax,
|
|
504
|
+
startPointerX: opts.startPointerX,
|
|
505
|
+
containerWidth: opts.containerWidth,
|
|
506
|
+
constraintHit: null,
|
|
507
|
+
constraintBound: 0,
|
|
508
|
+
pointerId: opts.pointerId,
|
|
509
|
+
};
|
|
510
|
+
|
|
511
|
+
// setPointerCapture: keeps pointermove firing on the handle element
|
|
512
|
+
// even when the cursor leaves it. Without capture, a fast drag past
|
|
513
|
+
// the handle's 14px width loses the pointer + the resize freezes.
|
|
514
|
+
// (Browsers without setPointerCapture support — none modern — fall
|
|
515
|
+
// back to the document-level handlers which still fire on the body.)
|
|
516
|
+
if (opts.captureEl && typeof opts.captureEl.setPointerCapture === 'function') {
|
|
517
|
+
try {
|
|
518
|
+
opts.captureEl.setPointerCapture(opts.pointerId);
|
|
519
|
+
} catch {
|
|
520
|
+
// Pointer capture can throw if the pointer was already released
|
|
521
|
+
// (e.g. very fast click); not fatal — handlers still fire on doc.
|
|
522
|
+
}
|
|
523
|
+
}
|
|
524
|
+
|
|
525
|
+
attachDocHandlers();
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
/** Commit the resize if it changed anything, then reset state to idle. */
|
|
529
|
+
function endResize(): void {
|
|
530
|
+
if (state.value.kind !== 'resizing') return;
|
|
531
|
+
const s = state.value;
|
|
532
|
+
|
|
533
|
+
// No-op: pointer ended where it started (drag-back-to-original). Skip
|
|
534
|
+
// the history record so the stack doesn't fill with self-equal entries.
|
|
535
|
+
const sectionChanged = s.currentColSpan !== s.startColSpan;
|
|
536
|
+
const neighbourChanged = s.neighbourId !== null
|
|
537
|
+
&& s.neighbourCurrentColSpan !== s.neighbourStartColSpan;
|
|
538
|
+
|
|
539
|
+
if (sectionChanged || neighbourChanged) {
|
|
540
|
+
const history = useLayoutHistory();
|
|
541
|
+
history.record(resizeSectionCommand({
|
|
542
|
+
rowId: s.rowId,
|
|
543
|
+
sectionId: s.sectionId,
|
|
544
|
+
fromColSpan: s.startColSpan,
|
|
545
|
+
toColSpan: s.currentColSpan,
|
|
546
|
+
neighbourId: s.neighbourId,
|
|
547
|
+
neighbourFromColSpan: s.neighbourId === null
|
|
548
|
+
? undefined : s.neighbourStartColSpan,
|
|
549
|
+
neighbourToColSpan: s.neighbourId === null
|
|
550
|
+
? undefined : s.neighbourCurrentColSpan,
|
|
551
|
+
label: `resize ${s.sectionType}`,
|
|
552
|
+
}));
|
|
553
|
+
// Narrate the final span (pointer-up = end of gesture). The mid-drag
|
|
554
|
+
// pointermove ticks DON'T narrate — that would flood SR users with
|
|
555
|
+
// 60 announcements/sec. Narrating once at end matches the
|
|
556
|
+
// drag-drop announcer's "announce on END not START" rule.
|
|
557
|
+
const ann = useLayoutAnnouncer();
|
|
558
|
+
ann.announce(narrateResize(s.sectionType, s.currentColSpan));
|
|
559
|
+
}
|
|
560
|
+
|
|
561
|
+
detachDocHandlers();
|
|
562
|
+
getDraftClosure = null;
|
|
563
|
+
state.value = { kind: 'idle' };
|
|
564
|
+
}
|
|
565
|
+
|
|
566
|
+
/** Revert the draft to start values + reset state. Esc keypress + (defensive)
|
|
567
|
+
* pointer-lost scenarios call this. NO history record — the draft never
|
|
568
|
+
* diverged from the user's perspective. */
|
|
569
|
+
function cancelResize(): void {
|
|
570
|
+
if (state.value.kind !== 'resizing') return;
|
|
571
|
+
const s = state.value;
|
|
572
|
+
if (getDraftClosure) {
|
|
573
|
+
const draft = getDraftClosure();
|
|
574
|
+
if (draft) {
|
|
575
|
+
const loc = findSectionLocation(draft, s.sectionId);
|
|
576
|
+
if (loc) loc.section.colSpan = s.startColSpan;
|
|
577
|
+
if (s.neighbourId !== null) {
|
|
578
|
+
const nLoc = findSectionLocation(draft, s.neighbourId);
|
|
579
|
+
if (nLoc) nLoc.section.colSpan = s.neighbourStartColSpan;
|
|
580
|
+
}
|
|
581
|
+
}
|
|
582
|
+
}
|
|
583
|
+
detachDocHandlers();
|
|
584
|
+
getDraftClosure = null;
|
|
585
|
+
state.value = { kind: 'idle' };
|
|
586
|
+
}
|
|
587
|
+
|
|
588
|
+
/* ------------------------------------------------------------------ */
|
|
589
|
+
/* Keyboard resize — Shift+Arrow */
|
|
590
|
+
/* ------------------------------------------------------------------ */
|
|
591
|
+
|
|
592
|
+
export interface KeyboardResizeOpts {
|
|
593
|
+
/** The row + section the keystroke applies to (looked up from the
|
|
594
|
+
* current selection by the caller). */
|
|
595
|
+
rowId: string;
|
|
596
|
+
sectionId: string;
|
|
597
|
+
/** Direction: 'shrink' = Shift+Left = -1; 'grow' = Shift+Right = +1.
|
|
598
|
+
* Composable doesn't care about the actual key; the caller maps it. */
|
|
599
|
+
direction: 'shrink' | 'grow';
|
|
600
|
+
/** Closure to read the live draft (consistent with pointer path). */
|
|
601
|
+
getDraft: () => LayoutRecord | null;
|
|
602
|
+
/** Per-section bounds — typically read by the caller from the
|
|
603
|
+
* section registry. */
|
|
604
|
+
sectionMin: number;
|
|
605
|
+
sectionMax: number;
|
|
606
|
+
/** Section type — used in narration. */
|
|
607
|
+
sectionType: string;
|
|
608
|
+
/** Right-neighbour bounds; pass null when the section is LAST in row. */
|
|
609
|
+
neighbour: {
|
|
610
|
+
sectionId: string;
|
|
611
|
+
min: number;
|
|
612
|
+
max: number;
|
|
613
|
+
} | null;
|
|
614
|
+
}
|
|
615
|
+
|
|
616
|
+
export interface KeyboardResizeResult {
|
|
617
|
+
/** The new span after the keystroke. Equal to the start when the
|
|
618
|
+
* keystroke hit a bound. */
|
|
619
|
+
newColSpan: number;
|
|
620
|
+
/** Which bound the keystroke hit, if any. */
|
|
621
|
+
constraintHit: ResizeConstraint;
|
|
622
|
+
/** The numeric bound (for narration). */
|
|
623
|
+
constraintBound: number;
|
|
624
|
+
/** Whether the keystroke produced a mutation worth recording. */
|
|
625
|
+
changed: boolean;
|
|
626
|
+
}
|
|
627
|
+
|
|
628
|
+
/**
|
|
629
|
+
* Apply a Shift+Arrow keyboard resize. ±1 column per press; same
|
|
630
|
+
* neighbour-absorption rule as the pointer path. Commits one command
|
|
631
|
+
* per keystroke (each press is a discrete user intent — unlike a
|
|
632
|
+
* pointer drag where 60 ticks coalesce to ONE command at release).
|
|
633
|
+
*
|
|
634
|
+
* Narration:
|
|
635
|
+
* - changed=true → "Hero now spans N of 12 columns."
|
|
636
|
+
* - constraintHit → narrateResizeBlocked surface — names the bound.
|
|
637
|
+
*
|
|
638
|
+
* Returns the result so the caller can do its own follow-up (e.g. the
|
|
639
|
+
* editor page might re-focus after the keystroke). The composable
|
|
640
|
+
* handles history + narration itself.
|
|
641
|
+
*/
|
|
642
|
+
function applyKeyboardResize(opts: KeyboardResizeOpts): KeyboardResizeResult | null {
|
|
643
|
+
const draft = opts.getDraft();
|
|
644
|
+
if (!draft) return null;
|
|
645
|
+
const loc = findSectionLocation(draft, opts.sectionId);
|
|
646
|
+
if (!loc) return null;
|
|
647
|
+
|
|
648
|
+
const startColSpan = loc.section.colSpan;
|
|
649
|
+
const desiredDelta = opts.direction === 'grow' ? 1 : -1;
|
|
650
|
+
|
|
651
|
+
// Resolve neighbour live (vs at registration). Keyboard presses are
|
|
652
|
+
// discrete + spaced out; the neighbour may have changed since the
|
|
653
|
+
// section was selected.
|
|
654
|
+
const neighbourLoc = opts.neighbour
|
|
655
|
+
? findSectionLocation(draft, opts.neighbour.sectionId)
|
|
656
|
+
: null;
|
|
657
|
+
const neighbourStart = neighbourLoc ? neighbourLoc.section.colSpan : null;
|
|
658
|
+
const neighbourMin = opts.neighbour?.min ?? 1;
|
|
659
|
+
const neighbourMax = opts.neighbour?.max ?? 12;
|
|
660
|
+
|
|
661
|
+
const clamped = clampResize({
|
|
662
|
+
startColSpan,
|
|
663
|
+
desiredDelta,
|
|
664
|
+
sectionMin: opts.sectionMin,
|
|
665
|
+
sectionMax: opts.sectionMax,
|
|
666
|
+
neighbourStart,
|
|
667
|
+
neighbourMin,
|
|
668
|
+
neighbourMax,
|
|
669
|
+
});
|
|
670
|
+
|
|
671
|
+
const sectionChanged = clamped.newColSpan !== startColSpan;
|
|
672
|
+
const neighbourChanged = neighbourLoc !== null
|
|
673
|
+
&& clamped.newNeighbourColSpan !== neighbourLoc.section.colSpan;
|
|
674
|
+
|
|
675
|
+
const ann = useLayoutAnnouncer();
|
|
676
|
+
|
|
677
|
+
if (!sectionChanged && !neighbourChanged) {
|
|
678
|
+
// Pressed at the bound — narrate the block + return.
|
|
679
|
+
if (clamped.constraintHit !== null) {
|
|
680
|
+
ann.announce(narrateResizeBlocked(
|
|
681
|
+
opts.sectionType,
|
|
682
|
+
clamped.constraintHit,
|
|
683
|
+
clamped.constraintBound,
|
|
684
|
+
));
|
|
685
|
+
}
|
|
686
|
+
return {
|
|
687
|
+
newColSpan: startColSpan,
|
|
688
|
+
constraintHit: clamped.constraintHit,
|
|
689
|
+
constraintBound: clamped.constraintBound,
|
|
690
|
+
changed: false,
|
|
691
|
+
};
|
|
692
|
+
}
|
|
693
|
+
|
|
694
|
+
// Capture BEFORE-snapshot for the command (read live values so
|
|
695
|
+
// intervening edits don't corrupt the invert).
|
|
696
|
+
const neighbourFromColSpan = neighbourLoc?.section.colSpan;
|
|
697
|
+
|
|
698
|
+
// Apply.
|
|
699
|
+
loc.section.colSpan = clamped.newColSpan;
|
|
700
|
+
if (neighbourLoc) {
|
|
701
|
+
neighbourLoc.section.colSpan = clamped.newNeighbourColSpan;
|
|
702
|
+
}
|
|
703
|
+
|
|
704
|
+
// Record.
|
|
705
|
+
const history = useLayoutHistory();
|
|
706
|
+
history.record(resizeSectionCommand({
|
|
707
|
+
rowId: opts.rowId,
|
|
708
|
+
sectionId: opts.sectionId,
|
|
709
|
+
fromColSpan: startColSpan,
|
|
710
|
+
toColSpan: clamped.newColSpan,
|
|
711
|
+
neighbourId: neighbourLoc ? opts.neighbour!.sectionId : null,
|
|
712
|
+
neighbourFromColSpan,
|
|
713
|
+
neighbourToColSpan: neighbourLoc ? clamped.newNeighbourColSpan : undefined,
|
|
714
|
+
label: `resize ${opts.sectionType} (keyboard)`,
|
|
715
|
+
}));
|
|
716
|
+
|
|
717
|
+
// Narrate. If the keystroke hit a bound (e.g. shrank to min in a
|
|
718
|
+
// direction that didn't have room for the full delta — rare edge),
|
|
719
|
+
// narrate the bound FIRST, then the result.
|
|
720
|
+
if (clamped.constraintHit !== null) {
|
|
721
|
+
ann.announce(narrateResizeBlocked(
|
|
722
|
+
opts.sectionType,
|
|
723
|
+
clamped.constraintHit,
|
|
724
|
+
clamped.constraintBound,
|
|
725
|
+
));
|
|
726
|
+
} else {
|
|
727
|
+
ann.announce(narrateResize(opts.sectionType, clamped.newColSpan));
|
|
728
|
+
}
|
|
729
|
+
|
|
730
|
+
return {
|
|
731
|
+
newColSpan: clamped.newColSpan,
|
|
732
|
+
constraintHit: clamped.constraintHit,
|
|
733
|
+
constraintBound: clamped.constraintBound,
|
|
734
|
+
changed: true,
|
|
735
|
+
};
|
|
736
|
+
}
|
|
737
|
+
|
|
738
|
+
/* ------------------------------------------------------------------ */
|
|
739
|
+
/* Composable surface */
|
|
740
|
+
/* ------------------------------------------------------------------ */
|
|
741
|
+
|
|
742
|
+
export interface LayoutResize {
|
|
743
|
+
/** Current state — `idle` or `resizing` with full gesture context.
|
|
744
|
+
* Read by LayoutSection (handle visual state) + LayoutRow (guide
|
|
745
|
+
* overlay). */
|
|
746
|
+
state: Ref<ResizeState>;
|
|
747
|
+
/** Begin a resize gesture from a pointerdown on the right-edge handle. */
|
|
748
|
+
startResize: (opts: StartResizeOpts) => void;
|
|
749
|
+
/** Commit + reset. Normally called by the document pointerup handler;
|
|
750
|
+
* exported so tests can drive without dispatching events. */
|
|
751
|
+
endResize: () => void;
|
|
752
|
+
/** Revert + reset. Esc keypress on a resize-in-flight; defensive
|
|
753
|
+
* pointer-lost recovery. */
|
|
754
|
+
cancelResize: () => void;
|
|
755
|
+
/** Apply a Shift+Arrow keyboard resize. */
|
|
756
|
+
applyKeyboardResize: (opts: KeyboardResizeOpts) => KeyboardResizeResult | null;
|
|
757
|
+
/** Helper for test fixtures + the LayoutSection handle's rendering —
|
|
758
|
+
* selects the right neighbour from a row's sections array. Returns
|
|
759
|
+
* null when the given section is LAST in the row. */
|
|
760
|
+
findRightNeighbour: (sections: LayoutSection[], sectionId: string) => LayoutSection | null;
|
|
761
|
+
}
|
|
762
|
+
|
|
763
|
+
/** Right neighbour helper. Pure — exported for tests + the component's
|
|
764
|
+
* handle visibility logic. */
|
|
765
|
+
function findRightNeighbour(
|
|
766
|
+
sections: LayoutSection[],
|
|
767
|
+
sectionId: string,
|
|
768
|
+
): LayoutSection | null {
|
|
769
|
+
const idx = sections.findIndex((s) => s.id === sectionId);
|
|
770
|
+
if (idx === -1 || idx === sections.length - 1) return null;
|
|
771
|
+
return sections[idx + 1] ?? null;
|
|
772
|
+
}
|
|
773
|
+
|
|
774
|
+
export function useLayoutResize(): LayoutResize {
|
|
775
|
+
return {
|
|
776
|
+
state,
|
|
777
|
+
startResize,
|
|
778
|
+
endResize,
|
|
779
|
+
cancelResize,
|
|
780
|
+
applyKeyboardResize,
|
|
781
|
+
findRightNeighbour,
|
|
782
|
+
};
|
|
783
|
+
}
|