@alpaca-editor/core 1.0.3974 → 1.0.3978

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 (139) hide show
  1. package/dist/components/ActionButton.js +1 -1
  2. package/dist/components/ActionButton.js.map +1 -1
  3. package/dist/components/FilterInput.d.ts +1 -0
  4. package/dist/components/FilterInput.js +2 -2
  5. package/dist/components/FilterInput.js.map +1 -1
  6. package/dist/components/ui/checkbox.d.ts +4 -0
  7. package/dist/components/ui/checkbox.js +10 -0
  8. package/dist/components/ui/checkbox.js.map +1 -0
  9. package/dist/components/ui/command.js +1 -1
  10. package/dist/components/ui/dialog.js +2 -2
  11. package/dist/components/ui/dialog.js.map +1 -1
  12. package/dist/components/ui/upload-button.d.ts +15 -0
  13. package/dist/components/ui/upload-button.js +56 -0
  14. package/dist/components/ui/upload-button.js.map +1 -0
  15. package/dist/config/config.js +7 -5
  16. package/dist/config/config.js.map +1 -1
  17. package/dist/editor/ContentTree.js +1 -1
  18. package/dist/editor/ContentTree.js.map +1 -1
  19. package/dist/editor/FieldActionsOverlay.js +1 -1
  20. package/dist/editor/FieldActionsOverlay.js.map +1 -1
  21. package/dist/editor/FieldList.js +3 -3
  22. package/dist/editor/FieldList.js.map +1 -1
  23. package/dist/editor/FieldListField.js +6 -8
  24. package/dist/editor/FieldListField.js.map +1 -1
  25. package/dist/editor/FieldListFieldWithFallbacks.js +2 -1
  26. package/dist/editor/FieldListFieldWithFallbacks.js.map +1 -1
  27. package/dist/editor/ImageEditor.js +1 -1
  28. package/dist/editor/ImageEditor.js.map +1 -1
  29. package/dist/editor/ItemInfo.js +4 -4
  30. package/dist/editor/ItemInfo.js.map +1 -1
  31. package/dist/editor/commands/componentCommands.js +2 -2
  32. package/dist/editor/commands/componentCommands.js.map +1 -1
  33. package/dist/editor/context-menu/InsertMenu.js +1 -1
  34. package/dist/editor/context-menu/InsertMenu.js.map +1 -1
  35. package/dist/editor/field-types/CheckboxEditor.js +3 -3
  36. package/dist/editor/field-types/CheckboxEditor.js.map +1 -1
  37. package/dist/editor/field-types/ImageFieldEditor.js +1 -1
  38. package/dist/editor/field-types/InternalLinkFieldEditor.js +3 -2
  39. package/dist/editor/field-types/InternalLinkFieldEditor.js.map +1 -1
  40. package/dist/editor/field-types/LinkFieldEditor.js +1 -1
  41. package/dist/editor/field-types/LinkFieldEditor.js.map +1 -1
  42. package/dist/editor/field-types/RawEditor.js +1 -1
  43. package/dist/editor/field-types/RawEditor.js.map +1 -1
  44. package/dist/editor/field-types/SingleLineText.js +1 -1
  45. package/dist/editor/fieldTypes.d.ts +1 -0
  46. package/dist/editor/media-selector/AiImageSearch.js.map +1 -1
  47. package/dist/editor/media-selector/AiImageSearchPrompt.js +3 -4
  48. package/dist/editor/media-selector/AiImageSearchPrompt.js.map +1 -1
  49. package/dist/editor/media-selector/MediaSelector.js +3 -3
  50. package/dist/editor/media-selector/MediaSelector.js.map +1 -1
  51. package/dist/editor/media-selector/Preview.d.ts +1 -1
  52. package/dist/editor/media-selector/Preview.js +14 -2
  53. package/dist/editor/media-selector/Preview.js.map +1 -1
  54. package/dist/editor/media-selector/Thumbnails.d.ts +1 -6
  55. package/dist/editor/media-selector/Thumbnails.js +3 -2
  56. package/dist/editor/media-selector/Thumbnails.js.map +1 -1
  57. package/dist/editor/media-selector/TreeSelector.js +22 -17
  58. package/dist/editor/media-selector/TreeSelector.js.map +1 -1
  59. package/dist/editor/media-selector/UploadZone.js +5 -3
  60. package/dist/editor/media-selector/UploadZone.js.map +1 -1
  61. package/dist/editor/media-selector/index.d.ts +1 -1
  62. package/dist/editor/media-selector/index.js.map +1 -1
  63. package/dist/editor/menubar/GenericToolbar.js +1 -1
  64. package/dist/editor/menubar/GenericToolbar.js.map +1 -1
  65. package/dist/editor/menubar/ToolbarFactory.js +2 -2
  66. package/dist/editor/menubar/ToolbarFactory.js.map +1 -1
  67. package/dist/editor/menubar/toolbar-sections/ReviewCommands.js +19 -19
  68. package/dist/editor/menubar/toolbar-sections/ReviewCommands.js.map +1 -1
  69. package/dist/editor/page-viewer/PageViewer.js +1 -1
  70. package/dist/editor/page-viewer/PageViewer.js.map +1 -1
  71. package/dist/editor/page-viewer/PageViewerFrame.js +1 -7
  72. package/dist/editor/page-viewer/PageViewerFrame.js.map +1 -1
  73. package/dist/editor/reviews/Comment.js +14 -18
  74. package/dist/editor/reviews/Comment.js.map +1 -1
  75. package/dist/editor/reviews/SuggestedEdit.js +6 -5
  76. package/dist/editor/reviews/SuggestedEdit.js.map +1 -1
  77. package/dist/editor/services/contentService.d.ts +12 -1
  78. package/dist/editor/services/contentService.js.map +1 -1
  79. package/dist/editor/sidebar/SidebarView.js +4 -2
  80. package/dist/editor/sidebar/SidebarView.js.map +1 -1
  81. package/dist/editor/ui/PerfectTree.d.ts +8 -3
  82. package/dist/editor/ui/PerfectTree.js +215 -8
  83. package/dist/editor/ui/PerfectTree.js.map +1 -1
  84. package/dist/editor/ui/Section.js +2 -1
  85. package/dist/editor/ui/Section.js.map +1 -1
  86. package/dist/page-wizard/PageWizard.d.ts +1 -1
  87. package/dist/page-wizard/steps/Components.d.ts +1 -1
  88. package/dist/page-wizard/steps/Components.js +2 -2
  89. package/dist/page-wizard/steps/Components.js.map +1 -1
  90. package/dist/revision.d.ts +2 -2
  91. package/dist/revision.js +2 -2
  92. package/dist/styles.css +81 -16
  93. package/package.json +2 -1
  94. package/src/components/ActionButton.tsx +2 -0
  95. package/src/components/FilterInput.tsx +7 -1
  96. package/src/components/ui/checkbox.tsx +32 -0
  97. package/src/components/ui/command.tsx +1 -1
  98. package/src/components/ui/dialog.tsx +5 -2
  99. package/src/components/ui/upload-button.tsx +117 -0
  100. package/src/config/config.tsx +11 -6
  101. package/src/editor/ContentTree.tsx +1 -0
  102. package/src/editor/FieldActionsOverlay.tsx +1 -1
  103. package/src/editor/FieldList.tsx +14 -10
  104. package/src/editor/FieldListField.tsx +20 -18
  105. package/src/editor/FieldListFieldWithFallbacks.tsx +14 -11
  106. package/src/editor/ImageEditor.tsx +1 -1
  107. package/src/editor/ItemInfo.tsx +10 -10
  108. package/src/editor/commands/componentCommands.tsx +2 -1
  109. package/src/editor/context-menu/InsertMenu.tsx +2 -1
  110. package/src/editor/field-types/CheckboxEditor.tsx +3 -3
  111. package/src/editor/field-types/ImageFieldEditor.tsx +1 -1
  112. package/src/editor/field-types/InternalLinkFieldEditor.tsx +8 -4
  113. package/src/editor/field-types/LinkFieldEditor.tsx +1 -1
  114. package/src/editor/field-types/RawEditor.tsx +1 -1
  115. package/src/editor/field-types/SingleLineText.tsx +1 -1
  116. package/src/editor/fieldTypes.ts +1 -0
  117. package/src/editor/media-selector/AiImageSearch.tsx +2 -1
  118. package/src/editor/media-selector/AiImageSearchPrompt.tsx +39 -20
  119. package/src/editor/media-selector/MediaFolderBrowser.tsx +1 -1
  120. package/src/editor/media-selector/MediaSelector.tsx +26 -17
  121. package/src/editor/media-selector/Preview.tsx +41 -3
  122. package/src/editor/media-selector/Thumbnails.tsx +13 -14
  123. package/src/editor/media-selector/TreeSelector.tsx +94 -40
  124. package/src/editor/media-selector/UploadZone.tsx +14 -12
  125. package/src/editor/media-selector/index.ts +1 -1
  126. package/src/editor/menubar/GenericToolbar.tsx +1 -3
  127. package/src/editor/menubar/ToolbarFactory.tsx +2 -2
  128. package/src/editor/menubar/toolbar-sections/ReviewCommands.tsx +7 -1
  129. package/src/editor/page-viewer/PageViewer.tsx +1 -1
  130. package/src/editor/page-viewer/PageViewerFrame.tsx +1 -10
  131. package/src/editor/reviews/Comment.tsx +51 -43
  132. package/src/editor/reviews/SuggestedEdit.tsx +30 -19
  133. package/src/editor/services/contentService.ts +13 -1
  134. package/src/editor/sidebar/SidebarView.tsx +8 -6
  135. package/src/editor/ui/PerfectTree.tsx +305 -9
  136. package/src/editor/ui/Section.tsx +16 -6
  137. package/src/page-wizard/PageWizard.tsx +1 -1
  138. package/src/page-wizard/steps/Components.tsx +8 -10
  139. package/src/revision.ts +2 -2
