@1agh/maude 0.15.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/LICENSE +21 -0
- package/README.md +166 -0
- package/cli/bin/maude.exe +15 -0
- package/cli/bin/maude.mjs +45 -0
- package/cli/bin/mdcc.exe +10 -0
- package/cli/bin/mdcc.mjs +7 -0
- package/cli/cli-wrapper.cjs +67 -0
- package/cli/commands/config.mjs +94 -0
- package/cli/commands/design.mjs +386 -0
- package/cli/commands/help.mjs +57 -0
- package/cli/commands/init.mjs +178 -0
- package/cli/commands/version.mjs +7 -0
- package/cli/install.cjs +113 -0
- package/cli/lib/argv.mjs +37 -0
- package/cli/lib/argv.test.mjs +46 -0
- package/cli/lib/copy-tree.mjs +78 -0
- package/package.json +94 -0
- package/plugins/design/dev-server/annotations-context-toolbar.tsx +397 -0
- package/plugins/design/dev-server/annotations-layer.tsx +1717 -0
- package/plugins/design/dev-server/api.ts +674 -0
- package/plugins/design/dev-server/bin/_screenshot-playwright.mjs +50 -0
- package/plugins/design/dev-server/bin/bootstrap-check.sh +83 -0
- package/plugins/design/dev-server/bin/canvas-edit.sh +48 -0
- package/plugins/design/dev-server/bin/handoff.sh +27 -0
- package/plugins/design/dev-server/bin/screenshot.sh +232 -0
- package/plugins/design/dev-server/bin/server-up.sh +135 -0
- package/plugins/design/dev-server/bin/slug.sh +22 -0
- package/plugins/design/dev-server/bin/smoke.sh +272 -0
- package/plugins/design/dev-server/build.ts +267 -0
- package/plugins/design/dev-server/canvas-build.ts +219 -0
- package/plugins/design/dev-server/canvas-edit.ts +388 -0
- package/plugins/design/dev-server/canvas-header.ts +165 -0
- package/plugins/design/dev-server/canvas-icons.tsx +131 -0
- package/plugins/design/dev-server/canvas-lib-inline.ts +260 -0
- package/plugins/design/dev-server/canvas-lib-resolver.ts +85 -0
- package/plugins/design/dev-server/canvas-lib.tsx +1995 -0
- package/plugins/design/dev-server/canvas-meta.schema.json +181 -0
- package/plugins/design/dev-server/canvas-pipeline.ts +270 -0
- package/plugins/design/dev-server/canvas-shell.tsx +813 -0
- package/plugins/design/dev-server/client/app.jsx +2027 -0
- package/plugins/design/dev-server/client/hmr.mjs +85 -0
- package/plugins/design/dev-server/client/iframe-lazy.mjs +121 -0
- package/plugins/design/dev-server/client/index.html +15 -0
- package/plugins/design/dev-server/client/styles/0-reset.css +18 -0
- package/plugins/design/dev-server/client/styles/1-tokens.css +297 -0
- package/plugins/design/dev-server/client/styles/2-layout.css +35 -0
- package/plugins/design/dev-server/client/styles/3-shell.css +906 -0
- package/plugins/design/dev-server/client/styles/4-components.css +1268 -0
- package/plugins/design/dev-server/client/styles/5-utilities.css +4 -0
- package/plugins/design/dev-server/client/styles/_index.css +24 -0
- package/plugins/design/dev-server/client/styles.css +1419 -0
- package/plugins/design/dev-server/config.schema.json +147 -0
- package/plugins/design/dev-server/context-menu.tsx +343 -0
- package/plugins/design/dev-server/context.ts +173 -0
- package/plugins/design/dev-server/dist/client.bundle.js +20323 -0
- package/plugins/design/dev-server/dist/styles.css +2875 -0
- package/plugins/design/dev-server/examples/README.md +9 -0
- package/plugins/design/dev-server/examples/perf-100-artboards.tsx +113 -0
- package/plugins/design/dev-server/fs-watch.ts +63 -0
- package/plugins/design/dev-server/handoff.ts +721 -0
- package/plugins/design/dev-server/history.ts +125 -0
- package/plugins/design/dev-server/hmr-broadcast.ts +114 -0
- package/plugins/design/dev-server/http.ts +413 -0
- package/plugins/design/dev-server/input-router.tsx +485 -0
- package/plugins/design/dev-server/inspect.ts +365 -0
- package/plugins/design/dev-server/locator.ts +159 -0
- package/plugins/design/dev-server/mem.ts +97 -0
- package/plugins/design/dev-server/runtime-bundle.ts +235 -0
- package/plugins/design/dev-server/server.mjs +1246 -0
- package/plugins/design/dev-server/server.ts +131 -0
- package/plugins/design/dev-server/test/_helpers.ts +81 -0
- package/plugins/design/dev-server/test/active-state.test.ts +145 -0
- package/plugins/design/dev-server/test/annotations-api.test.ts +146 -0
- package/plugins/design/dev-server/test/annotations-layer.test.ts +419 -0
- package/plugins/design/dev-server/test/binary-smoke.test.ts +47 -0
- package/plugins/design/dev-server/test/bundle-smoke.test.ts +29 -0
- package/plugins/design/dev-server/test/canvas-build.test.ts +78 -0
- package/plugins/design/dev-server/test/canvas-edit.test.ts +139 -0
- package/plugins/design/dev-server/test/canvas-header.test.ts +127 -0
- package/plugins/design/dev-server/test/canvas-lib-inline.test.ts +146 -0
- package/plugins/design/dev-server/test/canvas-lib-resolver.test.ts +112 -0
- package/plugins/design/dev-server/test/canvas-meta-api.test.ts +236 -0
- package/plugins/design/dev-server/test/canvas-pipeline.test.ts +180 -0
- package/plugins/design/dev-server/test/canvas-route.test.ts +176 -0
- package/plugins/design/dev-server/test/fs-watch.test.ts +41 -0
- package/plugins/design/dev-server/test/handoff-static-frames.test.ts +215 -0
- package/plugins/design/dev-server/test/handoff.test.ts +281 -0
- package/plugins/design/dev-server/test/history-rollback.test.ts +62 -0
- package/plugins/design/dev-server/test/hmr-broadcast.test.ts +108 -0
- package/plugins/design/dev-server/test/input-router.test.ts +316 -0
- package/plugins/design/dev-server/test/locator.test.ts +214 -0
- package/plugins/design/dev-server/test/perf-harness.ts +193 -0
- package/plugins/design/dev-server/test/phase-3.6-smoke.test.ts +77 -0
- package/plugins/design/dev-server/test/runtime-bundle.test.ts +69 -0
- package/plugins/design/dev-server/test/server-lifecycle.test.ts +28 -0
- package/plugins/design/dev-server/test/tool-palette.test.tsx +55 -0
- package/plugins/design/dev-server/test/use-annotation-selection.test.tsx +77 -0
- package/plugins/design/dev-server/test/use-artboard-drag.test.ts +325 -0
- package/plugins/design/dev-server/test/use-selection-set.test.tsx +166 -0
- package/plugins/design/dev-server/test/use-snap-guides.test.ts +190 -0
- package/plugins/design/dev-server/test/use-tool-mode.test.tsx +93 -0
- package/plugins/design/dev-server/test/ws-handshake.test.ts +33 -0
- package/plugins/design/dev-server/tool-palette.tsx +278 -0
- package/plugins/design/dev-server/tsconfig.json +26 -0
- package/plugins/design/dev-server/use-annotation-selection.tsx +92 -0
- package/plugins/design/dev-server/use-annotations-visibility.tsx +43 -0
- package/plugins/design/dev-server/use-artboard-drag.tsx +445 -0
- package/plugins/design/dev-server/use-selection-set.tsx +224 -0
- package/plugins/design/dev-server/use-snap-guides.tsx +215 -0
- package/plugins/design/dev-server/use-tool-mode.tsx +114 -0
- package/plugins/design/dev-server/ws.ts +90 -0
- package/plugins/design/templates/_shell.html +177 -0
- package/plugins/design/templates/canvas.tsx.template +54 -0
- package/plugins/design/templates/design-system-inspiration/_MAPPING.md +277 -0
- package/plugins/design/templates/design-system-inspiration/_README.md +71 -0
- package/plugins/design/templates/design-system-inspiration/audience-consumer/components-banner.html +68 -0
- package/plugins/design/templates/design-system-inspiration/audience-consumer/components-empty-state-generous.html +39 -0
- package/plugins/design/templates/design-system-inspiration/audience-consumer/components-feature-grid.html +62 -0
- package/plugins/design/templates/design-system-inspiration/audience-consumer/components-marketing-card.html +63 -0
- package/plugins/design/templates/design-system-inspiration/audience-consumer/components-testimonial.html +80 -0
- package/plugins/design/templates/design-system-inspiration/audience-developer/components-code-block.html +71 -0
- package/plugins/design/templates/design-system-inspiration/audience-developer/components-diff-view.html +65 -0
- package/plugins/design/templates/design-system-inspiration/audience-developer/components-log-stream.html +62 -0
- package/plugins/design/templates/design-system-inspiration/audience-developer/components-monospace-table.html +57 -0
- package/plugins/design/templates/design-system-inspiration/audience-developer/components-terminal-pane.html +67 -0
- package/plugins/design/templates/design-system-inspiration/audience-developer/type-mono.html +57 -0
- package/plugins/design/templates/design-system-inspiration/audience-pro/colors-presence.html +74 -0
- package/plugins/design/templates/design-system-inspiration/audience-pro/components-command-palette.html +90 -0
- package/plugins/design/templates/design-system-inspiration/audience-pro/components-keyboard.html +51 -0
- package/plugins/design/templates/design-system-inspiration/audience-pro/components-list.html +89 -0
- package/plugins/design/templates/design-system-inspiration/audience-pro/components-shortcuts-overlay.html +85 -0
- package/plugins/design/templates/design-system-inspiration/audience-pro/components-toast-menu.html +80 -0
- package/plugins/design/templates/design-system-inspiration/core/INDEX.md.tpl +25 -0
- package/plugins/design/templates/design-system-inspiration/core/README.orchestration.md.tpl +71 -0
- package/plugins/design/templates/design-system-inspiration/core/README.philosophy.md.tpl +77 -0
- package/plugins/design/templates/design-system-inspiration/core/SKILL.md.tpl +50 -0
- package/plugins/design/templates/design-system-inspiration/core/colors_and_type.css.tpl +111 -0
- package/plugins/design/templates/design-system-inspiration/core/config.json.tpl +28 -0
- package/plugins/design/templates/design-system-inspiration/core/preview/_layout.css +113 -0
- package/plugins/design/templates/design-system-inspiration/core/preview/colors-accent.html +48 -0
- package/plugins/design/templates/design-system-inspiration/core/preview/colors-surfaces.html +49 -0
- package/plugins/design/templates/design-system-inspiration/core/preview/colors-text.html +52 -0
- package/plugins/design/templates/design-system-inspiration/core/preview/components-buttons.html +83 -0
- package/plugins/design/templates/design-system-inspiration/core/preview/components-cards.html +79 -0
- package/plugins/design/templates/design-system-inspiration/core/preview/components-inputs.html +82 -0
- package/plugins/design/templates/design-system-inspiration/core/preview/motion.html +66 -0
- package/plugins/design/templates/design-system-inspiration/core/preview/spacing-scale.html +53 -0
- package/plugins/design/templates/design-system-inspiration/core/preview/type-scale.html +62 -0
- package/plugins/design/templates/design-system-inspiration/foundations/borders.html +39 -0
- package/plugins/design/templates/design-system-inspiration/foundations/elevation.html +46 -0
- package/plugins/design/templates/design-system-inspiration/foundations/focus.html +48 -0
- package/plugins/design/templates/design-system-inspiration/foundations/grid.html +51 -0
- package/plugins/design/templates/design-system-inspiration/foundations/iconography.html +73 -0
- package/plugins/design/templates/design-system-inspiration/foundations/opacity.html +45 -0
- package/plugins/design/templates/design-system-inspiration/foundations/radii.html +40 -0
- package/plugins/design/templates/design-system-inspiration/foundations/selection.html +45 -0
- package/plugins/design/templates/design-system-inspiration/meta/accessibility.html +62 -0
- package/plugins/design/templates/design-system-inspiration/meta/i18n.html +73 -0
- package/plugins/design/templates/design-system-inspiration/meta/presence-multiplayer.html +71 -0
- package/plugins/design/templates/design-system-inspiration/meta/tokens-index.html +80 -0
- package/plugins/design/templates/design-system-inspiration/patterns/patterns-auth.html +78 -0
- package/plugins/design/templates/design-system-inspiration/patterns/patterns-data-density.html +61 -0
- package/plugins/design/templates/design-system-inspiration/patterns/patterns-error-pages.html +70 -0
- package/plugins/design/templates/design-system-inspiration/patterns/patterns-form-layouts.html +70 -0
- package/plugins/design/templates/design-system-inspiration/patterns/patterns-onboarding.html +71 -0
- package/plugins/design/templates/design-system-inspiration/patterns/patterns-pricing.html +83 -0
- package/plugins/design/templates/design-system-inspiration/platform-desktop/components-resize-panels.html +63 -0
- package/plugins/design/templates/design-system-inspiration/platform-desktop/ui_kits-desktop-index.html +55 -0
- package/plugins/design/templates/design-system-inspiration/platform-desktop/ui_kits-desktop-showcase.html +302 -0
- package/plugins/design/templates/design-system-inspiration/platform-mobile/components-bottom-sheet.html +63 -0
- package/plugins/design/templates/design-system-inspiration/platform-mobile/components-pull-to-refresh.html +74 -0
- package/plugins/design/templates/design-system-inspiration/platform-mobile/components-segmented-control.html +51 -0
- package/plugins/design/templates/design-system-inspiration/platform-mobile/components-tab-bar.html +57 -0
- package/plugins/design/templates/design-system-inspiration/platform-mobile/ui_kits-mobile-index.html +58 -0
- package/plugins/design/templates/design-system-inspiration/platform-mobile/ui_kits-mobile-showcase.html +237 -0
- package/plugins/design/templates/design-system-inspiration/status/colors-status.html +49 -0
- package/plugins/design/templates/design-system-inspiration/status/components-status.html +63 -0
- package/plugins/design/templates/design-system-inspiration/status/skeletons.html +74 -0
- package/plugins/design/templates/design-system-inspiration/theme-both/colors-themes-side-by-side.html +59 -0
- package/plugins/design/templates/design-system-inspiration/universal/components-callout.html +74 -0
- package/plugins/design/templates/design-system-inspiration/universal/components-dialogs.html +81 -0
- package/plugins/design/templates/design-system-inspiration/universal/components-tables.html +101 -0
- package/plugins/design/templates/design-system-inspiration/universal/components-toggles.html +74 -0
- package/plugins/design/templates/design-system-inspiration/universal/components-tooltips.html +74 -0
- package/plugins/design/templates/design-system-inspiration/universal/empty-state.html.tpl +34 -0
- package/plugins/design/templates/design-system-inspiration/universal/logo.html +42 -0
- package/plugins/design/templates/ds-specimen.tsx.template +59 -0
- package/plugins/flow/.claude-plugin/config.schema.json +398 -0
- package/plugins/flow/README.md +45 -0
- package/plugins/flow/templates/ai-skeleton/INDEX.md +50 -0
- package/plugins/flow/templates/ai-skeleton/README.md +46 -0
- package/plugins/flow/templates/ai-skeleton/browser/har/.gitkeep +0 -0
- package/plugins/flow/templates/ai-skeleton/browser/snapshots/.gitkeep +0 -0
- package/plugins/flow/templates/ai-skeleton/business/README.md +12 -0
- package/plugins/flow/templates/ai-skeleton/context/README.md +12 -0
- package/plugins/flow/templates/ai-skeleton/decisions/README.md +20 -0
- package/plugins/flow/templates/ai-skeleton/design-import/.gitkeep +0 -0
- package/plugins/flow/templates/ai-skeleton/dev-logs/.gitkeep +0 -0
- package/plugins/flow/templates/ai-skeleton/device/.gitkeep +0 -0
- package/plugins/flow/templates/ai-skeleton/docs/README.md +5 -0
- package/plugins/flow/templates/ai-skeleton/logs/.gitkeep +0 -0
- package/plugins/flow/templates/ai-skeleton/logs/README.md +13 -0
- package/plugins/flow/templates/ai-skeleton/plans/README.md +16 -0
- package/plugins/flow/templates/ai-skeleton/plans/archive/.gitkeep +0 -0
- package/plugins/flow/templates/ai-skeleton/release-guide.md +35 -0
- package/plugins/flow/templates/ai-skeleton/reviews/README.md +15 -0
- package/plugins/flow/templates/ai-skeleton/scenarios/README.md +26 -0
- package/plugins/flow/templates/ai-skeleton/scenarios/_lib/.gitkeep +0 -0
- package/plugins/flow/templates/ai-skeleton/state/STATE.md +25 -0
- package/plugins/flow/templates/ai-skeleton/templates/HANDOFF.md +32 -0
- package/plugins/flow/templates/ai-skeleton/workflows.config.json +74 -0
|
@@ -0,0 +1,445 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file use-artboard-drag.tsx — Phase 4.2 artboard drag controller
|
|
3
|
+
* @scope plugins/design/dev-server/use-artboard-drag.tsx
|
|
4
|
+
* @purpose Owns the pointerdown → pointermove → pointerup state machine
|
|
5
|
+
* for dragging artboards on the infinite canvas. The pure
|
|
6
|
+
* `dragReducer` + `commitFromState` are unit-testable without a
|
|
7
|
+
* DOM. The `useArtboardDrag` hook attaches the listeners + wires
|
|
8
|
+
* commit on settle.
|
|
9
|
+
*
|
|
10
|
+
* Why a separate listener stack (not the input-router)?
|
|
11
|
+
* `input-router.classify()` is per-event-stateless — drag is a multi-event
|
|
12
|
+
* state machine (down → move ×N → up). Owning its own listeners mirrors
|
|
13
|
+
* `useViewportController`. The two stacks coexist:
|
|
14
|
+
*
|
|
15
|
+
* - router owns: hover, Cmd-select, right-click, V/H/C/Esc.
|
|
16
|
+
* - viewport-controller owns: wheel, middle-mouse pan, Space pan, Cmd+0/1.
|
|
17
|
+
* - this hook owns: pointerdown over artboard chrome (label + border) +
|
|
18
|
+
* its own pointermove/up while a drag is in flight.
|
|
19
|
+
*
|
|
20
|
+
* Click-vs-drag classifier: a 4 px screen-pixel threshold separates a click
|
|
21
|
+
* (delta < 4 px → label `onClick` fires, pan-to-focus) from a drag (delta
|
|
22
|
+
* ≥ 4 px → preventDefault on the synthetic click, commit positions on up).
|
|
23
|
+
*
|
|
24
|
+
* Multi-select: when `artboardId` (the drag leader) is in `selected`, every
|
|
25
|
+
* other artboard whose id matches a selection moves rigidly with the leader
|
|
26
|
+
* (relative offsets captured at drag-start).
|
|
27
|
+
*/
|
|
28
|
+
|
|
29
|
+
import {
|
|
30
|
+
type HTMLAttributes,
|
|
31
|
+
type PointerEvent as ReactPointerEvent,
|
|
32
|
+
useCallback,
|
|
33
|
+
useEffect,
|
|
34
|
+
useMemo,
|
|
35
|
+
useRef,
|
|
36
|
+
useState,
|
|
37
|
+
} from 'react';
|
|
38
|
+
|
|
39
|
+
import type { Selection } from './use-selection-set.tsx';
|
|
40
|
+
import { type Rect, type SnapResult, computeSnap } from './use-snap-guides.tsx';
|
|
41
|
+
|
|
42
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
43
|
+
// Constants
|
|
44
|
+
|
|
45
|
+
/** Screen-pixel distance the cursor must travel before pending → dragging. */
|
|
46
|
+
export const DRAG_THRESHOLD_PX = 4;
|
|
47
|
+
|
|
48
|
+
/** Default grid + tolerance (world units). Documented in DDR-028. */
|
|
49
|
+
export const DEFAULT_GRID_SIZE = 40;
|
|
50
|
+
export const DEFAULT_SNAP_TOLERANCE = 8;
|
|
51
|
+
|
|
52
|
+
/** How long the one-shot click suppressor stays armed after a drag's pointerup.
|
|
53
|
+
* Long enough to catch the synthetic `click` that follows pointerup; short
|
|
54
|
+
* enough that a legitimate later click on the same element still fires. */
|
|
55
|
+
export const CLICK_SUPPRESS_TIMEOUT_MS = 300;
|
|
56
|
+
|
|
57
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
58
|
+
// Types
|
|
59
|
+
|
|
60
|
+
export interface DragTargetSnapshot {
|
|
61
|
+
id: string;
|
|
62
|
+
/** Offset from the leader's starting top-left, in world units. */
|
|
63
|
+
offsetX: number;
|
|
64
|
+
offsetY: number;
|
|
65
|
+
/** Captured starting rect — used for ghost rendering. */
|
|
66
|
+
startRect: Rect;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export type DragState =
|
|
70
|
+
| { kind: 'idle' }
|
|
71
|
+
| {
|
|
72
|
+
kind: 'pending';
|
|
73
|
+
startClientX: number;
|
|
74
|
+
startClientY: number;
|
|
75
|
+
leaderId: string;
|
|
76
|
+
leaderStart: Rect;
|
|
77
|
+
followers: DragTargetSnapshot[];
|
|
78
|
+
others: Rect[];
|
|
79
|
+
}
|
|
80
|
+
| {
|
|
81
|
+
kind: 'dragging';
|
|
82
|
+
startClientX: number;
|
|
83
|
+
startClientY: number;
|
|
84
|
+
leaderId: string;
|
|
85
|
+
leaderStart: Rect;
|
|
86
|
+
followers: DragTargetSnapshot[];
|
|
87
|
+
others: Rect[];
|
|
88
|
+
cursorClientX: number;
|
|
89
|
+
cursorClientY: number;
|
|
90
|
+
/** Snapped leader rect — what the ghost renders at. */
|
|
91
|
+
leaderRect: Rect;
|
|
92
|
+
snap: SnapResult;
|
|
93
|
+
alt: boolean;
|
|
94
|
+
};
|
|
95
|
+
|
|
96
|
+
export type DragEvent =
|
|
97
|
+
| {
|
|
98
|
+
type: 'down';
|
|
99
|
+
clientX: number;
|
|
100
|
+
clientY: number;
|
|
101
|
+
leaderId: string;
|
|
102
|
+
leaderStart: Rect;
|
|
103
|
+
followers: DragTargetSnapshot[];
|
|
104
|
+
others: Rect[];
|
|
105
|
+
}
|
|
106
|
+
| {
|
|
107
|
+
type: 'move';
|
|
108
|
+
clientX: number;
|
|
109
|
+
clientY: number;
|
|
110
|
+
alt: boolean;
|
|
111
|
+
zoom: number;
|
|
112
|
+
gridSize: number;
|
|
113
|
+
tolerance: number;
|
|
114
|
+
}
|
|
115
|
+
| { type: 'up' }
|
|
116
|
+
| { type: 'cancel' };
|
|
117
|
+
|
|
118
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
119
|
+
// Pure reducer — unit-tested without a DOM. The hook below is a thin shell
|
|
120
|
+
// that wires DOM events into this function.
|
|
121
|
+
|
|
122
|
+
export function dragReducer(state: DragState, ev: DragEvent): DragState {
|
|
123
|
+
switch (ev.type) {
|
|
124
|
+
case 'down':
|
|
125
|
+
return {
|
|
126
|
+
kind: 'pending',
|
|
127
|
+
startClientX: ev.clientX,
|
|
128
|
+
startClientY: ev.clientY,
|
|
129
|
+
leaderId: ev.leaderId,
|
|
130
|
+
leaderStart: ev.leaderStart,
|
|
131
|
+
followers: ev.followers,
|
|
132
|
+
others: ev.others,
|
|
133
|
+
};
|
|
134
|
+
case 'move': {
|
|
135
|
+
if (state.kind === 'idle') return state;
|
|
136
|
+
const dxClient = ev.clientX - state.startClientX;
|
|
137
|
+
const dyClient = ev.clientY - state.startClientY;
|
|
138
|
+
if (state.kind === 'pending') {
|
|
139
|
+
if (Math.abs(dxClient) < DRAG_THRESHOLD_PX && Math.abs(dyClient) < DRAG_THRESHOLD_PX) {
|
|
140
|
+
return state;
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
const z = ev.zoom > 0 ? ev.zoom : 1;
|
|
144
|
+
const proposed: Rect = {
|
|
145
|
+
x: state.leaderStart.x + dxClient / z,
|
|
146
|
+
y: state.leaderStart.y + dyClient / z,
|
|
147
|
+
w: state.leaderStart.w,
|
|
148
|
+
h: state.leaderStart.h,
|
|
149
|
+
};
|
|
150
|
+
const snap = computeSnap(proposed, state.others, {
|
|
151
|
+
gridSize: ev.gridSize,
|
|
152
|
+
tolerance: ev.tolerance,
|
|
153
|
+
disabled: ev.alt,
|
|
154
|
+
});
|
|
155
|
+
return {
|
|
156
|
+
kind: 'dragging',
|
|
157
|
+
startClientX: state.startClientX,
|
|
158
|
+
startClientY: state.startClientY,
|
|
159
|
+
leaderId: state.leaderId,
|
|
160
|
+
leaderStart: state.leaderStart,
|
|
161
|
+
followers: state.followers,
|
|
162
|
+
others: state.others,
|
|
163
|
+
cursorClientX: ev.clientX,
|
|
164
|
+
cursorClientY: ev.clientY,
|
|
165
|
+
leaderRect: { x: snap.x, y: snap.y, w: proposed.w, h: proposed.h },
|
|
166
|
+
snap,
|
|
167
|
+
alt: ev.alt,
|
|
168
|
+
};
|
|
169
|
+
}
|
|
170
|
+
case 'up':
|
|
171
|
+
case 'cancel':
|
|
172
|
+
return { kind: 'idle' };
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/**
|
|
177
|
+
* Translate a dragging state into the commit payload (leader + every follower
|
|
178
|
+
* at their final snapped positions). Returns null when no drag is in flight.
|
|
179
|
+
*/
|
|
180
|
+
export function commitFromState(state: DragState): { id: string; x: number; y: number }[] | null {
|
|
181
|
+
if (state.kind !== 'dragging') return null;
|
|
182
|
+
const out: { id: string; x: number; y: number }[] = [
|
|
183
|
+
{ id: state.leaderId, x: state.leaderRect.x, y: state.leaderRect.y },
|
|
184
|
+
];
|
|
185
|
+
for (const f of state.followers) {
|
|
186
|
+
out.push({
|
|
187
|
+
id: f.id,
|
|
188
|
+
x: state.leaderRect.x + f.offsetX,
|
|
189
|
+
y: state.leaderRect.y + f.offsetY,
|
|
190
|
+
});
|
|
191
|
+
}
|
|
192
|
+
return out;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/**
|
|
196
|
+
* Compute follower snapshots given the leader's starting rect and the live
|
|
197
|
+
* selection set. A follower is any selected artboard whose id appears in
|
|
198
|
+
* `allRects` and isn't the leader itself. If the leader is not in
|
|
199
|
+
* `selectedIds`, followers = [] (the leader drags alone).
|
|
200
|
+
*/
|
|
201
|
+
export function computeFollowers(
|
|
202
|
+
leaderId: string,
|
|
203
|
+
leaderStart: Rect,
|
|
204
|
+
selectedIds: Set<string>,
|
|
205
|
+
allRects: Rect[]
|
|
206
|
+
): DragTargetSnapshot[] {
|
|
207
|
+
if (!selectedIds.has(leaderId)) return [];
|
|
208
|
+
const out: DragTargetSnapshot[] = [];
|
|
209
|
+
for (const r of allRects) {
|
|
210
|
+
if (!r.id || r.id === leaderId) continue;
|
|
211
|
+
if (!selectedIds.has(r.id)) continue;
|
|
212
|
+
out.push({
|
|
213
|
+
id: r.id,
|
|
214
|
+
offsetX: r.x - leaderStart.x,
|
|
215
|
+
offsetY: r.y - leaderStart.y,
|
|
216
|
+
startRect: r,
|
|
217
|
+
});
|
|
218
|
+
}
|
|
219
|
+
return out;
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
/**
|
|
223
|
+
* Project a `Selection[]` into the set of artboard ids it implies for
|
|
224
|
+
* multi-drag. Only `Selection.artboardId` is used — `Selection.id` is the
|
|
225
|
+
* `data-cd-id` of an arbitrary inner element and would pollute the set
|
|
226
|
+
* with child cd-ids that never match an artboard's `data-dc-screen`.
|
|
227
|
+
*/
|
|
228
|
+
export function selectionsToArtboardIds(selected: Selection[]): Set<string> {
|
|
229
|
+
const out = new Set<string>();
|
|
230
|
+
for (const s of selected) {
|
|
231
|
+
if (s.artboardId) out.add(s.artboardId);
|
|
232
|
+
}
|
|
233
|
+
return out;
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
/**
|
|
237
|
+
* Build the `others` rect array — every rect that isn't the leader or a
|
|
238
|
+
* follower (those are moving rigidly with the leader and shouldn't snap to
|
|
239
|
+
* themselves).
|
|
240
|
+
*/
|
|
241
|
+
export function computeOthers(
|
|
242
|
+
leaderId: string,
|
|
243
|
+
followerIds: Set<string>,
|
|
244
|
+
allRects: Rect[]
|
|
245
|
+
): Rect[] {
|
|
246
|
+
const out: Rect[] = [];
|
|
247
|
+
for (const r of allRects) {
|
|
248
|
+
if (!r.id) continue;
|
|
249
|
+
if (r.id === leaderId) continue;
|
|
250
|
+
if (followerIds.has(r.id)) continue;
|
|
251
|
+
out.push(r);
|
|
252
|
+
}
|
|
253
|
+
return out;
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
257
|
+
// Hook
|
|
258
|
+
|
|
259
|
+
export interface UseArtboardDragOptions {
|
|
260
|
+
/** This DCArtboard's id. */
|
|
261
|
+
artboardId: string;
|
|
262
|
+
/** Live multi-selection (from useSelectionSet). */
|
|
263
|
+
selected: Selection[];
|
|
264
|
+
/** Look up an artboard's current rect. */
|
|
265
|
+
rectFor: (id: string) => Rect | null;
|
|
266
|
+
/** Every artboard's rect in render order. */
|
|
267
|
+
allRects: Rect[];
|
|
268
|
+
/** Live viewport — `null` before first layout. */
|
|
269
|
+
viewport: { zoom: number } | null;
|
|
270
|
+
/** False short-circuits all event handling (e.g. activeTool !== "move"). */
|
|
271
|
+
enabled: boolean;
|
|
272
|
+
/** Called on drop with final positions for leader + followers. */
|
|
273
|
+
onCommit: (next: { id: string; x: number; y: number }[]) => void;
|
|
274
|
+
/** Snap config — defaults via DDR-028. */
|
|
275
|
+
gridSize?: number;
|
|
276
|
+
tolerance?: number;
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
export interface UseArtboardDragHandle {
|
|
280
|
+
/** Spread onto the artboard chrome (label + outer border). */
|
|
281
|
+
bindHandle: () => HTMLAttributes<HTMLElement>;
|
|
282
|
+
/** Current state — consumers read this to render ghosts + the cursor swap. */
|
|
283
|
+
dragState: DragState;
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
export function useArtboardDrag(opts: UseArtboardDragOptions): UseArtboardDragHandle {
|
|
287
|
+
const {
|
|
288
|
+
artboardId,
|
|
289
|
+
selected,
|
|
290
|
+
rectFor,
|
|
291
|
+
allRects,
|
|
292
|
+
viewport,
|
|
293
|
+
enabled,
|
|
294
|
+
onCommit,
|
|
295
|
+
gridSize = DEFAULT_GRID_SIZE,
|
|
296
|
+
tolerance = DEFAULT_SNAP_TOLERANCE,
|
|
297
|
+
} = opts;
|
|
298
|
+
|
|
299
|
+
const [dragState, setDragState] = useState<DragState>({ kind: 'idle' });
|
|
300
|
+
|
|
301
|
+
// Keep stable refs for callbacks that close over fast-changing inputs.
|
|
302
|
+
const stateRef = useRef<DragState>(dragState);
|
|
303
|
+
stateRef.current = dragState;
|
|
304
|
+
const zoomRef = useRef<number>(viewport?.zoom ?? 1);
|
|
305
|
+
zoomRef.current = viewport?.zoom ?? 1;
|
|
306
|
+
const onCommitRef = useRef(onCommit);
|
|
307
|
+
onCommitRef.current = onCommit;
|
|
308
|
+
const enabledRef = useRef(enabled);
|
|
309
|
+
enabledRef.current = enabled;
|
|
310
|
+
const gridSizeRef = useRef(gridSize);
|
|
311
|
+
gridSizeRef.current = gridSize;
|
|
312
|
+
const toleranceRef = useRef(tolerance);
|
|
313
|
+
toleranceRef.current = tolerance;
|
|
314
|
+
const allRectsRef = useRef(allRects);
|
|
315
|
+
allRectsRef.current = allRects;
|
|
316
|
+
const rectForRef = useRef(rectFor);
|
|
317
|
+
rectForRef.current = rectFor;
|
|
318
|
+
|
|
319
|
+
const selectedIdsRef = useRef<Set<string>>(new Set());
|
|
320
|
+
selectedIdsRef.current = useMemo(() => selectionsToArtboardIds(selected), [selected]);
|
|
321
|
+
|
|
322
|
+
// When `enabled` flips to false mid-drag, cancel (no commit). Mirrors the
|
|
323
|
+
// tool-mode flip scenario in the plan.
|
|
324
|
+
useEffect(() => {
|
|
325
|
+
if (!enabled && stateRef.current.kind !== 'idle') {
|
|
326
|
+
setDragState({ kind: 'idle' });
|
|
327
|
+
}
|
|
328
|
+
}, [enabled]);
|
|
329
|
+
|
|
330
|
+
// Suppress the synthetic click that fires after a drag's pointerup so the
|
|
331
|
+
// label's `onClick` (pan-to-focus) doesn't run. Bind one-shot at the
|
|
332
|
+
// pending→dragging transition; unbinds itself on the next click event.
|
|
333
|
+
const armClickSuppressor = useCallback(() => {
|
|
334
|
+
if (typeof document === 'undefined') return;
|
|
335
|
+
const handler = (e: MouseEvent) => {
|
|
336
|
+
e.stopPropagation();
|
|
337
|
+
e.preventDefault();
|
|
338
|
+
document.removeEventListener('click', handler, true);
|
|
339
|
+
};
|
|
340
|
+
document.addEventListener('click', handler, true);
|
|
341
|
+
// Auto-disarm after a tick if no click fires (defensive).
|
|
342
|
+
setTimeout(() => {
|
|
343
|
+
document.removeEventListener('click', handler, true);
|
|
344
|
+
}, CLICK_SUPPRESS_TIMEOUT_MS);
|
|
345
|
+
}, []);
|
|
346
|
+
|
|
347
|
+
const onPointerDown = useCallback(
|
|
348
|
+
(e: ReactPointerEvent<HTMLElement>) => {
|
|
349
|
+
if (!enabledRef.current) return;
|
|
350
|
+
if (e.button !== 0) return; // only left-button
|
|
351
|
+
// Don't claim modifier-held clicks — Cmd / Ctrl belongs to selection.
|
|
352
|
+
if (e.metaKey || e.ctrlKey) return;
|
|
353
|
+
// Drag handle is the chrome (label strip + outer border) only. Pointer
|
|
354
|
+
// events that originate inside `.dc-artboard-body` stay click-through
|
|
355
|
+
// so Cmd-select continues to work on inner content.
|
|
356
|
+
const target = e.target as Element | null;
|
|
357
|
+
if (target && typeof target.closest === 'function' && target.closest('.dc-artboard-body')) {
|
|
358
|
+
return;
|
|
359
|
+
}
|
|
360
|
+
const leaderStart = rectForRef.current(artboardId);
|
|
361
|
+
if (!leaderStart) return;
|
|
362
|
+
// Don't `setPointerCapture` on the chrome — that re-targets the
|
|
363
|
+
// subsequent `click` event to the captured ancestor (the article)
|
|
364
|
+
// instead of the label `<button>`, which would silently swallow the
|
|
365
|
+
// Phase 4 pan-to-focus `onClick={onFocus}` handler. The global
|
|
366
|
+
// window-level pointermove/up listeners (capture: true) below pick
|
|
367
|
+
// up every event regardless, so capture buys us nothing here.
|
|
368
|
+
const selSet = selectedIdsRef.current;
|
|
369
|
+
const followers = computeFollowers(artboardId, leaderStart, selSet, allRectsRef.current);
|
|
370
|
+
const followerIds = new Set(followers.map((f) => f.id));
|
|
371
|
+
const others = computeOthers(artboardId, followerIds, allRectsRef.current);
|
|
372
|
+
setDragState(
|
|
373
|
+
dragReducer(
|
|
374
|
+
{ kind: 'idle' },
|
|
375
|
+
{
|
|
376
|
+
type: 'down',
|
|
377
|
+
clientX: e.clientX,
|
|
378
|
+
clientY: e.clientY,
|
|
379
|
+
leaderId: artboardId,
|
|
380
|
+
leaderStart,
|
|
381
|
+
followers,
|
|
382
|
+
others,
|
|
383
|
+
}
|
|
384
|
+
)
|
|
385
|
+
);
|
|
386
|
+
},
|
|
387
|
+
[artboardId]
|
|
388
|
+
);
|
|
389
|
+
|
|
390
|
+
// Global pointermove / pointerup while a drag is in flight. Bound at the
|
|
391
|
+
// window level so a fast drag past any element still feeds the reducer.
|
|
392
|
+
useEffect(() => {
|
|
393
|
+
if (dragState.kind === 'idle') return;
|
|
394
|
+
if (typeof window === 'undefined') return;
|
|
395
|
+
|
|
396
|
+
const onMove = (ev: PointerEvent) => {
|
|
397
|
+
const next = dragReducer(stateRef.current, {
|
|
398
|
+
type: 'move',
|
|
399
|
+
clientX: ev.clientX,
|
|
400
|
+
clientY: ev.clientY,
|
|
401
|
+
alt: !!ev.altKey,
|
|
402
|
+
zoom: zoomRef.current,
|
|
403
|
+
gridSize: gridSizeRef.current,
|
|
404
|
+
tolerance: toleranceRef.current,
|
|
405
|
+
});
|
|
406
|
+
// Detect the pending → dragging transition to arm the click suppressor.
|
|
407
|
+
if (stateRef.current.kind === 'pending' && next.kind === 'dragging') {
|
|
408
|
+
armClickSuppressor();
|
|
409
|
+
}
|
|
410
|
+
setDragState(next);
|
|
411
|
+
};
|
|
412
|
+
|
|
413
|
+
const onUp = () => {
|
|
414
|
+
const finalState = stateRef.current;
|
|
415
|
+
const commit = commitFromState(finalState);
|
|
416
|
+
setDragState({ kind: 'idle' });
|
|
417
|
+
if (commit) {
|
|
418
|
+
try {
|
|
419
|
+
onCommitRef.current(commit);
|
|
420
|
+
} catch (err) {
|
|
421
|
+
console.warn('[use-artboard-drag] commit failed:', err);
|
|
422
|
+
}
|
|
423
|
+
}
|
|
424
|
+
};
|
|
425
|
+
|
|
426
|
+
window.addEventListener('pointermove', onMove, { capture: true });
|
|
427
|
+
window.addEventListener('pointerup', onUp, { capture: true });
|
|
428
|
+
window.addEventListener('pointercancel', onUp, { capture: true });
|
|
429
|
+
|
|
430
|
+
return () => {
|
|
431
|
+
window.removeEventListener('pointermove', onMove, { capture: true } as EventListenerOptions);
|
|
432
|
+
window.removeEventListener('pointerup', onUp, { capture: true } as EventListenerOptions);
|
|
433
|
+
window.removeEventListener('pointercancel', onUp, { capture: true } as EventListenerOptions);
|
|
434
|
+
};
|
|
435
|
+
}, [dragState.kind, armClickSuppressor]);
|
|
436
|
+
|
|
437
|
+
const bindHandle = useCallback(
|
|
438
|
+
(): HTMLAttributes<HTMLElement> => ({
|
|
439
|
+
onPointerDown,
|
|
440
|
+
}),
|
|
441
|
+
[onPointerDown]
|
|
442
|
+
);
|
|
443
|
+
|
|
444
|
+
return { bindHandle, dragState };
|
|
445
|
+
}
|
|
@@ -0,0 +1,224 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file use-selection-set.tsx — Phase 4.1 multi-selection store
|
|
3
|
+
* @scope plugins/design/dev-server/use-selection-set.tsx
|
|
4
|
+
* @purpose Multi-element selection state for canvas-shell. The canvas
|
|
5
|
+
* input router calls `replace()` / `add()` / `clear()`;
|
|
6
|
+
* the provider debounces and posts up to the dev-server shell
|
|
7
|
+
* through the existing `__design_selected` window.parent channel
|
|
8
|
+
* so `_active.json` reflects the current selection set.
|
|
9
|
+
*
|
|
10
|
+
* Schema migration. `_active.json#selected` historically holds
|
|
11
|
+
* selected: SelectedElement | null
|
|
12
|
+
* Phase 4.1 widens to
|
|
13
|
+
* selected: SelectedElement | SelectedElement[] | null
|
|
14
|
+
* Writer: emits a single object when N === 1 (back-compat with downstream
|
|
15
|
+
* tools that still read the legacy shape — `/design:edit`, handoff). Emits an
|
|
16
|
+
* array when N > 1. Reader (this hook on rehydrate) accepts all three.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
import {
|
|
20
|
+
type ReactNode,
|
|
21
|
+
createContext,
|
|
22
|
+
useCallback,
|
|
23
|
+
useContext,
|
|
24
|
+
useEffect,
|
|
25
|
+
useMemo,
|
|
26
|
+
useRef,
|
|
27
|
+
useState,
|
|
28
|
+
} from 'react';
|
|
29
|
+
|
|
30
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
31
|
+
// Types
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Minimal Selection shape that travels through the parent postMessage channel.
|
|
35
|
+
* Mirrors `SelectedElement` from inspect.ts but the canvas router computes it
|
|
36
|
+
* client-side and the inspector overlay's enrichment fields (html excerpt,
|
|
37
|
+
* dom_path, classes...) are filled in by the router right before the message
|
|
38
|
+
* is posted.
|
|
39
|
+
*/
|
|
40
|
+
export interface Selection {
|
|
41
|
+
/** Canvas file path — designRel-prefixed (e.g. `.design/ui/Foo.tsx`). */
|
|
42
|
+
file?: string;
|
|
43
|
+
/** Stable `data-cd-id` anchor when present. v2-grade only. */
|
|
44
|
+
id?: string;
|
|
45
|
+
/** CSS-selector fallback path (always present). */
|
|
46
|
+
selector: string;
|
|
47
|
+
/** Artboard host (`data-dc-screen`) — for scoping multi-edits in future. */
|
|
48
|
+
artboardId?: string | null;
|
|
49
|
+
/** Snapshot fields filled by the router from `resolveHoverTarget`. */
|
|
50
|
+
tag?: string;
|
|
51
|
+
classes?: string;
|
|
52
|
+
text?: string;
|
|
53
|
+
dom_path?: string[];
|
|
54
|
+
bounds?: { x: number; y: number; w: number; h: number } | null;
|
|
55
|
+
html?: string;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
interface SelectionSetValue {
|
|
59
|
+
selected: Selection[];
|
|
60
|
+
replace: (s: Selection | Selection[]) => void;
|
|
61
|
+
add: (s: Selection | Selection[]) => void;
|
|
62
|
+
remove: (s: Selection) => void;
|
|
63
|
+
toggle: (s: Selection) => void;
|
|
64
|
+
clear: () => void;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
const SelectionSetContext = createContext<SelectionSetValue | null>(null);
|
|
68
|
+
|
|
69
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
70
|
+
// Identity. Prefer `id` (data-cd-id stable anchor); fall back to selector.
|
|
71
|
+
|
|
72
|
+
function selectionKey(s: Selection): string {
|
|
73
|
+
return s.id ? `id:${s.id}` : `sel:${s.selector}`;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function dedupe(list: Selection[]): Selection[] {
|
|
77
|
+
const out: Selection[] = [];
|
|
78
|
+
const seen = new Set<string>();
|
|
79
|
+
for (const s of list) {
|
|
80
|
+
const k = selectionKey(s);
|
|
81
|
+
if (seen.has(k)) continue;
|
|
82
|
+
seen.add(k);
|
|
83
|
+
out.push(s);
|
|
84
|
+
}
|
|
85
|
+
return out;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
89
|
+
// Provider
|
|
90
|
+
|
|
91
|
+
const POST_DEBOUNCE_MS = 50; // mirrors canvas-lib's SETTLE/PUBLISH cadence
|
|
92
|
+
|
|
93
|
+
export function SelectionSetProvider({
|
|
94
|
+
children,
|
|
95
|
+
/** Override the postMessage destination (used in tests). */
|
|
96
|
+
postTarget,
|
|
97
|
+
}: {
|
|
98
|
+
children: ReactNode;
|
|
99
|
+
postTarget?: { postMessage: (msg: unknown, targetOrigin: string) => void } | null;
|
|
100
|
+
}) {
|
|
101
|
+
const [selected, setSelected] = useState<Selection[]>([]);
|
|
102
|
+
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
103
|
+
|
|
104
|
+
const post = useCallback(
|
|
105
|
+
(next: Selection[]) => {
|
|
106
|
+
if (timerRef.current) clearTimeout(timerRef.current);
|
|
107
|
+
timerRef.current = setTimeout(() => {
|
|
108
|
+
timerRef.current = null;
|
|
109
|
+
const target = postTarget ?? (typeof window !== 'undefined' ? window.parent : null);
|
|
110
|
+
if (!target) return;
|
|
111
|
+
// Wire shape: single object for N=1 (back-compat), array for N>1, null for empty.
|
|
112
|
+
const payload: Selection | Selection[] | null =
|
|
113
|
+
next.length === 0 ? null : next.length === 1 ? (next[0] ?? null) : next;
|
|
114
|
+
try {
|
|
115
|
+
target.postMessage({ dgn: 'select-set', selection: payload }, '*');
|
|
116
|
+
} catch {
|
|
117
|
+
/* iframe likely cross-origin or detached */
|
|
118
|
+
}
|
|
119
|
+
}, POST_DEBOUNCE_MS);
|
|
120
|
+
},
|
|
121
|
+
[postTarget]
|
|
122
|
+
);
|
|
123
|
+
|
|
124
|
+
// Cleanup the debounce timer on unmount.
|
|
125
|
+
useEffect(
|
|
126
|
+
() => () => {
|
|
127
|
+
if (timerRef.current) clearTimeout(timerRef.current);
|
|
128
|
+
},
|
|
129
|
+
[]
|
|
130
|
+
);
|
|
131
|
+
|
|
132
|
+
const replace = useCallback(
|
|
133
|
+
(s: Selection | Selection[]) => {
|
|
134
|
+
const next = dedupe(Array.isArray(s) ? s : [s]);
|
|
135
|
+
setSelected(next);
|
|
136
|
+
post(next);
|
|
137
|
+
},
|
|
138
|
+
[post]
|
|
139
|
+
);
|
|
140
|
+
|
|
141
|
+
const add = useCallback(
|
|
142
|
+
(s: Selection | Selection[]) => {
|
|
143
|
+
const incoming = Array.isArray(s) ? s : [s];
|
|
144
|
+
setSelected((prev) => {
|
|
145
|
+
const next = dedupe([...prev, ...incoming]);
|
|
146
|
+
post(next);
|
|
147
|
+
return next;
|
|
148
|
+
});
|
|
149
|
+
},
|
|
150
|
+
[post]
|
|
151
|
+
);
|
|
152
|
+
|
|
153
|
+
const remove = useCallback(
|
|
154
|
+
(s: Selection) => {
|
|
155
|
+
const k = selectionKey(s);
|
|
156
|
+
setSelected((prev) => {
|
|
157
|
+
const next = prev.filter((x) => selectionKey(x) !== k);
|
|
158
|
+
post(next);
|
|
159
|
+
return next;
|
|
160
|
+
});
|
|
161
|
+
},
|
|
162
|
+
[post]
|
|
163
|
+
);
|
|
164
|
+
|
|
165
|
+
const toggle = useCallback(
|
|
166
|
+
(s: Selection) => {
|
|
167
|
+
const k = selectionKey(s);
|
|
168
|
+
setSelected((prev) => {
|
|
169
|
+
const next = prev.some((x) => selectionKey(x) === k)
|
|
170
|
+
? prev.filter((x) => selectionKey(x) !== k)
|
|
171
|
+
: [...prev, s];
|
|
172
|
+
post(next);
|
|
173
|
+
return next;
|
|
174
|
+
});
|
|
175
|
+
},
|
|
176
|
+
[post]
|
|
177
|
+
);
|
|
178
|
+
|
|
179
|
+
const clear = useCallback(() => {
|
|
180
|
+
setSelected([]);
|
|
181
|
+
post([]);
|
|
182
|
+
}, [post]);
|
|
183
|
+
|
|
184
|
+
const value = useMemo<SelectionSetValue>(
|
|
185
|
+
() => ({ selected, replace, add, remove, toggle, clear }),
|
|
186
|
+
[selected, replace, add, remove, toggle, clear]
|
|
187
|
+
);
|
|
188
|
+
|
|
189
|
+
return <SelectionSetContext.Provider value={value}>{children}</SelectionSetContext.Provider>;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
193
|
+
// Hooks
|
|
194
|
+
|
|
195
|
+
export function useSelectionSet(): SelectionSetValue {
|
|
196
|
+
const ctx = useContext(SelectionSetContext);
|
|
197
|
+
if (!ctx) {
|
|
198
|
+
throw new Error('useSelectionSet must be used inside <SelectionSetProvider>');
|
|
199
|
+
}
|
|
200
|
+
return ctx;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
export function useSelectionSetOptional(): SelectionSetValue | null {
|
|
204
|
+
return useContext(SelectionSetContext);
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
208
|
+
// Wire-shape helpers — exported for tests and inspect.ts back-compat reader.
|
|
209
|
+
|
|
210
|
+
/** Convert any inbound shape to an array. */
|
|
211
|
+
export function normalizeSelectedRead(
|
|
212
|
+
raw: Selection | Selection[] | null | undefined
|
|
213
|
+
): Selection[] {
|
|
214
|
+
if (raw == null) return [];
|
|
215
|
+
if (Array.isArray(raw)) return dedupe(raw);
|
|
216
|
+
return [raw];
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
/** Convert internal array back to the wire shape (writer). */
|
|
220
|
+
export function denormalizeSelectedWrite(list: Selection[]): Selection | Selection[] | null {
|
|
221
|
+
if (list.length === 0) return null;
|
|
222
|
+
if (list.length === 1) return list[0] ?? null;
|
|
223
|
+
return list;
|
|
224
|
+
}
|