@parhelia/localization 0.1.10745
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +8 -0
- package/README.md +29 -0
- package/dist/core/src/editor/ui/DragPreview.d.ts +15 -0
- package/dist/core/src/editor/ui/DragPreview.d.ts.map +1 -0
- package/dist/core/src/editor/ui/DragPreview.js +32 -0
- package/dist/core/src/editor/ui/PerfectTree.d.ts +79 -0
- package/dist/core/src/editor/ui/PerfectTree.d.ts.map +1 -0
- package/dist/core/src/editor/ui/PerfectTree.js +857 -0
- package/dist/localization/src/LocalizeItemCommand.d.ts +8 -0
- package/dist/localization/src/LocalizeItemCommand.d.ts.map +1 -0
- package/dist/localization/src/LocalizeItemCommand.js +44 -0
- package/dist/localization/src/LocalizeItemDialog.d.ts +4 -0
- package/dist/localization/src/LocalizeItemDialog.d.ts.map +1 -0
- package/dist/localization/src/LocalizeItemDialog.js +126 -0
- package/dist/localization/src/LocalizeItemUtils.d.ts +17 -0
- package/dist/localization/src/LocalizeItemUtils.d.ts.map +1 -0
- package/dist/localization/src/LocalizeItemUtils.js +93 -0
- package/dist/localization/src/api/discovery.d.ts +36 -0
- package/dist/localization/src/api/discovery.d.ts.map +1 -0
- package/dist/localization/src/api/discovery.js +29 -0
- package/dist/localization/src/constants.d.ts +15 -0
- package/dist/localization/src/constants.d.ts.map +1 -0
- package/dist/localization/src/constants.js +21 -0
- package/dist/localization/src/hooks/useTranslationWizard.d.ts +6 -0
- package/dist/localization/src/hooks/useTranslationWizard.d.ts.map +1 -0
- package/dist/localization/src/hooks/useTranslationWizard.js +78 -0
- package/dist/localization/src/index.d.ts +69 -0
- package/dist/localization/src/index.d.ts.map +1 -0
- package/dist/localization/src/index.js +152 -0
- package/dist/localization/src/services/translationService.d.ts +102 -0
- package/dist/localization/src/services/translationService.d.ts.map +1 -0
- package/dist/localization/src/services/translationService.js +37 -0
- package/dist/localization/src/setup/LocalizationSetupStep.d.ts +3 -0
- package/dist/localization/src/setup/LocalizationSetupStep.d.ts.map +1 -0
- package/dist/localization/src/setup/LocalizationSetupStep.js +108 -0
- package/dist/localization/src/sidebar/TranslationSidebar.d.ts +2 -0
- package/dist/localization/src/sidebar/TranslationSidebar.d.ts.map +1 -0
- package/dist/localization/src/sidebar/TranslationSidebar.js +93 -0
- package/dist/localization/src/steps/MetadataInputStep.d.ts +4 -0
- package/dist/localization/src/steps/MetadataInputStep.d.ts.map +1 -0
- package/dist/localization/src/steps/MetadataInputStep.js +38 -0
- package/dist/localization/src/steps/ServiceLanguageSelectionStep.d.ts +3 -0
- package/dist/localization/src/steps/ServiceLanguageSelectionStep.d.ts.map +1 -0
- package/dist/localization/src/steps/ServiceLanguageSelectionStep.js +91 -0
- package/dist/localization/src/steps/SubitemDiscoveryStep.d.ts +3 -0
- package/dist/localization/src/steps/SubitemDiscoveryStep.d.ts.map +1 -0
- package/dist/localization/src/steps/SubitemDiscoveryStep.js +391 -0
- package/dist/localization/src/steps/index.d.ts +5 -0
- package/dist/localization/src/steps/index.d.ts.map +1 -0
- package/dist/localization/src/steps/index.js +4 -0
- package/dist/localization/src/steps/types.d.ts +68 -0
- package/dist/localization/src/steps/types.d.ts.map +1 -0
- package/dist/localization/src/steps/types.js +1 -0
- package/dist/localization/src/translation-center/BatchTranslationView.d.ts +7 -0
- package/dist/localization/src/translation-center/BatchTranslationView.d.ts.map +1 -0
- package/dist/localization/src/translation-center/BatchTranslationView.js +487 -0
- package/dist/localization/src/translation-center/RecentTranslations.d.ts +2 -0
- package/dist/localization/src/translation-center/RecentTranslations.d.ts.map +1 -0
- package/dist/localization/src/translation-center/RecentTranslations.js +199 -0
- package/dist/localization/src/translation-center/TranslationManagement.d.ts +2 -0
- package/dist/localization/src/translation-center/TranslationManagement.d.ts.map +1 -0
- package/dist/localization/src/translation-center/TranslationManagement.js +25 -0
- package/dist/localization/src/types.d.ts +18 -0
- package/dist/localization/src/types.d.ts.map +1 -0
- package/dist/localization/src/types.js +1 -0
- package/dist/localization/src/utils/createVersions.d.ts +14 -0
- package/dist/localization/src/utils/createVersions.d.ts.map +1 -0
- package/dist/localization/src/utils/createVersions.js +26 -0
- package/package.json +47 -0
- package/styles.css +1 -0
|
@@ -0,0 +1,857 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
|
|
2
|
+
import React, { useEffect, useMemo, useCallback, memo, useRef, useState, } from "react";
|
|
3
|
+
import { Loader2 } from "lucide-react";
|
|
4
|
+
import { ChevronRight } from "lucide-react";
|
|
5
|
+
import { setMultiDragImage } from "../ui/DragPreview";
|
|
6
|
+
/**
|
|
7
|
+
* Usage example with keyboard search:
|
|
8
|
+
*
|
|
9
|
+
* ```tsx
|
|
10
|
+
* import { PerfectTree } from './PerfectTree';
|
|
11
|
+
*
|
|
12
|
+
* <PerfectTree
|
|
13
|
+
* nodes={treeNodes}
|
|
14
|
+
* enableKeyboardSearch={true}
|
|
15
|
+
* renderNode={(node) => (
|
|
16
|
+
* <div>
|
|
17
|
+
* {node.icon}
|
|
18
|
+
* {node.label}
|
|
19
|
+
* </div>
|
|
20
|
+
* )}
|
|
21
|
+
* // ... other props
|
|
22
|
+
* />
|
|
23
|
+
* ```
|
|
24
|
+
*
|
|
25
|
+
* Note: Text highlighting is applied automatically when enableKeyboardSearch is true.
|
|
26
|
+
* The highlightText helper function is still available for manual highlighting if needed.
|
|
27
|
+
*/
|
|
28
|
+
// Local DropZone component to handle drag-over state.
|
|
29
|
+
const DropZone = memo(({ parent, index, isDragging, onDragOverZone, onDrop, onDragEnd, isLast, isValidDropZone, }) => {
|
|
30
|
+
const [isDragOver, setIsDragOver] = React.useState(false);
|
|
31
|
+
const [isValidDrop, setIsValidDrop] = React.useState(true);
|
|
32
|
+
useEffect(() => {
|
|
33
|
+
if (isDragging) {
|
|
34
|
+
if (isValidDropZone) {
|
|
35
|
+
const isValid = isValidDropZone(parent, index);
|
|
36
|
+
setIsValidDrop(isValid);
|
|
37
|
+
}
|
|
38
|
+
else {
|
|
39
|
+
setIsValidDrop(true);
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
}, [isValidDropZone, parent, index, isDragging]);
|
|
43
|
+
const handleDragEnter = useCallback((e) => {
|
|
44
|
+
e.preventDefault();
|
|
45
|
+
e.stopPropagation();
|
|
46
|
+
if (onDragOverZone) {
|
|
47
|
+
const allowed = onDragOverZone(parent, index, e);
|
|
48
|
+
setIsDragOver(allowed);
|
|
49
|
+
// e.dataTransfer.dropEffect = allowed ? "move" : "none";
|
|
50
|
+
}
|
|
51
|
+
}, [onDragOverZone, parent, index]);
|
|
52
|
+
const handleDragLeave = useCallback((e) => {
|
|
53
|
+
e.preventDefault();
|
|
54
|
+
e.stopPropagation();
|
|
55
|
+
setIsDragOver(false);
|
|
56
|
+
}, []);
|
|
57
|
+
const handleDragOver = useCallback((e) => {
|
|
58
|
+
e.preventDefault();
|
|
59
|
+
e.stopPropagation();
|
|
60
|
+
if (onDragOverZone) {
|
|
61
|
+
const allowed = onDragOverZone(parent, index, e);
|
|
62
|
+
setIsDragOver(allowed);
|
|
63
|
+
}
|
|
64
|
+
}, [onDragOverZone, parent, index]);
|
|
65
|
+
const handleDrop = useCallback((e) => {
|
|
66
|
+
e.preventDefault();
|
|
67
|
+
e.stopPropagation();
|
|
68
|
+
setIsDragOver(false);
|
|
69
|
+
if (onDrop) {
|
|
70
|
+
onDrop(parent, index, e);
|
|
71
|
+
}
|
|
72
|
+
}, [onDrop, parent, index]);
|
|
73
|
+
if (!isDragging || !isValidDrop)
|
|
74
|
+
return null;
|
|
75
|
+
return (_jsx("div", { className: `relative ${isLast ? "h-3" : ""}`, children: _jsx("div", { className: `drop-zone absolute top-[-5px] right-0 left-[45px] z-1000 h-3 rounded-md transition-colors duration-100 ${isDragOver ? "bg-sky-200" : ""}`, onDragEnter: handleDragEnter, onDragOver: handleDragOver, onDrop: handleDrop, onDragLeave: handleDragLeave }) }));
|
|
76
|
+
});
|
|
77
|
+
// Helper function to highlight matching text
|
|
78
|
+
export const highlightText = (text, searchTerm) => {
|
|
79
|
+
if (!searchTerm.trim())
|
|
80
|
+
return text;
|
|
81
|
+
const regex = new RegExp(`(${searchTerm.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")})`, "gi");
|
|
82
|
+
const parts = text.split(regex);
|
|
83
|
+
return parts.map((part, index) => {
|
|
84
|
+
if (regex.test(part)) {
|
|
85
|
+
return (_jsx("span", { className: "bg-yellow-200 underline", children: part }, index));
|
|
86
|
+
}
|
|
87
|
+
return part;
|
|
88
|
+
});
|
|
89
|
+
};
|
|
90
|
+
// Helper function to highlight text within React elements
|
|
91
|
+
const highlightReactElement = (element, searchTerm, nodeLabel) => {
|
|
92
|
+
try {
|
|
93
|
+
// If searchTerm doesn't match the node label, return as-is
|
|
94
|
+
if (!nodeLabel.toLowerCase().includes(searchTerm.toLowerCase())) {
|
|
95
|
+
return element;
|
|
96
|
+
}
|
|
97
|
+
const processChildren = (children) => {
|
|
98
|
+
return React.Children.map(children, (child) => {
|
|
99
|
+
if (typeof child === "string") {
|
|
100
|
+
// If it's a string and contains the search term, highlight it
|
|
101
|
+
if (child.toLowerCase().includes(searchTerm.toLowerCase())) {
|
|
102
|
+
return highlightText(child, searchTerm);
|
|
103
|
+
}
|
|
104
|
+
return child;
|
|
105
|
+
}
|
|
106
|
+
if (React.isValidElement(child)) {
|
|
107
|
+
// Recursively process React elements
|
|
108
|
+
const childProps = child.props;
|
|
109
|
+
if (childProps && childProps.children) {
|
|
110
|
+
return React.cloneElement(child, {
|
|
111
|
+
...childProps,
|
|
112
|
+
children: processChildren(childProps.children),
|
|
113
|
+
});
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
return child;
|
|
117
|
+
});
|
|
118
|
+
};
|
|
119
|
+
// Clone the element with processed children
|
|
120
|
+
const elementProps = element.props;
|
|
121
|
+
if (elementProps && elementProps.children) {
|
|
122
|
+
return React.cloneElement(element, {
|
|
123
|
+
...elementProps,
|
|
124
|
+
children: processChildren(elementProps.children),
|
|
125
|
+
});
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
catch (error) {
|
|
129
|
+
// If any error occurs, return the original element
|
|
130
|
+
console.warn("Error highlighting React element:", error);
|
|
131
|
+
}
|
|
132
|
+
return element;
|
|
133
|
+
};
|
|
134
|
+
// Helper function to check if a node matches the search term
|
|
135
|
+
const nodeMatchesSearch = (node, searchTerm) => {
|
|
136
|
+
if (!searchTerm.trim())
|
|
137
|
+
return true;
|
|
138
|
+
return node.label.toLowerCase().includes(searchTerm.toLowerCase());
|
|
139
|
+
};
|
|
140
|
+
// Helper function to filter tree nodes based on search term
|
|
141
|
+
const filterTreeNodes = (nodes, searchTerm, expandedKeys) => {
|
|
142
|
+
if (!searchTerm.trim())
|
|
143
|
+
return nodes;
|
|
144
|
+
const filterNode = (node) => {
|
|
145
|
+
const nodeMatches = nodeMatchesSearch(node, searchTerm);
|
|
146
|
+
const isExpanded = expandedKeys.includes(node.key);
|
|
147
|
+
// Process children only if the node is expanded
|
|
148
|
+
let filteredChildren = [];
|
|
149
|
+
let hasMatchingChildren = false;
|
|
150
|
+
if (isExpanded && node.children && Array.isArray(node.children)) {
|
|
151
|
+
filteredChildren = node.children
|
|
152
|
+
.map((child) => filterNode(child))
|
|
153
|
+
.filter((child) => child !== null);
|
|
154
|
+
hasMatchingChildren = filteredChildren.length > 0;
|
|
155
|
+
}
|
|
156
|
+
// Include node if it matches or has matching children
|
|
157
|
+
if (nodeMatches || hasMatchingChildren) {
|
|
158
|
+
return {
|
|
159
|
+
...node,
|
|
160
|
+
children: isExpanded ? filteredChildren : node.children,
|
|
161
|
+
};
|
|
162
|
+
}
|
|
163
|
+
return null;
|
|
164
|
+
};
|
|
165
|
+
return nodes
|
|
166
|
+
.map((node) => filterNode(node))
|
|
167
|
+
.filter((node) => node !== null);
|
|
168
|
+
};
|
|
169
|
+
// Helper function to flatten tree nodes into a depth-first list of visible nodes
|
|
170
|
+
const flattenVisibleNodes = (nodes, expandedKeys, parent = null, depth = 0) => {
|
|
171
|
+
const result = [];
|
|
172
|
+
for (const node of nodes) {
|
|
173
|
+
// Add current node
|
|
174
|
+
result.push({ node, parent, depth });
|
|
175
|
+
// If expanded and has children, recursively add children
|
|
176
|
+
const isExpanded = expandedKeys.includes(node.key);
|
|
177
|
+
if (isExpanded && node.children && Array.isArray(node.children)) {
|
|
178
|
+
result.push(...flattenVisibleNodes(node.children, expandedKeys, node, depth + 1));
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
return result;
|
|
182
|
+
};
|
|
183
|
+
// NodeContent component extracted and memoized
|
|
184
|
+
const NodeContent = memo(({ node, isExpanded, isSelected, onSelect, onToggleNode, onStartDrag, onDragEnd, onDragOverZone, onDrop, onDoubleClick, renderNode, onContextMenu, enableDragAndDrop = false, selectedKeys, isDragging, searchTerm = "", multiDragNoun, }) => {
|
|
185
|
+
const [isDragOver, setIsDragOver] = React.useState(false);
|
|
186
|
+
useEffect(() => {
|
|
187
|
+
if (!isDragging) {
|
|
188
|
+
setIsDragOver(false);
|
|
189
|
+
}
|
|
190
|
+
}, [isDragging]);
|
|
191
|
+
const handleDragStart = useCallback((event) => {
|
|
192
|
+
const isMultiSelect = isSelected && selectedKeys && selectedKeys.length > 1;
|
|
193
|
+
// Set drag preview for multiple items if applicable
|
|
194
|
+
if (isMultiSelect)
|
|
195
|
+
setMultiDragImage(event, selectedKeys.length, multiDragNoun || "items");
|
|
196
|
+
if (onStartDrag) {
|
|
197
|
+
onStartDrag({ node, event, isMultiSelect: isMultiSelect ?? false });
|
|
198
|
+
}
|
|
199
|
+
}, [node, onStartDrag, isSelected, selectedKeys]);
|
|
200
|
+
const handleDragLeave = useCallback((event) => {
|
|
201
|
+
event.preventDefault();
|
|
202
|
+
setIsDragOver(false);
|
|
203
|
+
}, []);
|
|
204
|
+
const handleDragEnter = useCallback((event) => {
|
|
205
|
+
event.preventDefault();
|
|
206
|
+
if (onDragOverZone) {
|
|
207
|
+
const allowed = onDragOverZone(node, -1, event);
|
|
208
|
+
setIsDragOver(allowed);
|
|
209
|
+
}
|
|
210
|
+
}, [node, onDragOverZone]);
|
|
211
|
+
const handleDragOver = useCallback((event) => {
|
|
212
|
+
event.preventDefault();
|
|
213
|
+
if (onDragOverZone) {
|
|
214
|
+
const allowed = onDragOverZone(node, -1, event);
|
|
215
|
+
setIsDragOver(allowed);
|
|
216
|
+
if (!allowed) {
|
|
217
|
+
event.dataTransfer.dropEffect = "none";
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
}, [node, onDragOverZone]);
|
|
221
|
+
const handleDrop = useCallback((e) => {
|
|
222
|
+
console.log("Dropping", node, -1, e);
|
|
223
|
+
e.preventDefault();
|
|
224
|
+
e.stopPropagation();
|
|
225
|
+
if (onDrop) {
|
|
226
|
+
onDrop(node, -1, e);
|
|
227
|
+
}
|
|
228
|
+
}, [node, onDrop]);
|
|
229
|
+
const handleDoubleClick = useCallback((e) => {
|
|
230
|
+
e.stopPropagation();
|
|
231
|
+
onDoubleClick?.(node);
|
|
232
|
+
}, [node, onDoubleClick]);
|
|
233
|
+
const handleSelect = useCallback((e) => {
|
|
234
|
+
e.stopPropagation();
|
|
235
|
+
onSelect(node.key, e);
|
|
236
|
+
}, [node.key, onSelect]);
|
|
237
|
+
const handleToggle = useCallback((e) => {
|
|
238
|
+
e.stopPropagation();
|
|
239
|
+
onToggleNode(node);
|
|
240
|
+
}, [node, onToggleNode]);
|
|
241
|
+
const renderToggle = () => {
|
|
242
|
+
if (node.hasChildren && node.children === null) {
|
|
243
|
+
return (_jsx("div", { className: "flex h-[23px] w-[24px] items-center justify-center", children: _jsx(Loader2, { className: "h-4 w-4 animate-spin text-gray-500" }) }));
|
|
244
|
+
}
|
|
245
|
+
return (_jsx("div", { className: "flex h-[23px] w-[24px] items-center justify-center", children: _jsx("span", { onClick: handleToggle, className: `toggle inline-block transform cursor-pointer text-gray-500 transition duration-150 select-none ${isExpanded ? "rotate-90" : "rotate-0"}`, children: _jsx(ChevronRight, { strokeWidth: 1, className: "h-5.5 w-5.5" }) }) }));
|
|
246
|
+
};
|
|
247
|
+
const handleContextMenu = useCallback((e) => {
|
|
248
|
+
e.stopPropagation();
|
|
249
|
+
e.preventDefault();
|
|
250
|
+
onContextMenu?.(node, e);
|
|
251
|
+
}, [node, onContextMenu]);
|
|
252
|
+
return (_jsxs("div", { className: `tree-node ${node?.type ? `tree-node-${node.type}` : ""} mb-0.5 flex cursor-pointer items-center`, draggable: enableDragAndDrop && !!node.isDraggable, onClick: handleSelect, onDragStart: (event) => handleDragStart(event), onDragEnd: onDragEnd, onDragLeave: handleDragLeave, onDragEnter: handleDragEnter, onDragOver: handleDragOver, onDrop: handleDrop, onDoubleClick: handleDoubleClick, onContextMenu: handleContextMenu, "data-node-key": node.key, "data-selected": isSelected, "data-node-type": node?.type, children: [node.hasChildren || node.children?.length ? (renderToggle()) : (_jsx("div", { className: "w-[24px]" })), _jsx("div", { className: `flex-1 rounded-md border border-transparent p-0.5 pr-1.5 hover:border-gray-300 ${isDragOver ? "bg-sky-200" : isSelected ? "bg-blue-100" : ""}`, onClick: handleSelect, children: renderNode(node, searchTerm) })] }));
|
|
253
|
+
});
|
|
254
|
+
export const PerfectTree = ({ nodes, selectedKeys = [], expandedKeys = [], renderNode, onToggleExpand, onSelect, onDragOverZone, onDrop, isDragging = false, onStartDrag, onDragEnd, onLazyLoad, onDoubleClick, onContextMenu, enableDragAndDrop = false, isValidDropZone, scrollToSelected = false, enableKeyboardSearch = false, searchClearDelay = 1500, disableAutoSelectOnExpand = false, multiDragNoun = "items", enableKeyboardNavigation = true, onKeyboardNavigationChange, }) => {
|
|
255
|
+
const [searchTerm, setSearchTerm] = useState("");
|
|
256
|
+
const [isFocused, setIsFocused] = useState(false);
|
|
257
|
+
const [keyboardNavPosition, setKeyboardNavPosition] = useState(null);
|
|
258
|
+
const searchTimeoutRef = useRef(null);
|
|
259
|
+
// When toggling a node, notify parent and trigger external lazy load if needed.
|
|
260
|
+
const handleToggle = useCallback((node) => {
|
|
261
|
+
const isCurrentlyExpanded = expandedKeys.includes(node.key);
|
|
262
|
+
// If the node is being expanded (not collapsed) and there's an active search filter
|
|
263
|
+
if (!isCurrentlyExpanded) {
|
|
264
|
+
// Only select the node for quick navigation if there's an active search filter
|
|
265
|
+
// and auto-selection is not disabled
|
|
266
|
+
if (searchTerm && onSelect && !disableAutoSelectOnExpand) {
|
|
267
|
+
onSelect(node.key, {});
|
|
268
|
+
}
|
|
269
|
+
// Clear the search filter
|
|
270
|
+
setSearchTerm("");
|
|
271
|
+
// Clear any pending search timeout
|
|
272
|
+
if (searchTimeoutRef.current) {
|
|
273
|
+
clearTimeout(searchTimeoutRef.current);
|
|
274
|
+
searchTimeoutRef.current = null;
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
if (onToggleExpand) {
|
|
278
|
+
onToggleExpand(node.key);
|
|
279
|
+
}
|
|
280
|
+
// If the node is expandable and its children haven't been loaded,
|
|
281
|
+
// call onLazyLoad (external async loading should update the node to `null` while loading)
|
|
282
|
+
if (node.hasChildren && node.children === undefined && onLazyLoad) {
|
|
283
|
+
onLazyLoad(node);
|
|
284
|
+
}
|
|
285
|
+
}, [
|
|
286
|
+
onToggleExpand,
|
|
287
|
+
onLazyLoad,
|
|
288
|
+
expandedKeys,
|
|
289
|
+
onSelect,
|
|
290
|
+
searchTerm,
|
|
291
|
+
disableAutoSelectOnExpand,
|
|
292
|
+
]);
|
|
293
|
+
const handleSelect = useCallback((nodeKey, event) => {
|
|
294
|
+
if (onSelect) {
|
|
295
|
+
onSelect(nodeKey, event);
|
|
296
|
+
}
|
|
297
|
+
}, [onSelect]);
|
|
298
|
+
// Global drag end handler.
|
|
299
|
+
const isDraggingRef = React.useRef(false);
|
|
300
|
+
const dragTimeoutRef = React.useRef(undefined);
|
|
301
|
+
useEffect(() => {
|
|
302
|
+
const handleGlobalDragEnd = (event) => {
|
|
303
|
+
if (isDraggingRef.current && onDragEnd) {
|
|
304
|
+
onDragEnd(event);
|
|
305
|
+
isDraggingRef.current = false;
|
|
306
|
+
}
|
|
307
|
+
// Clear timeout when drag ends properly
|
|
308
|
+
if (dragTimeoutRef.current !== undefined) {
|
|
309
|
+
clearTimeout(dragTimeoutRef.current);
|
|
310
|
+
dragTimeoutRef.current = undefined;
|
|
311
|
+
}
|
|
312
|
+
};
|
|
313
|
+
const handleEscapeKey = (event) => {
|
|
314
|
+
if (event.key === "Escape" && isDraggingRef.current && onDragEnd) {
|
|
315
|
+
onDragEnd(null);
|
|
316
|
+
isDraggingRef.current = false;
|
|
317
|
+
if (dragTimeoutRef.current !== undefined) {
|
|
318
|
+
clearTimeout(dragTimeoutRef.current);
|
|
319
|
+
dragTimeoutRef.current = undefined;
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
};
|
|
323
|
+
const handleMouseLeave = (event) => {
|
|
324
|
+
// Reset drag state if mouse leaves the window during a drag
|
|
325
|
+
if (isDraggingRef.current && onDragEnd) {
|
|
326
|
+
onDragEnd(null);
|
|
327
|
+
isDraggingRef.current = false;
|
|
328
|
+
if (dragTimeoutRef.current !== undefined) {
|
|
329
|
+
clearTimeout(dragTimeoutRef.current);
|
|
330
|
+
dragTimeoutRef.current = undefined;
|
|
331
|
+
}
|
|
332
|
+
}
|
|
333
|
+
};
|
|
334
|
+
document.addEventListener("dragend", handleGlobalDragEnd);
|
|
335
|
+
document.addEventListener("keydown", handleEscapeKey);
|
|
336
|
+
window.addEventListener("mouseleave", handleMouseLeave);
|
|
337
|
+
return () => {
|
|
338
|
+
document.removeEventListener("dragend", handleGlobalDragEnd);
|
|
339
|
+
document.removeEventListener("keydown", handleEscapeKey);
|
|
340
|
+
window.removeEventListener("mouseleave", handleMouseLeave);
|
|
341
|
+
};
|
|
342
|
+
}, [onDragEnd]);
|
|
343
|
+
// Scroll to selected node when scrollToSelected is enabled and selectedKeys change
|
|
344
|
+
const treeRef = useRef(null);
|
|
345
|
+
useEffect(() => {
|
|
346
|
+
if (scrollToSelected && selectedKeys.length > 0 && treeRef.current) {
|
|
347
|
+
const timeoutId = setTimeout(() => {
|
|
348
|
+
const treeContainer = treeRef.current;
|
|
349
|
+
if (!treeContainer)
|
|
350
|
+
return;
|
|
351
|
+
// Try multiple selection strategies
|
|
352
|
+
let selectedNode = null;
|
|
353
|
+
// Strategy 1: Use data-selected attribute
|
|
354
|
+
selectedNode = treeContainer.querySelector('[data-selected="true"]');
|
|
355
|
+
// Strategy 2: Use bg-blue-100 class
|
|
356
|
+
if (!selectedNode) {
|
|
357
|
+
selectedNode = treeContainer.querySelector(".bg-blue-100");
|
|
358
|
+
}
|
|
359
|
+
// Strategy 3: Use data-node-key attribute
|
|
360
|
+
if (!selectedNode && selectedKeys.length > 0) {
|
|
361
|
+
const nodeKey = selectedKeys[0];
|
|
362
|
+
if (nodeKey) {
|
|
363
|
+
selectedNode = treeContainer.querySelector(`[data-node-key="${nodeKey}"]`);
|
|
364
|
+
// Fallback: CSS.escape for special characters
|
|
365
|
+
if (!selectedNode) {
|
|
366
|
+
try {
|
|
367
|
+
const escapedKey = CSS.escape(nodeKey);
|
|
368
|
+
selectedNode = treeContainer.querySelector(`[data-node-key="${escapedKey}"]`);
|
|
369
|
+
}
|
|
370
|
+
catch (e) {
|
|
371
|
+
// Silently fail if CSS.escape doesn't work
|
|
372
|
+
}
|
|
373
|
+
}
|
|
374
|
+
}
|
|
375
|
+
}
|
|
376
|
+
if (selectedNode) {
|
|
377
|
+
// Find the scrollable parent container
|
|
378
|
+
let scrollContainer = selectedNode.closest(".overflow-auto");
|
|
379
|
+
if (!scrollContainer) {
|
|
380
|
+
scrollContainer = selectedNode.closest('[style*="overflow"]');
|
|
381
|
+
}
|
|
382
|
+
if (!scrollContainer) {
|
|
383
|
+
scrollContainer = treeContainer.parentElement;
|
|
384
|
+
}
|
|
385
|
+
if (scrollContainer) {
|
|
386
|
+
const containerRect = scrollContainer.getBoundingClientRect();
|
|
387
|
+
const nodeRect = selectedNode.getBoundingClientRect();
|
|
388
|
+
// Check if the node is already visible
|
|
389
|
+
const isVisible = nodeRect.top >= containerRect.top &&
|
|
390
|
+
nodeRect.bottom <= containerRect.bottom;
|
|
391
|
+
if (!isVisible) {
|
|
392
|
+
const scrollTop = scrollContainer.scrollTop;
|
|
393
|
+
const containerTop = containerRect.top;
|
|
394
|
+
const nodeTop = nodeRect.top;
|
|
395
|
+
const offset = nodeTop - containerTop;
|
|
396
|
+
const newScrollTop = scrollTop +
|
|
397
|
+
offset -
|
|
398
|
+
containerRect.height / 2 +
|
|
399
|
+
nodeRect.height / 2;
|
|
400
|
+
scrollContainer.scrollTo({
|
|
401
|
+
top: newScrollTop,
|
|
402
|
+
behavior: "smooth",
|
|
403
|
+
});
|
|
404
|
+
}
|
|
405
|
+
}
|
|
406
|
+
else {
|
|
407
|
+
selectedNode.scrollIntoView({
|
|
408
|
+
behavior: "smooth",
|
|
409
|
+
block: "nearest",
|
|
410
|
+
});
|
|
411
|
+
}
|
|
412
|
+
}
|
|
413
|
+
}, 300);
|
|
414
|
+
return () => clearTimeout(timeoutId);
|
|
415
|
+
}
|
|
416
|
+
}, [scrollToSelected, selectedKeys]);
|
|
417
|
+
// Enhanced renderNode function that handles highlighting
|
|
418
|
+
const enhancedRenderNode = useCallback((node, searchTermForNode = searchTerm) => {
|
|
419
|
+
// Get the original content from renderNode
|
|
420
|
+
const originalContent = renderNode(node, searchTermForNode);
|
|
421
|
+
// If there's no search term, return original content
|
|
422
|
+
if (!searchTermForNode?.trim()) {
|
|
423
|
+
return originalContent;
|
|
424
|
+
}
|
|
425
|
+
// Auto-highlight if the content is simple text
|
|
426
|
+
if (typeof originalContent === "string") {
|
|
427
|
+
return highlightText(originalContent, searchTermForNode);
|
|
428
|
+
}
|
|
429
|
+
// For React elements, try to find and highlight text content
|
|
430
|
+
if (React.isValidElement(originalContent)) {
|
|
431
|
+
return highlightReactElement(originalContent, searchTermForNode, node.label);
|
|
432
|
+
}
|
|
433
|
+
// Fallback: if we can't process the content, return original
|
|
434
|
+
return originalContent;
|
|
435
|
+
}, [renderNode, searchTerm]);
|
|
436
|
+
// Filter nodes based on search term
|
|
437
|
+
const filteredNodes = useMemo(() => {
|
|
438
|
+
return filterTreeNodes(nodes, searchTerm, expandedKeys);
|
|
439
|
+
}, [nodes, searchTerm, expandedKeys]);
|
|
440
|
+
// Wrapper for onStartDrag that has access to the refs
|
|
441
|
+
const handleStartDragWithTimeout = useCallback((data) => {
|
|
442
|
+
// Mark dragging as active
|
|
443
|
+
isDraggingRef.current = true;
|
|
444
|
+
// Failsafe timeout to recover if the browser fails to emit dragend
|
|
445
|
+
dragTimeoutRef.current = setTimeout(() => {
|
|
446
|
+
if (isDraggingRef.current && onDragEnd) {
|
|
447
|
+
onDragEnd(null);
|
|
448
|
+
isDraggingRef.current = false;
|
|
449
|
+
}
|
|
450
|
+
}, 30000); // 30s safety timeout
|
|
451
|
+
// Delegate to consumer
|
|
452
|
+
if (onStartDrag) {
|
|
453
|
+
onStartDrag(data);
|
|
454
|
+
}
|
|
455
|
+
}, [onStartDrag, onDragEnd]);
|
|
456
|
+
const handleDragEnd = useCallback((event) => {
|
|
457
|
+
isDraggingRef.current = false;
|
|
458
|
+
// Clear timeout when drag ends normally
|
|
459
|
+
if (dragTimeoutRef.current !== undefined) {
|
|
460
|
+
clearTimeout(dragTimeoutRef.current);
|
|
461
|
+
dragTimeoutRef.current = undefined;
|
|
462
|
+
}
|
|
463
|
+
if (onDragEnd) {
|
|
464
|
+
onDragEnd(event);
|
|
465
|
+
}
|
|
466
|
+
}, [onDragEnd]);
|
|
467
|
+
// Recursive function to render tree nodes along with drop zones.
|
|
468
|
+
const renderTreeList = useCallback((nodes, depth, parent = null) => {
|
|
469
|
+
return (_jsxs("div", { className: "tree-container flex flex-col", children: [nodes.map((node, index) => {
|
|
470
|
+
const children = node.children;
|
|
471
|
+
const isExpanded = expandedKeys.includes(node.key);
|
|
472
|
+
const isSelected = selectedKeys.includes(node.key);
|
|
473
|
+
return (_jsxs(React.Fragment, { children: [_jsx(DropZone, { parent: parent, index: index, isDragging: isDragging, onDragOverZone: onDragOverZone, onDrop: onDrop, onDragEnd: onDragEnd, isValidDropZone: isValidDropZone }), _jsxs("div", { style: {
|
|
474
|
+
marginLeft: depth > 0 ? "24px" : undefined,
|
|
475
|
+
}, className: "flex flex-col", children: [_jsx(NodeContent, { node: node, isExpanded: isExpanded, isSelected: isSelected, onSelect: handleSelect, onToggleNode: handleToggle, onStartDrag: handleStartDragWithTimeout, onDragEnd: handleDragEnd, onDragOverZone: onDragOverZone, onDrop: onDrop, onDoubleClick: onDoubleClick, onContextMenu: onContextMenu, renderNode: enhancedRenderNode, enableDragAndDrop: enableDragAndDrop, selectedKeys: selectedKeys, isDragging: isDragging, searchTerm: searchTerm, multiDragNoun: multiDragNoun }), isExpanded && (_jsx(_Fragment, { children: children && children.length > 0 ? (_jsx("div", { children: renderTreeList(children, depth + 1, node) })) : null }))] })] }, node.key));
|
|
476
|
+
}), _jsx(DropZone, { parent: parent, index: nodes.length, isDragging: isDragging, onDragOverZone: onDragOverZone, onDrop: onDrop, onDragEnd: onDragEnd, isLast: true, isValidDropZone: isValidDropZone })] }));
|
|
477
|
+
}, [
|
|
478
|
+
expandedKeys,
|
|
479
|
+
selectedKeys,
|
|
480
|
+
isDragging,
|
|
481
|
+
onDragOverZone,
|
|
482
|
+
onDrop,
|
|
483
|
+
onDragEnd,
|
|
484
|
+
handleStartDragWithTimeout,
|
|
485
|
+
onDoubleClick,
|
|
486
|
+
onContextMenu,
|
|
487
|
+
handleSelect,
|
|
488
|
+
handleToggle,
|
|
489
|
+
enhancedRenderNode,
|
|
490
|
+
searchTerm,
|
|
491
|
+
enableDragAndDrop,
|
|
492
|
+
multiDragNoun,
|
|
493
|
+
isValidDropZone,
|
|
494
|
+
]);
|
|
495
|
+
// Memoize the tree structure
|
|
496
|
+
const treeContent = useMemo(() => renderTreeList(filteredNodes, 0), [filteredNodes, renderTreeList]);
|
|
497
|
+
// Initialize keyboard navigation position when tree gains focus
|
|
498
|
+
useEffect(() => {
|
|
499
|
+
if (isFocused && !keyboardNavPosition) {
|
|
500
|
+
// Set initial position to first selected node, or first node
|
|
501
|
+
if (selectedKeys.length > 0 && selectedKeys[0]) {
|
|
502
|
+
setKeyboardNavPosition(selectedKeys[0]);
|
|
503
|
+
}
|
|
504
|
+
else if (filteredNodes.length > 0 && filteredNodes[0]) {
|
|
505
|
+
setKeyboardNavPosition(filteredNodes[0].key);
|
|
506
|
+
}
|
|
507
|
+
}
|
|
508
|
+
}, [isFocused, keyboardNavPosition, selectedKeys, filteredNodes]);
|
|
509
|
+
// Update keyboard navigation position when selection changes externally (e.g., via click)
|
|
510
|
+
useEffect(() => {
|
|
511
|
+
if (isFocused && selectedKeys.length > 0 && selectedKeys[0]) {
|
|
512
|
+
// Only update if the current nav position is not in the selected keys
|
|
513
|
+
// This preserves nav position during shift-select
|
|
514
|
+
if (!keyboardNavPosition || !selectedKeys.includes(keyboardNavPosition)) {
|
|
515
|
+
setKeyboardNavPosition(selectedKeys[0]);
|
|
516
|
+
}
|
|
517
|
+
}
|
|
518
|
+
}, [selectedKeys, isFocused, keyboardNavPosition]);
|
|
519
|
+
// Helper function to scroll a node into view
|
|
520
|
+
const scrollNodeIntoView = useCallback((nodeKey) => {
|
|
521
|
+
if (!treeRef.current)
|
|
522
|
+
return;
|
|
523
|
+
setTimeout(() => {
|
|
524
|
+
const treeContainer = treeRef.current;
|
|
525
|
+
if (!treeContainer)
|
|
526
|
+
return;
|
|
527
|
+
const targetNode = treeContainer.querySelector(`[data-node-key="${CSS.escape(nodeKey)}"]`);
|
|
528
|
+
if (targetNode) {
|
|
529
|
+
let scrollContainer = targetNode.closest(".overflow-auto");
|
|
530
|
+
if (!scrollContainer) {
|
|
531
|
+
scrollContainer = targetNode.closest('[style*="overflow"]');
|
|
532
|
+
}
|
|
533
|
+
if (!scrollContainer) {
|
|
534
|
+
scrollContainer = treeContainer.parentElement;
|
|
535
|
+
}
|
|
536
|
+
if (scrollContainer) {
|
|
537
|
+
const containerRect = scrollContainer.getBoundingClientRect();
|
|
538
|
+
const nodeRect = targetNode.getBoundingClientRect();
|
|
539
|
+
const isVisible = nodeRect.top >= containerRect.top &&
|
|
540
|
+
nodeRect.bottom <= containerRect.bottom;
|
|
541
|
+
if (!isVisible) {
|
|
542
|
+
const scrollTop = scrollContainer.scrollTop;
|
|
543
|
+
const containerTop = containerRect.top;
|
|
544
|
+
const nodeTop = nodeRect.top;
|
|
545
|
+
const offset = nodeTop - containerTop;
|
|
546
|
+
const newScrollTop = scrollTop +
|
|
547
|
+
offset -
|
|
548
|
+
containerRect.height / 2 +
|
|
549
|
+
nodeRect.height / 2;
|
|
550
|
+
scrollContainer.scrollTo({
|
|
551
|
+
top: newScrollTop,
|
|
552
|
+
behavior: "smooth",
|
|
553
|
+
});
|
|
554
|
+
}
|
|
555
|
+
}
|
|
556
|
+
else {
|
|
557
|
+
targetNode.scrollIntoView({
|
|
558
|
+
behavior: "smooth",
|
|
559
|
+
block: "nearest",
|
|
560
|
+
});
|
|
561
|
+
}
|
|
562
|
+
}
|
|
563
|
+
}, 0);
|
|
564
|
+
}, []);
|
|
565
|
+
// Keyboard navigation functionality
|
|
566
|
+
useEffect(() => {
|
|
567
|
+
if (!enableKeyboardNavigation || !isFocused)
|
|
568
|
+
return;
|
|
569
|
+
const handleKeyDown = (event) => {
|
|
570
|
+
// Ignore if user is typing in an input, textarea, or contenteditable element
|
|
571
|
+
const target = event.target;
|
|
572
|
+
if (target.tagName === "INPUT" ||
|
|
573
|
+
target.tagName === "TEXTAREA" ||
|
|
574
|
+
target.contentEditable === "true" ||
|
|
575
|
+
target.isContentEditable) {
|
|
576
|
+
return;
|
|
577
|
+
}
|
|
578
|
+
// Get flattened list of visible nodes
|
|
579
|
+
const visibleNodes = flattenVisibleNodes(filteredNodes, expandedKeys);
|
|
580
|
+
if (visibleNodes.length === 0)
|
|
581
|
+
return;
|
|
582
|
+
// Use keyboard navigation position as the current position
|
|
583
|
+
const currentIndex = keyboardNavPosition
|
|
584
|
+
? visibleNodes.findIndex((n) => n.node.key === keyboardNavPosition)
|
|
585
|
+
: -1;
|
|
586
|
+
let newSelectedKey = null;
|
|
587
|
+
let shouldUseShift = false;
|
|
588
|
+
switch (event.key) {
|
|
589
|
+
case "ArrowDown": {
|
|
590
|
+
event.preventDefault();
|
|
591
|
+
shouldUseShift = event.shiftKey;
|
|
592
|
+
if (currentIndex < visibleNodes.length - 1) {
|
|
593
|
+
const nextNode = visibleNodes[currentIndex + 1];
|
|
594
|
+
if (nextNode) {
|
|
595
|
+
newSelectedKey = nextNode.node.key;
|
|
596
|
+
}
|
|
597
|
+
}
|
|
598
|
+
else if (currentIndex === -1 &&
|
|
599
|
+
visibleNodes.length > 0 &&
|
|
600
|
+
visibleNodes[0]) {
|
|
601
|
+
// No selection, select first node
|
|
602
|
+
newSelectedKey = visibleNodes[0].node.key;
|
|
603
|
+
}
|
|
604
|
+
break;
|
|
605
|
+
}
|
|
606
|
+
case "ArrowUp": {
|
|
607
|
+
event.preventDefault();
|
|
608
|
+
shouldUseShift = event.shiftKey;
|
|
609
|
+
if (currentIndex > 0) {
|
|
610
|
+
const prevNode = visibleNodes[currentIndex - 1];
|
|
611
|
+
if (prevNode) {
|
|
612
|
+
newSelectedKey = prevNode.node.key;
|
|
613
|
+
}
|
|
614
|
+
}
|
|
615
|
+
else if (currentIndex === -1 &&
|
|
616
|
+
visibleNodes.length > 0 &&
|
|
617
|
+
visibleNodes[0]) {
|
|
618
|
+
// No selection, select first node
|
|
619
|
+
newSelectedKey = visibleNodes[0].node.key;
|
|
620
|
+
}
|
|
621
|
+
break;
|
|
622
|
+
}
|
|
623
|
+
case "ArrowRight": {
|
|
624
|
+
event.preventDefault();
|
|
625
|
+
const currentNode = currentIndex >= 0 ? visibleNodes[currentIndex] : undefined;
|
|
626
|
+
if (currentNode) {
|
|
627
|
+
const node = currentNode.node;
|
|
628
|
+
const isExpanded = expandedKeys.includes(node.key);
|
|
629
|
+
if (node.hasChildren || node.children?.length) {
|
|
630
|
+
if (!isExpanded) {
|
|
631
|
+
// Expand the node
|
|
632
|
+
if (onToggleExpand) {
|
|
633
|
+
onToggleExpand(node.key);
|
|
634
|
+
}
|
|
635
|
+
if (node.hasChildren &&
|
|
636
|
+
node.children === undefined &&
|
|
637
|
+
onLazyLoad) {
|
|
638
|
+
onLazyLoad(node);
|
|
639
|
+
}
|
|
640
|
+
}
|
|
641
|
+
else if (node.children &&
|
|
642
|
+
node.children.length > 0 &&
|
|
643
|
+
node.children[0]) {
|
|
644
|
+
// Move to first child
|
|
645
|
+
newSelectedKey = node.children[0].key;
|
|
646
|
+
}
|
|
647
|
+
}
|
|
648
|
+
}
|
|
649
|
+
break;
|
|
650
|
+
}
|
|
651
|
+
case "ArrowLeft": {
|
|
652
|
+
event.preventDefault();
|
|
653
|
+
const currentNode = currentIndex >= 0 ? visibleNodes[currentIndex] : undefined;
|
|
654
|
+
if (currentNode) {
|
|
655
|
+
const node = currentNode.node;
|
|
656
|
+
const isExpanded = expandedKeys.includes(node.key);
|
|
657
|
+
if (isExpanded && (node.hasChildren || node.children?.length)) {
|
|
658
|
+
// Collapse the node
|
|
659
|
+
if (onToggleExpand) {
|
|
660
|
+
onToggleExpand(node.key);
|
|
661
|
+
}
|
|
662
|
+
}
|
|
663
|
+
else if (currentNode.parent) {
|
|
664
|
+
// Move to parent node
|
|
665
|
+
newSelectedKey = currentNode.parent.key;
|
|
666
|
+
}
|
|
667
|
+
}
|
|
668
|
+
break;
|
|
669
|
+
}
|
|
670
|
+
case "Enter": {
|
|
671
|
+
event.preventDefault();
|
|
672
|
+
if (keyboardNavPosition) {
|
|
673
|
+
// Toggle expand/collapse if it has children
|
|
674
|
+
const currentNode = currentIndex >= 0 ? visibleNodes[currentIndex] : undefined;
|
|
675
|
+
if (currentNode) {
|
|
676
|
+
const node = currentNode.node;
|
|
677
|
+
if (node.hasChildren || node.children?.length) {
|
|
678
|
+
if (onToggleExpand) {
|
|
679
|
+
onToggleExpand(node.key);
|
|
680
|
+
}
|
|
681
|
+
if (node.hasChildren &&
|
|
682
|
+
node.children === undefined &&
|
|
683
|
+
onLazyLoad) {
|
|
684
|
+
onLazyLoad(node);
|
|
685
|
+
}
|
|
686
|
+
}
|
|
687
|
+
}
|
|
688
|
+
}
|
|
689
|
+
break;
|
|
690
|
+
}
|
|
691
|
+
case "Home": {
|
|
692
|
+
event.preventDefault();
|
|
693
|
+
if (visibleNodes.length > 0) {
|
|
694
|
+
const firstNode = visibleNodes[0];
|
|
695
|
+
if (firstNode) {
|
|
696
|
+
newSelectedKey = firstNode.node.key;
|
|
697
|
+
}
|
|
698
|
+
}
|
|
699
|
+
break;
|
|
700
|
+
}
|
|
701
|
+
case "End": {
|
|
702
|
+
event.preventDefault();
|
|
703
|
+
if (visibleNodes.length > 0) {
|
|
704
|
+
const lastNode = visibleNodes[visibleNodes.length - 1];
|
|
705
|
+
if (lastNode) {
|
|
706
|
+
newSelectedKey = lastNode.node.key;
|
|
707
|
+
}
|
|
708
|
+
}
|
|
709
|
+
break;
|
|
710
|
+
}
|
|
711
|
+
}
|
|
712
|
+
if (newSelectedKey && newSelectedKey !== keyboardNavPosition) {
|
|
713
|
+
// Update keyboard navigation position
|
|
714
|
+
setKeyboardNavPosition(newSelectedKey);
|
|
715
|
+
if (onSelect) {
|
|
716
|
+
// Create a synthetic mouse event with shiftKey set for range selection
|
|
717
|
+
const syntheticEvent = {
|
|
718
|
+
shiftKey: shouldUseShift,
|
|
719
|
+
ctrlKey: false,
|
|
720
|
+
metaKey: false,
|
|
721
|
+
button: 0,
|
|
722
|
+
};
|
|
723
|
+
onSelect(newSelectedKey, syntheticEvent);
|
|
724
|
+
}
|
|
725
|
+
scrollNodeIntoView(newSelectedKey);
|
|
726
|
+
if (onKeyboardNavigationChange) {
|
|
727
|
+
onKeyboardNavigationChange(newSelectedKey);
|
|
728
|
+
}
|
|
729
|
+
}
|
|
730
|
+
};
|
|
731
|
+
document.addEventListener("keydown", handleKeyDown);
|
|
732
|
+
return () => {
|
|
733
|
+
document.removeEventListener("keydown", handleKeyDown);
|
|
734
|
+
};
|
|
735
|
+
}, [
|
|
736
|
+
enableKeyboardNavigation,
|
|
737
|
+
isFocused,
|
|
738
|
+
keyboardNavPosition,
|
|
739
|
+
filteredNodes,
|
|
740
|
+
expandedKeys,
|
|
741
|
+
onToggleExpand,
|
|
742
|
+
onSelect,
|
|
743
|
+
onLazyLoad,
|
|
744
|
+
scrollNodeIntoView,
|
|
745
|
+
onKeyboardNavigationChange,
|
|
746
|
+
]);
|
|
747
|
+
// Keyboard search functionality
|
|
748
|
+
useEffect(() => {
|
|
749
|
+
if (!enableKeyboardSearch || !isFocused)
|
|
750
|
+
return;
|
|
751
|
+
const handleKeyDown = (event) => {
|
|
752
|
+
// Ignore if user is typing in an input, textarea, or contenteditable element
|
|
753
|
+
const target = event.target;
|
|
754
|
+
if (target.tagName === "INPUT" ||
|
|
755
|
+
target.tagName === "TEXTAREA" ||
|
|
756
|
+
target.contentEditable === "true" ||
|
|
757
|
+
target.isContentEditable) {
|
|
758
|
+
return;
|
|
759
|
+
}
|
|
760
|
+
// Ignore navigation keys for search
|
|
761
|
+
if ([
|
|
762
|
+
"ArrowUp",
|
|
763
|
+
"ArrowDown",
|
|
764
|
+
"ArrowLeft",
|
|
765
|
+
"ArrowRight",
|
|
766
|
+
"Enter",
|
|
767
|
+
"Home",
|
|
768
|
+
"End",
|
|
769
|
+
].includes(event.key)) {
|
|
770
|
+
return;
|
|
771
|
+
}
|
|
772
|
+
// Handle different key types
|
|
773
|
+
if (event.key === "Escape") {
|
|
774
|
+
// Clear search on Escape
|
|
775
|
+
setSearchTerm("");
|
|
776
|
+
if (searchTimeoutRef.current) {
|
|
777
|
+
clearTimeout(searchTimeoutRef.current);
|
|
778
|
+
searchTimeoutRef.current = null;
|
|
779
|
+
}
|
|
780
|
+
event.preventDefault();
|
|
781
|
+
}
|
|
782
|
+
else if (event.key === "Backspace") {
|
|
783
|
+
// Remove last character on Backspace
|
|
784
|
+
if (searchTerm.length > 0) {
|
|
785
|
+
setSearchTerm((prev) => prev.slice(0, -1));
|
|
786
|
+
resetSearchTimeout();
|
|
787
|
+
event.preventDefault();
|
|
788
|
+
}
|
|
789
|
+
}
|
|
790
|
+
else if (event.key.length === 1 &&
|
|
791
|
+
!event.ctrlKey &&
|
|
792
|
+
!event.metaKey &&
|
|
793
|
+
!event.altKey) {
|
|
794
|
+
// Add character for printable keys (letters, numbers, symbols)
|
|
795
|
+
setSearchTerm((prev) => prev + event.key);
|
|
796
|
+
resetSearchTimeout();
|
|
797
|
+
event.preventDefault();
|
|
798
|
+
}
|
|
799
|
+
};
|
|
800
|
+
const resetSearchTimeout = () => {
|
|
801
|
+
if (searchTimeoutRef.current) {
|
|
802
|
+
clearTimeout(searchTimeoutRef.current);
|
|
803
|
+
}
|
|
804
|
+
searchTimeoutRef.current = setTimeout(() => {
|
|
805
|
+
setSearchTerm("");
|
|
806
|
+
searchTimeoutRef.current = null;
|
|
807
|
+
}, searchClearDelay);
|
|
808
|
+
};
|
|
809
|
+
// Add event listener to document
|
|
810
|
+
document.addEventListener("keydown", handleKeyDown);
|
|
811
|
+
return () => {
|
|
812
|
+
document.removeEventListener("keydown", handleKeyDown);
|
|
813
|
+
if (searchTimeoutRef.current) {
|
|
814
|
+
clearTimeout(searchTimeoutRef.current);
|
|
815
|
+
}
|
|
816
|
+
};
|
|
817
|
+
}, [enableKeyboardSearch, isFocused, searchTerm, searchClearDelay]);
|
|
818
|
+
return (_jsxs("div", { ref: treeRef, className: "perfect-tree focus:outline-none", tabIndex: 0, onFocus: () => setIsFocused(true), onBlur: () => {
|
|
819
|
+
setIsFocused(false);
|
|
820
|
+
setSearchTerm("");
|
|
821
|
+
setKeyboardNavPosition(null);
|
|
822
|
+
if (searchTimeoutRef.current) {
|
|
823
|
+
clearTimeout(searchTimeoutRef.current);
|
|
824
|
+
searchTimeoutRef.current = null;
|
|
825
|
+
}
|
|
826
|
+
}, children: [enableKeyboardSearch && searchTerm && (_jsxs("div", { className: "mb-2 flex items-center px-2 py-1 text-xs", children: [_jsx("span", { className: "text-gray-2", children: "Filter:" }), _jsx("span", { className: "ml-1", children: searchTerm }), _jsx("span", { className: "text-gray-2 ml-1", children: "(ESC to clear)" })] })), treeContent] }));
|
|
827
|
+
};
|
|
828
|
+
// Custom comparison function for memo to prevent unnecessary re-renders
|
|
829
|
+
// when callback props change but actual data hasn't
|
|
830
|
+
const arePropsEqual = (prevProps, nextProps) => {
|
|
831
|
+
// Compare primitive and array props
|
|
832
|
+
if (prevProps.nodes !== nextProps.nodes ||
|
|
833
|
+
prevProps.isDragging !== nextProps.isDragging ||
|
|
834
|
+
prevProps.enableDragAndDrop !== nextProps.enableDragAndDrop ||
|
|
835
|
+
prevProps.scrollToSelected !== nextProps.scrollToSelected ||
|
|
836
|
+
prevProps.enableKeyboardSearch !== nextProps.enableKeyboardSearch ||
|
|
837
|
+
prevProps.searchClearDelay !== nextProps.searchClearDelay ||
|
|
838
|
+
prevProps.disableAutoSelectOnExpand !==
|
|
839
|
+
nextProps.disableAutoSelectOnExpand ||
|
|
840
|
+
prevProps.multiDragNoun !== nextProps.multiDragNoun ||
|
|
841
|
+
prevProps.enableKeyboardNavigation !== nextProps.enableKeyboardNavigation) {
|
|
842
|
+
return false;
|
|
843
|
+
}
|
|
844
|
+
// Compare arrays with shallow equality
|
|
845
|
+
if (prevProps.selectedKeys?.length !== nextProps.selectedKeys?.length ||
|
|
846
|
+
prevProps.selectedKeys?.some((key, i) => key !== nextProps.selectedKeys?.[i])) {
|
|
847
|
+
return false;
|
|
848
|
+
}
|
|
849
|
+
if (prevProps.expandedKeys?.length !== nextProps.expandedKeys?.length ||
|
|
850
|
+
prevProps.expandedKeys?.some((key, i) => key !== nextProps.expandedKeys?.[i])) {
|
|
851
|
+
return false;
|
|
852
|
+
}
|
|
853
|
+
// For callback props, we assume they are stable if memoized in parent
|
|
854
|
+
// If they change on every render, the parent should memoize them with useCallback
|
|
855
|
+
return true;
|
|
856
|
+
};
|
|
857
|
+
export default memo(PerfectTree, arePropsEqual);
|