@djangocfg/ui-tools 2.1.415 → 2.1.416
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/dist/file-icon/index.d.cts +1 -1
- package/dist/file-icon/index.d.ts +1 -1
- package/dist/slots-ClRpIzoh.d.cts +88 -0
- package/dist/slots-ClRpIzoh.d.ts +88 -0
- package/dist/tree/index.cjs +1994 -276
- package/dist/tree/index.cjs.map +1 -1
- package/dist/tree/index.d.cts +717 -72
- package/dist/tree/index.d.ts +717 -72
- package/dist/tree/index.mjs +1984 -279
- package/dist/tree/index.mjs.map +1 -1
- package/package.json +10 -6
- package/src/tools/chat/README.md +111 -1
- package/src/tools/chat/composer/Composer.tsx +138 -17
- package/src/tools/chat/composer/ComposerRichTextarea.tsx +25 -0
- package/src/tools/chat/composer/index.ts +22 -0
- package/src/tools/chat/composer/slash/README.md +187 -0
- package/src/tools/chat/composer/slash/SlashHighlightTextarea.tsx +144 -0
- package/src/tools/chat/composer/slash/SlashMenu.tsx +142 -0
- package/src/tools/chat/composer/slash/SlashToken.tsx +57 -0
- package/src/tools/chat/composer/slash/index.ts +44 -0
- package/src/tools/chat/composer/slash/labels.ts +19 -0
- package/src/tools/chat/composer/slash/state.ts +168 -0
- package/src/tools/chat/composer/slash/types.ts +64 -0
- package/src/tools/chat/composer/slash/useSlashCommands.ts +204 -0
- package/src/tools/chat/composer/types.ts +8 -0
- package/src/tools/chat/shell/SuggestedPrompts.tsx +194 -0
- package/src/tools/chat/shell/index.ts +6 -0
- package/src/tools/data/Listbox/lazy.tsx +1 -1
- package/src/tools/data/Masonry/lazy.tsx +1 -1
- package/src/tools/data/Timeline/lazy.tsx +1 -1
- package/src/tools/data/Tree/FinderTree.tsx +42 -0
- package/src/tools/data/Tree/README.md +337 -208
- package/src/tools/data/Tree/TreeDndProvider.tsx +137 -0
- package/src/tools/data/Tree/TreeRoot.tsx +170 -55
- package/src/tools/data/Tree/__tests__/dnd.test.ts +160 -0
- package/src/tools/data/Tree/__tests__/keyboard.test.ts +137 -0
- package/src/tools/data/Tree/__tests__/renameUtils.test.ts +52 -0
- package/src/tools/data/Tree/__tests__/selection.test.ts +227 -0
- package/src/tools/data/Tree/components/TreeDropIndicator.tsx +65 -0
- package/src/tools/data/Tree/components/TreeEmptyArea.tsx +160 -0
- package/src/tools/data/Tree/components/TreeRenameInput.tsx +114 -0
- package/src/tools/data/Tree/components/TreeRow.tsx +92 -8
- package/src/tools/data/Tree/components/index.ts +6 -0
- package/src/tools/data/Tree/context/TreeContext.tsx +204 -363
- package/src/tools/data/Tree/context/TreeContextValue.ts +139 -0
- package/src/tools/data/Tree/context/async-children/collect-ids.ts +27 -0
- package/src/tools/data/Tree/context/async-children/index.ts +8 -0
- package/src/tools/data/Tree/context/async-children/use-async-children.ts +157 -0
- package/src/tools/data/Tree/context/clipboard/index.ts +4 -0
- package/src/tools/data/Tree/context/clipboard/use-clipboard.ts +115 -0
- package/src/tools/data/Tree/context/dnd/index.ts +8 -0
- package/src/tools/data/Tree/context/dnd/use-dnd.ts +194 -0
- package/src/tools/data/Tree/context/expansion/index.ts +4 -0
- package/src/tools/data/Tree/context/expansion/use-expansion.ts +55 -0
- package/src/tools/data/Tree/context/hooks.ts +68 -1
- package/src/tools/data/Tree/context/index.ts +3 -0
- package/src/tools/data/Tree/context/menu/builtin-actions.ts +357 -0
- package/src/tools/data/Tree/context/menu/index.ts +10 -0
- package/src/tools/data/Tree/context/menu/use-resolved-menu.ts +127 -0
- package/src/tools/data/Tree/context/persist/index.ts +4 -0
- package/src/tools/data/Tree/context/persist/use-persist-sync.ts +74 -0
- package/src/tools/data/Tree/context/rename/index.ts +4 -0
- package/src/tools/data/Tree/context/rename/use-rename.ts +113 -0
- package/src/tools/data/Tree/context/selection/index.ts +4 -0
- package/src/tools/data/Tree/context/selection/use-selection.ts +146 -0
- package/src/tools/data/Tree/context/state/index.ts +6 -0
- package/src/tools/data/Tree/context/state/initial.ts +41 -0
- package/src/tools/data/Tree/context/state/reducer.ts +76 -0
- package/src/tools/data/Tree/context/state/types.ts +46 -0
- package/src/tools/data/Tree/data/clipboard.ts +33 -0
- package/src/tools/data/Tree/data/dnd.ts +123 -0
- package/src/tools/data/Tree/data/finderShortcuts.ts +67 -0
- package/src/tools/data/Tree/data/index.ts +19 -0
- package/src/tools/data/Tree/data/renameUtils.ts +51 -0
- package/src/tools/data/Tree/data/selection.ts +157 -0
- package/src/tools/data/Tree/hooks/finder-hotkeys/build-ctx.ts +48 -0
- package/src/tools/data/Tree/hooks/finder-hotkeys/index.ts +8 -0
- package/src/tools/data/Tree/hooks/finder-hotkeys/use-tree-finder-hotkeys.ts +166 -0
- package/src/tools/data/Tree/hooks/index.ts +23 -4
- package/src/tools/data/Tree/hooks/keyboard/activation.ts +27 -0
- package/src/tools/data/Tree/hooks/keyboard/arrow-nav.ts +26 -0
- package/src/tools/data/Tree/hooks/keyboard/expand-collapse.ts +54 -0
- package/src/tools/data/Tree/hooks/keyboard/index.ts +10 -0
- package/src/tools/data/Tree/hooks/keyboard/types.ts +39 -0
- package/src/tools/data/Tree/hooks/keyboard/use-tree-keyboard.ts +196 -0
- package/src/tools/data/Tree/hooks/type-ahead/index.ts +5 -0
- package/src/tools/data/Tree/hooks/type-ahead/match-prefix.ts +42 -0
- package/src/tools/data/Tree/hooks/{useTreeTypeAhead.ts → type-ahead/use-tree-type-ahead.ts} +8 -19
- package/src/tools/data/Tree/index.tsx +25 -2
- package/src/tools/data/Tree/types/activation.ts +30 -0
- package/src/tools/data/Tree/types/adapter.ts +70 -0
- package/src/tools/data/Tree/types/index.ts +27 -0
- package/src/tools/data/Tree/types/labels.ts +97 -0
- package/src/tools/data/Tree/types/loader.ts +9 -0
- package/src/tools/data/Tree/types/node.ts +38 -0
- package/src/tools/data/Tree/types/root-props.ts +142 -0
- package/src/tools/data/Tree/types/selection.ts +3 -0
- package/src/tools/data/Tree/types/slots.ts +64 -0
- package/src/tools/forms/MarkdownEditor/MarkdownEditor.tsx +85 -0
- package/src/tools/forms/MarkdownEditor/index.ts +1 -0
- package/src/tools/forms/MarkdownEditor/lazy.tsx +6 -0
- package/src/tools/forms/MarkdownEditor/slash/SlashCommandNode.ts +162 -0
- package/src/tools/forms/MarkdownEditor/slash/index.ts +4 -0
- package/src/tools/forms/MarkdownEditor/slash/syncSlashNode.ts +97 -0
- package/src/tools/forms/MarkdownEditor/slash/types.ts +13 -0
- package/src/tools/forms/MarkdownEditor/styles.css +18 -0
- package/dist/types-j2vhn4Kv.d.cts +0 -241
- package/dist/types-j2vhn4Kv.d.ts +0 -241
- package/src/tools/data/Tree/hooks/useTreeKeyboard.ts +0 -171
- package/src/tools/data/Tree/types.ts +0 -217
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { useEffect, useRef } from 'react';
|
|
4
|
+
|
|
5
|
+
import { saveTreeState } from '../../data/persist';
|
|
6
|
+
import type { TreeItemId } from '../../types';
|
|
7
|
+
|
|
8
|
+
export interface UsePersistSyncOptions {
|
|
9
|
+
expanded: ReadonlySet<TreeItemId>;
|
|
10
|
+
selected: ReadonlySet<TreeItemId>;
|
|
11
|
+
persistKey?: string;
|
|
12
|
+
persistSelection: boolean;
|
|
13
|
+
onSelectionChange?: (ids: TreeItemId[]) => void;
|
|
14
|
+
onExpansionChange?: (ids: TreeItemId[]) => void;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Persist `expanded` / `selected` into `localStorage` under `persistKey`,
|
|
19
|
+
* and forward changes through the consumer's notify callbacks. We
|
|
20
|
+
* compare by content (not identity) because the reducer recreates
|
|
21
|
+
* `Set`s on every change — without diffing we'd fire `onSelectionChange`
|
|
22
|
+
* on every render.
|
|
23
|
+
*/
|
|
24
|
+
export function usePersistSync({
|
|
25
|
+
expanded,
|
|
26
|
+
selected,
|
|
27
|
+
persistKey,
|
|
28
|
+
persistSelection,
|
|
29
|
+
onSelectionChange,
|
|
30
|
+
onExpansionChange,
|
|
31
|
+
}: UsePersistSyncOptions): void {
|
|
32
|
+
// Stable refs so we don't re-bind effects every render.
|
|
33
|
+
const onSelectionChangeRef = useRef(onSelectionChange);
|
|
34
|
+
const onExpansionChangeRef = useRef(onExpansionChange);
|
|
35
|
+
onSelectionChangeRef.current = onSelectionChange;
|
|
36
|
+
onExpansionChangeRef.current = onExpansionChange;
|
|
37
|
+
|
|
38
|
+
const lastSelectedArrRef = useRef<TreeItemId[]>([...selected]);
|
|
39
|
+
const lastExpandedArrRef = useRef<TreeItemId[]>([...expanded]);
|
|
40
|
+
|
|
41
|
+
useEffect(() => {
|
|
42
|
+
const arr = [...expanded];
|
|
43
|
+
if (!setEqualsArr(expanded, lastExpandedArrRef.current)) {
|
|
44
|
+
lastExpandedArrRef.current = arr;
|
|
45
|
+
onExpansionChangeRef.current?.(arr);
|
|
46
|
+
if (persistKey) {
|
|
47
|
+
saveTreeState(persistKey, {
|
|
48
|
+
expandedItems: arr,
|
|
49
|
+
selectedItems: persistSelection ? [...selected] : [],
|
|
50
|
+
});
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
}, [expanded, persistKey, persistSelection, selected]);
|
|
54
|
+
|
|
55
|
+
useEffect(() => {
|
|
56
|
+
const arr = [...selected];
|
|
57
|
+
if (!setEqualsArr(selected, lastSelectedArrRef.current)) {
|
|
58
|
+
lastSelectedArrRef.current = arr;
|
|
59
|
+
onSelectionChangeRef.current?.(arr);
|
|
60
|
+
if (persistKey && persistSelection) {
|
|
61
|
+
saveTreeState(persistKey, {
|
|
62
|
+
expandedItems: [...expanded],
|
|
63
|
+
selectedItems: arr,
|
|
64
|
+
});
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
}, [selected, persistKey, persistSelection, expanded]);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function setEqualsArr(set: ReadonlySet<string>, arr: readonly string[]): boolean {
|
|
71
|
+
if (set.size !== arr.length) return false;
|
|
72
|
+
for (const id of arr) if (!set.has(id)) return false;
|
|
73
|
+
return true;
|
|
74
|
+
}
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { useCallback } from 'react';
|
|
4
|
+
|
|
5
|
+
import { getDialog } from '@djangocfg/ui-core/lib/dialog-service';
|
|
6
|
+
|
|
7
|
+
import type {
|
|
8
|
+
TreeAdapter,
|
|
9
|
+
TreeItemId,
|
|
10
|
+
TreeLabels,
|
|
11
|
+
TreeNode,
|
|
12
|
+
} from '../../types';
|
|
13
|
+
import type { Action } from '../state';
|
|
14
|
+
|
|
15
|
+
export interface UseRenameOptions<T> {
|
|
16
|
+
dispatch: React.Dispatch<Action>;
|
|
17
|
+
adapter?: TreeAdapter<T>;
|
|
18
|
+
/** Master switch — hooks honour it on every entrypoint. */
|
|
19
|
+
enableInlineRename: boolean;
|
|
20
|
+
nodeById: Map<TreeItemId, TreeNode<T>>;
|
|
21
|
+
getItemName: (node: TreeNode<T>) => string;
|
|
22
|
+
labels: TreeLabels;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export interface UseRenameReturn {
|
|
26
|
+
/** True only when both the host opted in AND adapter exposes `rename`. */
|
|
27
|
+
enabled: boolean;
|
|
28
|
+
startRename: (id: TreeItemId) => void;
|
|
29
|
+
cancelRename: () => void;
|
|
30
|
+
commitRename: (id: TreeItemId, nextName: string) => Promise<boolean>;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Inline rename callbacks. The visible UI lives in `TreeRenameInput`;
|
|
35
|
+
* this hook owns the start/cancel/commit transitions and dialog-driven
|
|
36
|
+
* error surfacing.
|
|
37
|
+
*/
|
|
38
|
+
export function useRename<T>({
|
|
39
|
+
dispatch,
|
|
40
|
+
adapter,
|
|
41
|
+
enableInlineRename,
|
|
42
|
+
nodeById,
|
|
43
|
+
getItemName,
|
|
44
|
+
labels,
|
|
45
|
+
}: UseRenameOptions<T>): UseRenameReturn {
|
|
46
|
+
const enabled = enableInlineRename && !!adapter?.rename;
|
|
47
|
+
|
|
48
|
+
const startRename = useCallback(
|
|
49
|
+
(id: TreeItemId) => {
|
|
50
|
+
if (!enableInlineRename) return;
|
|
51
|
+
if (!adapter?.rename) {
|
|
52
|
+
if (process.env.NODE_ENV !== 'production') {
|
|
53
|
+
// eslint-disable-next-line no-console
|
|
54
|
+
console.warn(
|
|
55
|
+
'[Tree] startRename called but adapter.rename is not defined.',
|
|
56
|
+
);
|
|
57
|
+
}
|
|
58
|
+
return;
|
|
59
|
+
}
|
|
60
|
+
dispatch({ type: 'start-rename', id });
|
|
61
|
+
},
|
|
62
|
+
[dispatch, enableInlineRename, adapter],
|
|
63
|
+
);
|
|
64
|
+
|
|
65
|
+
const cancelRename = useCallback(
|
|
66
|
+
() => dispatch({ type: 'stop-rename' }),
|
|
67
|
+
[dispatch],
|
|
68
|
+
);
|
|
69
|
+
|
|
70
|
+
const commitRename = useCallback(
|
|
71
|
+
async (id: TreeItemId, nextName: string): Promise<boolean> => {
|
|
72
|
+
if (!adapter?.rename) {
|
|
73
|
+
dispatch({ type: 'stop-rename' });
|
|
74
|
+
return false;
|
|
75
|
+
}
|
|
76
|
+
const node = nodeById.get(id);
|
|
77
|
+
if (!node) {
|
|
78
|
+
dispatch({ type: 'stop-rename' });
|
|
79
|
+
return false;
|
|
80
|
+
}
|
|
81
|
+
const trimmed = nextName.trim();
|
|
82
|
+
if (trimmed === getItemName(node)) {
|
|
83
|
+
dispatch({ type: 'stop-rename' });
|
|
84
|
+
return true;
|
|
85
|
+
}
|
|
86
|
+
let err: string | null = null;
|
|
87
|
+
if (trimmed === '') err = labels.invalidNameEmpty;
|
|
88
|
+
else err = adapter.validateName?.(trimmed, { node }) ?? null;
|
|
89
|
+
|
|
90
|
+
if (err) {
|
|
91
|
+
const dialog = getDialog();
|
|
92
|
+
await dialog?.alert({ title: labels.error, message: err });
|
|
93
|
+
return false;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
try {
|
|
97
|
+
await adapter.rename(node, trimmed);
|
|
98
|
+
} catch (e) {
|
|
99
|
+
const dialog = getDialog();
|
|
100
|
+
await dialog?.alert({
|
|
101
|
+
title: labels.error,
|
|
102
|
+
message: e instanceof Error ? e.message : String(e),
|
|
103
|
+
});
|
|
104
|
+
return false;
|
|
105
|
+
}
|
|
106
|
+
dispatch({ type: 'stop-rename' });
|
|
107
|
+
return true;
|
|
108
|
+
},
|
|
109
|
+
[dispatch, adapter, nodeById, getItemName, labels],
|
|
110
|
+
);
|
|
111
|
+
|
|
112
|
+
return { enabled, startRename, cancelRename, commitRename };
|
|
113
|
+
}
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { useCallback, useRef } from 'react';
|
|
4
|
+
|
|
5
|
+
import {
|
|
6
|
+
selectionFromClick,
|
|
7
|
+
selectionFromMove,
|
|
8
|
+
selectionSelectAll,
|
|
9
|
+
type ClickModifiers,
|
|
10
|
+
} from '../../data/selection';
|
|
11
|
+
import type {
|
|
12
|
+
FlatRow,
|
|
13
|
+
TreeItemId,
|
|
14
|
+
TreeSelectionMode,
|
|
15
|
+
} from '../../types';
|
|
16
|
+
import type { Action } from '../state';
|
|
17
|
+
|
|
18
|
+
export interface UseSelectionOptions<T> {
|
|
19
|
+
dispatch: React.Dispatch<Action>;
|
|
20
|
+
selectionMode: TreeSelectionMode;
|
|
21
|
+
/** Current visible rows — fed in each render, latched into a ref. */
|
|
22
|
+
flatRows: FlatRow<T>[];
|
|
23
|
+
/** Current selection snapshot (latched into a ref alongside `flatRows`). */
|
|
24
|
+
selected: ReadonlySet<TreeItemId>;
|
|
25
|
+
anchor: TreeItemId | null;
|
|
26
|
+
focused: TreeItemId | null;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export interface UseSelectionReturn {
|
|
30
|
+
select: (id: TreeItemId) => void;
|
|
31
|
+
setSelectedIds: (ids: TreeItemId[]) => void;
|
|
32
|
+
clearSelection: () => void;
|
|
33
|
+
clickSelect: (id: TreeItemId, mods: ClickModifiers) => void;
|
|
34
|
+
moveSelect: (id: TreeItemId, opts: { extend: boolean }) => void;
|
|
35
|
+
selectAll: () => void;
|
|
36
|
+
setFocus: (id: TreeItemId | null) => void;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Finder/Explorer-style selection actions. Reads the *current* rows /
|
|
41
|
+
* selection from refs latched per-render, so the returned callbacks
|
|
42
|
+
* stay stable across renders (which matters for `react-hotkeys-hook`
|
|
43
|
+
* and for memoised TreeRow children).
|
|
44
|
+
*/
|
|
45
|
+
export function useSelection<T>({
|
|
46
|
+
dispatch,
|
|
47
|
+
selectionMode,
|
|
48
|
+
flatRows,
|
|
49
|
+
selected,
|
|
50
|
+
anchor,
|
|
51
|
+
focused,
|
|
52
|
+
}: UseSelectionOptions<T>): UseSelectionReturn {
|
|
53
|
+
// Latch the snapshot the callbacks read from.
|
|
54
|
+
const flatRowsRef = useRef<FlatRow<T>[]>(flatRows);
|
|
55
|
+
flatRowsRef.current = flatRows;
|
|
56
|
+
const selectionRef = useRef({ selected, anchor, focused });
|
|
57
|
+
selectionRef.current = { selected, anchor, focused };
|
|
58
|
+
|
|
59
|
+
const select = useCallback(
|
|
60
|
+
(id: TreeItemId) => dispatch({ type: 'select', id, mode: selectionMode }),
|
|
61
|
+
[dispatch, selectionMode],
|
|
62
|
+
);
|
|
63
|
+
const setSelectedIds = useCallback(
|
|
64
|
+
(ids: TreeItemId[]) => dispatch({ type: 'select-many', ids }),
|
|
65
|
+
[dispatch],
|
|
66
|
+
);
|
|
67
|
+
const clearSelection = useCallback(
|
|
68
|
+
() => dispatch({ type: 'clear-selection' }),
|
|
69
|
+
[dispatch],
|
|
70
|
+
);
|
|
71
|
+
const setFocus = useCallback(
|
|
72
|
+
(id: TreeItemId | null) => dispatch({ type: 'focus', id }),
|
|
73
|
+
[dispatch],
|
|
74
|
+
);
|
|
75
|
+
|
|
76
|
+
const clickSelect = useCallback(
|
|
77
|
+
(id: TreeItemId, mods: ClickModifiers) => {
|
|
78
|
+
if (selectionMode === 'none') return;
|
|
79
|
+
if (selectionMode === 'single') {
|
|
80
|
+
dispatch({ type: 'select', id, mode: 'single' });
|
|
81
|
+
return;
|
|
82
|
+
}
|
|
83
|
+
const next = selectionFromClick(
|
|
84
|
+
selectionRef.current,
|
|
85
|
+
flatRowsRef.current,
|
|
86
|
+
id,
|
|
87
|
+
mods,
|
|
88
|
+
true,
|
|
89
|
+
);
|
|
90
|
+
dispatch({
|
|
91
|
+
type: 'selection-replace',
|
|
92
|
+
selected: [...next.selected],
|
|
93
|
+
anchor: next.anchor,
|
|
94
|
+
focused: next.focused,
|
|
95
|
+
});
|
|
96
|
+
},
|
|
97
|
+
[dispatch, selectionMode],
|
|
98
|
+
);
|
|
99
|
+
|
|
100
|
+
const moveSelect = useCallback(
|
|
101
|
+
(id: TreeItemId, opts: { extend: boolean }) => {
|
|
102
|
+
if (selectionMode !== 'multiple' || !opts.extend) {
|
|
103
|
+
dispatch({ type: 'focus', id });
|
|
104
|
+
return;
|
|
105
|
+
}
|
|
106
|
+
const next = selectionFromMove(
|
|
107
|
+
selectionRef.current,
|
|
108
|
+
flatRowsRef.current,
|
|
109
|
+
id,
|
|
110
|
+
true,
|
|
111
|
+
true,
|
|
112
|
+
);
|
|
113
|
+
dispatch({
|
|
114
|
+
type: 'selection-replace',
|
|
115
|
+
selected: [...next.selected],
|
|
116
|
+
anchor: next.anchor,
|
|
117
|
+
focused: next.focused,
|
|
118
|
+
});
|
|
119
|
+
},
|
|
120
|
+
[dispatch, selectionMode],
|
|
121
|
+
);
|
|
122
|
+
|
|
123
|
+
const selectAll = useCallback(() => {
|
|
124
|
+
if (selectionMode !== 'multiple') return;
|
|
125
|
+
const next = selectionSelectAll(
|
|
126
|
+
flatRowsRef.current,
|
|
127
|
+
selectionRef.current.focused,
|
|
128
|
+
);
|
|
129
|
+
dispatch({
|
|
130
|
+
type: 'selection-replace',
|
|
131
|
+
selected: [...next.selected],
|
|
132
|
+
anchor: next.anchor,
|
|
133
|
+
focused: next.focused,
|
|
134
|
+
});
|
|
135
|
+
}, [dispatch, selectionMode]);
|
|
136
|
+
|
|
137
|
+
return {
|
|
138
|
+
select,
|
|
139
|
+
setSelectedIds,
|
|
140
|
+
clearSelection,
|
|
141
|
+
clickSelect,
|
|
142
|
+
moveSelect,
|
|
143
|
+
selectAll,
|
|
144
|
+
setFocus,
|
|
145
|
+
};
|
|
146
|
+
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import type { PersistedTreeState } from '../../data/persist';
|
|
4
|
+
import type { TreeItemId } from '../../types';
|
|
5
|
+
import type { State } from './types';
|
|
6
|
+
|
|
7
|
+
export interface CreateInitialStateInput {
|
|
8
|
+
persisted: PersistedTreeState | null;
|
|
9
|
+
initialExpandedIds?: TreeItemId[];
|
|
10
|
+
initialSelectedIds?: TreeItemId[];
|
|
11
|
+
persistSelection: boolean;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Build the reducer's initial state from persistence + initial props.
|
|
16
|
+
* Anchor is set to the first restored selection (if any) so a hard
|
|
17
|
+
* reload doesn't break shift-extend.
|
|
18
|
+
*/
|
|
19
|
+
export function createInitialState(input: CreateInitialStateInput): State {
|
|
20
|
+
const { persisted, initialExpandedIds, initialSelectedIds, persistSelection } =
|
|
21
|
+
input;
|
|
22
|
+
const initialSelected = new Set(
|
|
23
|
+
(persistSelection ? persisted?.selectedItems : undefined) ??
|
|
24
|
+
initialSelectedIds ??
|
|
25
|
+
[],
|
|
26
|
+
);
|
|
27
|
+
const initialAnchor: TreeItemId | null =
|
|
28
|
+
initialSelected.size > 0
|
|
29
|
+
? (initialSelected.values().next().value as TreeItemId)
|
|
30
|
+
: null;
|
|
31
|
+
return {
|
|
32
|
+
expanded: new Set(persisted?.expandedItems ?? initialExpandedIds ?? []),
|
|
33
|
+
selected: initialSelected,
|
|
34
|
+
anchor: initialAnchor,
|
|
35
|
+
focused: null,
|
|
36
|
+
query: '',
|
|
37
|
+
renaming: null,
|
|
38
|
+
clipboard: null,
|
|
39
|
+
cacheTick: 0,
|
|
40
|
+
};
|
|
41
|
+
}
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import type { Action, State } from './types';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Pure reducer for `<TreeProvider>`. Intentionally small — most domain
|
|
7
|
+
* logic (Finder selection rules, async cache, persistence) lives in
|
|
8
|
+
* dedicated hooks under `context/<feature>/` and only dispatches one of
|
|
9
|
+
* these actions when the result needs to land in React state.
|
|
10
|
+
*/
|
|
11
|
+
export function reducer(state: State, action: Action): State {
|
|
12
|
+
switch (action.type) {
|
|
13
|
+
case 'expand': {
|
|
14
|
+
if (state.expanded.has(action.id)) return state;
|
|
15
|
+
const next = new Set(state.expanded);
|
|
16
|
+
next.add(action.id);
|
|
17
|
+
return { ...state, expanded: next };
|
|
18
|
+
}
|
|
19
|
+
case 'collapse': {
|
|
20
|
+
if (!state.expanded.has(action.id)) return state;
|
|
21
|
+
const next = new Set(state.expanded);
|
|
22
|
+
next.delete(action.id);
|
|
23
|
+
return { ...state, expanded: next };
|
|
24
|
+
}
|
|
25
|
+
case 'toggle': {
|
|
26
|
+
const next = new Set(state.expanded);
|
|
27
|
+
if (next.has(action.id)) next.delete(action.id);
|
|
28
|
+
else next.add(action.id);
|
|
29
|
+
return { ...state, expanded: next };
|
|
30
|
+
}
|
|
31
|
+
case 'set-expanded':
|
|
32
|
+
return { ...state, expanded: new Set(action.ids) };
|
|
33
|
+
case 'select': {
|
|
34
|
+
if (action.mode === 'none') return state;
|
|
35
|
+
if (action.mode === 'single') {
|
|
36
|
+
return {
|
|
37
|
+
...state,
|
|
38
|
+
selected: new Set([action.id]),
|
|
39
|
+
anchor: action.id,
|
|
40
|
+
focused: action.id,
|
|
41
|
+
};
|
|
42
|
+
}
|
|
43
|
+
const next = new Set(state.selected);
|
|
44
|
+
if (next.has(action.id)) next.delete(action.id);
|
|
45
|
+
else next.add(action.id);
|
|
46
|
+
return { ...state, selected: next, anchor: action.id, focused: action.id };
|
|
47
|
+
}
|
|
48
|
+
case 'select-many':
|
|
49
|
+
return { ...state, selected: new Set(action.ids) };
|
|
50
|
+
case 'clear-selection':
|
|
51
|
+
return { ...state, selected: new Set(), anchor: null };
|
|
52
|
+
case 'selection-replace':
|
|
53
|
+
return {
|
|
54
|
+
...state,
|
|
55
|
+
selected: new Set(action.selected),
|
|
56
|
+
anchor: action.anchor,
|
|
57
|
+
focused: action.focused,
|
|
58
|
+
};
|
|
59
|
+
case 'set-anchor':
|
|
60
|
+
return { ...state, anchor: action.id };
|
|
61
|
+
case 'focus':
|
|
62
|
+
return { ...state, focused: action.id };
|
|
63
|
+
case 'set-query':
|
|
64
|
+
return { ...state, query: action.q };
|
|
65
|
+
case 'start-rename':
|
|
66
|
+
return { ...state, renaming: action.id };
|
|
67
|
+
case 'stop-rename':
|
|
68
|
+
return state.renaming === null ? state : { ...state, renaming: null };
|
|
69
|
+
case 'clipboard-set':
|
|
70
|
+
return { ...state, clipboard: action.payload };
|
|
71
|
+
case 'cache-tick':
|
|
72
|
+
return { ...state, cacheTick: state.cacheTick + 1 };
|
|
73
|
+
default:
|
|
74
|
+
return state;
|
|
75
|
+
}
|
|
76
|
+
}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import type { ClipboardState } from '../../data/clipboard';
|
|
4
|
+
import type { TreeItemId, TreeSelectionMode } from '../../types';
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* The full reducer state for `<TreeProvider>`. Async children live
|
|
8
|
+
* outside this object (see `async-children/` hook) since they're
|
|
9
|
+
* managed via refs to avoid React re-renders on every fetch.
|
|
10
|
+
*/
|
|
11
|
+
export interface State {
|
|
12
|
+
expanded: Set<TreeItemId>;
|
|
13
|
+
selected: Set<TreeItemId>;
|
|
14
|
+
/** Anchor for shift-range selection (Finder/Explorer). */
|
|
15
|
+
anchor: TreeItemId | null;
|
|
16
|
+
focused: TreeItemId | null;
|
|
17
|
+
query: string;
|
|
18
|
+
/** Id of the row currently in inline-rename mode (P3). */
|
|
19
|
+
renaming: TreeItemId | null;
|
|
20
|
+
/** Tree-local clipboard (cut / copy) — P5. */
|
|
21
|
+
clipboard: ClipboardState;
|
|
22
|
+
/** Bumped on every cache mutation so memos see a fresh dep. */
|
|
23
|
+
cacheTick: number;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export type Action =
|
|
27
|
+
| { type: 'expand'; id: TreeItemId }
|
|
28
|
+
| { type: 'collapse'; id: TreeItemId }
|
|
29
|
+
| { type: 'toggle'; id: TreeItemId }
|
|
30
|
+
| { type: 'set-expanded'; ids: TreeItemId[] }
|
|
31
|
+
| { type: 'select'; id: TreeItemId; mode: TreeSelectionMode }
|
|
32
|
+
| { type: 'select-many'; ids: TreeItemId[] }
|
|
33
|
+
| { type: 'clear-selection' }
|
|
34
|
+
| {
|
|
35
|
+
type: 'selection-replace';
|
|
36
|
+
selected: TreeItemId[];
|
|
37
|
+
anchor: TreeItemId | null;
|
|
38
|
+
focused: TreeItemId | null;
|
|
39
|
+
}
|
|
40
|
+
| { type: 'set-anchor'; id: TreeItemId | null }
|
|
41
|
+
| { type: 'focus'; id: TreeItemId | null }
|
|
42
|
+
| { type: 'set-query'; q: string }
|
|
43
|
+
| { type: 'start-rename'; id: TreeItemId }
|
|
44
|
+
| { type: 'stop-rename' }
|
|
45
|
+
| { type: 'clipboard-set'; payload: ClipboardState }
|
|
46
|
+
| { type: 'cache-tick' };
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import type { TreeItemId } from '../types';
|
|
4
|
+
|
|
5
|
+
// =====================================================================
|
|
6
|
+
// Tree-local clipboard.
|
|
7
|
+
//
|
|
8
|
+
// Why not the system clipboard? Because we need a visual "cut" state
|
|
9
|
+
// (Finder/Explorer dim cut items) and a single source of truth across
|
|
10
|
+
// Tree's own keyboard / menu / DnD entry points. The system clipboard
|
|
11
|
+
// can't carry that semantics in a way that survives focus changes.
|
|
12
|
+
//
|
|
13
|
+
// Clipboard owns *ids*, not full nodes — Tree resolves them through
|
|
14
|
+
// `getNodeById` at paste time. This keeps the model immutable to the
|
|
15
|
+
// underlying data refreshing under us (async load, refresh, etc.).
|
|
16
|
+
// =====================================================================
|
|
17
|
+
|
|
18
|
+
export type ClipboardKind = 'cut' | 'copy';
|
|
19
|
+
|
|
20
|
+
export interface ClipboardEntry {
|
|
21
|
+
kind: ClipboardKind;
|
|
22
|
+
ids: TreeItemId[];
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export type ClipboardState = ClipboardEntry | null;
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Was this id put on the clipboard with `kind: 'cut'`? Used by `TreeRow`
|
|
29
|
+
* to render dimmed (`opacity-60`) rows the way Finder does.
|
|
30
|
+
*/
|
|
31
|
+
export function isCutId(state: ClipboardState, id: TreeItemId): boolean {
|
|
32
|
+
return state?.kind === 'cut' && state.ids.includes(id);
|
|
33
|
+
}
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import type { FlatRow, TreeItemId, TreeMovePosition, TreeNode } from '../types';
|
|
4
|
+
|
|
5
|
+
// =====================================================================
|
|
6
|
+
// Drag-and-drop primitives — pure, framework-agnostic.
|
|
7
|
+
//
|
|
8
|
+
// @dnd-kit handles the pointer/keyboard sensors and overlay rendering;
|
|
9
|
+
// these helpers only translate pointer geometry into a domain-level
|
|
10
|
+
// drop position (`before` / `inside` / `after`) and validate whether
|
|
11
|
+
// a given drag/drop combo is allowed.
|
|
12
|
+
//
|
|
13
|
+
// Folder vs leaf semantics:
|
|
14
|
+
// - leaves → no `inside` zone; row splits into top/bottom halves
|
|
15
|
+
// for `before` / `after` reorder
|
|
16
|
+
// - folders → top third = `before`, middle third = `inside`,
|
|
17
|
+
// bottom third = `after`
|
|
18
|
+
// =====================================================================
|
|
19
|
+
|
|
20
|
+
export interface DropZoneInput {
|
|
21
|
+
/** Pointer Y in viewport coordinates. */
|
|
22
|
+
pointerY: number;
|
|
23
|
+
/** Row bounding box (`getBoundingClientRect()`). */
|
|
24
|
+
rowRect: { top: number; bottom: number; height: number };
|
|
25
|
+
/** Folders accept `inside` drops; leaves only reorder via before/after. */
|
|
26
|
+
isFolder: boolean;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Translate pointer geometry into a drop position relative to the row.
|
|
31
|
+
*
|
|
32
|
+
* For folders the row is split into three zones (top third / middle /
|
|
33
|
+
* bottom third). For leaves it's split in half (before / after).
|
|
34
|
+
*/
|
|
35
|
+
export function resolveDropZone(input: DropZoneInput): TreeMovePosition {
|
|
36
|
+
const { pointerY, rowRect, isFolder } = input;
|
|
37
|
+
const offset = pointerY - rowRect.top;
|
|
38
|
+
const ratio = rowRect.height > 0 ? offset / rowRect.height : 0.5;
|
|
39
|
+
|
|
40
|
+
if (isFolder) {
|
|
41
|
+
if (ratio < 0.33) return 'before';
|
|
42
|
+
if (ratio > 0.66) return 'after';
|
|
43
|
+
return 'inside';
|
|
44
|
+
}
|
|
45
|
+
return ratio < 0.5 ? 'before' : 'after';
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// =====================================================================
|
|
49
|
+
// canDrop — default validator.
|
|
50
|
+
//
|
|
51
|
+
// Rejects:
|
|
52
|
+
// 1. Dropping a node onto itself.
|
|
53
|
+
// 2. Dropping a folder into one of its own descendants (would create
|
|
54
|
+
// a cycle).
|
|
55
|
+
// 3. Dropping `inside` a non-folder (defensive — UI already routes
|
|
56
|
+
// these as `before`/`after`, but the API can be called directly).
|
|
57
|
+
//
|
|
58
|
+
// Consumers can pass their own `canDrop` via `<TreeRoot canDrop={…}>`
|
|
59
|
+
// to layer extra rules on top (e.g. read-only branches, type matching).
|
|
60
|
+
// =====================================================================
|
|
61
|
+
|
|
62
|
+
export interface CanDropInput<T> {
|
|
63
|
+
/** Nodes being dragged. */
|
|
64
|
+
source: TreeNode<T>[];
|
|
65
|
+
/** Row under the pointer (`null` = root drop zone). */
|
|
66
|
+
target: TreeNode<T> | null;
|
|
67
|
+
/** Resolved drop position. */
|
|
68
|
+
position: TreeMovePosition;
|
|
69
|
+
/** Tree's id→node lookup, used to walk descendants. */
|
|
70
|
+
getNodeById: (id: TreeItemId) => TreeNode<T> | undefined;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export function defaultCanDrop<T>(input: CanDropInput<T>): boolean {
|
|
74
|
+
const { source, target, position } = input;
|
|
75
|
+
if (source.length === 0) return false;
|
|
76
|
+
if (!target) return true; // root drop is always allowed when no other rule rejects.
|
|
77
|
+
|
|
78
|
+
// Inside a leaf doesn't make sense.
|
|
79
|
+
if (position === 'inside') {
|
|
80
|
+
const isFolder = Array.isArray(target.children) || !!target.isFolder;
|
|
81
|
+
if (!isFolder) return false;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
for (const node of source) {
|
|
85
|
+
if (node.id === target.id) return false;
|
|
86
|
+
if (isDescendant(node, target.id)) return false;
|
|
87
|
+
}
|
|
88
|
+
return true;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Walk `root`'s inline children searching for `id`. Cached async children
|
|
93
|
+
* are *not* walked here because the cycle check needs to be cheap and
|
|
94
|
+
* deterministic — a missing cycle in unloaded branches is acceptable
|
|
95
|
+
* (the consumer's `adapter.move` is the final gatekeeper).
|
|
96
|
+
*/
|
|
97
|
+
function isDescendant<T>(root: TreeNode<T>, id: TreeItemId): boolean {
|
|
98
|
+
if (!Array.isArray(root.children)) return false;
|
|
99
|
+
for (const child of root.children) {
|
|
100
|
+
if (child.id === id) return true;
|
|
101
|
+
if (isDescendant(child, id)) return true;
|
|
102
|
+
}
|
|
103
|
+
return false;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// =====================================================================
|
|
107
|
+
// DataTransfer mime — used both for in-tree drags and (later) for
|
|
108
|
+
// receiving dragged files from the OS. The id payload is the JSON-
|
|
109
|
+
// encoded list of `TreeItemId`s.
|
|
110
|
+
// =====================================================================
|
|
111
|
+
|
|
112
|
+
export const TREE_DND_MIME = 'application/x-djangocfg-tree';
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* Sentinel droppable id used by `<TreeEmptyArea>` to claim the
|
|
116
|
+
* "root drop target" slot. `<TreeDndProvider>` recognises this id and
|
|
117
|
+
* maps it to `{ id: null, position: 'inside' }` in `dnd.dropTarget`.
|
|
118
|
+
*
|
|
119
|
+
* Lives in `data/` (not in the component) so both producer and
|
|
120
|
+
* consumer reference one constant instead of duplicating the magic
|
|
121
|
+
* string.
|
|
122
|
+
*/
|
|
123
|
+
export const TREE_ROOT_DROP_ID = '__tree_root_drop__';
|