@@ -10,7 +10,11 @@ import {
10
10
  import { Button } from "../../components/ui/button";
11
11
  import { formatDate } from "../utils";
12
12
  import { SimpleIconButton } from "../ui/SimpleIconButton";
13
- import { OverlayPanel } from "primereact/overlaypanel";
13
+ import {
14
+ Popover,
15
+ PopoverContent,
16
+ PopoverTrigger,
17
+ } from "../../components/ui/popover";
14
18
  import { ProgressSpinner } from "primereact/progressspinner";
15
19
  import { useDebouncedCallback } from "use-debounce";
16
20
  import { ActionButton } from "../../components/ActionButton";
@@ -20,6 +24,8 @@ export function Comment({ comment }: { comment: CommentType }) {
20
24
  const [commentText, setCommentText] = useState(comment.text);
21
25
  const [isEditing, setIsEditing] = useState(false);
22
26
  const [isSaving, setIsSaving] = useState(false);
27
+ const [deletePopoverOpen, setDeletePopoverOpen] = useState(false);
28
+ const [resolvePopoverOpen, setResolvePopoverOpen] = useState(false);
23
29
  const ref = useRef<HTMLDivElement>(null);
24
30
 
25
31
  useEffect(() => {
@@ -90,8 +96,6 @@ export function Comment({ comment }: { comment: CommentType }) {
90
96
  };
91
97
 
92
98
  const renderHeader = () => {
93
- const overlayPanelRef = useRef<OverlayPanel>(null);
94
- const deleteOverlayPanelRef = useRef<OverlayPanel>(null);
95
99
  return (
96
100
  <>
97
101
  <div className="mb-3 flex items-start justify-between">
@@ -115,25 +119,29 @@ export function Comment({ comment }: { comment: CommentType }) {
115
119
  />
116
120
  )}
117
121
  {canDelete && (
118
- <SimpleIconButton
119
- icon="pi pi-trash"
120
- label="Delete"
121
- onClick={(e) => {
122
- deleteOverlayPanelRef.current?.toggle(e);
123
- }}
124
- />
125
- )}
126
- <OverlayPanel ref={deleteOverlayPanelRef}>
127
- <Button
128
- className="m-2"
129
- variant="outline"
130
- onClick={async () => {
131
- await deleteComment(comment);
132
- }}
122
+ <Popover
123
+ open={deletePopoverOpen}
124
+ onOpenChange={setDeletePopoverOpen}
133
125
  >
134
- Delete
135
- </Button>
136
- </OverlayPanel>
126
+ <PopoverTrigger asChild>
127
+ <button
128
+ className="pi pi-trash hover:bg-gray-5 cursor-pointer rounded-full p-[6px] text-xs"
129
+ title="Delete"
130
+ />
131
+ </PopoverTrigger>
132
+ <PopoverContent className="w-auto p-2" align="end">
133
+ <Button
134
+ variant="outline"
135
+ onClick={async () => {
136
+ await deleteComment(comment);
137
+ setDeletePopoverOpen(false);
138
+ }}
139
+ >
140
+ Delete
141
+ </Button>
142
+ </PopoverContent>
143
+ </Popover>
144
+ )}
137
145
  {canResolve && !comment.isResolved && (
138
146
  <SimpleIconButton
139
147
  icon="pi pi-check"
@@ -144,35 +152,35 @@ export function Comment({ comment }: { comment: CommentType }) {
144
152
  />
145
153
  )}
146
154
  {comment.isResolved && (
147
- <>
148
- <i
149
- className="pi pi-check cursor-pointer px-1 text-xs text-green-500"
150
- style={{ fontWeight: "bold" }}
151
- title={
152
- "Resolved by " +
153
- comment.resolvedBy +
154
- " (" +
155
- formatDate(new Date(comment.resolvedDate!)) +
156
- ")"
157
- }
158
- onClick={(e) => {
159
- if (canResolve) {
160
- overlayPanelRef.current?.toggle(e);
155
+ <Popover
156
+ open={resolvePopoverOpen}
157
+ onOpenChange={setResolvePopoverOpen}
158
+ >
159
+ <PopoverTrigger asChild>
160
+ <i
161
+ className="pi pi-check cursor-pointer px-1 text-xs text-green-500"
162
+ style={{ fontWeight: "bold" }}
163
+ title={
164
+ "Resolved by " +
165
+ comment.resolvedBy +
166
+ " (" +
167
+ formatDate(new Date(comment.resolvedDate!)) +
168
+ ")"
161
169
  }
162
- }}
163
- ></i>
164
- <OverlayPanel ref={overlayPanelRef}>
170
+ />
171
+ </PopoverTrigger>
172
+ <PopoverContent className="w-auto p-2" align="end">
165
173
  <Button
166
- className="m-2"
167
174
  variant="outline"
168
175
  onClick={async () => {
169
176
  await unresolveComment(comment);
177
+ setResolvePopoverOpen(false);
170
178
  }}
171
179
  >
172
180
  Unresolve
173
181
  </Button>
174
- </OverlayPanel>
175
- </>
182
+ </PopoverContent>
183
+ </Popover>
176
184
  )}
177
185
  {canResolve && !comment.isResolved && (
178
186
  <SimpleIconButton
@@ -249,7 +257,7 @@ export function Comment({ comment }: { comment: CommentType }) {
249
257
  autoFocus
250
258
  />
251
259
  {isSaving && (
252
- <div className="mt-1 flex justify-end gap-2">
260
+ <div className="flex-wra mt-1 flex justify-end gap-2">
253
261
  <div className="flex items-center gap-2 text-xs text-gray-500">
254
262
  <ProgressSpinner className="h-4 w-4" />
255
263
  Saving...
@@ -257,7 +265,7 @@ export function Comment({ comment }: { comment: CommentType }) {
257
265
  </div>
258
266
  )}
259
267
  {!isSaving && (
260
- <div className="mt-1 flex justify-end gap-2">
268
+ <div className="mt-1 flex flex-wrap justify-end gap-2">
261
269
  <ActionButton
262
270
  variant="outline"
263
271
  onClick={() => {
@@ -8,7 +8,11 @@ import {
8
8
  import { Button } from "../../components/ui/button";
9
9
  import { formatDate } from "../utils";
10
10
  import { SimpleIconButton } from "../ui/SimpleIconButton";
11
- import { OverlayPanel } from "primereact/overlaypanel";
11
+ import {
12
+ Popover,
13
+ PopoverContent,
14
+ PopoverTrigger,
15
+ } from "../../components/ui/popover";
12
16
  import { DiffView } from "./DiffView";
13
17
  // Import lucide icons (adjust names as needed)
14
18
  import { Trash2, GalleryVertical, Check, Brush, XCircle } from "lucide-react";
@@ -19,9 +23,9 @@ import { cn } from "../../lib/utils";
19
23
  export function SuggestedEditComponent({ edit }: { edit: SuggestedEditType }) {
20
24
  const editContext = useEditContext();
21
25
  const ref = useRef<HTMLDivElement>(null);
22
- const overlayPanelRef = useRef<OverlayPanel>(null);
23
26
  const [item, setItem] = useState<any>(null);
24
27
  const [patchPossible, setPatchPossible] = useState<boolean>(true);
28
+ const [deletePopoverOpen, setDeletePopoverOpen] = useState(false);
25
29
 
26
30
  const [patchWarning, setPatchWarning] = useState<string>("");
27
31
  const [applied, setApplied] = useState<boolean>(false);
@@ -151,24 +155,31 @@ export function SuggestedEditComponent({ edit }: { edit: SuggestedEditType }) {
151
155
  ></i>
152
156
  )}
153
157
  {canDelete && (
154
- <SimpleIconButton
155
- className="text-gray-500"
156
- icon={<Trash2 size={14} />}
157
- label="Delete"
158
- onClick={(e: any) => overlayPanelRef.current?.toggle(e)}
159
- />
160
- )}
161
- <OverlayPanel ref={overlayPanelRef}>
162
- <Button
163
- className="m-2"
164
- variant="outline"
165
- onClick={async () => {
166
- await deleteSuggestedEdit(edit);
167
- }}
158
+ <Popover
159
+ open={deletePopoverOpen}
160
+ onOpenChange={setDeletePopoverOpen}
168
161
  >
169
- Delete
170
- </Button>
171
- </OverlayPanel>
162
+ <PopoverTrigger asChild>
163
+ <button
164
+ className="cursor-pointer rounded-full p-1 text-gray-500 hover:bg-gray-100"
165
+ title="Delete"
166
+ >
167
+ <Trash2 size={14} />
168
+ </button>
169
+ </PopoverTrigger>
170
+ <PopoverContent className="w-auto p-2" align="end">
171
+ <Button
172
+ variant="outline"
173
+ onClick={async () => {
174
+ await deleteSuggestedEdit(edit);
175
+ setDeletePopoverOpen(false);
176
+ }}
177
+ >
178
+ Delete
179
+ </Button>
180
+ </PopoverContent>
181
+ </Popover>
182
+ )}
172
183
  </div>
173
184
  </div>
174
185
  );
@@ -10,7 +10,19 @@ import {
10
10
  } from "../../types";
11
11
  import { FullItem, ItemDescriptor, ItemStub } from "../pageModel";
12
12
 
13
- export type ItemTreeNodeData = {
13
+ export type Thumbnail = {
14
+ id: string;
15
+ name: string;
16
+ thumbUrl: string;
17
+ previewUrl: string;
18
+ size?: number;
19
+ width?: number;
20
+ height?: number;
21
+ updated?: string;
22
+ updatedBy?: string;
23
+ };
24
+
25
+ export type ItemTreeNodeData = Thumbnail & {
14
26
  id: string;
15
27
  name: string;
16
28
  displayName?: string;
@@ -3,6 +3,8 @@ import { classNames } from "primereact/utils";
3
3
  import { Sidebar } from "../../config/types";
4
4
  import { Splitter, SplitterPanel } from "../ui/Splitter";
5
5
  import { cn } from "../../lib/utils";
6
+ import { SimpleIconButton } from "../ui/SimpleIconButton";
7
+ import { X } from "lucide-react";
6
8
 
7
9
  export function SidebarView({
8
10
  sidebar,
@@ -39,12 +41,12 @@ export function SidebarView({
39
41
 
40
42
  {/* Close button - only show on the first panel */}
41
43
  {index === 0 && (
42
- <button
44
+ <SimpleIconButton
43
45
  onClick={onClose}
44
- className="hover:bg-gray-5 ml-2 flex h-6 w-6 items-center justify-center rounded"
45
- >
46
- <i className="pi pi-times text-sm"></i>
47
- </button>
46
+ icon={<X className="size-4" />}
47
+ label="Close"
48
+ className="ml-2"
49
+ />
48
50
  )}
49
51
  </div>
50
52
  );
@@ -66,7 +68,7 @@ export function SidebarView({
66
68
  <div
67
69
  className={cn(
68
70
  "flex h-full flex-col bg-white",
69
- detached ? "border-gray-3 rounded-xl border" : "",
71
+ detached ? "border-gray-3 rounded-md border" : "",
70
72
  )}
71
73
  >
72
74
  {getHeader(panel, 0)}
@@ -1,4 +1,11 @@
1
- import React, { useEffect, useMemo, useCallback, memo, useRef } from "react";
1
+ import React, {
2
+ useEffect,
3
+ useMemo,
4
+ useCallback,
5
+ memo,
6
+ useRef,
7
+ useState,
8
+ } from "react";
2
9
 
3
10
  import { ProgressSpinner } from "primereact/progressspinner";
4
11
  import { ChevronRight } from "lucide-react";
@@ -25,7 +32,7 @@ export interface TreeProps<T = any> {
25
32
  /** Keys of expanded nodes */
26
33
  expandedKeys?: string[];
27
34
  /** Callback to render a single node (template) */
28
- renderNode: (node: TreeNode<T>) => React.ReactNode;
35
+ renderNode: (node: TreeNode<T>, searchTerm?: string) => React.ReactNode;
29
36
  /** Called when a node's expand/collapse toggle is activated */
30
37
  onToggleExpand?: (key: string) => void;
31
38
  /** Called when a node is clicked for selection */
@@ -70,8 +77,35 @@ export interface TreeProps<T = any> {
70
77
  onContextMenu?: (node: TreeNode<T>, event: React.MouseEvent) => void;
71
78
  /** Whether to automatically scroll to the first selected node when selection changes */
72
79
  scrollToSelected?: boolean;
80
+ /** Whether to enable keyboard search functionality */
81
+ enableKeyboardSearch?: boolean;
82
+ /** Time in ms before search is cleared (default: 1500) */
83
+ searchClearDelay?: number;
73
84
  }
74
85
 
86
+ /**
87
+ * Usage example with keyboard search:
88
+ *
89
+ * ```tsx
90
+ * import { PerfectTree } from './PerfectTree';
91
+ *
92
+ * <PerfectTree
93
+ * nodes={treeNodes}
94
+ * enableKeyboardSearch={true}
95
+ * renderNode={(node) => (
96
+ * <div>
97
+ * {node.icon}
98
+ * {node.label}
99
+ * </div>
100
+ * )}
101
+ * // ... other props
102
+ * />
103
+ * ```
104
+ *
105
+ * Note: Text highlighting is applied automatically when enableKeyboardSearch is true.
106
+ * The highlightText helper function is still available for manual highlighting if needed.
107
+ */
108
+
75
109
  // Local DropZone component to handle drag-over state.
76
110
  const DropZone = memo(
77
111
  ({
@@ -179,6 +213,132 @@ const DropZone = memo(
179
213
  },
180
214
  );
181
215
 
216
+ // Helper function to highlight matching text
217
+ export const highlightText = (
218
+ text: string,
219
+ searchTerm: string,
220
+ ): React.ReactNode => {
221
+ if (!searchTerm.trim()) return text;
222
+
223
+ const regex = new RegExp(
224
+ `(${searchTerm.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")})`,
225
+ "gi",
226
+ );
227
+ const parts = text.split(regex);
228
+
229
+ return parts.map((part, index) => {
230
+ if (regex.test(part)) {
231
+ return (
232
+ <span key={index} className="bg-yellow-200 underline">
233
+ {part}
234
+ </span>
235
+ );
236
+ }
237
+ return part;
238
+ });
239
+ };
240
+
241
+ // Helper function to highlight text within React elements
242
+ const highlightReactElement = (
243
+ element: React.ReactElement,
244
+ searchTerm: string,
245
+ nodeLabel: string,
246
+ ): React.ReactElement => {
247
+ try {
248
+ // If searchTerm doesn't match the node label, return as-is
249
+ if (!nodeLabel.toLowerCase().includes(searchTerm.toLowerCase())) {
250
+ return element;
251
+ }
252
+
253
+ const processChildren = (children: React.ReactNode): React.ReactNode => {
254
+ return React.Children.map(children, (child) => {
255
+ if (typeof child === "string") {
256
+ // If it's a string and contains the search term, highlight it
257
+ if (child.toLowerCase().includes(searchTerm.toLowerCase())) {
258
+ return highlightText(child, searchTerm);
259
+ }
260
+ return child;
261
+ }
262
+
263
+ if (React.isValidElement(child)) {
264
+ // Recursively process React elements
265
+ const childProps = (child as any).props;
266
+ if (childProps && childProps.children) {
267
+ return React.cloneElement(child as any, {
268
+ ...childProps,
269
+ children: processChildren(childProps.children),
270
+ });
271
+ }
272
+ }
273
+
274
+ return child;
275
+ });
276
+ };
277
+
278
+ // Clone the element with processed children
279
+ const elementProps = (element as any).props;
280
+ if (elementProps && elementProps.children) {
281
+ return React.cloneElement(element as any, {
282
+ ...elementProps,
283
+ children: processChildren(elementProps.children),
284
+ });
285
+ }
286
+ } catch (error) {
287
+ // If any error occurs, return the original element
288
+ console.warn("Error highlighting React element:", error);
289
+ }
290
+
291
+ return element;
292
+ };
293
+
294
+ // Helper function to check if a node matches the search term
295
+ const nodeMatchesSearch = (
296
+ node: TreeNode<any>,
297
+ searchTerm: string,
298
+ ): boolean => {
299
+ if (!searchTerm.trim()) return true;
300
+ return node.label.toLowerCase().includes(searchTerm.toLowerCase());
301
+ };
302
+
303
+ // Helper function to filter tree nodes based on search term
304
+ const filterTreeNodes = (
305
+ nodes: TreeNode<any>[],
306
+ searchTerm: string,
307
+ expandedKeys: string[],
308
+ ): TreeNode<any>[] => {
309
+ if (!searchTerm.trim()) return nodes;
310
+
311
+ const filterNode = (node: TreeNode<any>): TreeNode<any> | null => {
312
+ const nodeMatches = nodeMatchesSearch(node, searchTerm);
313
+ const isExpanded = expandedKeys.includes(node.key);
314
+
315
+ // Process children only if the node is expanded
316
+ let filteredChildren: TreeNode<any>[] = [];
317
+ let hasMatchingChildren = false;
318
+
319
+ if (isExpanded && node.children && Array.isArray(node.children)) {
320
+ filteredChildren = node.children
321
+ .map((child) => filterNode(child))
322
+ .filter((child): child is TreeNode<any> => child !== null);
323
+ hasMatchingChildren = filteredChildren.length > 0;
324
+ }
325
+
326
+ // Include node if it matches or has matching children
327
+ if (nodeMatches || hasMatchingChildren) {
328
+ return {
329
+ ...node,
330
+ children: isExpanded ? filteredChildren : node.children,
331
+ };
332
+ }
333
+
334
+ return null;
335
+ };
336
+
337
+ return nodes
338
+ .map((node) => filterNode(node))
339
+ .filter((node): node is TreeNode<any> => node !== null);
340
+ };
341
+
182
342
  // NodeContent component extracted and memoized
183
343
  const NodeContent = memo(
184
344
  ({
@@ -197,6 +357,7 @@ const NodeContent = memo(
197
357
  enableDragAndDrop = false,
198
358
  selectedKeys,
199
359
  isDragging,
360
+ searchTerm = "",
200
361
  }: {
201
362
  node: TreeNode<any>;
202
363
  isExpanded: boolean;
@@ -221,10 +382,11 @@ const NodeContent = memo(
221
382
  ) => void;
222
383
  onDoubleClick?: (node: TreeNode<any>) => void;
223
384
  onContextMenu?: (node: TreeNode<any>, event: React.MouseEvent) => void;
224
- renderNode: (node: TreeNode<any>) => React.ReactNode;
385
+ renderNode: (node: TreeNode<any>, searchTerm?: string) => React.ReactNode;
225
386
  enableDragAndDrop?: boolean;
226
387
  selectedKeys?: string[];
227
388
  isDragging: boolean;
389
+ searchTerm?: string;
228
390
  }) => {
229
391
  const [isDragOver, setIsDragOver] = React.useState(false);
230
392
 
@@ -391,7 +553,7 @@ const NodeContent = memo(
391
553
  }`}
392
554
  onClick={handleSelect}
393
555
  >
394
- {renderNode(node)}
556
+ {renderNode(node, searchTerm)}
395
557
  </div>
396
558
  </div>
397
559
  );
@@ -416,7 +578,13 @@ export const PerfectTree = <T,>({
416
578
  enableDragAndDrop = false,
417
579
  isValidDropZone,
418
580
  scrollToSelected = false,
581
+ enableKeyboardSearch = false,
582
+ searchClearDelay = 1500,
419
583
  }: TreeProps<T>) => {
584
+ const [searchTerm, setSearchTerm] = useState("");
585
+ const [isFocused, setIsFocused] = useState(false);
586
+ const searchTimeoutRef = useRef<NodeJS.Timeout | null>(null);
587
+
420
588
  // When toggling a node, notify parent and trigger external lazy load if needed.
421
589
  const handleToggle = useCallback(
422
590
  (node: TreeNode<T>) => {
@@ -584,6 +752,42 @@ export const PerfectTree = <T,>({
584
752
  prevSelectedKeysRef.current = [...selectedKeys];
585
753
  }, [scrollToSelected, selectedKeys]);
586
754
 
755
+ // Enhanced renderNode function that handles highlighting
756
+ const enhancedRenderNode = useCallback(
757
+ (node: TreeNode<T>, searchTermForNode = searchTerm) => {
758
+ // Get the original content from renderNode
759
+ const originalContent = renderNode(node, searchTermForNode);
760
+
761
+ // If there's no search term, return original content
762
+ if (!searchTermForNode?.trim()) {
763
+ return originalContent;
764
+ }
765
+
766
+ // Auto-highlight if the content is simple text
767
+ if (typeof originalContent === "string") {
768
+ return highlightText(originalContent, searchTermForNode);
769
+ }
770
+
771
+ // For React elements, try to find and highlight text content
772
+ if (React.isValidElement(originalContent)) {
773
+ return highlightReactElement(
774
+ originalContent,
775
+ searchTermForNode,
776
+ node.label,
777
+ );
778
+ }
779
+
780
+ // Fallback: if we can't process the content, return original
781
+ return originalContent;
782
+ },
783
+ [renderNode, searchTerm],
784
+ );
785
+
786
+ // Filter nodes based on search term
787
+ const filteredNodes = useMemo(() => {
788
+ return filterTreeNodes(nodes, searchTerm, expandedKeys);
789
+ }, [nodes, searchTerm, expandedKeys]);
790
+
587
791
  // Recursive function to render tree nodes along with drop zones.
588
792
  const renderTreeList = useCallback(
589
793
  (
@@ -627,10 +831,11 @@ export const PerfectTree = <T,>({
627
831
  onDrop={onDrop}
628
832
  onDoubleClick={onDoubleClick}
629
833
  onContextMenu={onContextMenu}
630
- renderNode={renderNode}
834
+ renderNode={enhancedRenderNode}
631
835
  enableDragAndDrop={enableDragAndDrop}
632
836
  selectedKeys={selectedKeys}
633
837
  isDragging={isDragging}
838
+ searchTerm={searchTerm}
634
839
  />
635
840
  {isExpanded && (
636
841
  <>
@@ -667,17 +872,108 @@ export const PerfectTree = <T,>({
667
872
  onDoubleClick,
668
873
  handleSelect,
669
874
  handleToggle,
670
- renderNode,
875
+ enhancedRenderNode,
876
+ searchTerm,
671
877
  ],
672
878
  );
673
879
 
674
880
  // Memoize the tree structure
675
881
  const treeContent = useMemo(
676
- () => renderTreeList(nodes, 0),
677
- [nodes, renderTreeList],
882
+ () => renderTreeList(filteredNodes, 0),
883
+ [filteredNodes, renderTreeList],
678
884
  );
679
885
 
680
- return <div ref={treeRef}>{treeContent}</div>;
886
+ // Keyboard search functionality
887
+ useEffect(() => {
888
+ if (!enableKeyboardSearch || !isFocused) return;
889
+
890
+ const handleKeyDown = (event: KeyboardEvent) => {
891
+ // Ignore if user is typing in an input, textarea, or contenteditable element
892
+ const target = event.target as HTMLElement;
893
+ if (
894
+ target.tagName === "INPUT" ||
895
+ target.tagName === "TEXTAREA" ||
896
+ target.contentEditable === "true" ||
897
+ target.isContentEditable
898
+ ) {
899
+ return;
900
+ }
901
+
902
+ // Handle different key types
903
+ if (event.key === "Escape") {
904
+ // Clear search on Escape
905
+ setSearchTerm("");
906
+ if (searchTimeoutRef.current) {
907
+ clearTimeout(searchTimeoutRef.current);
908
+ searchTimeoutRef.current = null;
909
+ }
910
+ event.preventDefault();
911
+ } else if (event.key === "Backspace") {
912
+ // Remove last character on Backspace
913
+ if (searchTerm.length > 0) {
914
+ setSearchTerm((prev) => prev.slice(0, -1));
915
+ resetSearchTimeout();
916
+ event.preventDefault();
917
+ }
918
+ } else if (
919
+ event.key.length === 1 &&
920
+ !event.ctrlKey &&
921
+ !event.metaKey &&
922
+ !event.altKey
923
+ ) {
924
+ // Add character for printable keys (letters, numbers, symbols)
925
+ setSearchTerm((prev) => prev + event.key);
926
+ resetSearchTimeout();
927
+ event.preventDefault();
928
+ }
929
+ };
930
+
931
+ const resetSearchTimeout = () => {
932
+ if (searchTimeoutRef.current) {
933
+ clearTimeout(searchTimeoutRef.current);
934
+ }
935
+ searchTimeoutRef.current = setTimeout(() => {
936
+ setSearchTerm("");
937
+ searchTimeoutRef.current = null;
938
+ }, searchClearDelay);
939
+ };
940
+
941
+ // Add event listener to document
942
+ document.addEventListener("keydown", handleKeyDown);
943
+
944
+ return () => {
945
+ document.removeEventListener("keydown", handleKeyDown);
946
+ if (searchTimeoutRef.current) {
947
+ clearTimeout(searchTimeoutRef.current);
948
+ }
949
+ };
950
+ }, [enableKeyboardSearch, isFocused, searchTerm, searchClearDelay]);
951
+
952
+ return (
953
+ <div
954
+ ref={treeRef}
955
+ className="perfect-tree focus:outline-none"
956
+ tabIndex={0}
957
+ onFocus={() => setIsFocused(true)}
958
+ onBlur={() => {
959
+ setIsFocused(false);
960
+ setSearchTerm("");
961
+ if (searchTimeoutRef.current) {
962
+ clearTimeout(searchTimeoutRef.current);
963
+ searchTimeoutRef.current = null;
964
+ }
965
+ }}
966
+ >
967
+ {enableKeyboardSearch && searchTerm && (
968
+ <div className="mb-2 flex items-center px-2 py-1 text-xs">
969
+ <span className="text-gray-2">Filter:</span>
970
+ <span className="ml-1">{searchTerm}</span>
971
+ <span className="text-gray-2 ml-1">(ESC to clear)</span>
972
+ </div>
973
+ )}
974
+ {treeContent}
975
+ </div>
976
+ );
681
977
  };
682
978
 
683
979
  export default memo(PerfectTree);