@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,139 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import type { ClipboardState } from '../data/clipboard';
|
|
4
|
+
import type { UseDndReturn } from './dnd';
|
|
5
|
+
import type { ResolvedAppearance } from '../data/appearance';
|
|
6
|
+
import type { ClickModifiers } from '../data/selection';
|
|
7
|
+
import type {
|
|
8
|
+
FlatRow,
|
|
9
|
+
TreeActivateOptions,
|
|
10
|
+
TreeActivationMode,
|
|
11
|
+
TreeAdapter,
|
|
12
|
+
TreeContextMenuItem,
|
|
13
|
+
TreeContextMenuSlot,
|
|
14
|
+
TreeItemId,
|
|
15
|
+
TreeLabels,
|
|
16
|
+
TreeMovePosition,
|
|
17
|
+
TreeNode,
|
|
18
|
+
TreeRowRenderProps,
|
|
19
|
+
TreeRowSlot,
|
|
20
|
+
TreeSelectionMode,
|
|
21
|
+
} from '../types';
|
|
22
|
+
|
|
23
|
+
export interface TreeContextValue<T> {
|
|
24
|
+
// State
|
|
25
|
+
expanded: ReadonlySet<TreeItemId>;
|
|
26
|
+
selected: ReadonlySet<TreeItemId>;
|
|
27
|
+
/** Anchor for shift-range. `null` when nothing has been clicked yet. */
|
|
28
|
+
anchor: TreeItemId | null;
|
|
29
|
+
focused: TreeItemId | null;
|
|
30
|
+
query: string;
|
|
31
|
+
/** Id of the row currently in inline-rename mode (or `null`). */
|
|
32
|
+
renamingId: TreeItemId | null;
|
|
33
|
+
/** Is inline rename allowed by the host? */
|
|
34
|
+
inlineRenameEnabled: boolean;
|
|
35
|
+
|
|
36
|
+
/** Tree-local clipboard (cut / copy) — P5. `null` when empty. */
|
|
37
|
+
clipboard: ClipboardState;
|
|
38
|
+
|
|
39
|
+
// Flattened render rows (visible items only)
|
|
40
|
+
flatRows: FlatRow<T>[];
|
|
41
|
+
/** Search-matching node ids (subset of all flatRows). */
|
|
42
|
+
matchingIds: ReadonlySet<TreeItemId>;
|
|
43
|
+
|
|
44
|
+
// Imperative actions
|
|
45
|
+
expand: (id: TreeItemId) => void;
|
|
46
|
+
collapse: (id: TreeItemId) => void;
|
|
47
|
+
toggle: (id: TreeItemId) => void;
|
|
48
|
+
expandAll: () => void;
|
|
49
|
+
collapseAll: () => void;
|
|
50
|
+
select: (id: TreeItemId) => void;
|
|
51
|
+
setSelectedIds: (ids: TreeItemId[]) => void;
|
|
52
|
+
clearSelection: () => void;
|
|
53
|
+
/**
|
|
54
|
+
* Finder/Explorer-style click selection. Reads modifier keys from the
|
|
55
|
+
* passed event and decides between replace / toggle / range / union.
|
|
56
|
+
*/
|
|
57
|
+
clickSelect: (id: TreeItemId, mods: ClickModifiers) => void;
|
|
58
|
+
/**
|
|
59
|
+
* Move-focus with optional range-extend (shift+arrows).
|
|
60
|
+
*/
|
|
61
|
+
moveSelect: (id: TreeItemId, opts: { extend: boolean }) => void;
|
|
62
|
+
/** Select every currently visible row (mod+a). */
|
|
63
|
+
selectAll: () => void;
|
|
64
|
+
setFocus: (id: TreeItemId | null) => void;
|
|
65
|
+
setQuery: (q: string) => void;
|
|
66
|
+
|
|
67
|
+
/** Put the given ids on Tree's clipboard as `cut`. */
|
|
68
|
+
cutToClipboard: (ids: TreeItemId[]) => void;
|
|
69
|
+
/** Put the given ids on Tree's clipboard as `copy`. */
|
|
70
|
+
copyToClipboard: (ids: TreeItemId[]) => void;
|
|
71
|
+
/**
|
|
72
|
+
* Apply clipboard onto `target` (a row, or `null` for the root). Cut →
|
|
73
|
+
* `adapter.move`; copy → `adapter.copy`. After a successful cut+paste
|
|
74
|
+
* the clipboard is cleared. No-op when clipboard is empty or the
|
|
75
|
+
* matching adapter method is undefined.
|
|
76
|
+
*/
|
|
77
|
+
pasteFromClipboard: (
|
|
78
|
+
target: TreeNode<T> | null,
|
|
79
|
+
position?: TreeMovePosition,
|
|
80
|
+
) => Promise<void>;
|
|
81
|
+
/** Clear the clipboard without pasting. */
|
|
82
|
+
clearClipboard: () => void;
|
|
83
|
+
|
|
84
|
+
/** Begin inline rename on the given row. */
|
|
85
|
+
startRename: (id: TreeItemId) => void;
|
|
86
|
+
/** Cancel inline rename without committing. */
|
|
87
|
+
cancelRename: () => void;
|
|
88
|
+
/**
|
|
89
|
+
* Commit inline rename. Tree calls `adapter.rename` and then clears
|
|
90
|
+
* the renaming state. On validation failure surfaces an error via
|
|
91
|
+
* `window.dialog.alert` and keeps the input open.
|
|
92
|
+
*/
|
|
93
|
+
commitRename: (id: TreeItemId, nextName: string) => Promise<boolean>;
|
|
94
|
+
|
|
95
|
+
refresh: (id: TreeItemId) => Promise<void>;
|
|
96
|
+
refreshAll: () => Promise<void>;
|
|
97
|
+
activate: (node: TreeNode<T>, opts?: TreeActivateOptions) => void;
|
|
98
|
+
|
|
99
|
+
// Config / slots
|
|
100
|
+
labels: TreeLabels;
|
|
101
|
+
/** Resolved cosmetic config — never null. */
|
|
102
|
+
appearance: ResolvedAppearance;
|
|
103
|
+
/** Convenience alias for `appearance.indent`. */
|
|
104
|
+
indent: number;
|
|
105
|
+
selectionMode: TreeSelectionMode;
|
|
106
|
+
activationMode: TreeActivationMode;
|
|
107
|
+
enableSearch: boolean;
|
|
108
|
+
showIndentGuides: boolean;
|
|
109
|
+
getItemName: (node: TreeNode<T>) => string;
|
|
110
|
+
|
|
111
|
+
renderIcon?: TreeRowSlot<T>;
|
|
112
|
+
renderLabel?: TreeRowSlot<T>;
|
|
113
|
+
renderActions?: TreeRowSlot<T>;
|
|
114
|
+
renderContextMenu?: TreeContextMenuSlot<T>;
|
|
115
|
+
|
|
116
|
+
/** CRUD adapter (P2). May be undefined — Tree still renders normally. */
|
|
117
|
+
adapter?: TreeAdapter<T>;
|
|
118
|
+
/**
|
|
119
|
+
* Final, merged declarative menu resolver. Combines built-in adapter
|
|
120
|
+
* actions (filtered by `defaultMenuItems`) with the consumer's
|
|
121
|
+
* `contextMenuActions` resolver, and injects the current
|
|
122
|
+
* `selectedNodes` before delegating.
|
|
123
|
+
*/
|
|
124
|
+
resolvedContextMenuActions?: (
|
|
125
|
+
row: TreeRowRenderProps<T>,
|
|
126
|
+
) => TreeContextMenuItem<T>[] | null | undefined;
|
|
127
|
+
/**
|
|
128
|
+
* Imperative lookup for any node currently known to Tree (root +
|
|
129
|
+
* cached async children).
|
|
130
|
+
*/
|
|
131
|
+
getNodeById: (id: TreeItemId) => TreeNode<T> | undefined;
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* Drag-and-drop state and handlers (P6). `dnd.active` is `false`
|
|
135
|
+
* when the host didn't enable DnD or `adapter.move` is missing — in
|
|
136
|
+
* that case `TreeRow` skips all drag setup.
|
|
137
|
+
*/
|
|
138
|
+
dnd: UseDndReturn<T>;
|
|
139
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import type { ChildCache } from '../../data/childCache';
|
|
4
|
+
import type { TreeItemId, TreeNode } from '../../types';
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Walk `roots` + cached async children and push every *folder* id into
|
|
8
|
+
* `out`. Used by `expandAll`.
|
|
9
|
+
*
|
|
10
|
+
* Leaves are intentionally not collected — expanding a leaf is a no-op.
|
|
11
|
+
*/
|
|
12
|
+
export function collectAllFolderIds<T>(
|
|
13
|
+
roots: TreeNode<T>[],
|
|
14
|
+
cache: ChildCache<T>,
|
|
15
|
+
out: TreeItemId[],
|
|
16
|
+
): void {
|
|
17
|
+
for (const node of roots) {
|
|
18
|
+
if (Array.isArray(node.children)) {
|
|
19
|
+
out.push(node.id);
|
|
20
|
+
collectAllFolderIds(node.children, cache, out);
|
|
21
|
+
} else if (node.isFolder) {
|
|
22
|
+
out.push(node.id);
|
|
23
|
+
const entry = cache.get(node.id);
|
|
24
|
+
if (entry?.children) collectAllFolderIds(entry.children, cache, out);
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
}
|
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { useCallback, useEffect, useMemo, useRef } from 'react';
|
|
4
|
+
|
|
5
|
+
import { createChildCache, type ChildCache } from '../../data/childCache';
|
|
6
|
+
import type {
|
|
7
|
+
TreeItemId,
|
|
8
|
+
TreeLoadChildren,
|
|
9
|
+
TreeNode,
|
|
10
|
+
} from '../../types';
|
|
11
|
+
import { collectAllFolderIds } from './collect-ids';
|
|
12
|
+
|
|
13
|
+
export interface UseAsyncChildrenOptions<T> {
|
|
14
|
+
data: TreeNode<T>[];
|
|
15
|
+
loadChildren?: TreeLoadChildren<T>;
|
|
16
|
+
/** Current set of expanded ids — controls which async fetches fire. */
|
|
17
|
+
expanded: ReadonlySet<TreeItemId>;
|
|
18
|
+
/** Bumps every time the cache mutates — invalidates downstream memos. */
|
|
19
|
+
cacheTick: number;
|
|
20
|
+
/** Dispatch into the reducer (used to bump `cacheTick`). */
|
|
21
|
+
bumpCacheTick: () => void;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export interface UseAsyncChildrenReturn<T> {
|
|
25
|
+
/** Live async-children cache (mutable ref, not React state). */
|
|
26
|
+
cache: ChildCache<T>;
|
|
27
|
+
/** Id → node lookup across the root tree + cached async children. */
|
|
28
|
+
nodeById: Map<TreeItemId, TreeNode<T>>;
|
|
29
|
+
/** Fetch a single folder's children (deduped, idempotent). */
|
|
30
|
+
fetchChildren: (node: TreeNode<T>) => Promise<void> | void;
|
|
31
|
+
/** Drop one node's cache entry and refetch. */
|
|
32
|
+
refresh: (id: TreeItemId) => Promise<void>;
|
|
33
|
+
/** Clear the entire cache and refetch every currently-expanded folder. */
|
|
34
|
+
refreshAll: () => Promise<void>;
|
|
35
|
+
/** Collect every folder id known to Tree (root + cached). For `expandAll`. */
|
|
36
|
+
collectFolderIds: () => TreeItemId[];
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Manage async children: a long-lived cache (in a ref), dedup-ed
|
|
41
|
+
* `loadChildren` calls, and a `nodeById` map that walks both inline
|
|
42
|
+
* children and cached async ones. The cache lives outside reducer state
|
|
43
|
+
* because mutating a Map per render is cheaper than spreading it; we
|
|
44
|
+
* trigger downstream re-renders via the `cacheTick` counter.
|
|
45
|
+
*/
|
|
46
|
+
export function useAsyncChildren<T>({
|
|
47
|
+
data,
|
|
48
|
+
loadChildren,
|
|
49
|
+
expanded,
|
|
50
|
+
cacheTick,
|
|
51
|
+
bumpCacheTick,
|
|
52
|
+
}: UseAsyncChildrenOptions<T>): UseAsyncChildrenReturn<T> {
|
|
53
|
+
// The cache survives provider re-renders and async work.
|
|
54
|
+
const cacheRef = useRef<ChildCache<T>>(createChildCache<T>());
|
|
55
|
+
const inflightRef = useRef<Map<TreeItemId, Promise<void>>>(new Map());
|
|
56
|
+
|
|
57
|
+
const fetchChildren = useCallback(
|
|
58
|
+
async (node: TreeNode<T>) => {
|
|
59
|
+
if (!loadChildren) return;
|
|
60
|
+
if (Array.isArray(node.children)) return;
|
|
61
|
+
const existing = cacheRef.current.get(node.id);
|
|
62
|
+
if (existing?.status === 'loaded' || existing?.status === 'loading') return;
|
|
63
|
+
const inflight = inflightRef.current.get(node.id);
|
|
64
|
+
if (inflight) return inflight;
|
|
65
|
+
|
|
66
|
+
cacheRef.current.set(node.id, { status: 'loading', children: [] });
|
|
67
|
+
bumpCacheTick();
|
|
68
|
+
|
|
69
|
+
const promise = (async () => {
|
|
70
|
+
try {
|
|
71
|
+
const children = await loadChildren(node);
|
|
72
|
+
cacheRef.current.set(node.id, { status: 'loaded', children });
|
|
73
|
+
} catch (err) {
|
|
74
|
+
cacheRef.current.set(node.id, {
|
|
75
|
+
status: 'error',
|
|
76
|
+
children: [],
|
|
77
|
+
error: err instanceof Error ? err.message : String(err),
|
|
78
|
+
});
|
|
79
|
+
} finally {
|
|
80
|
+
inflightRef.current.delete(node.id);
|
|
81
|
+
bumpCacheTick();
|
|
82
|
+
}
|
|
83
|
+
})();
|
|
84
|
+
|
|
85
|
+
inflightRef.current.set(node.id, promise);
|
|
86
|
+
return promise;
|
|
87
|
+
},
|
|
88
|
+
[loadChildren, bumpCacheTick],
|
|
89
|
+
);
|
|
90
|
+
|
|
91
|
+
// Build a quick id → node map across roots + cached async children.
|
|
92
|
+
// Re-runs whenever the cache mutates (cacheTick) or `data` changes.
|
|
93
|
+
const nodeById = useMemo(() => {
|
|
94
|
+
const map = new Map<TreeItemId, TreeNode<T>>();
|
|
95
|
+
const walk = (nodes: TreeNode<T>[]) => {
|
|
96
|
+
for (const n of nodes) {
|
|
97
|
+
map.set(n.id, n);
|
|
98
|
+
if (Array.isArray(n.children)) walk(n.children);
|
|
99
|
+
else {
|
|
100
|
+
const entry = cacheRef.current.get(n.id);
|
|
101
|
+
if (entry?.children) walk(entry.children);
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
};
|
|
105
|
+
walk(data);
|
|
106
|
+
return map;
|
|
107
|
+
}, [data, cacheTick]);
|
|
108
|
+
|
|
109
|
+
// Auto-fetch every expanded folder that isn't already in cache. Cheap
|
|
110
|
+
// — `fetchChildren` short-circuits when cached or already inflight.
|
|
111
|
+
useEffect(() => {
|
|
112
|
+
if (!loadChildren) return;
|
|
113
|
+
for (const id of expanded) {
|
|
114
|
+
const node = nodeById.get(id);
|
|
115
|
+
if (!node) continue;
|
|
116
|
+
void fetchChildren(node);
|
|
117
|
+
}
|
|
118
|
+
}, [loadChildren, expanded, cacheTick, nodeById, fetchChildren]);
|
|
119
|
+
|
|
120
|
+
const refresh = useCallback(
|
|
121
|
+
async (id: TreeItemId) => {
|
|
122
|
+
const node = nodeById.get(id);
|
|
123
|
+
if (!node || !loadChildren) return;
|
|
124
|
+
cacheRef.current.delete(id);
|
|
125
|
+
bumpCacheTick();
|
|
126
|
+
await fetchChildren(node);
|
|
127
|
+
},
|
|
128
|
+
[nodeById, loadChildren, fetchChildren, bumpCacheTick],
|
|
129
|
+
);
|
|
130
|
+
|
|
131
|
+
const refreshAll = useCallback(async () => {
|
|
132
|
+
cacheRef.current.clear();
|
|
133
|
+
bumpCacheTick();
|
|
134
|
+
if (!loadChildren) return;
|
|
135
|
+
await Promise.all(
|
|
136
|
+
[...expanded].map((id) => {
|
|
137
|
+
const node = nodeById.get(id);
|
|
138
|
+
return node ? fetchChildren(node) : undefined;
|
|
139
|
+
}),
|
|
140
|
+
);
|
|
141
|
+
}, [loadChildren, expanded, nodeById, fetchChildren, bumpCacheTick]);
|
|
142
|
+
|
|
143
|
+
const collectFolderIds = useCallback(() => {
|
|
144
|
+
const ids: TreeItemId[] = [];
|
|
145
|
+
collectAllFolderIds(data, cacheRef.current, ids);
|
|
146
|
+
return ids;
|
|
147
|
+
}, [data]);
|
|
148
|
+
|
|
149
|
+
return {
|
|
150
|
+
cache: cacheRef.current,
|
|
151
|
+
nodeById,
|
|
152
|
+
fetchChildren,
|
|
153
|
+
refresh,
|
|
154
|
+
refreshAll,
|
|
155
|
+
collectFolderIds,
|
|
156
|
+
};
|
|
157
|
+
}
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { useCallback, useRef } from 'react';
|
|
4
|
+
|
|
5
|
+
import { getDialog } from '@djangocfg/ui-core/lib/dialog-service';
|
|
6
|
+
|
|
7
|
+
import type { ClipboardState } from '../../data/clipboard';
|
|
8
|
+
import type {
|
|
9
|
+
TreeAdapter,
|
|
10
|
+
TreeItemId,
|
|
11
|
+
TreeLabels,
|
|
12
|
+
TreeMovePosition,
|
|
13
|
+
TreeNode,
|
|
14
|
+
} from '../../types';
|
|
15
|
+
import type { Action } from '../state';
|
|
16
|
+
|
|
17
|
+
export interface UseClipboardOptions<T> {
|
|
18
|
+
dispatch: React.Dispatch<Action>;
|
|
19
|
+
/** Current clipboard snapshot — latched into a ref for async paste. */
|
|
20
|
+
clipboard: ClipboardState;
|
|
21
|
+
adapter?: TreeAdapter<T>;
|
|
22
|
+
nodeById: Map<TreeItemId, TreeNode<T>>;
|
|
23
|
+
labels: TreeLabels;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export interface UseClipboardReturn<T> {
|
|
27
|
+
cutToClipboard: (ids: TreeItemId[]) => void;
|
|
28
|
+
copyToClipboard: (ids: TreeItemId[]) => void;
|
|
29
|
+
/** Apply clipboard to `target` (a row, or `null` = root). */
|
|
30
|
+
pasteFromClipboard: (
|
|
31
|
+
target: TreeNode<T> | null,
|
|
32
|
+
position?: TreeMovePosition,
|
|
33
|
+
) => Promise<void>;
|
|
34
|
+
clearClipboard: () => void;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Tree-local clipboard. Cut + paste dispatches `adapter.move`, copy +
|
|
39
|
+
* paste dispatches `adapter.copy`. Errors are surfaced through
|
|
40
|
+
* `window.dialog.alert` so they don't get swallowed silently.
|
|
41
|
+
*/
|
|
42
|
+
export function useClipboard<T>({
|
|
43
|
+
dispatch,
|
|
44
|
+
clipboard,
|
|
45
|
+
adapter,
|
|
46
|
+
nodeById,
|
|
47
|
+
labels,
|
|
48
|
+
}: UseClipboardOptions<T>): UseClipboardReturn<T> {
|
|
49
|
+
const clipboardRef = useRef(clipboard);
|
|
50
|
+
clipboardRef.current = clipboard;
|
|
51
|
+
|
|
52
|
+
const cutToClipboard = useCallback(
|
|
53
|
+
(ids: TreeItemId[]) => {
|
|
54
|
+
if (ids.length === 0) return;
|
|
55
|
+
dispatch({ type: 'clipboard-set', payload: { kind: 'cut', ids } });
|
|
56
|
+
},
|
|
57
|
+
[dispatch],
|
|
58
|
+
);
|
|
59
|
+
|
|
60
|
+
const copyToClipboard = useCallback(
|
|
61
|
+
(ids: TreeItemId[]) => {
|
|
62
|
+
if (ids.length === 0) return;
|
|
63
|
+
dispatch({ type: 'clipboard-set', payload: { kind: 'copy', ids } });
|
|
64
|
+
},
|
|
65
|
+
[dispatch],
|
|
66
|
+
);
|
|
67
|
+
|
|
68
|
+
const clearClipboard = useCallback(
|
|
69
|
+
() => dispatch({ type: 'clipboard-set', payload: null }),
|
|
70
|
+
[dispatch],
|
|
71
|
+
);
|
|
72
|
+
|
|
73
|
+
const pasteFromClipboard = useCallback(
|
|
74
|
+
async (
|
|
75
|
+
target: TreeNode<T> | null,
|
|
76
|
+
position: TreeMovePosition = 'inside',
|
|
77
|
+
) => {
|
|
78
|
+
const cb = clipboardRef.current;
|
|
79
|
+
if (!cb || cb.ids.length === 0) return;
|
|
80
|
+
if (!adapter) return;
|
|
81
|
+
const nodes = cb.ids
|
|
82
|
+
.map((id) => nodeById.get(id))
|
|
83
|
+
.filter((n): n is TreeNode<T> => !!n);
|
|
84
|
+
if (nodes.length === 0) {
|
|
85
|
+
dispatch({ type: 'clipboard-set', payload: null });
|
|
86
|
+
return;
|
|
87
|
+
}
|
|
88
|
+
try {
|
|
89
|
+
if (cb.kind === 'cut') {
|
|
90
|
+
if (!adapter.move) return;
|
|
91
|
+
await adapter.move(nodes, target, position);
|
|
92
|
+
// Cut + paste consumes the clipboard. Copy + paste retains it.
|
|
93
|
+
dispatch({ type: 'clipboard-set', payload: null });
|
|
94
|
+
} else {
|
|
95
|
+
if (!adapter.copy) return;
|
|
96
|
+
await adapter.copy(nodes, target, position);
|
|
97
|
+
}
|
|
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
|
+
}
|
|
105
|
+
},
|
|
106
|
+
[dispatch, adapter, nodeById, labels],
|
|
107
|
+
);
|
|
108
|
+
|
|
109
|
+
return {
|
|
110
|
+
cutToClipboard,
|
|
111
|
+
copyToClipboard,
|
|
112
|
+
pasteFromClipboard,
|
|
113
|
+
clearClipboard,
|
|
114
|
+
};
|
|
115
|
+
}
|
|
@@ -0,0 +1,194 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { useCallback, useMemo, useState } from 'react';
|
|
4
|
+
|
|
5
|
+
import { getDialog } from '@djangocfg/ui-core/lib/dialog-service';
|
|
6
|
+
|
|
7
|
+
import { defaultCanDrop } from '../../data/dnd';
|
|
8
|
+
import type {
|
|
9
|
+
TreeAdapter,
|
|
10
|
+
TreeItemId,
|
|
11
|
+
TreeLabels,
|
|
12
|
+
TreeMovePosition,
|
|
13
|
+
TreeNode,
|
|
14
|
+
} from '../../types';
|
|
15
|
+
|
|
16
|
+
export interface UseDndOptions<T> {
|
|
17
|
+
enabled: boolean;
|
|
18
|
+
adapter?: TreeAdapter<T>;
|
|
19
|
+
nodeById: Map<TreeItemId, TreeNode<T>>;
|
|
20
|
+
/**
|
|
21
|
+
* Multi-selection at the moment dragging begins. If the dragged row
|
|
22
|
+
* is part of the selection, we drag all selected rows; otherwise we
|
|
23
|
+
* drag just the row.
|
|
24
|
+
*/
|
|
25
|
+
selected: ReadonlySet<TreeItemId>;
|
|
26
|
+
labels: TreeLabels;
|
|
27
|
+
/** Optional consumer-defined drop validator (layered on top of `defaultCanDrop`). */
|
|
28
|
+
canDrop?: (ctx: {
|
|
29
|
+
source: TreeNode<T>[];
|
|
30
|
+
target: TreeNode<T> | null;
|
|
31
|
+
position: TreeMovePosition;
|
|
32
|
+
}) => boolean;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export interface DropTargetState {
|
|
36
|
+
id: TreeItemId | null; // null = root drop zone
|
|
37
|
+
position: TreeMovePosition;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export interface UseDndReturn<T> {
|
|
41
|
+
/** True when the host enabled DnD AND adapter.move is defined. */
|
|
42
|
+
active: boolean;
|
|
43
|
+
/** Ids currently being dragged (empty when not dragging). */
|
|
44
|
+
draggingIds: ReadonlySet<TreeItemId>;
|
|
45
|
+
/** Live drop target (`null` when nothing under the pointer). */
|
|
46
|
+
dropTarget: DropTargetState | null;
|
|
47
|
+
/** Called by row sensors on dragstart. */
|
|
48
|
+
beginDrag: (rowId: TreeItemId) => void;
|
|
49
|
+
/**
|
|
50
|
+
* Called on dragover. `null` clears the indicator. Tree already
|
|
51
|
+
* filters self/cycle drops via `defaultCanDrop` — the row component
|
|
52
|
+
* decides the position from pointer geometry first.
|
|
53
|
+
*/
|
|
54
|
+
setDropTarget: (target: DropTargetState | null) => void;
|
|
55
|
+
/** Commit drop — calls `adapter.move` and resets transient state. */
|
|
56
|
+
commitDrop: () => Promise<void>;
|
|
57
|
+
/** Cancel without committing (Esc, drop outside). */
|
|
58
|
+
cancelDrag: () => void;
|
|
59
|
+
/**
|
|
60
|
+
* Validate a candidate drop in real time. Combines `defaultCanDrop`
|
|
61
|
+
* with the consumer's `canDrop`. Used by the row component to
|
|
62
|
+
* suppress the indicator on invalid hovers.
|
|
63
|
+
*/
|
|
64
|
+
isAllowedDrop: (target: TreeNode<T> | null, position: TreeMovePosition) => boolean;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export function useDnd<T>({
|
|
68
|
+
enabled,
|
|
69
|
+
adapter,
|
|
70
|
+
nodeById,
|
|
71
|
+
selected,
|
|
72
|
+
labels,
|
|
73
|
+
canDrop,
|
|
74
|
+
}: UseDndOptions<T>): UseDndReturn<T> {
|
|
75
|
+
const active = enabled && !!adapter?.move;
|
|
76
|
+
|
|
77
|
+
const [draggingIds, setDraggingIds] = useState<ReadonlySet<TreeItemId>>(
|
|
78
|
+
() => new Set(),
|
|
79
|
+
);
|
|
80
|
+
const [dropTarget, setDropTarget] = useState<DropTargetState | null>(null);
|
|
81
|
+
|
|
82
|
+
const beginDrag = useCallback(
|
|
83
|
+
(rowId: TreeItemId) => {
|
|
84
|
+
if (!active) return;
|
|
85
|
+
// If the dragged row is part of the selection, drag the whole
|
|
86
|
+
// selection. Otherwise it's a single-row drag (and we don't
|
|
87
|
+
// touch the existing selection).
|
|
88
|
+
const ids = selected.has(rowId)
|
|
89
|
+
? new Set(selected)
|
|
90
|
+
: new Set<TreeItemId>([rowId]);
|
|
91
|
+
setDraggingIds(ids);
|
|
92
|
+
},
|
|
93
|
+
[active, selected],
|
|
94
|
+
);
|
|
95
|
+
|
|
96
|
+
const cancelDrag = useCallback(() => {
|
|
97
|
+
setDraggingIds(new Set());
|
|
98
|
+
setDropTarget(null);
|
|
99
|
+
}, []);
|
|
100
|
+
|
|
101
|
+
const resolveSourceNodes = useCallback((): TreeNode<T>[] => {
|
|
102
|
+
const out: TreeNode<T>[] = [];
|
|
103
|
+
for (const id of draggingIds) {
|
|
104
|
+
const node = nodeById.get(id);
|
|
105
|
+
if (node) out.push(node);
|
|
106
|
+
}
|
|
107
|
+
return out;
|
|
108
|
+
}, [draggingIds, nodeById]);
|
|
109
|
+
|
|
110
|
+
const isAllowedDrop = useCallback(
|
|
111
|
+
(target: TreeNode<T> | null, position: TreeMovePosition): boolean => {
|
|
112
|
+
if (!active) return false;
|
|
113
|
+
if (draggingIds.size === 0) return false;
|
|
114
|
+
const source = resolveSourceNodes();
|
|
115
|
+
if (
|
|
116
|
+
!defaultCanDrop<T>({
|
|
117
|
+
source,
|
|
118
|
+
target,
|
|
119
|
+
position,
|
|
120
|
+
getNodeById: (id) => nodeById.get(id),
|
|
121
|
+
})
|
|
122
|
+
) {
|
|
123
|
+
return false;
|
|
124
|
+
}
|
|
125
|
+
// Layer the consumer's rule on top.
|
|
126
|
+
return canDrop?.({ source, target, position }) ?? true;
|
|
127
|
+
},
|
|
128
|
+
[active, draggingIds, resolveSourceNodes, nodeById, canDrop],
|
|
129
|
+
);
|
|
130
|
+
|
|
131
|
+
const commitDrop = useCallback(async () => {
|
|
132
|
+
if (!active || !adapter?.move) {
|
|
133
|
+
cancelDrag();
|
|
134
|
+
return;
|
|
135
|
+
}
|
|
136
|
+
const t = dropTarget;
|
|
137
|
+
if (!t) {
|
|
138
|
+
cancelDrag();
|
|
139
|
+
return;
|
|
140
|
+
}
|
|
141
|
+
const targetNode = t.id ? nodeById.get(t.id) ?? null : null;
|
|
142
|
+
const source = resolveSourceNodes();
|
|
143
|
+
if (source.length === 0) {
|
|
144
|
+
cancelDrag();
|
|
145
|
+
return;
|
|
146
|
+
}
|
|
147
|
+
if (!isAllowedDrop(targetNode, t.position)) {
|
|
148
|
+
cancelDrag();
|
|
149
|
+
return;
|
|
150
|
+
}
|
|
151
|
+
try {
|
|
152
|
+
await adapter.move(source, targetNode, t.position);
|
|
153
|
+
} catch (e) {
|
|
154
|
+
const dialog = getDialog();
|
|
155
|
+
await dialog?.alert({
|
|
156
|
+
title: labels.error,
|
|
157
|
+
message: e instanceof Error ? e.message : String(e),
|
|
158
|
+
});
|
|
159
|
+
} finally {
|
|
160
|
+
cancelDrag();
|
|
161
|
+
}
|
|
162
|
+
}, [
|
|
163
|
+
active,
|
|
164
|
+
adapter,
|
|
165
|
+
cancelDrag,
|
|
166
|
+
dropTarget,
|
|
167
|
+
isAllowedDrop,
|
|
168
|
+
labels,
|
|
169
|
+
nodeById,
|
|
170
|
+
resolveSourceNodes,
|
|
171
|
+
]);
|
|
172
|
+
|
|
173
|
+
return useMemo(
|
|
174
|
+
() => ({
|
|
175
|
+
active,
|
|
176
|
+
draggingIds,
|
|
177
|
+
dropTarget,
|
|
178
|
+
beginDrag,
|
|
179
|
+
setDropTarget,
|
|
180
|
+
commitDrop,
|
|
181
|
+
cancelDrag,
|
|
182
|
+
isAllowedDrop,
|
|
183
|
+
}),
|
|
184
|
+
[
|
|
185
|
+
active,
|
|
186
|
+
draggingIds,
|
|
187
|
+
dropTarget,
|
|
188
|
+
beginDrag,
|
|
189
|
+
commitDrop,
|
|
190
|
+
cancelDrag,
|
|
191
|
+
isAllowedDrop,
|
|
192
|
+
],
|
|
193
|
+
);
|
|
194
|
+
}
|