@hyperframes/studio 0.8.5 → 0.8.7
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-DcM3fVcp.js → hyperframes-player-bDcU464i.js} +1 -1
- package/dist/assets/index-Ba9zbpT9.css +1 -0
- package/dist/assets/index-CUXnyoIa.js +428 -0
- package/dist/assets/{index-DgJrvYGf.js → index-FkAs3zRu.js} +1 -1
- package/dist/assets/{index-Del6XWZj.js → index-MSOSP1-J.js} +1 -1
- package/dist/index.d.ts +98 -17
- package/dist/index.html +2 -2
- package/dist/index.js +10337 -7200
- package/dist/index.js.map +1 -1
- package/package.json +7 -7
- package/src/App.tsx +22 -23
- package/src/captions/components/CaptionAnimationPanel.tsx +46 -13
- package/src/captions/components/CaptionOverlay.tsx +172 -29
- package/src/captions/components/CaptionOverlayUtils.ts +54 -0
- package/src/captions/components/CaptionPropertyPanel.tsx +49 -23
- package/src/captions/components/CaptionTimeline.tsx +36 -79
- package/src/captions/components/shared.tsx +76 -1
- package/src/captions/hooks/useCaptionSync.ts +72 -13
- package/src/captions/keyboard.test.ts +12 -0
- package/src/captions/keyboard.ts +12 -1
- package/src/captions/store.ts +120 -14
- package/src/components/EditorShell.selectionSync.test.tsx +1 -1
- package/src/components/EditorShell.tsx +13 -1
- package/src/components/StudioRightPanel.tsx +2 -0
- package/src/components/StudioRightPanel.types.ts +1 -0
- package/src/components/editor/AnimationCard.tsx +2 -1
- package/src/components/editor/AnimationCardParts.tsx +6 -3
- package/src/components/editor/ArcPathControls.tsx +10 -4
- package/src/components/editor/BlockParamsPanel.test.tsx +170 -0
- package/src/components/editor/BlockParamsPanel.tsx +153 -11
- package/src/components/editor/BorderRadiusEditor.tsx +3 -22
- package/src/components/editor/FileTree.tsx +41 -12
- package/src/components/editor/FileTreeNodes.tsx +90 -20
- package/src/components/editor/GsapAnimationList.tsx +5 -0
- package/src/components/editor/KeyframeDiamond.tsx +3 -1
- package/src/components/editor/KeyframeEaseList.tsx +3 -2
- package/src/components/editor/KeyframeNavigation.tsx +6 -2
- package/src/components/editor/LayersPanel.tsx +11 -2
- package/src/components/editor/MotionPathNode.tsx +1 -0
- package/src/components/editor/PropertyPanel.test.tsx +7 -6
- package/src/components/editor/PropertyPanel.tsx +13 -5
- package/src/components/editor/PropertyPanelFlat.tsx +2 -0
- package/src/components/editor/SnapToolbar.tsx +20 -4
- package/src/components/editor/TimelineFxPopover.test.tsx +113 -0
- package/src/components/editor/TimelineFxPopover.tsx +138 -0
- package/src/components/editor/Transform3DCube.tsx +22 -1
- package/src/components/editor/manualOffsetDrag.test.ts +49 -0
- package/src/components/editor/manualOffsetDrag.ts +13 -1
- package/src/components/editor/propertyPanel3dTransform.tsx +11 -3
- package/src/components/editor/propertyPanelAudioFxGroup.test.tsx +139 -3
- package/src/components/editor/propertyPanelAudioFxGroup.tsx +4 -0
- package/src/components/editor/propertyPanelColor.tsx +9 -1
- package/src/components/editor/propertyPanelColorGradingControls.tsx +28 -7
- package/src/components/editor/propertyPanelCommitField.tsx +9 -1
- package/src/components/editor/propertyPanelFill.tsx +50 -2
- package/src/components/editor/propertyPanelFlatProps.ts +1 -0
- package/src/components/editor/propertyPanelFont.tsx +45 -9
- package/src/components/editor/propertyPanelFxPresetStyle.ts +18 -0
- package/src/components/editor/propertyPanelFxSection.test.tsx +3 -3
- package/src/components/editor/propertyPanelFxSection.tsx +8 -23
- package/src/components/editor/propertyPanelPrimitives.tsx +9 -12
- package/src/components/editor/propertyPanelStyleSections.tsx +21 -2
- package/src/components/editor/propertyPanelTransformCommit.ts +3 -3
- package/src/components/editor/propertyPanelTypes.ts +2 -0
- package/src/components/editor/useApplyAudioFxPreset.ts +36 -0
- package/src/components/editor/useFxCarve.ts +0 -0
- package/src/components/editor/useFxCarveGrouping.ts +132 -0
- package/src/components/nle/PreviewOverlays.tsx +69 -2
- package/src/components/nle/PreviewPane.tsx +1 -0
- package/src/components/nle/useTimelineEditCallbacks.test.tsx +1 -0
- package/src/components/nle/useTimelineEditCallbacks.ts +27 -0
- package/src/components/panels/SlideshowPanel.tsx +81 -12
- package/src/components/panels/SlideshowSubPanels.tsx +61 -7
- package/src/components/sidebar/AssetCard.test.tsx +2 -2
- package/src/components/sidebar/AssetCard.tsx +94 -46
- package/src/components/sidebar/AssetContextMenu.tsx +196 -42
- package/src/components/sidebar/AssetsTab.tsx +156 -96
- package/src/components/sidebar/AudioRow.tsx +95 -40
- package/src/components/sidebar/BlocksTab.tsx +40 -128
- package/src/components/sidebar/CompositionsTab.tsx +46 -34
- package/src/components/sidebar/LeftSidebar.tsx +87 -75
- package/src/components/sidebar/PromptPreviewModal.tsx +126 -0
- package/src/components/sidebar/assetHelpers.ts +8 -0
- package/src/contexts/TimelineEditContext.tsx +5 -0
- package/src/hooks/timelineAudioGroupVolume.ts +148 -0
- package/src/hooks/timelineEditingHelpers.ts +78 -1
- package/src/hooks/timelineElementFxAttribute.ts +150 -0
- package/src/hooks/timelineTrackVisibility.test.ts +222 -1
- package/src/hooks/timelineTrackVisibility.ts +187 -4
- package/src/hooks/useAppHotkeys.ts +22 -0
- package/src/hooks/useAudioSoloBridge.ts +61 -0
- package/src/hooks/useCaptionDetection.ts +22 -2
- package/src/hooks/useDomEditCommits.test.tsx +24 -0
- package/src/hooks/useEffectiveTimelineDuration.ts +20 -0
- package/src/hooks/useGroupLevel.ts +36 -0
- package/src/hooks/useHydrateActiveCompPathFromUrl.ts +36 -0
- package/src/hooks/useMusicBeatAnalysis.ts +13 -0
- package/src/hooks/useTimelineDeleteOps.ts +170 -0
- package/src/hooks/useTimelineEditing.ts +53 -129
- package/src/player/components/AudioWaveform.tsx +29 -26
- package/src/player/components/BeatStrip.test.tsx +5 -9
- package/src/player/components/BeatStrip.tsx +12 -5
- package/src/player/components/ClipContextMenu.tsx +8 -2
- package/src/player/components/EditModal.tsx +48 -7
- package/src/player/components/KeyframeDiamondContextMenu.tsx +90 -10
- package/src/player/components/LayerDisclosureRow.tsx +12 -13
- package/src/player/components/Player.tsx +43 -8
- package/src/player/components/PlayerControls.tsx +107 -63
- package/src/player/components/ShortcutsPanel.tsx +23 -4
- package/src/player/components/SpeedMenu.tsx +34 -42
- package/src/player/components/Timeline.test.ts +7 -7
- package/src/player/components/Timeline.tsx +7 -10
- package/src/player/components/TimelineFxButton.test.tsx +81 -0
- package/src/player/components/TimelineFxButton.tsx +143 -0
- package/src/player/components/TimelineGroupBusStrip.test.tsx +146 -0
- package/src/player/components/TimelineGroupBusStrip.tsx +103 -0
- package/src/player/components/TimelineGroupHeader.tsx +176 -0
- package/src/player/components/TimelineGroupRow.tsx +146 -0
- package/src/player/components/TimelineLanes.test.tsx +5 -0
- package/src/player/components/TimelineLanes.tsx +39 -33
- package/src/player/components/TimelineOverlays.tsx +34 -0
- package/src/player/components/TimelineSoloButton.tsx +32 -0
- package/src/player/components/TimelineTrackHeader.tsx +74 -98
- package/src/player/components/TimelineTrackPlainHeader.tsx +119 -0
- package/src/player/components/VideoThumbnail.tsx +6 -1
- package/src/player/components/menuKeyboardNav.ts +47 -0
- package/src/player/components/timelineCallbacks.ts +26 -0
- package/src/player/components/timelineKeyboardNavigation.test.ts +8 -0
- package/src/player/components/timelineKeyboardNavigation.ts +147 -30
- package/src/player/components/timelineLaneProps.ts +3 -0
- package/src/player/components/timelineLayout.ts +1 -1
- package/src/player/components/timelineNavigationIdentity.ts +4 -0
- package/src/player/components/useTimelineLaneRowIndexes.ts +38 -0
- package/src/player/components/useTimelineLogicalFocus.ts +9 -0
- package/src/player/components/useTimelineLogicalRows.test.tsx +8 -0
- package/src/player/components/useTimelineLogicalRows.ts +17 -12
- package/src/player/components/useTimelineMultiDragActorWindows.ts +30 -0
- package/src/player/components/useTimelineTrackDerivations.ts +150 -6
- package/src/player/components/useTimelineTrackLayout.ts +61 -4
- package/src/player/hooks/previewMessageRouter.ts +127 -0
- package/src/player/hooks/useExpandedTimelineElements.test.ts +64 -17
- package/src/player/hooks/useExpandedTimelineElements.ts +8 -11
- package/src/player/hooks/useTimelinePlayer.ts +9 -47
- package/src/player/lib/timelineDOM.ts +51 -0
- package/src/player/store/audioSoloSlice.test.ts +113 -0
- package/src/player/store/audioSoloSlice.ts +43 -0
- package/src/player/store/editingModeSlice.ts +45 -0
- package/src/player/store/groupLevels.ts +33 -0
- package/src/player/store/keyframeSlice.ts +26 -0
- package/src/player/store/playerStore.ts +21 -22
- package/src/player/store/timelineElement.ts +10 -0
- package/src/telemetry/events.ts +12 -0
- package/dist/assets/index-Cx_UrhoW.js +0 -428
- package/dist/assets/index-DcF4s1PG.css +0 -1
|
@@ -1,5 +1,8 @@
|
|
|
1
|
-
import { memo,
|
|
1
|
+
import { memo, useCallback, useEffect, useRef, useState } from "react";
|
|
2
2
|
import type { BlockParam } from "@hyperframes/core/registry";
|
|
3
|
+
import { useFileManagerContextOptional } from "../../contexts/FileManagerContext";
|
|
4
|
+
import { useStudioPlaybackContext } from "../../contexts/StudioContext";
|
|
5
|
+
import { trackBlockParamCommit } from "../../telemetry/events";
|
|
3
6
|
|
|
4
7
|
interface BlockParamsPanelProps {
|
|
5
8
|
blockName: string;
|
|
@@ -9,12 +12,17 @@ interface BlockParamsPanelProps {
|
|
|
9
12
|
onClose: () => void;
|
|
10
13
|
}
|
|
11
14
|
|
|
15
|
+
type CommitState = { tone: "idle" | "saving" | "saved" | "error"; message?: string };
|
|
16
|
+
|
|
12
17
|
export const BlockParamsPanel = memo(function BlockParamsPanel({
|
|
18
|
+
blockName,
|
|
13
19
|
blockTitle,
|
|
14
20
|
params,
|
|
15
|
-
compositionPath
|
|
21
|
+
compositionPath,
|
|
16
22
|
onClose,
|
|
17
23
|
}: BlockParamsPanelProps) {
|
|
24
|
+
const fileManager = useFileManagerContextOptional();
|
|
25
|
+
const { setRefreshKey } = useStudioPlaybackContext();
|
|
18
26
|
const [values, setValues] = useState<Record<string, string>>(() => {
|
|
19
27
|
const initial: Record<string, string> = {};
|
|
20
28
|
for (const p of params) {
|
|
@@ -22,10 +30,107 @@ export const BlockParamsPanel = memo(function BlockParamsPanel({
|
|
|
22
30
|
}
|
|
23
31
|
return initial;
|
|
24
32
|
});
|
|
33
|
+
// Last value actually written to the block file per param — the literal we
|
|
34
|
+
// substitute when the next commit rewrites the file.
|
|
35
|
+
const appliedRef = useRef<Record<string, string>>(
|
|
36
|
+
Object.fromEntries(params.map((p) => [p.key, p.default])),
|
|
37
|
+
);
|
|
38
|
+
const [commitState, setCommitState] = useState<CommitState>({ tone: "idle" });
|
|
39
|
+
// Per-param debounce timers: a single shared timer would let a second
|
|
40
|
+
// param's edit silently cancel the first param's pending commit.
|
|
41
|
+
const commitTimersRef = useRef<Map<string, ReturnType<typeof setTimeout>>>(new Map());
|
|
25
42
|
|
|
26
|
-
const
|
|
27
|
-
|
|
28
|
-
|
|
43
|
+
const commitParamNow = useCallback(
|
|
44
|
+
async (key: string, nextValue: string) => {
|
|
45
|
+
if (!fileManager) return;
|
|
46
|
+
const previous = appliedRef.current[key];
|
|
47
|
+
if (previous === undefined || previous === nextValue || !nextValue.trim()) return;
|
|
48
|
+
setCommitState({ tone: "saving" });
|
|
49
|
+
try {
|
|
50
|
+
const content = await fileManager.readProjectFile(compositionPath);
|
|
51
|
+
// Token-boundary match so "#0c0c0c" never rewrites part of "#0c0c0cff".
|
|
52
|
+
const matcher = new RegExp(
|
|
53
|
+
`(?<![-\\w#])${previous.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}(?![-\\w])`,
|
|
54
|
+
"g",
|
|
55
|
+
);
|
|
56
|
+
const matches = content.match(matcher)?.length ?? 0;
|
|
57
|
+
if (matches === 0) {
|
|
58
|
+
setCommitState({
|
|
59
|
+
tone: "error",
|
|
60
|
+
message: `Couldn't find the current value in ${compositionPath} — it may have been edited by hand.`,
|
|
61
|
+
});
|
|
62
|
+
trackBlockParamCommit({ tone: "error", blockName, key });
|
|
63
|
+
return;
|
|
64
|
+
}
|
|
65
|
+
// The panel maps a param to a bare literal, with no per-occurrence
|
|
66
|
+
// binding metadata. At the string level it cannot tell "both of these
|
|
67
|
+
// belong to this param" from "one is unrelated adjacent content" (a
|
|
68
|
+
// sibling block, a decoration, a comment). A blind replace of a literal
|
|
69
|
+
// that appears more than once could silently mutate that unrelated
|
|
70
|
+
// content — so refuse and tell the user to disambiguate by hand. Only a
|
|
71
|
+
// unique single occurrence is safe to rewrite automatically.
|
|
72
|
+
if (matches > 1) {
|
|
73
|
+
setCommitState({
|
|
74
|
+
tone: "error",
|
|
75
|
+
message: `"${previous}" appears ${matches}× in ${compositionPath} — the panel can't tell which one belongs to this parameter, so it won't risk changing unrelated content. Edit the file directly to disambiguate.`,
|
|
76
|
+
});
|
|
77
|
+
trackBlockParamCommit({ tone: "error", blockName, key });
|
|
78
|
+
return;
|
|
79
|
+
}
|
|
80
|
+
await fileManager.writeProjectFile(compositionPath, content.replace(matcher, nextValue));
|
|
81
|
+
appliedRef.current[key] = nextValue;
|
|
82
|
+
setCommitState({ tone: "saved" });
|
|
83
|
+
trackBlockParamCommit({ tone: "saved", blockName, key });
|
|
84
|
+
setRefreshKey((k) => k + 1);
|
|
85
|
+
} catch {
|
|
86
|
+
setCommitState({ tone: "error", message: "Couldn't save the block file. Retry?" });
|
|
87
|
+
trackBlockParamCommit({ tone: "error", blockName, key });
|
|
88
|
+
}
|
|
89
|
+
},
|
|
90
|
+
[fileManager, compositionPath, setRefreshKey, blockName],
|
|
91
|
+
);
|
|
92
|
+
|
|
93
|
+
// Commits are serialized: two params committing concurrently would each
|
|
94
|
+
// read-modify-write the same file and the second write would drop the first.
|
|
95
|
+
const commitChainRef = useRef<Promise<void>>(Promise.resolve());
|
|
96
|
+
const commitParam = useCallback(
|
|
97
|
+
(key: string, nextValue: string) => {
|
|
98
|
+
const run = commitChainRef.current.then(() => commitParamNow(key, nextValue));
|
|
99
|
+
commitChainRef.current = run.catch(() => undefined);
|
|
100
|
+
return run;
|
|
101
|
+
},
|
|
102
|
+
[commitParamNow],
|
|
103
|
+
);
|
|
104
|
+
|
|
105
|
+
const handleChange = useCallback(
|
|
106
|
+
(key: string, value: string) => {
|
|
107
|
+
setValues((prev) => ({ ...prev, [key]: value }));
|
|
108
|
+
const timers = commitTimersRef.current;
|
|
109
|
+
const existing = timers.get(key);
|
|
110
|
+
if (existing) clearTimeout(existing);
|
|
111
|
+
timers.set(
|
|
112
|
+
key,
|
|
113
|
+
setTimeout(() => {
|
|
114
|
+
timers.delete(key);
|
|
115
|
+
void commitParam(key, value);
|
|
116
|
+
}, 300),
|
|
117
|
+
);
|
|
118
|
+
},
|
|
119
|
+
[commitParam],
|
|
120
|
+
);
|
|
121
|
+
|
|
122
|
+
// Flush (not discard) pending commits on unmount so closing the panel
|
|
123
|
+
// within the debounce window doesn't silently drop the last edit.
|
|
124
|
+
const flushRef = useRef<() => void>(() => {});
|
|
125
|
+
flushRef.current = () => {
|
|
126
|
+
for (const [key, timer] of commitTimersRef.current) {
|
|
127
|
+
clearTimeout(timer);
|
|
128
|
+
const value = values[key];
|
|
129
|
+
if (value !== undefined) void commitParam(key, value);
|
|
130
|
+
}
|
|
131
|
+
commitTimersRef.current.clear();
|
|
132
|
+
};
|
|
133
|
+
useEffect(() => () => flushRef.current(), []);
|
|
29
134
|
|
|
30
135
|
return (
|
|
31
136
|
<div className="flex flex-col h-full">
|
|
@@ -34,7 +139,8 @@ export const BlockParamsPanel = memo(function BlockParamsPanel({
|
|
|
34
139
|
<button
|
|
35
140
|
type="button"
|
|
36
141
|
onClick={onClose}
|
|
37
|
-
|
|
142
|
+
aria-label="Close block parameters"
|
|
143
|
+
className="p-1.5 -m-1 text-neutral-500 hover:text-neutral-300 active:scale-[0.97] transition-colors"
|
|
38
144
|
>
|
|
39
145
|
<svg
|
|
40
146
|
width="14"
|
|
@@ -56,14 +162,38 @@ export const BlockParamsPanel = memo(function BlockParamsPanel({
|
|
|
56
162
|
<div className="text-[9px] font-medium text-neutral-500 uppercase tracking-wider">
|
|
57
163
|
Parameters
|
|
58
164
|
</div>
|
|
165
|
+
{params.length === 0 && (
|
|
166
|
+
<div className="text-[10px] text-neutral-500">This block has no editable parameters.</div>
|
|
167
|
+
)}
|
|
168
|
+
{!fileManager && params.length > 0 && (
|
|
169
|
+
<div className="text-[10px] text-amber-400/90">
|
|
170
|
+
Block params can't be edited here — no project file access.
|
|
171
|
+
</div>
|
|
172
|
+
)}
|
|
59
173
|
{params.map((param) => (
|
|
60
174
|
<ParamControl
|
|
61
175
|
key={param.key}
|
|
62
176
|
param={param}
|
|
63
177
|
value={values[param.key] ?? param.default}
|
|
178
|
+
disabled={!fileManager}
|
|
64
179
|
onChange={(v) => handleChange(param.key, v)}
|
|
65
180
|
/>
|
|
66
181
|
))}
|
|
182
|
+
{commitState.tone === "saving" && (
|
|
183
|
+
<div className="text-[10px] text-neutral-500" role="status">
|
|
184
|
+
Saving…
|
|
185
|
+
</div>
|
|
186
|
+
)}
|
|
187
|
+
{commitState.tone === "saved" && (
|
|
188
|
+
<div className="text-[10px] text-emerald-500/90" role="status">
|
|
189
|
+
Saved to {compositionPath}
|
|
190
|
+
</div>
|
|
191
|
+
)}
|
|
192
|
+
{commitState.tone === "error" && (
|
|
193
|
+
<div className="text-[10px] text-red-400" role="alert">
|
|
194
|
+
{commitState.message}
|
|
195
|
+
</div>
|
|
196
|
+
)}
|
|
67
197
|
</div>
|
|
68
198
|
</div>
|
|
69
199
|
);
|
|
@@ -72,10 +202,12 @@ export const BlockParamsPanel = memo(function BlockParamsPanel({
|
|
|
72
202
|
function ParamControl({
|
|
73
203
|
param,
|
|
74
204
|
value,
|
|
205
|
+
disabled,
|
|
75
206
|
onChange,
|
|
76
207
|
}: {
|
|
77
208
|
param: BlockParam;
|
|
78
209
|
value: string;
|
|
210
|
+
disabled?: boolean;
|
|
79
211
|
onChange: (value: string) => void;
|
|
80
212
|
}) {
|
|
81
213
|
return (
|
|
@@ -87,14 +219,18 @@ function ParamControl({
|
|
|
87
219
|
<input
|
|
88
220
|
type="color"
|
|
89
221
|
value={value}
|
|
222
|
+
disabled={disabled}
|
|
223
|
+
aria-label={`${param.label} color`}
|
|
90
224
|
onChange={(e) => onChange(e.target.value)}
|
|
91
|
-
className="w-7 h-7 rounded border border-neutral-700 bg-transparent cursor-pointer"
|
|
225
|
+
className="w-7 h-7 rounded border border-neutral-700 bg-transparent cursor-pointer disabled:cursor-not-allowed disabled:opacity-50"
|
|
92
226
|
/>
|
|
93
227
|
<input
|
|
94
228
|
type="text"
|
|
95
229
|
value={value}
|
|
230
|
+
disabled={disabled}
|
|
231
|
+
aria-label={`${param.label} value`}
|
|
96
232
|
onChange={(e) => onChange(e.target.value)}
|
|
97
|
-
className="flex-1 bg-neutral-900 border border-neutral-800 rounded px-2 py-1 text-[10px] text-neutral-200 font-mono focus:outline-none focus:border-neutral-700"
|
|
233
|
+
className="flex-1 bg-neutral-900 border border-neutral-800 rounded px-2 py-1 text-[10px] text-neutral-200 font-mono focus:outline-none focus:border-neutral-700 disabled:cursor-not-allowed disabled:opacity-50"
|
|
98
234
|
/>
|
|
99
235
|
</div>
|
|
100
236
|
)}
|
|
@@ -107,8 +243,10 @@ function ParamControl({
|
|
|
107
243
|
max={param.max ?? 100}
|
|
108
244
|
step={param.step ?? 1}
|
|
109
245
|
value={value}
|
|
246
|
+
disabled={disabled}
|
|
247
|
+
aria-label={param.label}
|
|
110
248
|
onChange={(e) => onChange(e.target.value)}
|
|
111
|
-
className="flex-1"
|
|
249
|
+
className="flex-1 disabled:cursor-not-allowed disabled:opacity-50"
|
|
112
250
|
/>
|
|
113
251
|
<span className="text-[10px] text-neutral-400 w-8 text-right tabular-nums">{value}</span>
|
|
114
252
|
</div>
|
|
@@ -118,16 +256,20 @@ function ParamControl({
|
|
|
118
256
|
<input
|
|
119
257
|
type="text"
|
|
120
258
|
value={value}
|
|
259
|
+
disabled={disabled}
|
|
260
|
+
aria-label={param.label}
|
|
121
261
|
onChange={(e) => onChange(e.target.value)}
|
|
122
|
-
className="w-full bg-neutral-900 border border-neutral-800 rounded px-2 py-1 text-[10px] text-neutral-200 focus:outline-none focus:border-neutral-700"
|
|
262
|
+
className="w-full bg-neutral-900 border border-neutral-800 rounded px-2 py-1 text-[10px] text-neutral-200 focus:outline-none focus:border-neutral-700 disabled:cursor-not-allowed disabled:opacity-50"
|
|
123
263
|
/>
|
|
124
264
|
)}
|
|
125
265
|
|
|
126
266
|
{param.type === "select" && param.options && (
|
|
127
267
|
<select
|
|
128
268
|
value={value}
|
|
269
|
+
disabled={disabled}
|
|
270
|
+
aria-label={param.label}
|
|
129
271
|
onChange={(e) => onChange(e.target.value)}
|
|
130
|
-
className="w-full bg-neutral-900 border border-neutral-800 rounded px-2 py-1 text-[10px] text-neutral-200 focus:outline-none focus:border-neutral-700"
|
|
272
|
+
className="w-full bg-neutral-900 border border-neutral-800 rounded px-2 py-1 text-[10px] text-neutral-200 focus:outline-none focus:border-neutral-700 disabled:cursor-not-allowed disabled:opacity-50"
|
|
131
273
|
>
|
|
132
274
|
{param.options.map((opt) => (
|
|
133
275
|
<option key={opt.value} value={opt.value}>
|
|
@@ -79,34 +79,15 @@ export function BorderRadiusEditor({
|
|
|
79
79
|
stroke="rgba(255,255,255,0.24)"
|
|
80
80
|
strokeWidth={1.5}
|
|
81
81
|
/>
|
|
82
|
-
<circle
|
|
83
|
-
|
|
84
|
-
cy={sTL}
|
|
85
|
-
r={3}
|
|
86
|
-
fill={linked ? "#3b82f6" : "#a78bfa"}
|
|
87
|
-
className="cursor-pointer"
|
|
88
|
-
/>
|
|
89
|
-
<circle
|
|
90
|
-
cx={PREVIEW_W - sTR}
|
|
91
|
-
cy={sTR}
|
|
92
|
-
r={3}
|
|
93
|
-
fill={linked ? "#3b82f6" : "#a78bfa"}
|
|
94
|
-
className="cursor-pointer"
|
|
95
|
-
/>
|
|
82
|
+
<circle cx={sTL} cy={sTL} r={3} fill={linked ? "#3b82f6" : "#a78bfa"} />
|
|
83
|
+
<circle cx={PREVIEW_W - sTR} cy={sTR} r={3} fill={linked ? "#3b82f6" : "#a78bfa"} />
|
|
96
84
|
<circle
|
|
97
85
|
cx={PREVIEW_W - sBR}
|
|
98
86
|
cy={PREVIEW_H - sBR}
|
|
99
87
|
r={3}
|
|
100
88
|
fill={linked ? "#3b82f6" : "#a78bfa"}
|
|
101
|
-
className="cursor-pointer"
|
|
102
|
-
/>
|
|
103
|
-
<circle
|
|
104
|
-
cx={sBL}
|
|
105
|
-
cy={PREVIEW_H - sBL}
|
|
106
|
-
r={3}
|
|
107
|
-
fill={linked ? "#3b82f6" : "#a78bfa"}
|
|
108
|
-
className="cursor-pointer"
|
|
109
89
|
/>
|
|
90
|
+
<circle cx={sBL} cy={PREVIEW_H - sBL} r={3} fill={linked ? "#3b82f6" : "#a78bfa"} />
|
|
110
91
|
</svg>
|
|
111
92
|
|
|
112
93
|
<button
|
|
@@ -49,7 +49,12 @@ export const FileTree = memo(function FileTree({
|
|
|
49
49
|
|
|
50
50
|
const [contextMenu, setContextMenu] = useState<ContextMenuState | null>(null);
|
|
51
51
|
const [inlineInput, setInlineInput] = useState<InlineInputState | null>(null);
|
|
52
|
-
const [deleteTarget, setDeleteTarget] = useState<
|
|
52
|
+
const [deleteTarget, setDeleteTarget] = useState<{
|
|
53
|
+
path: string;
|
|
54
|
+
isFolder: boolean;
|
|
55
|
+
x: number;
|
|
56
|
+
y: number;
|
|
57
|
+
} | null>(null);
|
|
53
58
|
const [dragOverFolder, setDragOverFolder] = useState<string | null>(null);
|
|
54
59
|
const dragSourceRef = useRef<string | null>(null);
|
|
55
60
|
|
|
@@ -145,13 +150,23 @@ export const FileTree = memo(function FileTree({
|
|
|
145
150
|
|
|
146
151
|
// ── Delete ──
|
|
147
152
|
|
|
148
|
-
const handleDelete = useCallback(
|
|
149
|
-
|
|
150
|
-
|
|
153
|
+
const handleDelete = useCallback(
|
|
154
|
+
(path: string) => {
|
|
155
|
+
// Anchor the confirm near where the context menu was opened so it
|
|
156
|
+
// appears next to the row the user acted on.
|
|
157
|
+
setDeleteTarget({
|
|
158
|
+
path,
|
|
159
|
+
isFolder: contextMenu?.targetIsFolder ?? false,
|
|
160
|
+
x: contextMenu?.x ?? window.innerWidth / 2,
|
|
161
|
+
y: contextMenu?.y ?? window.innerHeight / 2,
|
|
162
|
+
});
|
|
163
|
+
},
|
|
164
|
+
[contextMenu],
|
|
165
|
+
);
|
|
151
166
|
|
|
152
167
|
const handleDeleteConfirm = useCallback(() => {
|
|
153
168
|
if (deleteTarget) {
|
|
154
|
-
onDeleteFile?.(deleteTarget);
|
|
169
|
+
onDeleteFile?.(deleteTarget.path);
|
|
155
170
|
setDeleteTarget(null);
|
|
156
171
|
}
|
|
157
172
|
}, [deleteTarget, onDeleteFile]);
|
|
@@ -227,15 +242,17 @@ export const FileTree = memo(function FileTree({
|
|
|
227
242
|
<div className="flex items-center gap-0.5">
|
|
228
243
|
<button
|
|
229
244
|
onClick={() => handleNewFile("")}
|
|
230
|
-
className="p-
|
|
245
|
+
className="p-1.5 rounded hover:bg-neutral-800 text-neutral-600 hover:text-neutral-400 active:scale-[0.97] transition-colors"
|
|
231
246
|
title="New File"
|
|
247
|
+
aria-label="New File"
|
|
232
248
|
>
|
|
233
249
|
<Plus size={12} weight="bold" />
|
|
234
250
|
</button>
|
|
235
251
|
<button
|
|
236
252
|
onClick={() => handleNewFolder("")}
|
|
237
|
-
className="p-
|
|
253
|
+
className="p-1.5 rounded hover:bg-neutral-800 text-neutral-600 hover:text-neutral-400 active:scale-[0.97] transition-colors"
|
|
238
254
|
title="New Folder"
|
|
255
|
+
aria-label="New Folder"
|
|
239
256
|
>
|
|
240
257
|
<FolderSimplePlus size={12} weight="duotone" />
|
|
241
258
|
</button>
|
|
@@ -274,6 +291,11 @@ export const FileTree = memo(function FileTree({
|
|
|
274
291
|
onCancel={() => inlineInput.onCancel?.()}
|
|
275
292
|
/>
|
|
276
293
|
)}
|
|
294
|
+
{children.length === 0 && !inlineInput && (
|
|
295
|
+
<div className="px-3 py-4 text-center text-[11px] text-neutral-600">
|
|
296
|
+
No files yet{hasFileOps ? " — use + above to create one" : ""}.
|
|
297
|
+
</div>
|
|
298
|
+
)}
|
|
277
299
|
{children.map((child) =>
|
|
278
300
|
child.isFile && child.children.size === 0 ? (
|
|
279
301
|
<TreeFile
|
|
@@ -308,15 +330,22 @@ export const FileTree = memo(function FileTree({
|
|
|
308
330
|
)}
|
|
309
331
|
</div>
|
|
310
332
|
|
|
311
|
-
{/* Delete confirmation
|
|
333
|
+
{/* Delete confirmation — anchored near the row it was invoked on */}
|
|
312
334
|
{deleteTarget && (
|
|
313
|
-
<div
|
|
335
|
+
<div
|
|
336
|
+
className="fixed z-50 w-56"
|
|
337
|
+
style={{
|
|
338
|
+
left: Math.min(deleteTarget.x, window.innerWidth - 240),
|
|
339
|
+
top: Math.min(deleteTarget.y, window.innerHeight - 120),
|
|
340
|
+
}}
|
|
341
|
+
>
|
|
314
342
|
<DeleteConfirm
|
|
315
343
|
name={
|
|
316
|
-
deleteTarget.includes("/")
|
|
317
|
-
? deleteTarget.slice(deleteTarget.lastIndexOf("/") + 1)
|
|
318
|
-
: deleteTarget
|
|
344
|
+
deleteTarget.path.includes("/")
|
|
345
|
+
? deleteTarget.path.slice(deleteTarget.path.lastIndexOf("/") + 1)
|
|
346
|
+
: deleteTarget.path
|
|
319
347
|
}
|
|
348
|
+
isFolder={deleteTarget.isFolder}
|
|
320
349
|
onConfirm={handleDeleteConfirm}
|
|
321
350
|
onCancel={handleDeleteCancel}
|
|
322
351
|
/>
|
|
@@ -43,9 +43,14 @@ export function ContextMenu({
|
|
|
43
43
|
onDelete: (path: string) => void;
|
|
44
44
|
}) {
|
|
45
45
|
const menuRef = useRef<HTMLDivElement>(null);
|
|
46
|
+
const restoreFocusRef = useRef<HTMLElement | null>(null);
|
|
46
47
|
|
|
47
48
|
// eslint-disable-next-line no-restricted-syntax
|
|
48
49
|
useEffect(() => {
|
|
50
|
+
restoreFocusRef.current =
|
|
51
|
+
document.activeElement instanceof HTMLElement ? document.activeElement : null;
|
|
52
|
+
const firstItem = menuRef.current?.querySelector("button");
|
|
53
|
+
if (firstItem instanceof HTMLElement) firstItem.focus();
|
|
49
54
|
const handleClickOutside = (e: MouseEvent) => {
|
|
50
55
|
if (menuRef.current && !menuRef.current.contains(e.target as Node)) {
|
|
51
56
|
onClose();
|
|
@@ -59,9 +64,28 @@ export function ContextMenu({
|
|
|
59
64
|
return () => {
|
|
60
65
|
document.removeEventListener("mousedown", handleClickOutside);
|
|
61
66
|
document.removeEventListener("keydown", handleEscape);
|
|
67
|
+
restoreFocusRef.current?.focus();
|
|
62
68
|
};
|
|
63
69
|
}, [onClose]);
|
|
64
70
|
|
|
71
|
+
const handleMenuKeyDown = (e: React.KeyboardEvent) => {
|
|
72
|
+
if (e.key !== "ArrowDown" && e.key !== "ArrowUp" && e.key !== "Home" && e.key !== "End") {
|
|
73
|
+
return;
|
|
74
|
+
}
|
|
75
|
+
const menu = menuRef.current;
|
|
76
|
+
if (!menu) return;
|
|
77
|
+
e.preventDefault();
|
|
78
|
+
const items = Array.from(menu.querySelectorAll("button"));
|
|
79
|
+
if (items.length === 0) return;
|
|
80
|
+
const current = items.indexOf(document.activeElement as HTMLButtonElement);
|
|
81
|
+
let next = 0;
|
|
82
|
+
if (e.key === "ArrowDown") next = current < 0 ? 0 : (current + 1) % items.length;
|
|
83
|
+
else if (e.key === "ArrowUp") {
|
|
84
|
+
next = current < 0 ? items.length - 1 : (current - 1 + items.length) % items.length;
|
|
85
|
+
} else if (e.key === "End") next = items.length - 1;
|
|
86
|
+
items[next]?.focus();
|
|
87
|
+
};
|
|
88
|
+
|
|
65
89
|
const adjustedX = Math.min(state.x, window.innerWidth - 180);
|
|
66
90
|
const adjustedY = Math.min(state.y, window.innerHeight - 200);
|
|
67
91
|
|
|
@@ -74,13 +98,16 @@ export function ContextMenu({
|
|
|
74
98
|
return (
|
|
75
99
|
<div
|
|
76
100
|
ref={menuRef}
|
|
101
|
+
role="menu"
|
|
102
|
+
onKeyDown={handleMenuKeyDown}
|
|
77
103
|
className="fixed z-50 bg-neutral-900 border border-neutral-700 rounded-md shadow-lg py-1 min-w-[160px]"
|
|
78
104
|
style={{ left: adjustedX, top: adjustedY }}
|
|
79
105
|
>
|
|
80
106
|
{state.targetIsFolder && (
|
|
81
107
|
<>
|
|
82
108
|
<button
|
|
83
|
-
|
|
109
|
+
role="menuitem"
|
|
110
|
+
className="w-full flex items-center gap-2 px-3 py-1.5 text-xs text-neutral-300 hover:bg-neutral-800 focus-visible:bg-neutral-800 active:bg-neutral-700 outline-none cursor-pointer text-left"
|
|
84
111
|
onClick={() => {
|
|
85
112
|
onNewFile(state.targetPath);
|
|
86
113
|
onClose();
|
|
@@ -90,7 +117,8 @@ export function ContextMenu({
|
|
|
90
117
|
New File
|
|
91
118
|
</button>
|
|
92
119
|
<button
|
|
93
|
-
|
|
120
|
+
role="menuitem"
|
|
121
|
+
className="w-full flex items-center gap-2 px-3 py-1.5 text-xs text-neutral-300 hover:bg-neutral-800 focus-visible:bg-neutral-800 active:bg-neutral-700 outline-none cursor-pointer text-left"
|
|
94
122
|
onClick={() => {
|
|
95
123
|
onNewFolder(state.targetPath);
|
|
96
124
|
onClose();
|
|
@@ -105,7 +133,8 @@ export function ContextMenu({
|
|
|
105
133
|
{!state.targetIsFolder && (
|
|
106
134
|
<>
|
|
107
135
|
<button
|
|
108
|
-
|
|
136
|
+
role="menuitem"
|
|
137
|
+
className="w-full flex items-center gap-2 px-3 py-1.5 text-xs text-neutral-300 hover:bg-neutral-800 focus-visible:bg-neutral-800 active:bg-neutral-700 outline-none cursor-pointer text-left"
|
|
109
138
|
onClick={() => {
|
|
110
139
|
onNewFile(parentPath);
|
|
111
140
|
onClose();
|
|
@@ -118,7 +147,8 @@ export function ContextMenu({
|
|
|
118
147
|
</>
|
|
119
148
|
)}
|
|
120
149
|
<button
|
|
121
|
-
|
|
150
|
+
role="menuitem"
|
|
151
|
+
className="w-full flex items-center gap-2 px-3 py-1.5 text-xs text-neutral-300 hover:bg-neutral-800 focus-visible:bg-neutral-800 active:bg-neutral-700 outline-none cursor-pointer text-left"
|
|
122
152
|
onClick={() => {
|
|
123
153
|
onRename(state.targetPath);
|
|
124
154
|
onClose();
|
|
@@ -129,7 +159,8 @@ export function ContextMenu({
|
|
|
129
159
|
</button>
|
|
130
160
|
{!state.targetIsFolder && (
|
|
131
161
|
<button
|
|
132
|
-
|
|
162
|
+
role="menuitem"
|
|
163
|
+
className="w-full flex items-center gap-2 px-3 py-1.5 text-xs text-neutral-300 hover:bg-neutral-800 focus-visible:bg-neutral-800 active:bg-neutral-700 outline-none cursor-pointer text-left"
|
|
133
164
|
onClick={() => {
|
|
134
165
|
onDuplicate(state.targetPath);
|
|
135
166
|
onClose();
|
|
@@ -141,7 +172,8 @@ export function ContextMenu({
|
|
|
141
172
|
)}
|
|
142
173
|
<div className="border-t border-neutral-700 my-1" />
|
|
143
174
|
<button
|
|
144
|
-
|
|
175
|
+
role="menuitem"
|
|
176
|
+
className="w-full flex items-center gap-2 px-3 py-1.5 text-xs text-red-400 hover:bg-red-900/30 focus-visible:bg-red-900/30 active:bg-red-900/50 outline-none cursor-pointer text-left"
|
|
145
177
|
onClick={() => {
|
|
146
178
|
onDelete(state.targetPath);
|
|
147
179
|
onClose();
|
|
@@ -172,6 +204,13 @@ export function InlineInput({
|
|
|
172
204
|
const inputRef = useRef<HTMLInputElement>(null);
|
|
173
205
|
const committedRef = useRef(false);
|
|
174
206
|
const [value, setValue] = useState(defaultValue);
|
|
207
|
+
const [error, setError] = useState<string | null>(null);
|
|
208
|
+
|
|
209
|
+
const validate = (name: string): string | null => {
|
|
210
|
+
if (/[/\\]/.test(name)) return "Name can't contain / or \\";
|
|
211
|
+
if (name.includes("..")) return "Name can't contain ..";
|
|
212
|
+
return null;
|
|
213
|
+
};
|
|
175
214
|
|
|
176
215
|
// eslint-disable-next-line no-restricted-syntax
|
|
177
216
|
useEffect(() => {
|
|
@@ -196,8 +235,16 @@ export function InlineInput({
|
|
|
196
235
|
if (e.key === "Enter") {
|
|
197
236
|
e.preventDefault();
|
|
198
237
|
const trimmed = value.trim();
|
|
199
|
-
if (
|
|
200
|
-
|
|
238
|
+
if (!trimmed) {
|
|
239
|
+
onCancel();
|
|
240
|
+
return;
|
|
241
|
+
}
|
|
242
|
+
const invalid = validate(trimmed);
|
|
243
|
+
if (invalid) {
|
|
244
|
+
setError(invalid);
|
|
245
|
+
return;
|
|
246
|
+
}
|
|
247
|
+
commit(trimmed);
|
|
201
248
|
} else if (e.key === "Escape") {
|
|
202
249
|
e.preventDefault();
|
|
203
250
|
onCancel();
|
|
@@ -206,8 +253,7 @@ export function InlineInput({
|
|
|
206
253
|
|
|
207
254
|
const handleBlur = () => {
|
|
208
255
|
const trimmed = value.trim();
|
|
209
|
-
if (trimmed && trimmed !== defaultValue && !(
|
|
210
|
-
commit(trimmed);
|
|
256
|
+
if (trimmed && trimmed !== defaultValue && !validate(trimmed)) commit(trimmed);
|
|
211
257
|
else onCancel();
|
|
212
258
|
};
|
|
213
259
|
|
|
@@ -221,15 +267,28 @@ export function InlineInput({
|
|
|
221
267
|
) : (
|
|
222
268
|
<FileIcon path={value} />
|
|
223
269
|
)}
|
|
224
|
-
<
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
270
|
+
<div className="flex-1 min-w-0">
|
|
271
|
+
<input
|
|
272
|
+
ref={inputRef}
|
|
273
|
+
value={value}
|
|
274
|
+
onChange={(e) => {
|
|
275
|
+
setValue(e.target.value);
|
|
276
|
+
if (error) setError(null);
|
|
277
|
+
}}
|
|
278
|
+
onKeyDown={handleKeyDown}
|
|
279
|
+
onBlur={handleBlur}
|
|
280
|
+
aria-invalid={error ? true : undefined}
|
|
281
|
+
className={`w-full min-w-0 bg-neutral-800 text-neutral-200 text-xs px-1.5 py-0.5 rounded border outline-none ${
|
|
282
|
+
error ? "border-red-500/70" : "border-neutral-600 focus:border-[#3CE6AC]"
|
|
283
|
+
}`}
|
|
284
|
+
spellCheck={false}
|
|
285
|
+
/>
|
|
286
|
+
{error && (
|
|
287
|
+
<div className="mt-0.5 text-[10px] text-red-400" role="alert">
|
|
288
|
+
{error}
|
|
289
|
+
</div>
|
|
290
|
+
)}
|
|
291
|
+
</div>
|
|
233
292
|
</div>
|
|
234
293
|
);
|
|
235
294
|
}
|
|
@@ -238,10 +297,12 @@ export function InlineInput({
|
|
|
238
297
|
|
|
239
298
|
export function DeleteConfirm({
|
|
240
299
|
name,
|
|
300
|
+
isFolder,
|
|
241
301
|
onConfirm,
|
|
242
302
|
onCancel,
|
|
243
303
|
}: {
|
|
244
304
|
name: string;
|
|
305
|
+
isFolder?: boolean;
|
|
245
306
|
onConfirm: () => void;
|
|
246
307
|
onCancel: () => void;
|
|
247
308
|
}) {
|
|
@@ -269,7 +330,16 @@ export function DeleteConfirm({
|
|
|
269
330
|
className="mx-1 my-0.5 p-2 bg-neutral-800 border border-neutral-700 rounded-md text-xs"
|
|
270
331
|
>
|
|
271
332
|
<p className="text-neutral-300 mb-2">
|
|
272
|
-
|
|
333
|
+
{isFolder ? (
|
|
334
|
+
<>
|
|
335
|
+
Delete folder <span className="font-medium text-neutral-100">{name}</span> and
|
|
336
|
+
everything inside it?
|
|
337
|
+
</>
|
|
338
|
+
) : (
|
|
339
|
+
<>
|
|
340
|
+
Delete <span className="font-medium text-neutral-100">{name}</span>?
|
|
341
|
+
</>
|
|
342
|
+
)}
|
|
273
343
|
</p>
|
|
274
344
|
<div className="flex gap-1.5">
|
|
275
345
|
<button
|
|
@@ -61,6 +61,11 @@ export function GsapAnimationList({
|
|
|
61
61
|
|
|
62
62
|
return (
|
|
63
63
|
<div className="space-y-2">
|
|
64
|
+
{animations.length === 0 && (
|
|
65
|
+
<p className="text-[11px] leading-4 text-neutral-500">
|
|
66
|
+
No animations on this element yet — add an effect below to animate it.
|
|
67
|
+
</p>
|
|
68
|
+
)}
|
|
64
69
|
{animations.map((animation, index) => (
|
|
65
70
|
<AnimationCard
|
|
66
71
|
{...trackedCallbacks}
|
|
@@ -29,9 +29,11 @@ export const KeyframeDiamond = memo(function KeyframeDiamond({
|
|
|
29
29
|
e.stopPropagation();
|
|
30
30
|
onClick();
|
|
31
31
|
}}
|
|
32
|
-
className="flex-shrink-0 p-0.5 transition-opacity hover:opacity-100"
|
|
32
|
+
className="relative flex-shrink-0 p-0.5 transition-opacity hover:opacity-100 before:absolute before:-inset-1.5 before:content-['']"
|
|
33
33
|
style={{ color, opacity }}
|
|
34
34
|
title={title}
|
|
35
|
+
aria-label={title}
|
|
36
|
+
aria-pressed={state === "active"}
|
|
35
37
|
>
|
|
36
38
|
<svg width={size} height={size} viewBox="0 0 10 10">
|
|
37
39
|
{isHold ? (
|
|
@@ -104,7 +104,8 @@ export function KeyframeEaseList({
|
|
|
104
104
|
<button
|
|
105
105
|
type="button"
|
|
106
106
|
onClick={() => onToggle(isExpanded ? null : kf.percentage)}
|
|
107
|
-
|
|
107
|
+
aria-expanded={isExpanded}
|
|
108
|
+
className="flex w-full items-center gap-2 px-2 py-1.5 text-left active:scale-[0.99]"
|
|
108
109
|
>
|
|
109
110
|
<span className="text-[10px] font-medium text-neutral-400">{label}</span>
|
|
110
111
|
<span className="ml-auto text-[9px] text-neutral-500">{easeLabel}</span>
|
|
@@ -113,7 +114,7 @@ export function KeyframeEaseList({
|
|
|
113
114
|
height="8"
|
|
114
115
|
viewBox="0 0 10 10"
|
|
115
116
|
fill="currentColor"
|
|
116
|
-
className={`text-neutral-500 transition-transform ${isExpanded ? "" : "-rotate-90"}`}
|
|
117
|
+
className={`text-neutral-500 transition-transform duration-150 ${isExpanded ? "" : "-rotate-90"}`}
|
|
117
118
|
>
|
|
118
119
|
<path d="M2 3l3 4 3-4z" />
|
|
119
120
|
</svg>
|