@notis_ai/cli 0.2.0 → 0.2.2

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.
Files changed (81) hide show
  1. package/README.md +122 -158
  2. package/dist/scaffolds/notes/app/globals.css +3 -0
  3. package/dist/scaffolds/notes/app/layout.tsx +6 -0
  4. package/dist/scaffolds/notes/app/page.tsx +1465 -0
  5. package/dist/scaffolds/notes/components/ui/badge.tsx +28 -0
  6. package/dist/scaffolds/notes/components/ui/button.tsx +53 -0
  7. package/dist/scaffolds/notes/components/ui/card.tsx +56 -0
  8. package/dist/scaffolds/notes/components.json +20 -0
  9. package/dist/scaffolds/notes/lib/utils.ts +6 -0
  10. package/dist/scaffolds/notes/notis.config.ts +31 -0
  11. package/dist/scaffolds/notes/package.json +31 -0
  12. package/dist/scaffolds/notes/postcss.config.mjs +8 -0
  13. package/dist/scaffolds/notes/tailwind.config.ts +58 -0
  14. package/dist/scaffolds/notes/tsconfig.json +22 -0
  15. package/dist/scaffolds/notes/vite.config.ts +10 -0
  16. package/dist/scaffolds.json +13 -0
  17. package/package.json +12 -2
  18. package/skills/notis-apps/SKILL.md +162 -0
  19. package/skills/notis-apps/cli.md +208 -0
  20. package/skills/notis-cli/SKILL.md +258 -0
  21. package/skills/notis-query/cli.md +40 -0
  22. package/src/cli.js +1 -1
  23. package/src/command-specs/apps.js +1029 -59
  24. package/src/command-specs/helpers.js +6 -41
  25. package/src/command-specs/index.js +1 -7
  26. package/src/command-specs/meta.js +7 -6
  27. package/src/command-specs/tools.js +29 -34
  28. package/src/runtime/agent-browser.js +192 -0
  29. package/src/runtime/app-boundary-validator.js +132 -0
  30. package/src/runtime/app-dev-server.js +605 -0
  31. package/src/runtime/app-dev-sessions.js +87 -0
  32. package/src/runtime/app-platform.js +1037 -82
  33. package/src/runtime/cli-mode.generated.js +4 -0
  34. package/src/runtime/cli-mode.js +29 -0
  35. package/src/runtime/output.js +2 -2
  36. package/src/runtime/ports.js +15 -0
  37. package/src/runtime/profiles.js +36 -5
  38. package/src/runtime/transport.js +131 -6
  39. package/template/.harness/index.html.tmpl +211 -0
  40. package/template/app/globals.css +3 -0
  41. package/template/app/layout.tsx +6 -0
  42. package/template/app/page.tsx +90 -0
  43. package/template/components/ui/badge.tsx +28 -0
  44. package/template/components/ui/button.tsx +53 -0
  45. package/template/components/ui/card.tsx +56 -0
  46. package/template/components.json +20 -0
  47. package/template/lib/utils.ts +6 -0
  48. package/template/metadata/cover.png +0 -0
  49. package/template/metadata/screenshot-1.png +0 -0
  50. package/template/metadata/screenshot-2.png +0 -0
  51. package/template/metadata/screenshot-3.png +0 -0
  52. package/template/notis.config.ts +37 -0
  53. package/template/package.json +31 -0
  54. package/template/packages/sdk/package.json +32 -0
  55. package/template/packages/sdk/src/components/MultiSelectActionBar.tsx +273 -0
  56. package/template/packages/sdk/src/components/MultiSelectCheckbox.tsx +91 -0
  57. package/template/packages/sdk/src/components/MultiSelectDragOverlay.tsx +39 -0
  58. package/template/packages/sdk/src/config.ts +71 -0
  59. package/template/packages/sdk/src/hooks/useBackend.ts +41 -0
  60. package/template/packages/sdk/src/hooks/useDatabase.ts +76 -0
  61. package/template/packages/sdk/src/hooks/useMultiSelect.ts +503 -0
  62. package/template/packages/sdk/src/hooks/useNotis.ts +34 -0
  63. package/template/packages/sdk/src/hooks/useNotisNavigation.ts +49 -0
  64. package/template/packages/sdk/src/hooks/useTool.ts +64 -0
  65. package/template/packages/sdk/src/hooks/useTools.ts +56 -0
  66. package/template/packages/sdk/src/hooks/useTopBarSearch.ts +73 -0
  67. package/template/packages/sdk/src/hooks/useUpsertDocument.ts +50 -0
  68. package/template/packages/sdk/src/index.ts +54 -0
  69. package/template/packages/sdk/src/provider.tsx +43 -0
  70. package/template/packages/sdk/src/runtime.ts +161 -0
  71. package/template/packages/sdk/src/styles.css +38 -0
  72. package/template/packages/sdk/src/ui.ts +15 -0
  73. package/template/packages/sdk/src/vite.ts +56 -0
  74. package/template/packages/sdk/tsconfig.json +15 -0
  75. package/template/postcss.config.mjs +8 -0
  76. package/template/tailwind.config.ts +58 -0
  77. package/template/tsconfig.json +22 -0
  78. package/template/vite.config.ts +10 -0
  79. package/src/command-specs/auth.js +0 -178
  80. package/src/command-specs/db.js +0 -163
  81. package/src/runtime/app-preview-server.js +0 -272
