@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
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
import {
|
|
2
|
+
FileHtml,
|
|
3
|
+
FileCss,
|
|
4
|
+
FileJs,
|
|
5
|
+
FileJsx,
|
|
6
|
+
FileTs,
|
|
7
|
+
FileTsx,
|
|
8
|
+
FileTxt,
|
|
9
|
+
FileMd,
|
|
10
|
+
FileSvg,
|
|
11
|
+
FilePng,
|
|
12
|
+
FileJpg,
|
|
13
|
+
FileVideo,
|
|
14
|
+
FileCode,
|
|
15
|
+
File,
|
|
16
|
+
Waveform,
|
|
17
|
+
TextAa,
|
|
18
|
+
Image as PhImage,
|
|
19
|
+
} from "@phosphor-icons/react";
|
|
20
|
+
|
|
21
|
+
const SZ = 14;
|
|
22
|
+
const W = "duotone" as const;
|
|
23
|
+
|
|
24
|
+
export function FileIcon({ path }: { path: string }) {
|
|
25
|
+
const ext = path.split(".").pop()?.toLowerCase() ?? "";
|
|
26
|
+
const c = "flex-shrink-0";
|
|
27
|
+
if (ext === "html") return <FileHtml size={SZ} weight={W} color="#E44D26" className={c} />;
|
|
28
|
+
if (ext === "css") return <FileCss size={SZ} weight={W} color="#264DE4" className={c} />;
|
|
29
|
+
if (ext === "js" || ext === "mjs" || ext === "cjs")
|
|
30
|
+
return <FileJs size={SZ} weight={W} color="#F0DB4F" className={c} />;
|
|
31
|
+
if (ext === "jsx") return <FileJsx size={SZ} weight={W} color="#61DAFB" className={c} />;
|
|
32
|
+
if (ext === "ts" || ext === "mts")
|
|
33
|
+
return <FileTs size={SZ} weight={W} color="#3178C6" className={c} />;
|
|
34
|
+
if (ext === "tsx") return <FileTsx size={SZ} weight={W} color="#3178C6" className={c} />;
|
|
35
|
+
if (ext === "json") return <FileCode size={SZ} weight={W} color="#4ADE80" className={c} />;
|
|
36
|
+
if (ext === "svg") return <FileSvg size={SZ} weight={W} color="#F97316" className={c} />;
|
|
37
|
+
if (ext === "md" || ext === "mdx")
|
|
38
|
+
return <FileMd size={SZ} weight={W} color="#9CA3AF" className={c} />;
|
|
39
|
+
if (ext === "txt") return <FileTxt size={SZ} weight={W} color="#9CA3AF" className={c} />;
|
|
40
|
+
if (ext === "png") return <FilePng size={SZ} weight={W} color="#22C55E" className={c} />;
|
|
41
|
+
if (ext === "jpg" || ext === "jpeg")
|
|
42
|
+
return <FileJpg size={SZ} weight={W} color="#22C55E" className={c} />;
|
|
43
|
+
if (ext === "webp" || ext === "gif" || ext === "ico")
|
|
44
|
+
return <PhImage size={SZ} weight={W} color="#22C55E" className={c} />;
|
|
45
|
+
if (ext === "mp4" || ext === "webm" || ext === "mov")
|
|
46
|
+
return <FileVideo size={SZ} weight={W} color="#A855F7" className={c} />;
|
|
47
|
+
if (ext === "mp3" || ext === "wav" || ext === "ogg" || ext === "m4a")
|
|
48
|
+
return <Waveform size={SZ} weight={W} color="#3CE6AC" className={c} />;
|
|
49
|
+
if (ext === "woff" || ext === "woff2" || ext === "ttf" || ext === "otf")
|
|
50
|
+
return <TextAa size={SZ} weight={W} color="#6B7280" className={c} />;
|
|
51
|
+
return <File size={SZ} weight={W} color="#6B7280" className={c} />;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// ── Tree Types ──
|
|
55
|
+
|
|
56
|
+
export interface TreeNode {
|
|
57
|
+
name: string;
|
|
58
|
+
fullPath: string;
|
|
59
|
+
children: Map<string, TreeNode>;
|
|
60
|
+
isFile: boolean;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export interface ContextMenuState {
|
|
64
|
+
x: number;
|
|
65
|
+
y: number;
|
|
66
|
+
targetPath: string;
|
|
67
|
+
targetIsFolder: boolean;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export interface InlineInputState {
|
|
71
|
+
/** Parent folder path (empty string for root) */
|
|
72
|
+
parentPath: string;
|
|
73
|
+
/** "file" or "folder" creation, or "rename" */
|
|
74
|
+
mode: "new-file" | "new-folder" | "rename";
|
|
75
|
+
/** For rename mode, the original full path */
|
|
76
|
+
originalPath?: string;
|
|
77
|
+
/** For rename mode, the original name */
|
|
78
|
+
originalName?: string;
|
|
79
|
+
onCommit?: (name: string) => void;
|
|
80
|
+
onCancel?: () => void;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// ── Tree Helpers ──
|
|
84
|
+
|
|
85
|
+
export function buildTree(files: string[]): TreeNode {
|
|
86
|
+
const root: TreeNode = { name: "", fullPath: "", children: new Map(), isFile: false };
|
|
87
|
+
for (const file of files) {
|
|
88
|
+
const parts = file.split("/");
|
|
89
|
+
let current = root;
|
|
90
|
+
for (let i = 0; i < parts.length; i++) {
|
|
91
|
+
const part = parts[i];
|
|
92
|
+
const isLast = i === parts.length - 1;
|
|
93
|
+
const fullPath = parts.slice(0, i + 1).join("/");
|
|
94
|
+
if (!current.children.has(part)) {
|
|
95
|
+
current.children.set(part, {
|
|
96
|
+
name: part,
|
|
97
|
+
fullPath,
|
|
98
|
+
children: new Map(),
|
|
99
|
+
isFile: isLast,
|
|
100
|
+
});
|
|
101
|
+
}
|
|
102
|
+
current = current.children.get(part)!;
|
|
103
|
+
if (isLast) current.isFile = true;
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
return root;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
export function sortChildren(children: Map<string, TreeNode>): TreeNode[] {
|
|
110
|
+
return Array.from(children.values()).sort((a, b) => {
|
|
111
|
+
// index.html always first
|
|
112
|
+
if (a.name === "index.html") return -1;
|
|
113
|
+
if (b.name === "index.html") return 1;
|
|
114
|
+
// Directories before files
|
|
115
|
+
if (!a.isFile && b.isFile) return -1;
|
|
116
|
+
if (a.isFile && !b.isFile) return 1;
|
|
117
|
+
return a.name.localeCompare(b.name);
|
|
118
|
+
});
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
export function isActiveInSubtree(node: TreeNode, activeFile: string | null): boolean {
|
|
122
|
+
if (!activeFile) return false;
|
|
123
|
+
if (node.fullPath === activeFile) return true;
|
|
124
|
+
for (const child of node.children.values()) {
|
|
125
|
+
if (isActiveInSubtree(child, activeFile)) return true;
|
|
126
|
+
}
|
|
127
|
+
return false;
|
|
128
|
+
}
|
|
@@ -0,0 +1,496 @@
|
|
|
1
|
+
import { memo, useState, useCallback, useMemo, useRef, useEffect } from "react";
|
|
2
|
+
import {
|
|
3
|
+
PencilSimple,
|
|
4
|
+
Copy,
|
|
5
|
+
Trash,
|
|
6
|
+
FilePlus,
|
|
7
|
+
FolderSimplePlus,
|
|
8
|
+
FolderSimple,
|
|
9
|
+
} from "@phosphor-icons/react";
|
|
10
|
+
import { ChevronDown, ChevronRight } from "../../icons/SystemIcons";
|
|
11
|
+
import {
|
|
12
|
+
FileIcon,
|
|
13
|
+
buildTree as _buildTree,
|
|
14
|
+
sortChildren,
|
|
15
|
+
isActiveInSubtree,
|
|
16
|
+
type TreeNode,
|
|
17
|
+
type ContextMenuState,
|
|
18
|
+
type InlineInputState,
|
|
19
|
+
} from "./FileTreeIcons";
|
|
20
|
+
|
|
21
|
+
// Re-export for FileTree.tsx consumers
|
|
22
|
+
export type { TreeNode, ContextMenuState, InlineInputState };
|
|
23
|
+
export { buildTree, sortChildren, isActiveInSubtree } from "./FileTreeIcons";
|
|
24
|
+
|
|
25
|
+
const SZ_ICON = 14;
|
|
26
|
+
|
|
27
|
+
// ── Context Menu Component ──
|
|
28
|
+
|
|
29
|
+
export function ContextMenu({
|
|
30
|
+
state,
|
|
31
|
+
onClose,
|
|
32
|
+
onNewFile,
|
|
33
|
+
onNewFolder,
|
|
34
|
+
onRename,
|
|
35
|
+
onDuplicate,
|
|
36
|
+
onDelete,
|
|
37
|
+
}: {
|
|
38
|
+
state: ContextMenuState;
|
|
39
|
+
onClose: () => void;
|
|
40
|
+
onNewFile: (parentPath: string) => void;
|
|
41
|
+
onNewFolder: (parentPath: string) => void;
|
|
42
|
+
onRename: (path: string) => void;
|
|
43
|
+
onDuplicate: (path: string) => void;
|
|
44
|
+
onDelete: (path: string) => void;
|
|
45
|
+
}) {
|
|
46
|
+
const menuRef = useRef<HTMLDivElement>(null);
|
|
47
|
+
|
|
48
|
+
// eslint-disable-next-line no-restricted-syntax
|
|
49
|
+
useEffect(() => {
|
|
50
|
+
const handleClickOutside = (e: MouseEvent) => {
|
|
51
|
+
if (menuRef.current && !menuRef.current.contains(e.target as Node)) {
|
|
52
|
+
onClose();
|
|
53
|
+
}
|
|
54
|
+
};
|
|
55
|
+
const handleEscape = (e: KeyboardEvent) => {
|
|
56
|
+
if (e.key === "Escape") onClose();
|
|
57
|
+
};
|
|
58
|
+
document.addEventListener("mousedown", handleClickOutside);
|
|
59
|
+
document.addEventListener("keydown", handleEscape);
|
|
60
|
+
return () => {
|
|
61
|
+
document.removeEventListener("mousedown", handleClickOutside);
|
|
62
|
+
document.removeEventListener("keydown", handleEscape);
|
|
63
|
+
};
|
|
64
|
+
}, [onClose]);
|
|
65
|
+
|
|
66
|
+
const adjustedX = Math.min(state.x, window.innerWidth - 180);
|
|
67
|
+
const adjustedY = Math.min(state.y, window.innerHeight - 200);
|
|
68
|
+
|
|
69
|
+
const parentPath = state.targetIsFolder
|
|
70
|
+
? state.targetPath
|
|
71
|
+
: state.targetPath.includes("/")
|
|
72
|
+
? state.targetPath.slice(0, state.targetPath.lastIndexOf("/"))
|
|
73
|
+
: "";
|
|
74
|
+
|
|
75
|
+
return (
|
|
76
|
+
<div
|
|
77
|
+
ref={menuRef}
|
|
78
|
+
className="fixed z-50 bg-neutral-900 border border-neutral-700 rounded-md shadow-lg py-1 min-w-[160px]"
|
|
79
|
+
style={{ left: adjustedX, top: adjustedY }}
|
|
80
|
+
>
|
|
81
|
+
{state.targetIsFolder && (
|
|
82
|
+
<>
|
|
83
|
+
<button
|
|
84
|
+
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"
|
|
85
|
+
onClick={() => {
|
|
86
|
+
onNewFile(state.targetPath);
|
|
87
|
+
onClose();
|
|
88
|
+
}}
|
|
89
|
+
>
|
|
90
|
+
<FilePlus size={12} weight="duotone" className="text-neutral-500" />
|
|
91
|
+
New File
|
|
92
|
+
</button>
|
|
93
|
+
<button
|
|
94
|
+
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"
|
|
95
|
+
onClick={() => {
|
|
96
|
+
onNewFolder(state.targetPath);
|
|
97
|
+
onClose();
|
|
98
|
+
}}
|
|
99
|
+
>
|
|
100
|
+
<FolderSimplePlus size={12} weight="duotone" className="text-neutral-500" />
|
|
101
|
+
New Folder
|
|
102
|
+
</button>
|
|
103
|
+
<div className="border-t border-neutral-700 my-1" />
|
|
104
|
+
</>
|
|
105
|
+
)}
|
|
106
|
+
{!state.targetIsFolder && (
|
|
107
|
+
<>
|
|
108
|
+
<button
|
|
109
|
+
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"
|
|
110
|
+
onClick={() => {
|
|
111
|
+
onNewFile(parentPath);
|
|
112
|
+
onClose();
|
|
113
|
+
}}
|
|
114
|
+
>
|
|
115
|
+
<FilePlus size={12} weight="duotone" className="text-neutral-500" />
|
|
116
|
+
New File
|
|
117
|
+
</button>
|
|
118
|
+
<div className="border-t border-neutral-700 my-1" />
|
|
119
|
+
</>
|
|
120
|
+
)}
|
|
121
|
+
<button
|
|
122
|
+
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"
|
|
123
|
+
onClick={() => {
|
|
124
|
+
onRename(state.targetPath);
|
|
125
|
+
onClose();
|
|
126
|
+
}}
|
|
127
|
+
>
|
|
128
|
+
<PencilSimple size={12} weight="duotone" className="text-neutral-500" />
|
|
129
|
+
Rename
|
|
130
|
+
</button>
|
|
131
|
+
{!state.targetIsFolder && (
|
|
132
|
+
<button
|
|
133
|
+
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"
|
|
134
|
+
onClick={() => {
|
|
135
|
+
onDuplicate(state.targetPath);
|
|
136
|
+
onClose();
|
|
137
|
+
}}
|
|
138
|
+
>
|
|
139
|
+
<Copy size={12} weight="duotone" className="text-neutral-500" />
|
|
140
|
+
Duplicate
|
|
141
|
+
</button>
|
|
142
|
+
)}
|
|
143
|
+
<div className="border-t border-neutral-700 my-1" />
|
|
144
|
+
<button
|
|
145
|
+
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"
|
|
146
|
+
onClick={() => {
|
|
147
|
+
onDelete(state.targetPath);
|
|
148
|
+
onClose();
|
|
149
|
+
}}
|
|
150
|
+
>
|
|
151
|
+
<Trash size={12} weight="duotone" />
|
|
152
|
+
Delete
|
|
153
|
+
</button>
|
|
154
|
+
</div>
|
|
155
|
+
);
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
// ── Inline Input (for new file/folder/rename) ──
|
|
159
|
+
|
|
160
|
+
export function InlineInput({
|
|
161
|
+
defaultValue,
|
|
162
|
+
depth,
|
|
163
|
+
isFolder,
|
|
164
|
+
onCommit,
|
|
165
|
+
onCancel,
|
|
166
|
+
}: {
|
|
167
|
+
defaultValue: string;
|
|
168
|
+
depth: number;
|
|
169
|
+
isFolder: boolean;
|
|
170
|
+
onCommit: (value: string) => void;
|
|
171
|
+
onCancel: () => void;
|
|
172
|
+
}) {
|
|
173
|
+
const inputRef = useRef<HTMLInputElement>(null);
|
|
174
|
+
const committedRef = useRef(false);
|
|
175
|
+
const [value, setValue] = useState(defaultValue);
|
|
176
|
+
|
|
177
|
+
// eslint-disable-next-line no-restricted-syntax
|
|
178
|
+
useEffect(() => {
|
|
179
|
+
const el = inputRef.current;
|
|
180
|
+
if (!el) return;
|
|
181
|
+
el.focus();
|
|
182
|
+
if (defaultValue && defaultValue.includes(".")) {
|
|
183
|
+
const dotIdx = defaultValue.lastIndexOf(".");
|
|
184
|
+
el.setSelectionRange(0, dotIdx);
|
|
185
|
+
} else {
|
|
186
|
+
el.select();
|
|
187
|
+
}
|
|
188
|
+
}, [defaultValue]);
|
|
189
|
+
|
|
190
|
+
const commit = (name: string) => {
|
|
191
|
+
if (committedRef.current) return;
|
|
192
|
+
committedRef.current = true;
|
|
193
|
+
onCommit(name);
|
|
194
|
+
};
|
|
195
|
+
|
|
196
|
+
const handleKeyDown = (e: React.KeyboardEvent) => {
|
|
197
|
+
if (e.key === "Enter") {
|
|
198
|
+
e.preventDefault();
|
|
199
|
+
const trimmed = value.trim();
|
|
200
|
+
if (trimmed && !(/[/\\]/.test(trimmed) || trimmed.includes(".."))) commit(trimmed);
|
|
201
|
+
else onCancel();
|
|
202
|
+
} else if (e.key === "Escape") {
|
|
203
|
+
e.preventDefault();
|
|
204
|
+
onCancel();
|
|
205
|
+
}
|
|
206
|
+
};
|
|
207
|
+
|
|
208
|
+
const handleBlur = () => {
|
|
209
|
+
const trimmed = value.trim();
|
|
210
|
+
if (trimmed && trimmed !== defaultValue && !(/[/\\]/.test(trimmed) || trimmed.includes("..")))
|
|
211
|
+
commit(trimmed);
|
|
212
|
+
else onCancel();
|
|
213
|
+
};
|
|
214
|
+
|
|
215
|
+
return (
|
|
216
|
+
<div
|
|
217
|
+
className="flex items-center gap-2 py-0.5 min-h-7"
|
|
218
|
+
style={{ paddingLeft: `${8 + depth * 12 + (isFolder ? 0 : 14)}px` }}
|
|
219
|
+
>
|
|
220
|
+
{isFolder ? (
|
|
221
|
+
<FolderSimple size={SZ_ICON} weight="duotone" color="#6B7280" className="flex-shrink-0" />
|
|
222
|
+
) : (
|
|
223
|
+
<FileIcon path={value} />
|
|
224
|
+
)}
|
|
225
|
+
<input
|
|
226
|
+
ref={inputRef}
|
|
227
|
+
value={value}
|
|
228
|
+
onChange={(e) => setValue(e.target.value)}
|
|
229
|
+
onKeyDown={handleKeyDown}
|
|
230
|
+
onBlur={handleBlur}
|
|
231
|
+
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]"
|
|
232
|
+
spellCheck={false}
|
|
233
|
+
/>
|
|
234
|
+
</div>
|
|
235
|
+
);
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
// ── Delete Confirmation ──
|
|
239
|
+
|
|
240
|
+
export function DeleteConfirm({
|
|
241
|
+
name,
|
|
242
|
+
onConfirm,
|
|
243
|
+
onCancel,
|
|
244
|
+
}: {
|
|
245
|
+
name: string;
|
|
246
|
+
onConfirm: () => void;
|
|
247
|
+
onCancel: () => void;
|
|
248
|
+
}) {
|
|
249
|
+
const ref = useRef<HTMLDivElement>(null);
|
|
250
|
+
|
|
251
|
+
// eslint-disable-next-line no-restricted-syntax
|
|
252
|
+
useEffect(() => {
|
|
253
|
+
const handleEscape = (e: KeyboardEvent) => {
|
|
254
|
+
if (e.key === "Escape") onCancel();
|
|
255
|
+
};
|
|
256
|
+
const handleClickOutside = (e: MouseEvent) => {
|
|
257
|
+
if (ref.current && !ref.current.contains(e.target as Node)) onCancel();
|
|
258
|
+
};
|
|
259
|
+
document.addEventListener("keydown", handleEscape);
|
|
260
|
+
document.addEventListener("mousedown", handleClickOutside);
|
|
261
|
+
return () => {
|
|
262
|
+
document.removeEventListener("keydown", handleEscape);
|
|
263
|
+
document.removeEventListener("mousedown", handleClickOutside);
|
|
264
|
+
};
|
|
265
|
+
}, [onCancel]);
|
|
266
|
+
|
|
267
|
+
return (
|
|
268
|
+
<div
|
|
269
|
+
ref={ref}
|
|
270
|
+
className="mx-1 my-0.5 p-2 bg-neutral-800 border border-neutral-700 rounded-md text-xs"
|
|
271
|
+
>
|
|
272
|
+
<p className="text-neutral-300 mb-2">
|
|
273
|
+
Delete <span className="font-medium text-neutral-100">{name}</span>?
|
|
274
|
+
</p>
|
|
275
|
+
<div className="flex gap-1.5">
|
|
276
|
+
<button
|
|
277
|
+
onClick={onCancel}
|
|
278
|
+
className="flex-1 px-2 py-1 rounded bg-neutral-700 text-neutral-300 hover:bg-neutral-600 transition-colors"
|
|
279
|
+
>
|
|
280
|
+
Cancel
|
|
281
|
+
</button>
|
|
282
|
+
<button
|
|
283
|
+
onClick={onConfirm}
|
|
284
|
+
className="flex-1 px-2 py-1 rounded bg-red-900/60 text-red-300 hover:bg-red-800/60 transition-colors"
|
|
285
|
+
>
|
|
286
|
+
Delete
|
|
287
|
+
</button>
|
|
288
|
+
</div>
|
|
289
|
+
</div>
|
|
290
|
+
);
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
// ── TreeFile ──
|
|
294
|
+
|
|
295
|
+
export const TreeFile = memo(function TreeFile({
|
|
296
|
+
node,
|
|
297
|
+
depth,
|
|
298
|
+
activeFile,
|
|
299
|
+
onSelectFile,
|
|
300
|
+
onContextMenu,
|
|
301
|
+
inlineInput,
|
|
302
|
+
onDragStart,
|
|
303
|
+
}: {
|
|
304
|
+
node: TreeNode;
|
|
305
|
+
depth: number;
|
|
306
|
+
activeFile: string | null;
|
|
307
|
+
onSelectFile: (path: string) => void;
|
|
308
|
+
onContextMenu: (e: React.MouseEvent, path: string, isFolder: boolean) => void;
|
|
309
|
+
inlineInput: InlineInputState | null;
|
|
310
|
+
onDragStart: (e: React.DragEvent, path: string) => void;
|
|
311
|
+
}) {
|
|
312
|
+
const isActive = node.fullPath === activeFile;
|
|
313
|
+
const isRenaming = inlineInput?.mode === "rename" && inlineInput.originalPath === node.fullPath;
|
|
314
|
+
|
|
315
|
+
if (isRenaming) {
|
|
316
|
+
return (
|
|
317
|
+
<InlineInput
|
|
318
|
+
defaultValue={inlineInput.originalName ?? node.name}
|
|
319
|
+
depth={depth}
|
|
320
|
+
isFolder={false}
|
|
321
|
+
onCommit={(name) => {
|
|
322
|
+
inlineInput?.onCommit?.(name);
|
|
323
|
+
}}
|
|
324
|
+
onCancel={() => {
|
|
325
|
+
inlineInput?.onCancel?.();
|
|
326
|
+
}}
|
|
327
|
+
/>
|
|
328
|
+
);
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
return (
|
|
332
|
+
<button
|
|
333
|
+
draggable
|
|
334
|
+
onDragStart={(e) => onDragStart(e, node.fullPath)}
|
|
335
|
+
onClick={() => onSelectFile(node.fullPath)}
|
|
336
|
+
onContextMenu={(e) => {
|
|
337
|
+
e.preventDefault();
|
|
338
|
+
onContextMenu(e, node.fullPath, false);
|
|
339
|
+
}}
|
|
340
|
+
className={`w-full flex items-center gap-2 py-1 min-h-7 text-left transition-all text-xs ${
|
|
341
|
+
isActive
|
|
342
|
+
? "bg-neutral-800/60 text-neutral-200"
|
|
343
|
+
: "text-neutral-500 hover:bg-neutral-800/30 hover:text-neutral-300"
|
|
344
|
+
}`}
|
|
345
|
+
style={{ paddingLeft: `${8 + depth * 12 + 14}px` }}
|
|
346
|
+
>
|
|
347
|
+
<FileIcon path={node.name} />
|
|
348
|
+
<span className="truncate">{node.name}</span>
|
|
349
|
+
</button>
|
|
350
|
+
);
|
|
351
|
+
});
|
|
352
|
+
|
|
353
|
+
// ── TreeFolder ──
|
|
354
|
+
|
|
355
|
+
export const TreeFolder = memo(function TreeFolder({
|
|
356
|
+
node,
|
|
357
|
+
depth,
|
|
358
|
+
activeFile,
|
|
359
|
+
onSelectFile,
|
|
360
|
+
defaultOpen,
|
|
361
|
+
onContextMenu,
|
|
362
|
+
inlineInput,
|
|
363
|
+
onDragStart,
|
|
364
|
+
onDragOver,
|
|
365
|
+
onDrop,
|
|
366
|
+
onDragLeave,
|
|
367
|
+
dragOverFolder,
|
|
368
|
+
}: {
|
|
369
|
+
node: TreeNode;
|
|
370
|
+
depth: number;
|
|
371
|
+
activeFile: string | null;
|
|
372
|
+
onSelectFile: (path: string) => void;
|
|
373
|
+
defaultOpen: boolean;
|
|
374
|
+
onContextMenu: (e: React.MouseEvent, path: string, isFolder: boolean) => void;
|
|
375
|
+
inlineInput: InlineInputState | null;
|
|
376
|
+
onDragStart: (e: React.DragEvent, path: string) => void;
|
|
377
|
+
onDragOver: (e: React.DragEvent, folderPath: string) => void;
|
|
378
|
+
onDrop: (e: React.DragEvent, folderPath: string) => void;
|
|
379
|
+
onDragLeave: () => void;
|
|
380
|
+
dragOverFolder: string | null;
|
|
381
|
+
}) {
|
|
382
|
+
const [isOpen, setIsOpen] = useState(defaultOpen);
|
|
383
|
+
const toggle = useCallback(() => setIsOpen((v) => !v), []);
|
|
384
|
+
const children = useMemo(() => sortChildren(node.children), [node.children]);
|
|
385
|
+
const Chevron = isOpen ? ChevronDown : ChevronRight;
|
|
386
|
+
const isDragOver = dragOverFolder === node.fullPath;
|
|
387
|
+
const isRenaming = inlineInput?.mode === "rename" && inlineInput.originalPath === node.fullPath;
|
|
388
|
+
|
|
389
|
+
if (isRenaming) {
|
|
390
|
+
return (
|
|
391
|
+
<InlineInput
|
|
392
|
+
defaultValue={inlineInput.originalName ?? node.name}
|
|
393
|
+
depth={depth}
|
|
394
|
+
isFolder={true}
|
|
395
|
+
onCommit={(name) => {
|
|
396
|
+
inlineInput?.onCommit?.(name);
|
|
397
|
+
}}
|
|
398
|
+
onCancel={() => {
|
|
399
|
+
inlineInput?.onCancel?.();
|
|
400
|
+
}}
|
|
401
|
+
/>
|
|
402
|
+
);
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
return (
|
|
406
|
+
<>
|
|
407
|
+
<button
|
|
408
|
+
draggable
|
|
409
|
+
onDragStart={(e) => onDragStart(e, node.fullPath)}
|
|
410
|
+
onClick={toggle}
|
|
411
|
+
onContextMenu={(e) => {
|
|
412
|
+
e.preventDefault();
|
|
413
|
+
onContextMenu(e, node.fullPath, true);
|
|
414
|
+
}}
|
|
415
|
+
onDragOver={(e) => {
|
|
416
|
+
e.preventDefault();
|
|
417
|
+
e.stopPropagation();
|
|
418
|
+
onDragOver(e, node.fullPath);
|
|
419
|
+
}}
|
|
420
|
+
onDrop={(e) => {
|
|
421
|
+
e.preventDefault();
|
|
422
|
+
e.stopPropagation();
|
|
423
|
+
onDrop(e, node.fullPath);
|
|
424
|
+
}}
|
|
425
|
+
onDragLeave={onDragLeave}
|
|
426
|
+
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 ${
|
|
427
|
+
isDragOver ? "bg-[#3CE6AC]/10 outline outline-1 outline-[#3CE6AC]/40" : ""
|
|
428
|
+
}`}
|
|
429
|
+
style={{ paddingLeft: `${8 + depth * 12}px` }}
|
|
430
|
+
>
|
|
431
|
+
<Chevron size={10} className="flex-shrink-0 text-neutral-600" />
|
|
432
|
+
<span className="truncate font-medium">{node.name}</span>
|
|
433
|
+
</button>
|
|
434
|
+
{isOpen && (
|
|
435
|
+
<>
|
|
436
|
+
{inlineInput &&
|
|
437
|
+
(inlineInput.mode === "new-file" || inlineInput.mode === "new-folder") &&
|
|
438
|
+
inlineInput.parentPath === node.fullPath && (
|
|
439
|
+
<InlineInput
|
|
440
|
+
defaultValue=""
|
|
441
|
+
depth={depth + 1}
|
|
442
|
+
isFolder={inlineInput.mode === "new-folder"}
|
|
443
|
+
onCommit={(name) => {
|
|
444
|
+
inlineInput?.onCommit?.(name);
|
|
445
|
+
}}
|
|
446
|
+
onCancel={() => {
|
|
447
|
+
inlineInput?.onCancel?.();
|
|
448
|
+
}}
|
|
449
|
+
/>
|
|
450
|
+
)}
|
|
451
|
+
{children.map((child) =>
|
|
452
|
+
child.isFile && child.children.size === 0 ? (
|
|
453
|
+
<TreeFile
|
|
454
|
+
key={child.fullPath}
|
|
455
|
+
node={child}
|
|
456
|
+
depth={depth + 1}
|
|
457
|
+
activeFile={activeFile}
|
|
458
|
+
onSelectFile={onSelectFile}
|
|
459
|
+
onContextMenu={onContextMenu}
|
|
460
|
+
inlineInput={inlineInput}
|
|
461
|
+
onDragStart={onDragStart}
|
|
462
|
+
/>
|
|
463
|
+
) : child.children.size > 0 ? (
|
|
464
|
+
<TreeFolder
|
|
465
|
+
key={child.fullPath}
|
|
466
|
+
node={child}
|
|
467
|
+
depth={depth + 1}
|
|
468
|
+
activeFile={activeFile}
|
|
469
|
+
onSelectFile={onSelectFile}
|
|
470
|
+
defaultOpen={isActiveInSubtree(child, activeFile)}
|
|
471
|
+
onContextMenu={onContextMenu}
|
|
472
|
+
inlineInput={inlineInput}
|
|
473
|
+
onDragStart={onDragStart}
|
|
474
|
+
onDragOver={onDragOver}
|
|
475
|
+
onDrop={onDrop}
|
|
476
|
+
onDragLeave={onDragLeave}
|
|
477
|
+
dragOverFolder={dragOverFolder}
|
|
478
|
+
/>
|
|
479
|
+
) : (
|
|
480
|
+
<TreeFile
|
|
481
|
+
key={child.fullPath}
|
|
482
|
+
node={child}
|
|
483
|
+
depth={depth + 1}
|
|
484
|
+
activeFile={activeFile}
|
|
485
|
+
onSelectFile={onSelectFile}
|
|
486
|
+
onContextMenu={onContextMenu}
|
|
487
|
+
inlineInput={inlineInput}
|
|
488
|
+
onDragStart={onDragStart}
|
|
489
|
+
/>
|
|
490
|
+
),
|
|
491
|
+
)}
|
|
492
|
+
</>
|
|
493
|
+
)}
|
|
494
|
+
</>
|
|
495
|
+
);
|
|
496
|
+
});
|