@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.
Files changed (70) hide show
  1. package/LICENSE +8 -0
  2. package/README.md +29 -0
  3. package/dist/core/src/editor/ui/DragPreview.d.ts +15 -0
  4. package/dist/core/src/editor/ui/DragPreview.d.ts.map +1 -0
  5. package/dist/core/src/editor/ui/DragPreview.js +32 -0
  6. package/dist/core/src/editor/ui/PerfectTree.d.ts +79 -0
  7. package/dist/core/src/editor/ui/PerfectTree.d.ts.map +1 -0
  8. package/dist/core/src/editor/ui/PerfectTree.js +857 -0
  9. package/dist/localization/src/LocalizeItemCommand.d.ts +8 -0
  10. package/dist/localization/src/LocalizeItemCommand.d.ts.map +1 -0
  11. package/dist/localization/src/LocalizeItemCommand.js +44 -0
  12. package/dist/localization/src/LocalizeItemDialog.d.ts +4 -0
  13. package/dist/localization/src/LocalizeItemDialog.d.ts.map +1 -0
  14. package/dist/localization/src/LocalizeItemDialog.js +126 -0
  15. package/dist/localization/src/LocalizeItemUtils.d.ts +17 -0
  16. package/dist/localization/src/LocalizeItemUtils.d.ts.map +1 -0
  17. package/dist/localization/src/LocalizeItemUtils.js +93 -0
  18. package/dist/localization/src/api/discovery.d.ts +36 -0
  19. package/dist/localization/src/api/discovery.d.ts.map +1 -0
  20. package/dist/localization/src/api/discovery.js +29 -0
  21. package/dist/localization/src/constants.d.ts +15 -0
  22. package/dist/localization/src/constants.d.ts.map +1 -0
  23. package/dist/localization/src/constants.js +21 -0
  24. package/dist/localization/src/hooks/useTranslationWizard.d.ts +6 -0
  25. package/dist/localization/src/hooks/useTranslationWizard.d.ts.map +1 -0
  26. package/dist/localization/src/hooks/useTranslationWizard.js +78 -0
  27. package/dist/localization/src/index.d.ts +69 -0
  28. package/dist/localization/src/index.d.ts.map +1 -0
  29. package/dist/localization/src/index.js +152 -0
  30. package/dist/localization/src/services/translationService.d.ts +102 -0
  31. package/dist/localization/src/services/translationService.d.ts.map +1 -0
  32. package/dist/localization/src/services/translationService.js +37 -0
  33. package/dist/localization/src/setup/LocalizationSetupStep.d.ts +3 -0
  34. package/dist/localization/src/setup/LocalizationSetupStep.d.ts.map +1 -0
  35. package/dist/localization/src/setup/LocalizationSetupStep.js +108 -0
  36. package/dist/localization/src/sidebar/TranslationSidebar.d.ts +2 -0
  37. package/dist/localization/src/sidebar/TranslationSidebar.d.ts.map +1 -0
  38. package/dist/localization/src/sidebar/TranslationSidebar.js +93 -0
  39. package/dist/localization/src/steps/MetadataInputStep.d.ts +4 -0
  40. package/dist/localization/src/steps/MetadataInputStep.d.ts.map +1 -0
  41. package/dist/localization/src/steps/MetadataInputStep.js +38 -0
  42. package/dist/localization/src/steps/ServiceLanguageSelectionStep.d.ts +3 -0
  43. package/dist/localization/src/steps/ServiceLanguageSelectionStep.d.ts.map +1 -0
  44. package/dist/localization/src/steps/ServiceLanguageSelectionStep.js +91 -0
  45. package/dist/localization/src/steps/SubitemDiscoveryStep.d.ts +3 -0
  46. package/dist/localization/src/steps/SubitemDiscoveryStep.d.ts.map +1 -0
  47. package/dist/localization/src/steps/SubitemDiscoveryStep.js +391 -0
  48. package/dist/localization/src/steps/index.d.ts +5 -0
  49. package/dist/localization/src/steps/index.d.ts.map +1 -0
  50. package/dist/localization/src/steps/index.js +4 -0
  51. package/dist/localization/src/steps/types.d.ts +68 -0
  52. package/dist/localization/src/steps/types.d.ts.map +1 -0
  53. package/dist/localization/src/steps/types.js +1 -0
  54. package/dist/localization/src/translation-center/BatchTranslationView.d.ts +7 -0
  55. package/dist/localization/src/translation-center/BatchTranslationView.d.ts.map +1 -0
  56. package/dist/localization/src/translation-center/BatchTranslationView.js +487 -0
  57. package/dist/localization/src/translation-center/RecentTranslations.d.ts +2 -0
  58. package/dist/localization/src/translation-center/RecentTranslations.d.ts.map +1 -0
  59. package/dist/localization/src/translation-center/RecentTranslations.js +199 -0
  60. package/dist/localization/src/translation-center/TranslationManagement.d.ts +2 -0
  61. package/dist/localization/src/translation-center/TranslationManagement.d.ts.map +1 -0
  62. package/dist/localization/src/translation-center/TranslationManagement.js +25 -0
  63. package/dist/localization/src/types.d.ts +18 -0
  64. package/dist/localization/src/types.d.ts.map +1 -0
  65. package/dist/localization/src/types.js +1 -0
  66. package/dist/localization/src/utils/createVersions.d.ts +14 -0
  67. package/dist/localization/src/utils/createVersions.d.ts.map +1 -0
  68. package/dist/localization/src/utils/createVersions.js +26 -0
  69. package/package.json +47 -0
  70. 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);