@@ -0,0 +1,71 @@
1
+ /**
2
+ * Configuration utilities for notis.config.ts.
3
+ *
4
+ * Usage:
5
+ * ```ts
6
+ * // notis.config.ts
7
+ * import { defineNotisApp } from '@notis/sdk/config';
8
+ *
9
+ * export default defineNotisApp({
10
+ * name: 'My App',
11
+ * description: 'Does things',
12
+ * icon: 'phosphor:squares-four',
13
+ * databases: ['tasks'],
14
+ * routes: [
15
+ * {
16
+ * path: '/',
17
+ * slug: 'notes',
18
+ * name: 'Notes',
19
+ * default: true,
20
+ * collection: {
21
+ * database: 'notes',
22
+ * titleProperty: 'Name',
23
+ * parentProperty: 'Parent',
24
+ * sidebar: {
25
+ * mode: 'tree',
26
+ * allowCreate: true,
27
+ * },
28
+ * },
29
+ * },
30
+ * ],
31
+ * tools: [...],
32
+ * });
33
+ * ```
34
+ */
35
+
36
+ export interface NotisRouteConfig {
37
+ path: string;
38
+ slug: string;
39
+ name: string;
40
+ icon?: string;
41
+ parentSlug?: string | null;
42
+ default?: boolean;
43
+ exportName?: string;
44
+ collection?: {
45
+ database: string;
46
+ titleProperty: string;
47
+ parentProperty?: string | null;
48
+ sidebar?: {
49
+ mode: 'flat-list' | 'tree';
50
+ allowCreate: boolean;
51
+ };
52
+ };
53
+ }
54
+
55
+ export interface NotisAppConfig {
56
+ name: string;
57
+ description?: string;
58
+ icon?: string;
59
+ databases?: string[];
60
+ routes?: NotisRouteConfig[];
61
+ tools?: string[];
62
+ }
63
+
64
+ /**
65
+ * Identity function that provides type checking and autocomplete for the
66
+ * Notis app configuration. The returned object is read at build time by
67
+ * `notis apps build` to generate the manifest.
68
+ */
69
+ export function defineNotisApp(config: NotisAppConfig): NotisAppConfig {
70
+ return config;
71
+ }
@@ -0,0 +1,41 @@
1
+ 'use client';
2
+
3
+ import { useCallback } from 'react';
4
+ import { useNotisRuntime } from '../provider';
5
+
6
+ interface UseBackendResult {
7
+ /**
8
+ * Make an authenticated request to the Notis backend.
9
+ * The path should be an absolute backend path (e.g. `/portal_composio/actions`).
10
+ */
11
+ request: (path: string, options?: {
12
+ method?: string;
13
+ headers?: Record<string, string>;
14
+ body?: unknown;
15
+ }) => Promise<unknown>;
16
+ }
17
+
18
+ /**
19
+ * Raw backend request proxy. Use this for custom server endpoints or
20
+ * integration APIs not covered by the typed hooks.
21
+ *
22
+ * ```tsx
23
+ * const { request } = useBackend();
24
+ * const data = await request('/portal_composio/actions', { method: 'POST', body: { ... } });
25
+ * ```
26
+ */
27
+ export function useBackend(): UseBackendResult {
28
+ const runtime = useNotisRuntime();
29
+
30
+ const request = useCallback(
31
+ async (path: string, options?: { method?: string; headers?: Record<string, string>; body?: unknown }) => {
32
+ if (!runtime) {
33
+ throw new Error('Notis runtime not available. Ensure NotisProvider is mounted.');
34
+ }
35
+ return runtime.request(path, options);
36
+ },
37
+ [runtime],
38
+ );
39
+
40
+ return { request };
41
+ }
@@ -0,0 +1,76 @@
1
+ 'use client';
2
+
3
+ import { useCallback, useEffect, useState } from 'react';
4
+ import { useNotisRuntime } from '../provider';
5
+ import type { DocumentRecord, QueryFilter } from '../runtime';
6
+
7
+ interface UseDatabaseOptions {
8
+ filter?: QueryFilter;
9
+ pageSize?: number;
10
+ offset?: number;
11
+ enabled?: boolean;
12
+ }
13
+
14
+ interface UseDatabaseResult {
15
+ documents: DocumentRecord[];
16
+ loading: boolean;
17
+ error: Error | null;
18
+ refetch: () => void;
19
+ }
20
+
21
+ export function useDatabase(
22
+ databaseSlug: string,
23
+ options: UseDatabaseOptions = {},
24
+ ): UseDatabaseResult {
25
+ const runtime = useNotisRuntime();
26
+ const [documents, setDocuments] = useState<DocumentRecord[]>([]);
27
+ const [loading, setLoading] = useState(true);
28
+ const [error, setError] = useState<Error | null>(null);
29
+ const [fetchKey, setFetchKey] = useState(0);
30
+
31
+ const enabled = options.enabled !== false;
32
+ const filterKey = JSON.stringify(options.filter ?? null);
33
+
34
+ const refetch = useCallback(() => {
35
+ setFetchKey((key) => key + 1);
36
+ }, []);
37
+
38
+ useEffect(() => {
39
+ if (!runtime || !enabled) {
40
+ setLoading(false);
41
+ return;
42
+ }
43
+
44
+ let cancelled = false;
45
+ setLoading(true);
46
+ setError(null);
47
+
48
+ runtime
49
+ .queryDatabase({
50
+ databaseSlug,
51
+ query: {
52
+ ...options.filter,
53
+ page_size: options.pageSize,
54
+ },
55
+ offset: options.offset,
56
+ })
57
+ .then((result) => {
58
+ if (!cancelled) {
59
+ setDocuments(result.documents);
60
+ setLoading(false);
61
+ }
62
+ })
63
+ .catch((err) => {
64
+ if (!cancelled) {
65
+ setError(err instanceof Error ? err : new Error(String(err)));
66
+ setLoading(false);
67
+ }
68
+ });
69
+
70
+ return () => {
71
+ cancelled = true;
72
+ };
73
+ }, [runtime, databaseSlug, fetchKey, enabled, filterKey, options.offset, options.pageSize]);
74
+
75
+ return { documents, loading, error, refetch };
76
+ }
@@ -0,0 +1,503 @@
1
+ 'use client';
2
+
3
+ import {
4
+ useCallback,
5
+ useEffect,
6
+ useMemo,
7
+ useRef,
8
+ useState,
9
+ type MouseEvent as ReactMouseEvent,
10
+ } from 'react';
11
+
12
+ const ROW_ATTR = 'data-notis-row-id';
13
+ const INTERACTIVE_SELECTOR = 'button, a, input, textarea, select, [role="button"]';
14
+
15
+ export interface DragRect {
16
+ left: number;
17
+ top: number;
18
+ width: number;
19
+ height: number;
20
+ }
21
+
22
+ export interface UseMultiSelectOptions<T> {
23
+ items: T[];
24
+ getId: (item: T) => string;
25
+ /** When false, suppresses the global Esc / Cmd+A / X / Shift+Arrow listeners. Default true. */
26
+ bindKeyboardShortcuts?: boolean;
27
+ /** When false, disables the rubber-band drag-select rectangle. Default true. */
28
+ enableDragSelect?: boolean;
29
+ /** Distance (px) the cursor must move past mousedown before a drag begins. Default 5. */
30
+ dragThreshold?: number;
31
+ /** Initial head id (the row that Shift+Arrow extends from). */
32
+ initialHeadId?: string | null;
33
+ /** Fires when the head moves via Shift+Arrow so the consumer can scroll the row into view. */
34
+ onHeadChange?: (id: string | null) => void;
35
+ }
36
+
37
+ export interface MultiSelectController<T> {
38
+ selectedIds: ReadonlySet<string>;
39
+ selectedCount: number;
40
+ lastClickedId: string | null;
41
+ headId: string | null;
42
+ /** Viewport-fixed drag rectangle, or null when no drag is active. */
43
+ dragRect: DragRect | null;
44
+
45
+ isSelected: (id: string) => boolean;
46
+ getSelectedItems: () => T[];
47
+
48
+ toggle: (id: string) => void;
49
+ select: (ids: string[]) => void;
50
+ clear: () => void;
51
+ selectAll: () => void;
52
+ selectRange: (anchorId: string, targetId: string) => void;
53
+ setHead: (id: string | null) => void;
54
+
55
+ /** Click handler for a row's hover checkbox. Toggles, with shift-click range from lastClickedId. */
56
+ onCheckboxClick: (id: string) => (event: ReactMouseEvent) => void;
57
+ /** mousedown on a row body — handles Cmd/Ctrl+click toggle without opening detail. */
58
+ onRowMouseDown: (id: string) => (event: ReactMouseEvent) => void;
59
+
60
+ /** Spread on each selectable row so drag-select can find them. */
61
+ getRowProps: (id: string) => { [ROW_ATTR]: string };
62
+ /** Spread on the list container — owns the drag-select mousedown/move/up + click-empty-to-clear. */
63
+ getContainerProps: () => {
64
+ ref: (node: HTMLElement | null) => void;
65
+ onMouseDown: (event: ReactMouseEvent) => void;
66
+ onClickCapture: (event: ReactMouseEvent) => void;
67
+ style: { userSelect: 'none' };
68
+ };
69
+ }
70
+
71
+ function isEditableTarget(target: EventTarget | null): boolean {
72
+ if (!(target instanceof HTMLElement)) return false;
73
+ if (target.isContentEditable) return true;
74
+ const tag = target.tagName;
75
+ return tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT';
76
+ }
77
+
78
+ function rectsIntersect(
79
+ a: { left: number; top: number; right: number; bottom: number },
80
+ b: { left: number; top: number; right: number; bottom: number },
81
+ ): boolean {
82
+ return a.left < b.right && a.right > b.left && a.top < b.bottom && a.bottom > b.top;
83
+ }
84
+
85
+ export function useMultiSelect<T>(opts: UseMultiSelectOptions<T>): MultiSelectController<T> {
86
+ const {
87
+ items,
88
+ getId,
89
+ bindKeyboardShortcuts = true,
90
+ enableDragSelect = true,
91
+ dragThreshold = 5,
92
+ initialHeadId = null,
93
+ onHeadChange,
94
+ } = opts;
95
+
96
+ const [selectedIds, setSelectedIds] = useState<Set<string>>(() => new Set());
97
+ const [lastClickedId, setLastClickedId] = useState<string | null>(null);
98
+ const [headId, setHeadIdState] = useState<string | null>(initialHeadId);
99
+ const [dragRect, setDragRect] = useState<DragRect | null>(null);
100
+
101
+ // Latest-state refs so document listeners and stable callbacks read fresh
102
+ // values without retriggering the effect.
103
+ const itemsRef = useRef(items);
104
+ const getIdRef = useRef(getId);
105
+ const selectedIdsRef = useRef(selectedIds);
106
+ const lastClickedIdRef = useRef(lastClickedId);
107
+ const headIdRef = useRef(headId);
108
+ const onHeadChangeRef = useRef(onHeadChange);
109
+ const containerRef = useRef<HTMLElement | null>(null);
110
+
111
+ itemsRef.current = items;
112
+ getIdRef.current = getId;
113
+ selectedIdsRef.current = selectedIds;
114
+ lastClickedIdRef.current = lastClickedId;
115
+ headIdRef.current = headId;
116
+ onHeadChangeRef.current = onHeadChange;
117
+
118
+ const orderedIds = useMemo(() => items.map((item) => getId(item)), [items, getId]);
119
+ const orderedIdsRef = useRef(orderedIds);
120
+ orderedIdsRef.current = orderedIds;
121
+
122
+ const isSelected = useCallback((id: string) => selectedIds.has(id), [selectedIds]);
123
+
124
+ const getSelectedItems = useCallback(() => {
125
+ const set = selectedIdsRef.current;
126
+ return itemsRef.current.filter((item) => set.has(getIdRef.current(item)));
127
+ }, []);
128
+
129
+ const setHead = useCallback((id: string | null) => {
130
+ setHeadIdState((prev) => {
131
+ if (prev === id) return prev;
132
+ onHeadChangeRef.current?.(id);
133
+ return id;
134
+ });
135
+ }, []);
136
+
137
+ const toggle = useCallback((id: string) => {
138
+ setSelectedIds((prev) => {
139
+ const next = new Set(prev);
140
+ if (next.has(id)) {
141
+ next.delete(id);
142
+ } else {
143
+ next.add(id);
144
+ }
145
+ return next;
146
+ });
147
+ setLastClickedId(id);
148
+ }, []);
149
+
150
+ const select = useCallback((ids: string[]) => {
151
+ setSelectedIds(new Set(ids));
152
+ setLastClickedId(ids.length > 0 ? ids[ids.length - 1] ?? null : null);
153
+ }, []);
154
+
155
+ const clear = useCallback(() => {
156
+ setSelectedIds((prev) => (prev.size === 0 ? prev : new Set()));
157
+ }, []);
158
+
159
+ const selectAll = useCallback(() => {
160
+ const all = orderedIdsRef.current;
161
+ setSelectedIds(new Set(all));
162
+ setLastClickedId(all.length > 0 ? all[all.length - 1] ?? null : null);
163
+ }, []);
164
+
165
+ const selectRange = useCallback((anchorId: string, targetId: string) => {
166
+ const all = orderedIdsRef.current;
167
+ const anchorIdx = all.indexOf(anchorId);
168
+ const targetIdx = all.indexOf(targetId);
169
+ if (anchorIdx === -1 || targetIdx === -1) {
170
+ setSelectedIds(new Set([targetId]));
171
+ setLastClickedId(targetId);
172
+ return;
173
+ }
174
+ const start = Math.min(anchorIdx, targetIdx);
175
+ const end = Math.max(anchorIdx, targetIdx);
176
+ const range = all.slice(start, end + 1);
177
+ setSelectedIds((prev) => {
178
+ const next = new Set(prev);
179
+ for (const id of range) next.add(id);
180
+ return next;
181
+ });
182
+ setLastClickedId(targetId);
183
+ }, []);
184
+
185
+ const onCheckboxClick = useCallback(
186
+ (id: string) =>
187
+ (event: ReactMouseEvent) => {
188
+ event.stopPropagation();
189
+ if (event.shiftKey && lastClickedIdRef.current && lastClickedIdRef.current !== id) {
190
+ selectRange(lastClickedIdRef.current, id);
191
+ return;
192
+ }
193
+ toggle(id);
194
+ },
195
+ [selectRange, toggle],
196
+ );
197
+
198
+ const onRowMouseDown = useCallback(
199
+ (id: string) =>
200
+ (event: ReactMouseEvent) => {
201
+ if (event.button !== 0) return;
202
+ if (!(event.metaKey || event.ctrlKey)) return;
203
+ const target = event.target as HTMLElement | null;
204
+ if (target && target.closest(INTERACTIVE_SELECTOR)) return;
205
+ event.preventDefault();
206
+ event.stopPropagation();
207
+ toggle(id);
208
+ // The trailing click on the row should NOT open it.
209
+ armSuppressNextClick();
210
+ },
211
+ [toggle],
212
+ );
213
+
214
+ const getRowProps = useCallback(
215
+ (id: string) => ({
216
+ [ROW_ATTR]: id,
217
+ }),
218
+ [],
219
+ );
220
+
221
+ // ---------------------------------------------------------------------------
222
+ // Drag-select
223
+ // ---------------------------------------------------------------------------
224
+
225
+ const dragPendingRef = useRef(false);
226
+ const dragActiveRef = useRef(false);
227
+ const dragStartRef = useRef<{ x: number; y: number }>({ x: 0, y: 0 });
228
+ const dragCurrentRef = useRef<{ x: number; y: number }>({ x: 0, y: 0 });
229
+ // Tracks whether the most recent mousedown originated outside any row, which
230
+ // means a bare click should clear the selection.
231
+ const clickShouldClearRef = useRef(false);
232
+ // Set true after a gesture that should NOT trigger the row's onClick: an
233
+ // active drag-select, or a cmd/ctrl-click toggle. The container's
234
+ // onClickCapture consumes the next click before it reaches the row.
235
+ const suppressNextClickRef = useRef(false);
236
+
237
+ const armSuppressNextClick = () => {
238
+ suppressNextClickRef.current = true;
239
+ setTimeout(() => {
240
+ suppressNextClickRef.current = false;
241
+ }, 0);
242
+ };
243
+
244
+ const computeDragRect = useCallback((): DragRect => {
245
+ const start = dragStartRef.current;
246
+ const current = dragCurrentRef.current;
247
+ return {
248
+ left: Math.min(start.x, current.x),
249
+ top: Math.min(start.y, current.y),
250
+ width: Math.abs(current.x - start.x),
251
+ height: Math.abs(current.y - start.y),
252
+ };
253
+ }, []);
254
+
255
+ const computeIntersectingIds = useCallback(
256
+ (rect: { left: number; top: number; right: number; bottom: number }): string[] => {
257
+ const container = containerRef.current;
258
+ if (!container) return [];
259
+ const nodes = container.querySelectorAll<HTMLElement>(`[${ROW_ATTR}]`);
260
+ const ids: string[] = [];
261
+ nodes.forEach((node) => {
262
+ const rowRect = node.getBoundingClientRect();
263
+ if (rectsIntersect(rect, rowRect)) {
264
+ const id = node.getAttribute(ROW_ATTR);
265
+ if (id) ids.push(id);
266
+ }
267
+ });
268
+ return ids;
269
+ },
270
+ [],
271
+ );
272
+
273
+ const setContainerRef = useCallback(
274
+ (node: HTMLElement | null) => {
275
+ containerRef.current = node;
276
+ },
277
+ [],
278
+ );
279
+
280
+ const handleContainerMouseDown = useCallback(
281
+ (event: ReactMouseEvent) => {
282
+ if (event.button !== 0) return;
283
+ const target = event.target as HTMLElement | null;
284
+ if (!target) return;
285
+ // Cmd/Ctrl-click on a row is handled by `onRowMouseDown` (which calls
286
+ // stopPropagation), so we never reach here for that case. Skip if the
287
+ // mousedown lands on an interactive element so its native focus/text
288
+ // behavior is preserved.
289
+ if (target.closest(INTERACTIVE_SELECTOR)) {
290
+ clickShouldClearRef.current = false;
291
+ return;
292
+ }
293
+ const isOnRow = Boolean(target.closest(`[${ROW_ATTR}]`));
294
+ // A click on empty space (not on a row) should clear the selection.
295
+ clickShouldClearRef.current = !isOnRow;
296
+ if (!enableDragSelect) return;
297
+ // Arm a possible drag from anywhere in the container — including row
298
+ // bodies. If the user moves past the threshold we activate a rubber-band
299
+ // selection AND suppress the trailing click on the row.
300
+ dragPendingRef.current = true;
301
+ dragStartRef.current = { x: event.clientX, y: event.clientY };
302
+ dragCurrentRef.current = { x: event.clientX, y: event.clientY };
303
+ },
304
+ [enableDragSelect],
305
+ );
306
+
307
+ // Capture-phase click handler intercepts the trailing click after a drag so
308
+ // the row's bubble-phase onClick (which would open the row) never fires.
309
+ const handleContainerClickCapture = useCallback(
310
+ (event: ReactMouseEvent) => {
311
+ if (suppressNextClickRef.current) {
312
+ suppressNextClickRef.current = false;
313
+ event.stopPropagation();
314
+ event.preventDefault();
315
+ }
316
+ },
317
+ [],
318
+ );
319
+
320
+ // Attach document mousemove/mouseup while a drag is pending or active.
321
+ useEffect(() => {
322
+ if (!enableDragSelect) return;
323
+
324
+ const handleMouseMove = (event: MouseEvent) => {
325
+ if (dragPendingRef.current) {
326
+ const dx = event.clientX - dragStartRef.current.x;
327
+ const dy = event.clientY - dragStartRef.current.y;
328
+ if (Math.abs(dx) > dragThreshold || Math.abs(dy) > dragThreshold) {
329
+ dragPendingRef.current = false;
330
+ dragActiveRef.current = true;
331
+ window.getSelection()?.removeAllRanges();
332
+ dragCurrentRef.current = { x: event.clientX, y: event.clientY };
333
+ setDragRect(computeDragRect());
334
+ }
335
+ return;
336
+ }
337
+ if (!dragActiveRef.current) return;
338
+ event.preventDefault();
339
+ dragCurrentRef.current = { x: event.clientX, y: event.clientY };
340
+ const rect = computeDragRect();
341
+ setDragRect(rect);
342
+ const ids = computeIntersectingIds({
343
+ left: rect.left,
344
+ top: rect.top,
345
+ right: rect.left + rect.width,
346
+ bottom: rect.top + rect.height,
347
+ });
348
+ setSelectedIds(new Set(ids));
349
+ setLastClickedId(ids.length > 0 ? ids[ids.length - 1] ?? null : null);
350
+ };
351
+
352
+ const handleMouseUp = () => {
353
+ const wasPending = dragPendingRef.current;
354
+ const wasActive = dragActiveRef.current;
355
+ dragPendingRef.current = false;
356
+ dragActiveRef.current = false;
357
+ if (wasActive) {
358
+ setDragRect(null);
359
+ clickShouldClearRef.current = false;
360
+ // The trailing click on the row should not open it.
361
+ armSuppressNextClick();
362
+ return;
363
+ }
364
+ if (wasPending && clickShouldClearRef.current) {
365
+ // Bare click on empty space — clear the selection.
366
+ clickShouldClearRef.current = false;
367
+ if (selectedIdsRef.current.size > 0) {
368
+ setSelectedIds(new Set());
369
+ }
370
+ }
371
+ };
372
+
373
+ document.addEventListener('mousemove', handleMouseMove);
374
+ document.addEventListener('mouseup', handleMouseUp);
375
+ return () => {
376
+ document.removeEventListener('mousemove', handleMouseMove);
377
+ document.removeEventListener('mouseup', handleMouseUp);
378
+ };
379
+ }, [computeDragRect, computeIntersectingIds, dragThreshold, enableDragSelect]);
380
+
381
+ // ---------------------------------------------------------------------------
382
+ // Keyboard shortcuts
383
+ // ---------------------------------------------------------------------------
384
+
385
+ useEffect(() => {
386
+ if (!bindKeyboardShortcuts) return;
387
+
388
+ const handleKeyDown = (event: KeyboardEvent) => {
389
+ if (isEditableTarget(event.target)) return;
390
+
391
+ // Esc — clear selection
392
+ if (event.key === 'Escape') {
393
+ if (selectedIdsRef.current.size > 0) {
394
+ event.preventDefault();
395
+ setSelectedIds(new Set());
396
+ }
397
+ return;
398
+ }
399
+
400
+ // Cmd/Ctrl + A — select all
401
+ if ((event.metaKey || event.ctrlKey) && !event.shiftKey && !event.altKey) {
402
+ const key = event.key.toLowerCase();
403
+ if (key === 'a') {
404
+ if (orderedIdsRef.current.length === 0) return;
405
+ event.preventDefault();
406
+ const all = orderedIdsRef.current;
407
+ setSelectedIds(new Set(all));
408
+ setLastClickedId(all[all.length - 1] ?? null);
409
+ return;
410
+ }
411
+ }
412
+
413
+ // X — toggle the focused/head row
414
+ if (
415
+ event.key === 'x' &&
416
+ !event.shiftKey &&
417
+ !event.metaKey &&
418
+ !event.ctrlKey &&
419
+ !event.altKey
420
+ ) {
421
+ const targetId = headIdRef.current ?? lastClickedIdRef.current;
422
+ if (!targetId) return;
423
+ event.preventDefault();
424
+ toggle(targetId);
425
+ return;
426
+ }
427
+
428
+ // Shift + ArrowUp / ArrowDown — extend range from anchor
429
+ if (
430
+ (event.key === 'ArrowDown' || event.key === 'ArrowUp') &&
431
+ event.shiftKey &&
432
+ !event.metaKey &&
433
+ !event.ctrlKey &&
434
+ !event.altKey
435
+ ) {
436
+ const all = orderedIdsRef.current;
437
+ if (all.length === 0) return;
438
+ event.preventDefault();
439
+ const direction = event.key === 'ArrowDown' ? 1 : -1;
440
+ const currentHead = headIdRef.current;
441
+ const currentIdx = currentHead ? all.indexOf(currentHead) : -1;
442
+ const newIdx =
443
+ currentIdx === -1
444
+ ? direction === 1
445
+ ? 0
446
+ : all.length - 1
447
+ : Math.min(all.length - 1, Math.max(0, currentIdx + direction));
448
+ if (newIdx === currentIdx) return;
449
+ const newHead = all[newIdx];
450
+ if (!newHead) return;
451
+ const anchorId = lastClickedIdRef.current ?? currentHead ?? newHead;
452
+ const anchorIdx = all.indexOf(anchorId);
453
+ if (anchorIdx === -1) {
454
+ setSelectedIds(new Set([newHead]));
455
+ } else {
456
+ const start = Math.min(anchorIdx, newIdx);
457
+ const end = Math.max(anchorIdx, newIdx);
458
+ setSelectedIds(new Set(all.slice(start, end + 1)));
459
+ }
460
+ if (lastClickedIdRef.current === null) {
461
+ setLastClickedId(anchorId);
462
+ }
463
+ setHead(newHead);
464
+ return;
465
+ }
466
+ };
467
+
468
+ document.addEventListener('keydown', handleKeyDown);
469
+ return () => {
470
+ document.removeEventListener('keydown', handleKeyDown);
471
+ };
472
+ }, [bindKeyboardShortcuts, setHead, toggle]);
473
+
474
+ const getContainerProps = useCallback(
475
+ () => ({
476
+ ref: setContainerRef,
477
+ onMouseDown: handleContainerMouseDown,
478
+ onClickCapture: handleContainerClickCapture,
479
+ style: { userSelect: 'none' as const },
480
+ }),
481
+ [handleContainerClickCapture, handleContainerMouseDown, setContainerRef],
482
+ );
483
+
484
+ return {
485
+ selectedIds,
486
+ selectedCount: selectedIds.size,
487
+ lastClickedId,
488
+ headId,
489
+ dragRect,
490
+ isSelected,
491
+ getSelectedItems,
492
+ toggle,
493
+ select,
494
+ clear,
495
+ selectAll,
496
+ selectRange,
497
+ setHead,
498
+ onCheckboxClick,
499
+ onRowMouseDown,
500
+ getRowProps,
501
+ getContainerProps,
502
+ };
503
+ }