@hyperframes/studio 0.6.0-alpha.9 → 0.6.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/assets/hyperframes-player-CzwFysqv.js +418 -0
- package/dist/assets/index-D1JDq7Gg.css +1 -0
- package/dist/assets/index-hYc4aP7M.js +117 -0
- package/dist/favicon.svg +14 -0
- package/dist/index.html +3 -2
- package/package.json +9 -9
- package/src/App.tsx +421 -4303
- package/src/captions/components/CaptionOverlay.tsx +13 -246
- package/src/captions/components/CaptionOverlayUtils.ts +221 -0
- package/src/components/AskAgentModal.tsx +120 -0
- package/src/components/StudioHeader.tsx +133 -0
- package/src/components/StudioLeftSidebar.tsx +125 -0
- package/src/components/StudioPreviewArea.tsx +167 -0
- package/src/components/StudioRightPanel.tsx +198 -0
- package/src/components/TimelineToolbar.tsx +89 -0
- package/src/components/editor/DomEditOverlay.tsx +88 -993
- package/src/components/editor/EaseCurveEditor.tsx +221 -0
- package/src/components/editor/FileTree.tsx +13 -621
- package/src/components/editor/FileTreeIcons.tsx +128 -0
- package/src/components/editor/FileTreeNodes.tsx +496 -0
- package/src/components/editor/MotionPanel.tsx +16 -390
- package/src/components/editor/MotionPanelFields.tsx +185 -0
- package/src/components/editor/PropertyPanel.test.ts +0 -49
- package/src/components/editor/PropertyPanel.tsx +132 -2763
- package/src/components/editor/domEditOverlayGeometry.ts +211 -0
- package/src/components/editor/domEditOverlayGestures.ts +138 -0
- package/src/components/editor/domEditOverlayStartGesture.ts +155 -0
- package/src/components/editor/domEditing.ts +44 -1117
- package/src/components/editor/domEditingAgentPrompt.ts +97 -0
- package/src/components/editor/domEditingDom.ts +266 -0
- package/src/components/editor/domEditingElement.ts +329 -0
- package/src/components/editor/domEditingLayers.ts +460 -0
- package/src/components/editor/domEditingTypes.ts +125 -0
- package/src/components/editor/manualEditingAvailability.test.ts +2 -2
- package/src/components/editor/manualEditingAvailability.ts +1 -1
- package/src/components/editor/manualEdits.ts +84 -1049
- package/src/components/editor/manualEditsDom.ts +436 -0
- package/src/components/editor/manualEditsParsing.ts +280 -0
- package/src/components/editor/manualEditsSnapshot.ts +333 -0
- package/src/components/editor/manualEditsTypes.ts +141 -0
- package/src/components/editor/propertyPanelColor.tsx +371 -0
- package/src/components/editor/propertyPanelFill.tsx +421 -0
- package/src/components/editor/propertyPanelFont.tsx +455 -0
- package/src/components/editor/propertyPanelHelpers.ts +401 -0
- package/src/components/editor/propertyPanelPrimitives.tsx +357 -0
- package/src/components/editor/propertyPanelSections.tsx +453 -0
- package/src/components/editor/propertyPanelStyleSections.tsx +411 -0
- package/src/components/editor/studioMotion.ts +47 -434
- package/src/components/editor/studioMotionOps.ts +299 -0
- package/src/components/editor/studioMotionTypes.ts +168 -0
- package/src/components/editor/useDomEditOverlayGestures.ts +393 -0
- package/src/components/editor/useDomEditOverlayRects.ts +207 -0
- package/src/components/nle/NLELayout.tsx +68 -155
- package/src/components/nle/NLEPreview.tsx +3 -0
- package/src/components/nle/useCompositionStack.ts +126 -0
- package/src/components/renders/RenderQueue.tsx +102 -31
- package/src/components/renders/useRenderQueue.ts +8 -2
- package/src/components/sidebar/LeftSidebar.tsx +186 -186
- package/src/contexts/DomEditContext.tsx +137 -0
- package/src/contexts/FileManagerContext.tsx +110 -0
- package/src/contexts/PanelLayoutContext.tsx +68 -0
- package/src/contexts/StudioContext.tsx +135 -0
- package/src/hooks/useAppHotkeys.ts +326 -0
- package/src/hooks/useAskAgentModal.ts +162 -0
- package/src/hooks/useCaptionDetection.ts +132 -0
- package/src/hooks/useCompositionDimensions.ts +25 -0
- package/src/hooks/useConsoleErrorCapture.ts +60 -0
- package/src/hooks/useDomEditCommits.ts +437 -0
- package/src/hooks/useDomEditSession.ts +342 -0
- package/src/hooks/useDomEditTextCommits.ts +330 -0
- package/src/hooks/useDomSelection.ts +398 -0
- package/src/hooks/useFileManager.ts +431 -0
- package/src/hooks/useFrameCapture.ts +77 -0
- package/src/hooks/useLintModal.ts +35 -0
- package/src/hooks/useManifestPersistence.ts +492 -0
- package/src/hooks/usePanelLayout.ts +68 -0
- package/src/hooks/usePreviewInteraction.ts +153 -0
- package/src/hooks/useRenderClipContent.ts +124 -0
- package/src/hooks/useTimelineEditing.ts +472 -0
- package/src/hooks/useToast.ts +20 -0
- package/src/player/components/Player.tsx +33 -2
- package/src/player/components/Timeline.test.ts +0 -8
- package/src/player/components/Timeline.tsx +196 -1518
- package/src/player/components/TimelineCanvas.tsx +434 -0
- package/src/player/components/TimelineClip.tsx +9 -244
- package/src/player/components/TimelineEmptyState.tsx +102 -0
- package/src/player/components/TimelineRuler.tsx +90 -0
- package/src/player/components/timelineIcons.tsx +49 -0
- package/src/player/components/timelineLayout.ts +215 -0
- package/src/player/components/timelineUtils.ts +211 -0
- package/src/player/components/useTimelineClipDrag.ts +388 -0
- package/src/player/components/useTimelinePlayhead.ts +200 -0
- package/src/player/components/useTimelineRangeSelection.ts +135 -0
- package/src/player/hooks/usePlaybackKeyboard.ts +171 -0
- package/src/player/hooks/useTimelinePlayer.ts +105 -1371
- package/src/player/hooks/useTimelineSyncCallbacks.ts +288 -0
- package/src/player/lib/playbackAdapter.ts +145 -0
- package/src/player/lib/playbackShortcuts.ts +68 -0
- package/src/player/lib/playbackTypes.ts +60 -0
- package/src/player/lib/timelineDOM.ts +373 -0
- package/src/player/lib/timelineElementHelpers.ts +303 -0
- package/src/player/lib/timelineIframeHelpers.ts +269 -0
- package/src/utils/domEditHelpers.ts +50 -0
- package/src/utils/studioFontHelpers.ts +83 -0
- package/src/utils/studioHelpers.ts +214 -0
- package/src/utils/studioPreviewHelpers.ts +185 -0
- package/src/utils/timelineDiscovery.ts +1 -1
- package/dist/assets/hyperframes-player-DjsVzYFP.js +0 -418
- package/dist/assets/index-14zH9lqh.css +0 -1
- package/dist/assets/index-DYCiFGWQ.js +0 -108
- package/src/player/components/TimelineClip.test.ts +0 -92
|
@@ -1,31 +1,17 @@
|
|
|
1
|
-
import { memo, useState, useCallback, useMemo, useRef
|
|
1
|
+
import { memo, useState, useCallback, useMemo, useRef } from "react";
|
|
2
|
+
import { Plus, FolderSimplePlus } from "@phosphor-icons/react";
|
|
2
3
|
import {
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
FileVideo,
|
|
15
|
-
FileCode,
|
|
16
|
-
File,
|
|
17
|
-
Waveform,
|
|
18
|
-
TextAa,
|
|
19
|
-
Image as PhImage,
|
|
20
|
-
PencilSimple,
|
|
21
|
-
Copy,
|
|
22
|
-
Trash,
|
|
23
|
-
Plus,
|
|
24
|
-
FolderSimplePlus,
|
|
25
|
-
FilePlus,
|
|
26
|
-
FolderSimple,
|
|
27
|
-
} from "@phosphor-icons/react";
|
|
28
|
-
import { ChevronDown, ChevronRight } from "../../icons/SystemIcons";
|
|
4
|
+
buildTree,
|
|
5
|
+
sortChildren,
|
|
6
|
+
isActiveInSubtree,
|
|
7
|
+
ContextMenu,
|
|
8
|
+
InlineInput,
|
|
9
|
+
DeleteConfirm,
|
|
10
|
+
TreeFile,
|
|
11
|
+
TreeFolder,
|
|
12
|
+
type ContextMenuState,
|
|
13
|
+
type InlineInputState,
|
|
14
|
+
} from "./FileTreeNodes";
|
|
29
15
|
|
|
30
16
|
// ── Types ──
|
|
31
17
|
|
|
@@ -42,593 +28,6 @@ export interface FileTreeProps {
|
|
|
42
28
|
onImportFiles?: (files: FileList, dir?: string) => void;
|
|
43
29
|
}
|
|
44
30
|
|
|
45
|
-
interface TreeNode {
|
|
46
|
-
name: string;
|
|
47
|
-
fullPath: string;
|
|
48
|
-
children: Map<string, TreeNode>;
|
|
49
|
-
isFile: boolean;
|
|
50
|
-
}
|
|
51
|
-
|
|
52
|
-
interface ContextMenuState {
|
|
53
|
-
x: number;
|
|
54
|
-
y: number;
|
|
55
|
-
targetPath: string;
|
|
56
|
-
targetIsFolder: boolean;
|
|
57
|
-
}
|
|
58
|
-
|
|
59
|
-
interface InlineInputState {
|
|
60
|
-
/** Parent folder path (empty string for root) */
|
|
61
|
-
parentPath: string;
|
|
62
|
-
/** "file" or "folder" creation, or "rename" */
|
|
63
|
-
mode: "new-file" | "new-folder" | "rename";
|
|
64
|
-
/** For rename mode, the original full path */
|
|
65
|
-
originalPath?: string;
|
|
66
|
-
/** For rename mode, the original name */
|
|
67
|
-
originalName?: string;
|
|
68
|
-
onCommit?: (name: string) => void;
|
|
69
|
-
onCancel?: () => void;
|
|
70
|
-
}
|
|
71
|
-
|
|
72
|
-
// ── Constants ──
|
|
73
|
-
|
|
74
|
-
const SZ = 14;
|
|
75
|
-
const W = "duotone" as const;
|
|
76
|
-
|
|
77
|
-
// ── FileIcon ──
|
|
78
|
-
|
|
79
|
-
function FileIcon({ path }: { path: string }) {
|
|
80
|
-
const ext = path.split(".").pop()?.toLowerCase() ?? "";
|
|
81
|
-
const c = "flex-shrink-0";
|
|
82
|
-
if (ext === "html") return <FileHtml size={SZ} weight={W} color="#E44D26" className={c} />;
|
|
83
|
-
if (ext === "css") return <FileCss size={SZ} weight={W} color="#264DE4" className={c} />;
|
|
84
|
-
if (ext === "js" || ext === "mjs" || ext === "cjs")
|
|
85
|
-
return <FileJs size={SZ} weight={W} color="#F0DB4F" className={c} />;
|
|
86
|
-
if (ext === "jsx") return <FileJsx size={SZ} weight={W} color="#61DAFB" className={c} />;
|
|
87
|
-
if (ext === "ts" || ext === "mts")
|
|
88
|
-
return <FileTs size={SZ} weight={W} color="#3178C6" className={c} />;
|
|
89
|
-
if (ext === "tsx") return <FileTsx size={SZ} weight={W} color="#3178C6" className={c} />;
|
|
90
|
-
if (ext === "json") return <FileCode size={SZ} weight={W} color="#4ADE80" className={c} />;
|
|
91
|
-
if (ext === "svg") return <FileSvg size={SZ} weight={W} color="#F97316" className={c} />;
|
|
92
|
-
if (ext === "md" || ext === "mdx")
|
|
93
|
-
return <FileMd size={SZ} weight={W} color="#9CA3AF" className={c} />;
|
|
94
|
-
if (ext === "txt") return <FileTxt size={SZ} weight={W} color="#9CA3AF" className={c} />;
|
|
95
|
-
if (ext === "png") return <FilePng size={SZ} weight={W} color="#22C55E" className={c} />;
|
|
96
|
-
if (ext === "jpg" || ext === "jpeg")
|
|
97
|
-
return <FileJpg size={SZ} weight={W} color="#22C55E" className={c} />;
|
|
98
|
-
if (ext === "webp" || ext === "gif" || ext === "ico")
|
|
99
|
-
return <PhImage size={SZ} weight={W} color="#22C55E" className={c} />;
|
|
100
|
-
if (ext === "mp4" || ext === "webm" || ext === "mov")
|
|
101
|
-
return <FileVideo size={SZ} weight={W} color="#A855F7" className={c} />;
|
|
102
|
-
if (ext === "mp3" || ext === "wav" || ext === "ogg" || ext === "m4a")
|
|
103
|
-
return <Waveform size={SZ} weight={W} color="#3CE6AC" className={c} />;
|
|
104
|
-
if (ext === "woff" || ext === "woff2" || ext === "ttf" || ext === "otf")
|
|
105
|
-
return <TextAa size={SZ} weight={W} color="#6B7280" className={c} />;
|
|
106
|
-
return <File size={SZ} weight={W} color="#6B7280" className={c} />;
|
|
107
|
-
}
|
|
108
|
-
|
|
109
|
-
// ── Tree Helpers ──
|
|
110
|
-
|
|
111
|
-
function buildTree(files: string[]): TreeNode {
|
|
112
|
-
const root: TreeNode = { name: "", fullPath: "", children: new Map(), isFile: false };
|
|
113
|
-
for (const file of files) {
|
|
114
|
-
const parts = file.split("/");
|
|
115
|
-
let current = root;
|
|
116
|
-
for (let i = 0; i < parts.length; i++) {
|
|
117
|
-
const part = parts[i];
|
|
118
|
-
const isLast = i === parts.length - 1;
|
|
119
|
-
const fullPath = parts.slice(0, i + 1).join("/");
|
|
120
|
-
if (!current.children.has(part)) {
|
|
121
|
-
current.children.set(part, {
|
|
122
|
-
name: part,
|
|
123
|
-
fullPath,
|
|
124
|
-
children: new Map(),
|
|
125
|
-
isFile: isLast,
|
|
126
|
-
});
|
|
127
|
-
}
|
|
128
|
-
current = current.children.get(part)!;
|
|
129
|
-
if (isLast) current.isFile = true;
|
|
130
|
-
}
|
|
131
|
-
}
|
|
132
|
-
return root;
|
|
133
|
-
}
|
|
134
|
-
|
|
135
|
-
function sortChildren(children: Map<string, TreeNode>): TreeNode[] {
|
|
136
|
-
return Array.from(children.values()).sort((a, b) => {
|
|
137
|
-
// index.html always first
|
|
138
|
-
if (a.name === "index.html") return -1;
|
|
139
|
-
if (b.name === "index.html") return 1;
|
|
140
|
-
// Directories before files
|
|
141
|
-
if (!a.isFile && b.isFile) return -1;
|
|
142
|
-
if (a.isFile && !b.isFile) return 1;
|
|
143
|
-
return a.name.localeCompare(b.name);
|
|
144
|
-
});
|
|
145
|
-
}
|
|
146
|
-
|
|
147
|
-
function isActiveInSubtree(node: TreeNode, activeFile: string | null): boolean {
|
|
148
|
-
if (!activeFile) return false;
|
|
149
|
-
if (node.fullPath === activeFile) return true;
|
|
150
|
-
for (const child of node.children.values()) {
|
|
151
|
-
if (isActiveInSubtree(child, activeFile)) return true;
|
|
152
|
-
}
|
|
153
|
-
return false;
|
|
154
|
-
}
|
|
155
|
-
|
|
156
|
-
// ── Context Menu Component ──
|
|
157
|
-
|
|
158
|
-
function ContextMenu({
|
|
159
|
-
state,
|
|
160
|
-
onClose,
|
|
161
|
-
onNewFile,
|
|
162
|
-
onNewFolder,
|
|
163
|
-
onRename,
|
|
164
|
-
onDuplicate,
|
|
165
|
-
onDelete,
|
|
166
|
-
}: {
|
|
167
|
-
state: ContextMenuState;
|
|
168
|
-
onClose: () => void;
|
|
169
|
-
onNewFile: (parentPath: string) => void;
|
|
170
|
-
onNewFolder: (parentPath: string) => void;
|
|
171
|
-
onRename: (path: string) => void;
|
|
172
|
-
onDuplicate: (path: string) => void;
|
|
173
|
-
onDelete: (path: string) => void;
|
|
174
|
-
}) {
|
|
175
|
-
const menuRef = useRef<HTMLDivElement>(null);
|
|
176
|
-
|
|
177
|
-
// eslint-disable-next-line no-restricted-syntax
|
|
178
|
-
useEffect(() => {
|
|
179
|
-
const handleClickOutside = (e: MouseEvent) => {
|
|
180
|
-
if (menuRef.current && !menuRef.current.contains(e.target as Node)) {
|
|
181
|
-
onClose();
|
|
182
|
-
}
|
|
183
|
-
};
|
|
184
|
-
const handleEscape = (e: KeyboardEvent) => {
|
|
185
|
-
if (e.key === "Escape") onClose();
|
|
186
|
-
};
|
|
187
|
-
document.addEventListener("mousedown", handleClickOutside);
|
|
188
|
-
document.addEventListener("keydown", handleEscape);
|
|
189
|
-
return () => {
|
|
190
|
-
document.removeEventListener("mousedown", handleClickOutside);
|
|
191
|
-
document.removeEventListener("keydown", handleEscape);
|
|
192
|
-
};
|
|
193
|
-
}, [onClose]);
|
|
194
|
-
|
|
195
|
-
// Adjust position so menu doesn't overflow viewport
|
|
196
|
-
const adjustedX = Math.min(state.x, window.innerWidth - 180);
|
|
197
|
-
const adjustedY = Math.min(state.y, window.innerHeight - 200);
|
|
198
|
-
|
|
199
|
-
const parentPath = state.targetIsFolder
|
|
200
|
-
? state.targetPath
|
|
201
|
-
: state.targetPath.includes("/")
|
|
202
|
-
? state.targetPath.slice(0, state.targetPath.lastIndexOf("/"))
|
|
203
|
-
: "";
|
|
204
|
-
|
|
205
|
-
return (
|
|
206
|
-
<div
|
|
207
|
-
ref={menuRef}
|
|
208
|
-
className="fixed z-50 bg-neutral-900 border border-neutral-700 rounded-md shadow-lg py-1 min-w-[160px]"
|
|
209
|
-
style={{ left: adjustedX, top: adjustedY }}
|
|
210
|
-
>
|
|
211
|
-
{state.targetIsFolder && (
|
|
212
|
-
<>
|
|
213
|
-
<button
|
|
214
|
-
className="w-full flex items-center gap-2 px-3 py-1.5 text-xs text-neutral-300 hover:bg-neutral-800 cursor-pointer text-left"
|
|
215
|
-
onClick={() => {
|
|
216
|
-
onNewFile(state.targetPath);
|
|
217
|
-
onClose();
|
|
218
|
-
}}
|
|
219
|
-
>
|
|
220
|
-
<FilePlus size={12} weight="duotone" className="text-neutral-500" />
|
|
221
|
-
New File
|
|
222
|
-
</button>
|
|
223
|
-
<button
|
|
224
|
-
className="w-full flex items-center gap-2 px-3 py-1.5 text-xs text-neutral-300 hover:bg-neutral-800 cursor-pointer text-left"
|
|
225
|
-
onClick={() => {
|
|
226
|
-
onNewFolder(state.targetPath);
|
|
227
|
-
onClose();
|
|
228
|
-
}}
|
|
229
|
-
>
|
|
230
|
-
<FolderSimplePlus size={12} weight="duotone" className="text-neutral-500" />
|
|
231
|
-
New Folder
|
|
232
|
-
</button>
|
|
233
|
-
<div className="border-t border-neutral-700 my-1" />
|
|
234
|
-
</>
|
|
235
|
-
)}
|
|
236
|
-
{!state.targetIsFolder && (
|
|
237
|
-
<>
|
|
238
|
-
<button
|
|
239
|
-
className="w-full flex items-center gap-2 px-3 py-1.5 text-xs text-neutral-300 hover:bg-neutral-800 cursor-pointer text-left"
|
|
240
|
-
onClick={() => {
|
|
241
|
-
onNewFile(parentPath);
|
|
242
|
-
onClose();
|
|
243
|
-
}}
|
|
244
|
-
>
|
|
245
|
-
<FilePlus size={12} weight="duotone" className="text-neutral-500" />
|
|
246
|
-
New File
|
|
247
|
-
</button>
|
|
248
|
-
<div className="border-t border-neutral-700 my-1" />
|
|
249
|
-
</>
|
|
250
|
-
)}
|
|
251
|
-
<button
|
|
252
|
-
className="w-full flex items-center gap-2 px-3 py-1.5 text-xs text-neutral-300 hover:bg-neutral-800 cursor-pointer text-left"
|
|
253
|
-
onClick={() => {
|
|
254
|
-
onRename(state.targetPath);
|
|
255
|
-
onClose();
|
|
256
|
-
}}
|
|
257
|
-
>
|
|
258
|
-
<PencilSimple size={12} weight="duotone" className="text-neutral-500" />
|
|
259
|
-
Rename
|
|
260
|
-
</button>
|
|
261
|
-
{!state.targetIsFolder && (
|
|
262
|
-
<button
|
|
263
|
-
className="w-full flex items-center gap-2 px-3 py-1.5 text-xs text-neutral-300 hover:bg-neutral-800 cursor-pointer text-left"
|
|
264
|
-
onClick={() => {
|
|
265
|
-
onDuplicate(state.targetPath);
|
|
266
|
-
onClose();
|
|
267
|
-
}}
|
|
268
|
-
>
|
|
269
|
-
<Copy size={12} weight="duotone" className="text-neutral-500" />
|
|
270
|
-
Duplicate
|
|
271
|
-
</button>
|
|
272
|
-
)}
|
|
273
|
-
<div className="border-t border-neutral-700 my-1" />
|
|
274
|
-
<button
|
|
275
|
-
className="w-full flex items-center gap-2 px-3 py-1.5 text-xs text-red-400 hover:bg-red-900/30 cursor-pointer text-left"
|
|
276
|
-
onClick={() => {
|
|
277
|
-
onDelete(state.targetPath);
|
|
278
|
-
onClose();
|
|
279
|
-
}}
|
|
280
|
-
>
|
|
281
|
-
<Trash size={12} weight="duotone" />
|
|
282
|
-
Delete
|
|
283
|
-
</button>
|
|
284
|
-
</div>
|
|
285
|
-
);
|
|
286
|
-
}
|
|
287
|
-
|
|
288
|
-
// ── Inline Input (for new file/folder/rename) ──
|
|
289
|
-
|
|
290
|
-
function InlineInput({
|
|
291
|
-
defaultValue,
|
|
292
|
-
depth,
|
|
293
|
-
isFolder,
|
|
294
|
-
onCommit,
|
|
295
|
-
onCancel,
|
|
296
|
-
}: {
|
|
297
|
-
defaultValue: string;
|
|
298
|
-
depth: number;
|
|
299
|
-
isFolder: boolean;
|
|
300
|
-
onCommit: (value: string) => void;
|
|
301
|
-
onCancel: () => void;
|
|
302
|
-
}) {
|
|
303
|
-
const inputRef = useRef<HTMLInputElement>(null);
|
|
304
|
-
const committedRef = useRef(false);
|
|
305
|
-
const [value, setValue] = useState(defaultValue);
|
|
306
|
-
|
|
307
|
-
// eslint-disable-next-line no-restricted-syntax
|
|
308
|
-
useEffect(() => {
|
|
309
|
-
const el = inputRef.current;
|
|
310
|
-
if (!el) return;
|
|
311
|
-
el.focus();
|
|
312
|
-
// Select just the filename (not extension) for rename
|
|
313
|
-
if (defaultValue && defaultValue.includes(".")) {
|
|
314
|
-
const dotIdx = defaultValue.lastIndexOf(".");
|
|
315
|
-
el.setSelectionRange(0, dotIdx);
|
|
316
|
-
} else {
|
|
317
|
-
el.select();
|
|
318
|
-
}
|
|
319
|
-
}, [defaultValue]);
|
|
320
|
-
|
|
321
|
-
const commit = (name: string) => {
|
|
322
|
-
if (committedRef.current) return;
|
|
323
|
-
committedRef.current = true;
|
|
324
|
-
onCommit(name);
|
|
325
|
-
};
|
|
326
|
-
|
|
327
|
-
const handleKeyDown = (e: React.KeyboardEvent) => {
|
|
328
|
-
if (e.key === "Enter") {
|
|
329
|
-
e.preventDefault();
|
|
330
|
-
const trimmed = value.trim();
|
|
331
|
-
if (trimmed && !(/[/\\]/.test(trimmed) || trimmed.includes(".."))) commit(trimmed);
|
|
332
|
-
else onCancel();
|
|
333
|
-
} else if (e.key === "Escape") {
|
|
334
|
-
e.preventDefault();
|
|
335
|
-
onCancel();
|
|
336
|
-
}
|
|
337
|
-
};
|
|
338
|
-
|
|
339
|
-
const handleBlur = () => {
|
|
340
|
-
const trimmed = value.trim();
|
|
341
|
-
if (trimmed && trimmed !== defaultValue && !(/[/\\]/.test(trimmed) || trimmed.includes("..")))
|
|
342
|
-
commit(trimmed);
|
|
343
|
-
else onCancel();
|
|
344
|
-
};
|
|
345
|
-
|
|
346
|
-
return (
|
|
347
|
-
<div
|
|
348
|
-
className="flex items-center gap-2 py-0.5 min-h-7"
|
|
349
|
-
style={{ paddingLeft: `${8 + depth * 12 + (isFolder ? 0 : 14)}px` }}
|
|
350
|
-
>
|
|
351
|
-
{isFolder ? (
|
|
352
|
-
<FolderSimple size={SZ} weight="duotone" color="#6B7280" className="flex-shrink-0" />
|
|
353
|
-
) : (
|
|
354
|
-
<FileIcon path={value} />
|
|
355
|
-
)}
|
|
356
|
-
<input
|
|
357
|
-
ref={inputRef}
|
|
358
|
-
value={value}
|
|
359
|
-
onChange={(e) => setValue(e.target.value)}
|
|
360
|
-
onKeyDown={handleKeyDown}
|
|
361
|
-
onBlur={handleBlur}
|
|
362
|
-
className="flex-1 min-w-0 bg-neutral-800 text-neutral-200 text-xs px-1.5 py-0.5 rounded border border-neutral-600 outline-none focus:border-[#3CE6AC]"
|
|
363
|
-
spellCheck={false}
|
|
364
|
-
/>
|
|
365
|
-
</div>
|
|
366
|
-
);
|
|
367
|
-
}
|
|
368
|
-
|
|
369
|
-
// ── Delete Confirmation ──
|
|
370
|
-
|
|
371
|
-
function DeleteConfirm({
|
|
372
|
-
name,
|
|
373
|
-
onConfirm,
|
|
374
|
-
onCancel,
|
|
375
|
-
}: {
|
|
376
|
-
name: string;
|
|
377
|
-
onConfirm: () => void;
|
|
378
|
-
onCancel: () => void;
|
|
379
|
-
}) {
|
|
380
|
-
const ref = useRef<HTMLDivElement>(null);
|
|
381
|
-
|
|
382
|
-
// eslint-disable-next-line no-restricted-syntax
|
|
383
|
-
useEffect(() => {
|
|
384
|
-
const handleEscape = (e: KeyboardEvent) => {
|
|
385
|
-
if (e.key === "Escape") onCancel();
|
|
386
|
-
};
|
|
387
|
-
const handleClickOutside = (e: MouseEvent) => {
|
|
388
|
-
if (ref.current && !ref.current.contains(e.target as Node)) onCancel();
|
|
389
|
-
};
|
|
390
|
-
document.addEventListener("keydown", handleEscape);
|
|
391
|
-
document.addEventListener("mousedown", handleClickOutside);
|
|
392
|
-
return () => {
|
|
393
|
-
document.removeEventListener("keydown", handleEscape);
|
|
394
|
-
document.removeEventListener("mousedown", handleClickOutside);
|
|
395
|
-
};
|
|
396
|
-
}, [onCancel]);
|
|
397
|
-
|
|
398
|
-
return (
|
|
399
|
-
<div
|
|
400
|
-
ref={ref}
|
|
401
|
-
className="mx-1 my-0.5 p-2 bg-neutral-800 border border-neutral-700 rounded-md text-xs"
|
|
402
|
-
>
|
|
403
|
-
<p className="text-neutral-300 mb-2">
|
|
404
|
-
Delete <span className="font-medium text-neutral-100">{name}</span>?
|
|
405
|
-
</p>
|
|
406
|
-
<div className="flex gap-1.5">
|
|
407
|
-
<button
|
|
408
|
-
onClick={onCancel}
|
|
409
|
-
className="flex-1 px-2 py-1 rounded bg-neutral-700 text-neutral-300 hover:bg-neutral-600 transition-colors"
|
|
410
|
-
>
|
|
411
|
-
Cancel
|
|
412
|
-
</button>
|
|
413
|
-
<button
|
|
414
|
-
onClick={onConfirm}
|
|
415
|
-
className="flex-1 px-2 py-1 rounded bg-red-900/60 text-red-300 hover:bg-red-800/60 transition-colors"
|
|
416
|
-
>
|
|
417
|
-
Delete
|
|
418
|
-
</button>
|
|
419
|
-
</div>
|
|
420
|
-
</div>
|
|
421
|
-
);
|
|
422
|
-
}
|
|
423
|
-
|
|
424
|
-
// ── TreeFolder ──
|
|
425
|
-
|
|
426
|
-
function TreeFolder({
|
|
427
|
-
node,
|
|
428
|
-
depth,
|
|
429
|
-
activeFile,
|
|
430
|
-
onSelectFile,
|
|
431
|
-
defaultOpen,
|
|
432
|
-
onContextMenu,
|
|
433
|
-
inlineInput,
|
|
434
|
-
onDragStart,
|
|
435
|
-
onDragOver,
|
|
436
|
-
onDrop,
|
|
437
|
-
onDragLeave,
|
|
438
|
-
dragOverFolder,
|
|
439
|
-
}: {
|
|
440
|
-
node: TreeNode;
|
|
441
|
-
depth: number;
|
|
442
|
-
activeFile: string | null;
|
|
443
|
-
onSelectFile: (path: string) => void;
|
|
444
|
-
defaultOpen: boolean;
|
|
445
|
-
onContextMenu: (e: React.MouseEvent, path: string, isFolder: boolean) => void;
|
|
446
|
-
inlineInput: InlineInputState | null;
|
|
447
|
-
onDragStart: (e: React.DragEvent, path: string) => void;
|
|
448
|
-
onDragOver: (e: React.DragEvent, folderPath: string) => void;
|
|
449
|
-
onDrop: (e: React.DragEvent, folderPath: string) => void;
|
|
450
|
-
onDragLeave: () => void;
|
|
451
|
-
dragOverFolder: string | null;
|
|
452
|
-
}) {
|
|
453
|
-
const [isOpen, setIsOpen] = useState(defaultOpen);
|
|
454
|
-
const toggle = useCallback(() => setIsOpen((v) => !v), []);
|
|
455
|
-
const children = useMemo(() => sortChildren(node.children), [node.children]);
|
|
456
|
-
const Chevron = isOpen ? ChevronDown : ChevronRight;
|
|
457
|
-
const isDragOver = dragOverFolder === node.fullPath;
|
|
458
|
-
const isRenaming = inlineInput?.mode === "rename" && inlineInput.originalPath === node.fullPath;
|
|
459
|
-
|
|
460
|
-
if (isRenaming) {
|
|
461
|
-
return (
|
|
462
|
-
<InlineInput
|
|
463
|
-
defaultValue={inlineInput.originalName ?? node.name}
|
|
464
|
-
depth={depth}
|
|
465
|
-
isFolder={true}
|
|
466
|
-
onCommit={(name) => {
|
|
467
|
-
inlineInput?.onCommit?.(name);
|
|
468
|
-
}}
|
|
469
|
-
onCancel={() => {
|
|
470
|
-
inlineInput?.onCancel?.();
|
|
471
|
-
}}
|
|
472
|
-
/>
|
|
473
|
-
);
|
|
474
|
-
}
|
|
475
|
-
|
|
476
|
-
return (
|
|
477
|
-
<>
|
|
478
|
-
<button
|
|
479
|
-
draggable
|
|
480
|
-
onDragStart={(e) => onDragStart(e, node.fullPath)}
|
|
481
|
-
onClick={toggle}
|
|
482
|
-
onContextMenu={(e) => {
|
|
483
|
-
e.preventDefault();
|
|
484
|
-
onContextMenu(e, node.fullPath, true);
|
|
485
|
-
}}
|
|
486
|
-
onDragOver={(e) => {
|
|
487
|
-
e.preventDefault();
|
|
488
|
-
e.stopPropagation();
|
|
489
|
-
onDragOver(e, node.fullPath);
|
|
490
|
-
}}
|
|
491
|
-
onDrop={(e) => {
|
|
492
|
-
e.preventDefault();
|
|
493
|
-
e.stopPropagation();
|
|
494
|
-
onDrop(e, node.fullPath);
|
|
495
|
-
}}
|
|
496
|
-
onDragLeave={onDragLeave}
|
|
497
|
-
className={`w-full flex items-center gap-1.5 px-2.5 py-1 min-h-7 text-left text-xs text-neutral-400 hover:bg-neutral-800/30 hover:text-neutral-300 transition-colors ${
|
|
498
|
-
isDragOver ? "bg-[#3CE6AC]/10 outline outline-1 outline-[#3CE6AC]/40" : ""
|
|
499
|
-
}`}
|
|
500
|
-
style={{ paddingLeft: `${8 + depth * 12}px` }}
|
|
501
|
-
>
|
|
502
|
-
<Chevron size={10} className="flex-shrink-0 text-neutral-600" />
|
|
503
|
-
<span className="truncate font-medium">{node.name}</span>
|
|
504
|
-
</button>
|
|
505
|
-
{isOpen && (
|
|
506
|
-
<>
|
|
507
|
-
{/* Inline input for new file/folder inside this folder */}
|
|
508
|
-
{inlineInput &&
|
|
509
|
-
(inlineInput.mode === "new-file" || inlineInput.mode === "new-folder") &&
|
|
510
|
-
inlineInput.parentPath === node.fullPath && (
|
|
511
|
-
<InlineInput
|
|
512
|
-
defaultValue=""
|
|
513
|
-
depth={depth + 1}
|
|
514
|
-
isFolder={inlineInput.mode === "new-folder"}
|
|
515
|
-
onCommit={(name) => {
|
|
516
|
-
// onCommit is handled by the parent FileTree component
|
|
517
|
-
// via the inlineInputCommit callback
|
|
518
|
-
inlineInput?.onCommit?.(name);
|
|
519
|
-
}}
|
|
520
|
-
onCancel={() => {
|
|
521
|
-
inlineInput?.onCancel?.();
|
|
522
|
-
}}
|
|
523
|
-
/>
|
|
524
|
-
)}
|
|
525
|
-
{children.map((child) =>
|
|
526
|
-
child.isFile && child.children.size === 0 ? (
|
|
527
|
-
<TreeFile
|
|
528
|
-
key={child.fullPath}
|
|
529
|
-
node={child}
|
|
530
|
-
depth={depth + 1}
|
|
531
|
-
activeFile={activeFile}
|
|
532
|
-
onSelectFile={onSelectFile}
|
|
533
|
-
onContextMenu={onContextMenu}
|
|
534
|
-
inlineInput={inlineInput}
|
|
535
|
-
onDragStart={onDragStart}
|
|
536
|
-
/>
|
|
537
|
-
) : child.children.size > 0 ? (
|
|
538
|
-
<TreeFolder
|
|
539
|
-
key={child.fullPath}
|
|
540
|
-
node={child}
|
|
541
|
-
depth={depth + 1}
|
|
542
|
-
activeFile={activeFile}
|
|
543
|
-
onSelectFile={onSelectFile}
|
|
544
|
-
defaultOpen={isActiveInSubtree(child, activeFile)}
|
|
545
|
-
onContextMenu={onContextMenu}
|
|
546
|
-
inlineInput={inlineInput}
|
|
547
|
-
onDragStart={onDragStart}
|
|
548
|
-
onDragOver={onDragOver}
|
|
549
|
-
onDrop={onDrop}
|
|
550
|
-
onDragLeave={onDragLeave}
|
|
551
|
-
dragOverFolder={dragOverFolder}
|
|
552
|
-
/>
|
|
553
|
-
) : (
|
|
554
|
-
<TreeFile
|
|
555
|
-
key={child.fullPath}
|
|
556
|
-
node={child}
|
|
557
|
-
depth={depth + 1}
|
|
558
|
-
activeFile={activeFile}
|
|
559
|
-
onSelectFile={onSelectFile}
|
|
560
|
-
onContextMenu={onContextMenu}
|
|
561
|
-
inlineInput={inlineInput}
|
|
562
|
-
onDragStart={onDragStart}
|
|
563
|
-
/>
|
|
564
|
-
),
|
|
565
|
-
)}
|
|
566
|
-
</>
|
|
567
|
-
)}
|
|
568
|
-
</>
|
|
569
|
-
);
|
|
570
|
-
}
|
|
571
|
-
|
|
572
|
-
// ── TreeFile ──
|
|
573
|
-
|
|
574
|
-
function TreeFile({
|
|
575
|
-
node,
|
|
576
|
-
depth,
|
|
577
|
-
activeFile,
|
|
578
|
-
onSelectFile,
|
|
579
|
-
onContextMenu,
|
|
580
|
-
inlineInput,
|
|
581
|
-
onDragStart,
|
|
582
|
-
}: {
|
|
583
|
-
node: TreeNode;
|
|
584
|
-
depth: number;
|
|
585
|
-
activeFile: string | null;
|
|
586
|
-
onSelectFile: (path: string) => void;
|
|
587
|
-
onContextMenu: (e: React.MouseEvent, path: string, isFolder: boolean) => void;
|
|
588
|
-
inlineInput: InlineInputState | null;
|
|
589
|
-
onDragStart: (e: React.DragEvent, path: string) => void;
|
|
590
|
-
}) {
|
|
591
|
-
const isActive = node.fullPath === activeFile;
|
|
592
|
-
const isRenaming = inlineInput?.mode === "rename" && inlineInput.originalPath === node.fullPath;
|
|
593
|
-
|
|
594
|
-
if (isRenaming) {
|
|
595
|
-
return (
|
|
596
|
-
<InlineInput
|
|
597
|
-
defaultValue={inlineInput.originalName ?? node.name}
|
|
598
|
-
depth={depth}
|
|
599
|
-
isFolder={false}
|
|
600
|
-
onCommit={(name) => {
|
|
601
|
-
inlineInput?.onCommit?.(name);
|
|
602
|
-
}}
|
|
603
|
-
onCancel={() => {
|
|
604
|
-
inlineInput?.onCancel?.();
|
|
605
|
-
}}
|
|
606
|
-
/>
|
|
607
|
-
);
|
|
608
|
-
}
|
|
609
|
-
|
|
610
|
-
return (
|
|
611
|
-
<button
|
|
612
|
-
draggable
|
|
613
|
-
onDragStart={(e) => onDragStart(e, node.fullPath)}
|
|
614
|
-
onClick={() => onSelectFile(node.fullPath)}
|
|
615
|
-
onContextMenu={(e) => {
|
|
616
|
-
e.preventDefault();
|
|
617
|
-
onContextMenu(e, node.fullPath, false);
|
|
618
|
-
}}
|
|
619
|
-
className={`w-full flex items-center gap-2 py-1 min-h-7 text-left transition-all text-xs ${
|
|
620
|
-
isActive
|
|
621
|
-
? "bg-neutral-800/60 text-neutral-200"
|
|
622
|
-
: "text-neutral-500 hover:bg-neutral-800/30 hover:text-neutral-300"
|
|
623
|
-
}`}
|
|
624
|
-
style={{ paddingLeft: `${8 + depth * 12 + 14}px` }}
|
|
625
|
-
>
|
|
626
|
-
<FileIcon path={node.name} />
|
|
627
|
-
<span className="truncate">{node.name}</span>
|
|
628
|
-
</button>
|
|
629
|
-
);
|
|
630
|
-
}
|
|
631
|
-
|
|
632
31
|
// ── Main FileTree Component ──
|
|
633
32
|
|
|
634
33
|
export const FileTree = memo(function FileTree({
|
|
@@ -748,8 +147,6 @@ export const FileTree = memo(function FileTree({
|
|
|
748
147
|
setDeleteTarget(path);
|
|
749
148
|
}, []);
|
|
750
149
|
|
|
751
|
-
// Since DeleteConfirm is rendered inside TreeFile, we need callbacks on that component.
|
|
752
|
-
// Instead, let's use a portal-style approach: render the confirm at the FileTree level.
|
|
753
150
|
const handleDeleteConfirm = useCallback(() => {
|
|
754
151
|
if (deleteTarget) {
|
|
755
152
|
onDeleteFile?.(deleteTarget);
|
|
@@ -775,7 +172,6 @@ export const FileTree = memo(function FileTree({
|
|
|
775
172
|
|
|
776
173
|
const handleDrop = useCallback(
|
|
777
174
|
(e: React.DragEvent, folderPath: string) => {
|
|
778
|
-
// External files from desktop — import into the target folder
|
|
779
175
|
if (e.dataTransfer.files.length > 0 && !dragSourceRef.current) {
|
|
780
176
|
e.preventDefault();
|
|
781
177
|
onImportFiles?.(e.dataTransfer.files, folderPath || undefined);
|
|
@@ -788,12 +184,10 @@ export const FileTree = memo(function FileTree({
|
|
|
788
184
|
setDragOverFolder(null);
|
|
789
185
|
return;
|
|
790
186
|
}
|
|
791
|
-
// Extract filename from source path
|
|
792
187
|
const fileName = sourcePath.includes("/")
|
|
793
188
|
? sourcePath.slice(sourcePath.lastIndexOf("/") + 1)
|
|
794
189
|
: sourcePath;
|
|
795
190
|
const newPath = folderPath ? `${folderPath}/${fileName}` : fileName;
|
|
796
|
-
// Don't move to same location or into own subtree
|
|
797
191
|
if (newPath !== sourcePath && !folderPath.startsWith(sourcePath + "/")) {
|
|
798
192
|
onMoveFile(sourcePath, newPath);
|
|
799
193
|
}
|
|
@@ -812,7 +206,6 @@ export const FileTree = memo(function FileTree({
|
|
|
812
206
|
const handleRootContextMenu = useCallback(
|
|
813
207
|
(e: React.MouseEvent) => {
|
|
814
208
|
if (!hasFileOps) return;
|
|
815
|
-
// Only trigger if clicking directly on the container, not on a file/folder button
|
|
816
209
|
if (e.target === e.currentTarget) {
|
|
817
210
|
e.preventDefault();
|
|
818
211
|
setContextMenu({ x: e.clientX, y: e.clientY, targetPath: "", targetIsFolder: true });
|
|
@@ -857,7 +250,6 @@ export const FileTree = memo(function FileTree({
|
|
|
857
250
|
onContextMenu={handleRootContextMenu}
|
|
858
251
|
onDragOver={(e) => {
|
|
859
252
|
e.preventDefault();
|
|
860
|
-
// Show root highlight when dragging over the background (not a child folder)
|
|
861
253
|
if (e.target === e.currentTarget) setDragOverFolder("");
|
|
862
254
|
}}
|
|
863
255
|
onDragLeave={(e) => {
|