@djangocfg/ui-tools 2.1.415 → 2.1.417
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/audio-player/index.cjs +2099 -0
- package/dist/audio-player/index.cjs.map +1 -0
- package/dist/audio-player/index.css +65 -0
- package/dist/audio-player/index.css.map +1 -0
- package/dist/audio-player/index.d.cts +174 -0
- package/dist/audio-player/index.d.ts +174 -0
- package/dist/audio-player/index.mjs +2076 -0
- package/dist/audio-player/index.mjs.map +1 -0
- package/dist/composer-registry/index.cjs +45 -0
- package/dist/composer-registry/index.cjs.map +1 -0
- package/dist/composer-registry/index.d.cts +73 -0
- package/dist/composer-registry/index.d.ts +73 -0
- package/dist/composer-registry/index.mjs +39 -0
- package/dist/composer-registry/index.mjs.map +1 -0
- 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 +2019 -279
- package/dist/tree/index.cjs.map +1 -1
- package/dist/tree/index.d.cts +731 -72
- package/dist/tree/index.d.ts +731 -72
- package/dist/tree/index.mjs +2009 -282
- package/dist/tree/index.mjs.map +1 -1
- package/package.json +18 -9
- package/src/tools/chat/README.md +111 -1
- package/src/tools/chat/composer/Composer.tsx +146 -25
- 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/context/ChatProvider.tsx +13 -78
- package/src/tools/chat/hooks/useAutoFocusOnStreamEnd.ts +12 -15
- package/src/tools/chat/hooks/useFocusOnEmptyClick.ts +4 -5
- package/src/tools/chat/launcher/header/ChatHeader.tsx +14 -19
- package/src/tools/chat/launcher/header/ChatHeaderActionButton.tsx +8 -12
- 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 +111 -72
- 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 +103 -8
- package/src/tools/data/Tree/components/index.ts +6 -0
- package/src/tools/data/Tree/context/TreeContext.tsx +223 -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 +11 -0
- package/src/tools/data/Tree/context/menu/render.tsx +75 -0
- package/src/tools/data/Tree/context/menu/use-resolved-menu.ts +141 -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 +26 -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 +158 -0
- package/src/tools/data/Tree/types/selection.ts +3 -0
- package/src/tools/data/Tree/types/slots.ts +64 -0
- package/src/tools/dev/OpenapiViewer/components/DocsLayout/EndpointDoc/Header/MetaActions.tsx +6 -9
- package/src/tools/dev/OpenapiViewer/components/DocsLayout/index.tsx +2 -4
- 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/src/tools/input/SpeechRecognition/widgets/VoiceComposerSlot.tsx +11 -12
- package/src/tools/integration/ComposerRegistry/index.ts +105 -0
- package/src/tools/media/AudioPlayer/Player.tsx +2 -0
- package/src/tools/media/AudioPlayer/PlayerShell.tsx +37 -22
- package/src/tools/media/AudioPlayer/lazy.tsx +30 -42
- package/src/tools/media/AudioPlayer/parts/Controls/IconButton.tsx +10 -11
- package/src/tools/media/AudioPlayer/parts/Controls/VolumeControl.tsx +52 -115
- package/src/tools/media/AudioPlayer/types.ts +15 -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,141 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { useMemo } from 'react';
|
|
4
|
+
|
|
5
|
+
import type { ClipboardState } from '../../data/clipboard';
|
|
6
|
+
import type {
|
|
7
|
+
TreeAdapter,
|
|
8
|
+
TreeBuiltinAction,
|
|
9
|
+
TreeContextMenuActionsResolver,
|
|
10
|
+
TreeContextMenuItem,
|
|
11
|
+
TreeItemId,
|
|
12
|
+
TreeLabels,
|
|
13
|
+
TreeMovePosition,
|
|
14
|
+
TreeNode,
|
|
15
|
+
TreeRowRenderProps,
|
|
16
|
+
} from '../../types';
|
|
17
|
+
|
|
18
|
+
export type { TreeContextMenuActionsResolver };
|
|
19
|
+
import {
|
|
20
|
+
buildDefaultMenuItems,
|
|
21
|
+
type BuiltinActionContext,
|
|
22
|
+
} from './builtin-actions';
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Internal row-driven resolver — same shape as
|
|
26
|
+
* `TreeContextMenuActionsResolver` but takes a plain `TreeRowRenderProps`
|
|
27
|
+
* instead of the context-with-selection. The provider injects
|
|
28
|
+
* `selectedNodes` itself.
|
|
29
|
+
*/
|
|
30
|
+
export type ResolvedMenuResolver<T> = (
|
|
31
|
+
row: TreeRowRenderProps<T>,
|
|
32
|
+
) => TreeContextMenuItem<T>[] | null | undefined;
|
|
33
|
+
|
|
34
|
+
export interface UseResolvedMenuOptions<T> {
|
|
35
|
+
adapter?: TreeAdapter<T>;
|
|
36
|
+
contextMenuActions?: TreeContextMenuActionsResolver<T>;
|
|
37
|
+
defaultMenuItems?: TreeBuiltinAction[];
|
|
38
|
+
labels: TreeLabels;
|
|
39
|
+
selected: ReadonlySet<TreeItemId>;
|
|
40
|
+
clipboard: ClipboardState;
|
|
41
|
+
nodeById: Map<TreeItemId, TreeNode<T>>;
|
|
42
|
+
getItemName: (node: TreeNode<T>) => string;
|
|
43
|
+
enableInlineRename: boolean;
|
|
44
|
+
startRename: (id: TreeItemId) => void;
|
|
45
|
+
cutToClipboard: (ids: TreeItemId[]) => void;
|
|
46
|
+
copyToClipboard: (ids: TreeItemId[]) => void;
|
|
47
|
+
pasteFromClipboard: (
|
|
48
|
+
target: TreeNode<T> | null,
|
|
49
|
+
position?: TreeMovePosition,
|
|
50
|
+
) => Promise<void>;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Build the merged declarative menu resolver — built-in adapter actions
|
|
55
|
+
* (filtered by `defaultMenuItems`) prepended to the consumer's
|
|
56
|
+
* `contextMenuActions` result. Returns `undefined` when neither side
|
|
57
|
+
* supplies anything, so `TreeRow` can skip rendering a menu entirely.
|
|
58
|
+
*
|
|
59
|
+
* The resolver injects `selectedNodes` on every call so the consumer's
|
|
60
|
+
* resolver receives the multi-selection without having to read it from
|
|
61
|
+
* elsewhere. Finder/Explorer convention applies: if the right-clicked
|
|
62
|
+
* row isn't in the current selection, the menu acts on a single-row
|
|
63
|
+
* effective selection (the row itself).
|
|
64
|
+
*/
|
|
65
|
+
export function useResolvedMenu<T>(
|
|
66
|
+
opts: UseResolvedMenuOptions<T>,
|
|
67
|
+
): ResolvedMenuResolver<T> | undefined {
|
|
68
|
+
const {
|
|
69
|
+
adapter,
|
|
70
|
+
contextMenuActions,
|
|
71
|
+
defaultMenuItems,
|
|
72
|
+
labels,
|
|
73
|
+
selected,
|
|
74
|
+
clipboard,
|
|
75
|
+
nodeById,
|
|
76
|
+
getItemName,
|
|
77
|
+
enableInlineRename,
|
|
78
|
+
startRename,
|
|
79
|
+
cutToClipboard,
|
|
80
|
+
copyToClipboard,
|
|
81
|
+
pasteFromClipboard,
|
|
82
|
+
} = opts;
|
|
83
|
+
|
|
84
|
+
return useMemo<ResolvedMenuResolver<T> | undefined>(() => {
|
|
85
|
+
if (!adapter && !contextMenuActions) return undefined;
|
|
86
|
+
|
|
87
|
+
return (rowProps) => {
|
|
88
|
+
const selectedIds = selected.has(rowProps.node.id)
|
|
89
|
+
? [...selected]
|
|
90
|
+
: [rowProps.node.id];
|
|
91
|
+
const selectedNodes = selectedIds
|
|
92
|
+
.map((id) => nodeById.get(id))
|
|
93
|
+
.filter((n): n is TreeNode<T> => !!n);
|
|
94
|
+
|
|
95
|
+
const builtin = adapter
|
|
96
|
+
? buildDefaultMenuItems<T>(
|
|
97
|
+
{
|
|
98
|
+
adapter,
|
|
99
|
+
labels,
|
|
100
|
+
selectedNodes,
|
|
101
|
+
targetNode: rowProps.node,
|
|
102
|
+
getName: getItemName,
|
|
103
|
+
startInlineRename:
|
|
104
|
+
enableInlineRename && adapter.rename ? startRename : undefined,
|
|
105
|
+
clipboard: {
|
|
106
|
+
hasItems: !!clipboard && clipboard.ids.length > 0,
|
|
107
|
+
cut: cutToClipboard,
|
|
108
|
+
copy: copyToClipboard,
|
|
109
|
+
paste: () => pasteFromClipboard(rowProps.node, 'inside'),
|
|
110
|
+
},
|
|
111
|
+
} satisfies BuiltinActionContext<T>,
|
|
112
|
+
defaultMenuItems
|
|
113
|
+
? (defaultMenuItems as (TreeBuiltinAction | 'separator')[])
|
|
114
|
+
: undefined,
|
|
115
|
+
)
|
|
116
|
+
: null;
|
|
117
|
+
|
|
118
|
+
const user =
|
|
119
|
+
contextMenuActions?.({ ...rowProps, selectedNodes }) ?? null;
|
|
120
|
+
|
|
121
|
+
if (!builtin && !user) return null;
|
|
122
|
+
if (!user) return builtin;
|
|
123
|
+
if (!builtin) return user;
|
|
124
|
+
return [...builtin, 'separator', ...user];
|
|
125
|
+
};
|
|
126
|
+
}, [
|
|
127
|
+
adapter,
|
|
128
|
+
contextMenuActions,
|
|
129
|
+
defaultMenuItems,
|
|
130
|
+
labels,
|
|
131
|
+
selected,
|
|
132
|
+
clipboard,
|
|
133
|
+
nodeById,
|
|
134
|
+
getItemName,
|
|
135
|
+
enableInlineRename,
|
|
136
|
+
startRename,
|
|
137
|
+
cutToClipboard,
|
|
138
|
+
copyToClipboard,
|
|
139
|
+
pasteFromClipboard,
|
|
140
|
+
]);
|
|
141
|
+
}
|
|
@@ -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
|
+
}
|