@hyperframes/studio 0.6.89 → 0.6.91
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/index-CgYcO2PV.js +146 -0
- package/dist/index.html +1 -1
- package/package.json +4 -4
- package/src/App.tsx +2 -0
- package/src/components/StudioPreviewArea.tsx +6 -0
- package/src/components/TimelineToolbar.tsx +52 -14
- package/src/components/editor/manualEditingAvailability.test.ts +12 -0
- package/src/components/editor/manualEditingAvailability.ts +12 -0
- package/src/components/nle/NLELayout.tsx +6 -18
- package/src/components/nle/TimelineEditorNotice.tsx +2 -25
- package/src/hooks/useAppHotkeys.ts +48 -1
- package/src/hooks/useContextMenuDismiss.ts +29 -0
- package/src/hooks/useDomEditSession.ts +7 -4
- package/src/hooks/useRazorSplit.ts +303 -0
- package/src/hooks/useTimelineEditing.ts +15 -98
- package/src/player/components/ClipContextMenu.tsx +5 -21
- package/src/player/components/KeyframeDiamondContextMenu.tsx +3 -20
- package/src/player/components/PlayheadIndicator.tsx +43 -0
- package/src/player/components/Timeline.tsx +38 -35
- package/src/player/components/TimelineCanvas.tsx +29 -22
- package/src/player/components/timelineCallbacks.ts +44 -0
- package/src/player/components/timelineDragDrop.ts +2 -14
- package/src/player/components/useTimelineZoom.ts +18 -0
- package/src/player/store/playerStore.ts +8 -0
- package/src/utils/timelineElementSplit.ts +16 -0
- package/dist/assets/index-2SbRRd33.js +0 -146
package/dist/index.html
CHANGED
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
|
|
6
6
|
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
|
7
7
|
<title>HyperFrames Studio</title>
|
|
8
|
-
<script type="module" crossorigin src="/assets/index-
|
|
8
|
+
<script type="module" crossorigin src="/assets/index-CgYcO2PV.js"></script>
|
|
9
9
|
<link rel="stylesheet" crossorigin href="/assets/index-D2NkPomd.css">
|
|
10
10
|
</head>
|
|
11
11
|
<body>
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@hyperframes/studio",
|
|
3
|
-
"version": "0.6.
|
|
3
|
+
"version": "0.6.91",
|
|
4
4
|
"description": "",
|
|
5
5
|
"repository": {
|
|
6
6
|
"type": "git",
|
|
@@ -31,8 +31,8 @@
|
|
|
31
31
|
"@codemirror/view": "6.40.0",
|
|
32
32
|
"@phosphor-icons/react": "^2.1.10",
|
|
33
33
|
"mediabunny": "^1.45.3",
|
|
34
|
-
"@hyperframes/core": "0.6.
|
|
35
|
-
"@hyperframes/player": "0.6.
|
|
34
|
+
"@hyperframes/core": "0.6.91",
|
|
35
|
+
"@hyperframes/player": "0.6.91"
|
|
36
36
|
},
|
|
37
37
|
"devDependencies": {
|
|
38
38
|
"@types/react": "19",
|
|
@@ -46,7 +46,7 @@
|
|
|
46
46
|
"vite": "^6.4.2",
|
|
47
47
|
"vitest": "^3.2.4",
|
|
48
48
|
"zustand": "^5.0.0",
|
|
49
|
-
"@hyperframes/producer": "0.6.
|
|
49
|
+
"@hyperframes/producer": "0.6.91"
|
|
50
50
|
},
|
|
51
51
|
"peerDependencies": {
|
|
52
52
|
"react": "19",
|
package/src/App.tsx
CHANGED
|
@@ -504,6 +504,8 @@ export function StudioApp() {
|
|
|
504
504
|
handleTimelineElementResize={timelineEditing.handleTimelineElementResize}
|
|
505
505
|
handleBlockedTimelineEdit={timelineEditing.handleBlockedTimelineEdit}
|
|
506
506
|
handleTimelineElementSplit={timelineEditing.handleTimelineElementSplit}
|
|
507
|
+
handleRazorSplit={timelineEditing.handleRazorSplit}
|
|
508
|
+
handleRazorSplitAll={timelineEditing.handleRazorSplitAll}
|
|
507
509
|
setCompIdToSrc={setCompIdToSrc}
|
|
508
510
|
setCompositionLoading={setCompositionLoading}
|
|
509
511
|
shouldShowSelectedDomBounds={shouldShowSelectedDomBounds}
|
|
@@ -52,6 +52,8 @@ export interface StudioPreviewAreaProps {
|
|
|
52
52
|
) => Promise<void> | void;
|
|
53
53
|
handleBlockedTimelineEdit: (element: TimelineElement, intent: BlockedTimelineEditIntent) => void;
|
|
54
54
|
handleTimelineElementSplit: (element: TimelineElement, splitTime: number) => Promise<void> | void;
|
|
55
|
+
handleRazorSplit: (element: TimelineElement, splitTime: number) => Promise<void> | void;
|
|
56
|
+
handleRazorSplitAll: (splitTime: number) => Promise<void> | void;
|
|
55
57
|
setCompIdToSrc: (map: Map<string, string>) => void;
|
|
56
58
|
setCompositionLoading: (loading: boolean) => void;
|
|
57
59
|
shouldShowSelectedDomBounds: boolean;
|
|
@@ -73,6 +75,8 @@ export function StudioPreviewArea({
|
|
|
73
75
|
handleTimelineElementResize,
|
|
74
76
|
handleBlockedTimelineEdit,
|
|
75
77
|
handleTimelineElementSplit,
|
|
78
|
+
handleRazorSplit,
|
|
79
|
+
handleRazorSplitAll,
|
|
76
80
|
setCompIdToSrc,
|
|
77
81
|
setCompositionLoading,
|
|
78
82
|
shouldShowSelectedDomBounds,
|
|
@@ -146,6 +150,8 @@ export function StudioPreviewArea({
|
|
|
146
150
|
onResizeElement={handleTimelineElementResize}
|
|
147
151
|
onBlockedEditAttempt={handleBlockedTimelineEdit}
|
|
148
152
|
onSplitElement={handleTimelineElementSplit}
|
|
153
|
+
onRazorSplit={handleRazorSplit}
|
|
154
|
+
onRazorSplitAll={handleRazorSplitAll}
|
|
149
155
|
onSelectTimelineElement={handleTimelineElementSelect}
|
|
150
156
|
onDeleteAllKeyframes={(_elId) => {
|
|
151
157
|
const anim =
|
|
@@ -4,13 +4,18 @@ import {
|
|
|
4
4
|
getNextTimelineZoomPercent,
|
|
5
5
|
getTimelineZoomPercent,
|
|
6
6
|
} from "../player/components/timelineZoom";
|
|
7
|
+
import { useTimelineZoom } from "../player/components/useTimelineZoom";
|
|
7
8
|
import { getTimelineToggleTitle } from "../utils/timelineDiscovery";
|
|
8
9
|
import { usePlayerStore, type TimelineElement } from "../player";
|
|
9
|
-
import {
|
|
10
|
+
import {
|
|
11
|
+
STUDIO_KEYFRAMES_ENABLED,
|
|
12
|
+
STUDIO_RAZOR_TOOL_ENABLED,
|
|
13
|
+
} from "./editor/manualEditingAvailability";
|
|
10
14
|
import { Tooltip } from "./ui";
|
|
11
15
|
import { Scissors } from "../icons/SystemIcons";
|
|
12
16
|
import type { GsapAnimation } from "@hyperframes/core/gsap-parser";
|
|
13
17
|
import type { DomEditSelection } from "./editor/domEditingTypes";
|
|
18
|
+
import { canSplitElement } from "../utils/timelineElementSplit";
|
|
14
19
|
|
|
15
20
|
function AutoKeyframeToggle() {
|
|
16
21
|
const enabled = usePlayerStore((s) => s.autoKeyframeEnabled);
|
|
@@ -58,14 +63,17 @@ function useKeyframeToggle(session?: DomEditSessionSlice) {
|
|
|
58
63
|
const anims = session.selectedGsapAnimations;
|
|
59
64
|
const kfAnim = anims.find((a) => a.keyframes);
|
|
60
65
|
|
|
66
|
+
const computePct = (time: number) => {
|
|
67
|
+
const elStart = Number.parseFloat(sel?.dataAttributes?.start ?? "0") || 0;
|
|
68
|
+
const elDuration = Number.parseFloat(sel?.dataAttributes?.duration ?? "1") || 1;
|
|
69
|
+
return elDuration > 0
|
|
70
|
+
? Math.max(0, Math.min(100, Math.round(((time - elStart) / elDuration) * 1000) / 10))
|
|
71
|
+
: 0;
|
|
72
|
+
};
|
|
73
|
+
|
|
61
74
|
let state: "active" | "inactive" | "none" = "none";
|
|
62
75
|
if (kfAnim?.keyframes && sel) {
|
|
63
|
-
const
|
|
64
|
-
const elDuration = Number.parseFloat(sel.dataAttributes?.duration ?? "1") || 1;
|
|
65
|
-
const pct =
|
|
66
|
-
elDuration > 0
|
|
67
|
-
? Math.max(0, Math.min(100, Math.round(((currentTime - elStart) / elDuration) * 1000) / 10))
|
|
68
|
-
: 0;
|
|
76
|
+
const pct = computePct(currentTime);
|
|
69
77
|
state = kfAnim.keyframes.keyframes.some((k) => Math.abs(k.percentage - pct) <= 1)
|
|
70
78
|
? "active"
|
|
71
79
|
: "inactive";
|
|
@@ -74,15 +82,15 @@ function useKeyframeToggle(session?: DomEditSessionSlice) {
|
|
|
74
82
|
return { state, onToggle: sel ? onToggle : undefined };
|
|
75
83
|
}
|
|
76
84
|
|
|
85
|
+
// fallow-ignore-next-line complexity
|
|
77
86
|
export function TimelineToolbar({
|
|
78
87
|
toggleTimelineVisibility,
|
|
79
88
|
domEditSession,
|
|
80
89
|
onSplitElement,
|
|
81
90
|
}: TimelineToolbarProps) {
|
|
82
|
-
const
|
|
83
|
-
const
|
|
84
|
-
const setZoomMode =
|
|
85
|
-
const setManualZoomPercent = usePlayerStore((s) => s.setManualZoomPercent);
|
|
91
|
+
const activeTool = usePlayerStore((s) => s.activeTool);
|
|
92
|
+
const setActiveTool = usePlayerStore((s) => s.setActiveTool);
|
|
93
|
+
const { zoomMode, manualZoomPercent, setZoomMode, setManualZoomPercent } = useTimelineZoom();
|
|
86
94
|
const displayedTimelineZoomPercent = getTimelineZoomPercent(zoomMode, manualZoomPercent);
|
|
87
95
|
const { state: keyframeState, onToggle: onToggleKeyframe } = useKeyframeToggle(domEditSession);
|
|
88
96
|
|
|
@@ -93,6 +101,38 @@ export function TimelineToolbar({
|
|
|
93
101
|
<div className="text-[10px] font-medium uppercase tracking-[0.16em] text-neutral-500">
|
|
94
102
|
Timeline
|
|
95
103
|
</div>
|
|
104
|
+
{STUDIO_RAZOR_TOOL_ENABLED && (
|
|
105
|
+
<div className="flex items-center border border-neutral-800 rounded overflow-hidden">
|
|
106
|
+
<Tooltip label="Selection tool (V)">
|
|
107
|
+
<button
|
|
108
|
+
type="button"
|
|
109
|
+
onClick={() => setActiveTool("select")}
|
|
110
|
+
className={`flex h-6 w-6 items-center justify-center transition-colors ${
|
|
111
|
+
activeTool === "select"
|
|
112
|
+
? "bg-neutral-700 text-neutral-200"
|
|
113
|
+
: "text-neutral-500 hover:text-neutral-300"
|
|
114
|
+
}`}
|
|
115
|
+
>
|
|
116
|
+
<svg width="12" height="12" viewBox="0 0 12 12" fill="currentColor">
|
|
117
|
+
<path d="M2 0.5L10 6L6.5 6.5L8.5 11L6.5 11.5L4.5 7L2 9Z" />
|
|
118
|
+
</svg>
|
|
119
|
+
</button>
|
|
120
|
+
</Tooltip>
|
|
121
|
+
<Tooltip label="Razor tool (B)">
|
|
122
|
+
<button
|
|
123
|
+
type="button"
|
|
124
|
+
onClick={() => setActiveTool("razor")}
|
|
125
|
+
className={`flex h-6 w-6 items-center justify-center transition-colors ${
|
|
126
|
+
activeTool === "razor"
|
|
127
|
+
? "bg-neutral-700 text-neutral-200"
|
|
128
|
+
: "text-neutral-500 hover:text-neutral-300"
|
|
129
|
+
}`}
|
|
130
|
+
>
|
|
131
|
+
<Scissors size={11} />
|
|
132
|
+
</button>
|
|
133
|
+
</Tooltip>
|
|
134
|
+
</div>
|
|
135
|
+
)}
|
|
96
136
|
{STUDIO_KEYFRAMES_ENABLED && onToggleKeyframe && (
|
|
97
137
|
<>
|
|
98
138
|
<Tooltip
|
|
@@ -138,9 +178,7 @@ export function TimelineToolbar({
|
|
|
138
178
|
const el = selectedElementId
|
|
139
179
|
? elements.find((e) => (e.key ?? e.id) === selectedElementId)
|
|
140
180
|
: null;
|
|
141
|
-
|
|
142
|
-
el && !el.compositionSrc && ["video", "audio", "img"].includes(el.tag);
|
|
143
|
-
if (!splittable) return null;
|
|
181
|
+
if (!el || !canSplitElement(el)) return null;
|
|
144
182
|
const canSplit = currentTime > el.start && currentTime < el.start + el.duration;
|
|
145
183
|
return (
|
|
146
184
|
<Tooltip label="Split clip at playhead (S)">
|
|
@@ -25,6 +25,18 @@ describe("manual editing availability", () => {
|
|
|
25
25
|
expect(availability.STUDIO_MOTION_PANEL_ENABLED).toBe(false);
|
|
26
26
|
});
|
|
27
27
|
|
|
28
|
+
it("disables GSAP drag intercept by default", async () => {
|
|
29
|
+
const availability = await loadAvailabilityWithEnv({});
|
|
30
|
+
expect(availability.STUDIO_GSAP_DRAG_INTERCEPT_ENABLED).toBe(false);
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
it("enables GSAP drag intercept when env var is set", async () => {
|
|
34
|
+
const availability = await loadAvailabilityWithEnv({
|
|
35
|
+
VITE_STUDIO_ENABLE_GSAP_DRAG_INTERCEPT: "true",
|
|
36
|
+
});
|
|
37
|
+
expect(availability.STUDIO_GSAP_DRAG_INTERCEPT_ENABLED).toBe(true);
|
|
38
|
+
});
|
|
39
|
+
|
|
28
40
|
it("disables preview selection when the inspector panel flag is explicitly off", async () => {
|
|
29
41
|
const availability = await loadAvailabilityWithEnv({
|
|
30
42
|
VITE_STUDIO_ENABLE_INSPECTOR_PANELS: "0",
|
|
@@ -47,6 +47,12 @@ export const STUDIO_PREVIEW_MANUAL_EDITING_ENABLED = resolveStudioBooleanEnvFlag
|
|
|
47
47
|
true,
|
|
48
48
|
);
|
|
49
49
|
|
|
50
|
+
export const STUDIO_GSAP_DRAG_INTERCEPT_ENABLED = resolveStudioBooleanEnvFlag(
|
|
51
|
+
env,
|
|
52
|
+
["VITE_STUDIO_ENABLE_GSAP_DRAG_INTERCEPT"],
|
|
53
|
+
false,
|
|
54
|
+
);
|
|
55
|
+
|
|
50
56
|
export const STUDIO_INSPECTOR_PANELS_ENABLED = resolveStudioBooleanEnvFlag(
|
|
51
57
|
env,
|
|
52
58
|
[STUDIO_INSPECTOR_PANELS_ENV, "VITE_STUDIO_INSPECTOR_PANELS_ENABLED"],
|
|
@@ -77,6 +83,12 @@ export const STUDIO_KEYFRAMES_ENABLED = resolveStudioBooleanEnvFlag(
|
|
|
77
83
|
true,
|
|
78
84
|
);
|
|
79
85
|
|
|
86
|
+
export const STUDIO_RAZOR_TOOL_ENABLED = resolveStudioBooleanEnvFlag(
|
|
87
|
+
env,
|
|
88
|
+
["VITE_STUDIO_ENABLE_RAZOR_TOOL", "VITE_STUDIO_RAZOR_TOOL_ENABLED"],
|
|
89
|
+
false,
|
|
90
|
+
);
|
|
91
|
+
|
|
80
92
|
export const STUDIO_PREVIEW_SELECTION_ENABLED = STUDIO_INSPECTOR_PANELS_ENABLED;
|
|
81
93
|
|
|
82
94
|
export const STUDIO_MANUAL_EDITING_DISABLED_TITLE = "Manual editing is temporarily disabled";
|
|
@@ -10,7 +10,7 @@ import {
|
|
|
10
10
|
import { useMountEffect } from "../../hooks/useMountEffect";
|
|
11
11
|
import { useTimelinePlayer, PlayerControls, Timeline, usePlayerStore } from "../../player";
|
|
12
12
|
import type { TimelineElement } from "../../player";
|
|
13
|
-
import type {
|
|
13
|
+
import type { TimelineEditCallbacks } from "../../player/components/timelineCallbacks";
|
|
14
14
|
import { NLEPreview } from "./NLEPreview";
|
|
15
15
|
import { CompositionBreadcrumb } from "./CompositionBreadcrumb";
|
|
16
16
|
import { usePreviewBlockDrop } from "./usePreviewBlockDrop";
|
|
@@ -20,7 +20,7 @@ import {
|
|
|
20
20
|
getTimelineToggleTitle,
|
|
21
21
|
} from "../../utils/timelineDiscovery";
|
|
22
22
|
|
|
23
|
-
interface NLELayoutProps {
|
|
23
|
+
interface NLELayoutProps extends TimelineEditCallbacks {
|
|
24
24
|
projectId: string;
|
|
25
25
|
portrait?: boolean;
|
|
26
26
|
/** Slot for overlays rendered on top of the preview (cursors, highlights, etc.) */
|
|
@@ -59,23 +59,7 @@ interface NLELayoutProps {
|
|
|
59
59
|
blockName: string,
|
|
60
60
|
position: { left: number; top: number },
|
|
61
61
|
) => Promise<void> | void;
|
|
62
|
-
/** Persist timeline move actions back into source HTML */
|
|
63
|
-
onMoveElement?: (
|
|
64
|
-
element: TimelineElement,
|
|
65
|
-
updates: Pick<TimelineElement, "start" | "track">,
|
|
66
|
-
) => Promise<void> | void;
|
|
67
|
-
onResizeElement?: (
|
|
68
|
-
element: TimelineElement,
|
|
69
|
-
updates: Pick<TimelineElement, "start" | "duration" | "playbackStart">,
|
|
70
|
-
) => Promise<void> | void;
|
|
71
|
-
onBlockedEditAttempt?: (element: TimelineElement, intent: BlockedTimelineEditIntent) => void;
|
|
72
|
-
onSplitElement?: (element: TimelineElement, splitTime: number) => Promise<void> | void;
|
|
73
62
|
onSelectTimelineElement?: (element: TimelineElement | null) => void;
|
|
74
|
-
onDeleteKeyframe?: (elementId: string, percentage: number) => void;
|
|
75
|
-
onDeleteAllKeyframes?: (elementId: string) => void;
|
|
76
|
-
onChangeKeyframeEase?: (elementId: string, percentage: number, ease: string) => void;
|
|
77
|
-
onMoveKeyframe?: (element: TimelineElement, oldPct: number, newPct: number) => void;
|
|
78
|
-
onToggleKeyframeAtPlayhead?: (element: TimelineElement) => void;
|
|
79
63
|
/** Exposes the compIdToSrc map for parent components (e.g., useRenderClipContent) */
|
|
80
64
|
onCompIdToSrcChange?: (map: Map<string, string>) => void;
|
|
81
65
|
/** Whether the timeline panel is visible (default: true) */
|
|
@@ -124,6 +108,8 @@ export const NLELayout = memo(function NLELayout({
|
|
|
124
108
|
onResizeElement,
|
|
125
109
|
onBlockedEditAttempt,
|
|
126
110
|
onSplitElement,
|
|
111
|
+
onRazorSplit,
|
|
112
|
+
onRazorSplitAll,
|
|
127
113
|
onSelectTimelineElement,
|
|
128
114
|
onDeleteKeyframe,
|
|
129
115
|
onDeleteAllKeyframes,
|
|
@@ -460,6 +446,8 @@ export const NLELayout = memo(function NLELayout({
|
|
|
460
446
|
onResizeElement={onResizeElement}
|
|
461
447
|
onBlockedEditAttempt={onBlockedEditAttempt}
|
|
462
448
|
onSplitElement={onSplitElement}
|
|
449
|
+
onRazorSplit={onRazorSplit}
|
|
450
|
+
onRazorSplitAll={onRazorSplitAll}
|
|
463
451
|
onSelectElement={onSelectTimelineElement}
|
|
464
452
|
onDeleteKeyframe={onDeleteKeyframe}
|
|
465
453
|
onDeleteAllKeyframes={onDeleteAllKeyframes}
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { TIMELINE_TOGGLE_SHORTCUT_LABEL } from "../../utils/timelineDiscovery";
|
|
2
|
+
import { PlayheadIndicator } from "../../player/components/PlayheadIndicator";
|
|
2
3
|
|
|
3
4
|
interface TimelineEditorNoticeProps {
|
|
4
5
|
onDismiss: () => void;
|
|
@@ -76,31 +77,7 @@ export function TimelineEditorNotice({ onDismiss }: TimelineEditorNoticeProps) {
|
|
|
76
77
|
"hfTimelineNoticePlayheadSweep 2.8s cubic-bezier(0.4, 0, 0.2, 1) infinite",
|
|
77
78
|
}}
|
|
78
79
|
>
|
|
79
|
-
<
|
|
80
|
-
className="absolute top-0 bottom-0"
|
|
81
|
-
style={{
|
|
82
|
-
left: "50%",
|
|
83
|
-
width: 2,
|
|
84
|
-
marginLeft: -1,
|
|
85
|
-
background: "var(--hf-accent, #3CE6AC)",
|
|
86
|
-
boxShadow: "0 0 8px rgba(60,230,172,0.5)",
|
|
87
|
-
}}
|
|
88
|
-
/>
|
|
89
|
-
<div
|
|
90
|
-
className="absolute"
|
|
91
|
-
style={{ left: "50%", top: 0, transform: "translateX(-50%)" }}
|
|
92
|
-
>
|
|
93
|
-
<div
|
|
94
|
-
style={{
|
|
95
|
-
width: 0,
|
|
96
|
-
height: 0,
|
|
97
|
-
borderLeft: "6px solid transparent",
|
|
98
|
-
borderRight: "6px solid transparent",
|
|
99
|
-
borderTop: "8px solid var(--hf-accent, #3CE6AC)",
|
|
100
|
-
filter: "drop-shadow(0 1px 3px rgba(0,0,0,0.6))",
|
|
101
|
-
}}
|
|
102
|
-
/>
|
|
103
|
-
</div>
|
|
80
|
+
<PlayheadIndicator />
|
|
104
81
|
</div>
|
|
105
82
|
|
|
106
83
|
<div className="flex flex-col gap-1.5">
|
|
@@ -6,6 +6,8 @@ import type { LeftSidebarHandle } from "../components/sidebar/LeftSidebar";
|
|
|
6
6
|
import { STUDIO_MOTION_PATH } from "../components/editor/studioMotion";
|
|
7
7
|
import { shouldHandleTimelineToggleHotkey, isEditableTarget } from "../utils/timelineDiscovery";
|
|
8
8
|
import { shouldIgnoreHistoryShortcut } from "../utils/studioHelpers";
|
|
9
|
+
import { canSplitElement } from "../utils/timelineElementSplit";
|
|
10
|
+
import { STUDIO_RAZOR_TOOL_ENABLED } from "../components/editor/manualEditingAvailability";
|
|
9
11
|
|
|
10
12
|
/** Safely resolves contentWindow for a potentially cross-origin iframe. */
|
|
11
13
|
function iframeContentWindow(iframe: HTMLIFrameElement | null): Window | null {
|
|
@@ -327,7 +329,7 @@ export function useAppHotkeys({
|
|
|
327
329
|
const element = elements.find((el) => (el.key ?? el.id) === selectedElementId);
|
|
328
330
|
if (
|
|
329
331
|
element &&
|
|
330
|
-
|
|
332
|
+
canSplitElement(element) &&
|
|
331
333
|
currentTime > element.start &&
|
|
332
334
|
currentTime < element.start + element.duration
|
|
333
335
|
) {
|
|
@@ -338,6 +340,51 @@ export function useAppHotkeys({
|
|
|
338
340
|
}
|
|
339
341
|
}
|
|
340
342
|
|
|
343
|
+
// B — toggle razor tool
|
|
344
|
+
if (
|
|
345
|
+
STUDIO_RAZOR_TOOL_ENABLED &&
|
|
346
|
+
event.key.toLowerCase() === "b" &&
|
|
347
|
+
!event.metaKey &&
|
|
348
|
+
!event.ctrlKey &&
|
|
349
|
+
!event.altKey &&
|
|
350
|
+
!event.shiftKey &&
|
|
351
|
+
!isEditableTarget(event.target)
|
|
352
|
+
) {
|
|
353
|
+
event.preventDefault();
|
|
354
|
+
const { activeTool, setActiveTool } = usePlayerStore.getState();
|
|
355
|
+
setActiveTool(activeTool === "razor" ? "select" : "razor");
|
|
356
|
+
return;
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
// V — return to selection tool
|
|
360
|
+
if (
|
|
361
|
+
event.key.toLowerCase() === "v" &&
|
|
362
|
+
!event.metaKey &&
|
|
363
|
+
!event.ctrlKey &&
|
|
364
|
+
!event.altKey &&
|
|
365
|
+
!event.shiftKey &&
|
|
366
|
+
!isEditableTarget(event.target)
|
|
367
|
+
) {
|
|
368
|
+
event.preventDefault();
|
|
369
|
+
usePlayerStore.getState().setActiveTool("select");
|
|
370
|
+
return;
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
// Escape — exit razor mode (only when no selection to deselect first)
|
|
374
|
+
if (event.key === "Escape" && !isEditableTarget(event.target)) {
|
|
375
|
+
const { activeTool, selectedElementId, setActiveTool, setSelectedElementId } =
|
|
376
|
+
usePlayerStore.getState();
|
|
377
|
+
if (activeTool === "razor") {
|
|
378
|
+
if (selectedElementId) {
|
|
379
|
+
setSelectedElementId(null);
|
|
380
|
+
} else {
|
|
381
|
+
setActiveTool("select");
|
|
382
|
+
}
|
|
383
|
+
event.preventDefault();
|
|
384
|
+
return;
|
|
385
|
+
}
|
|
386
|
+
}
|
|
387
|
+
|
|
341
388
|
// Delete / Backspace — remove selected keyframes > reset keyframes > remove element
|
|
342
389
|
if (
|
|
343
390
|
(event.key === "Delete" || event.key === "Backspace") &&
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import { useCallback, useEffect, useRef, type RefObject } from "react";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Shared dismiss logic for context menus: closes on outside click or Escape.
|
|
5
|
+
* Returns a ref to attach to the menu container element.
|
|
6
|
+
*/
|
|
7
|
+
export function useContextMenuDismiss(onClose: () => void): RefObject<HTMLDivElement | null> {
|
|
8
|
+
const menuRef = useRef<HTMLDivElement>(null);
|
|
9
|
+
|
|
10
|
+
const dismiss = useCallback(
|
|
11
|
+
(e: MouseEvent | KeyboardEvent) => {
|
|
12
|
+
if (e instanceof KeyboardEvent && e.key !== "Escape") return;
|
|
13
|
+
if (e instanceof MouseEvent && menuRef.current?.contains(e.target as Node)) return;
|
|
14
|
+
onClose();
|
|
15
|
+
},
|
|
16
|
+
[onClose],
|
|
17
|
+
);
|
|
18
|
+
|
|
19
|
+
useEffect(() => {
|
|
20
|
+
document.addEventListener("mousedown", dismiss);
|
|
21
|
+
document.addEventListener("keydown", dismiss);
|
|
22
|
+
return () => {
|
|
23
|
+
document.removeEventListener("mousedown", dismiss);
|
|
24
|
+
document.removeEventListener("keydown", dismiss);
|
|
25
|
+
};
|
|
26
|
+
}, [dismiss]);
|
|
27
|
+
|
|
28
|
+
return menuRef;
|
|
29
|
+
}
|
|
@@ -1,7 +1,10 @@
|
|
|
1
1
|
import { useCallback, useEffect, useRef } from "react";
|
|
2
2
|
import type { TimelineElement } from "../player";
|
|
3
3
|
import { usePlayerStore } from "../player";
|
|
4
|
-
import {
|
|
4
|
+
import {
|
|
5
|
+
STUDIO_GSAP_DRAG_INTERCEPT_ENABLED,
|
|
6
|
+
STUDIO_GSAP_PANEL_ENABLED,
|
|
7
|
+
} from "../components/editor/manualEditingAvailability";
|
|
5
8
|
import { type DomEditSelection } from "../components/editor/domEditing";
|
|
6
9
|
import { useDomEditPreviewSync } from "./useDomEditPreviewSync";
|
|
7
10
|
import type { ImportedFontAsset } from "../components/editor/fontAssets";
|
|
@@ -326,7 +329,7 @@ export function useDomEditSession({
|
|
|
326
329
|
// GSAP-aware: intercept offset/resize/rotation to commit via script mutation when animated.
|
|
327
330
|
const handleGsapAwarePathOffsetCommit = useCallback(
|
|
328
331
|
async (selection: DomEditSelection, next: { x: number; y: number }) => {
|
|
329
|
-
if (gsapCommitMutation) {
|
|
332
|
+
if (STUDIO_GSAP_DRAG_INTERCEPT_ENABLED && gsapCommitMutation) {
|
|
330
333
|
const handled = await tryGsapDragIntercept(
|
|
331
334
|
selection,
|
|
332
335
|
next,
|
|
@@ -372,7 +375,7 @@ export function useDomEditSession({
|
|
|
372
375
|
|
|
373
376
|
const handleGsapAwareBoxSizeCommit = useCallback(
|
|
374
377
|
async (selection: DomEditSelection, next: { width: number; height: number }) => {
|
|
375
|
-
if (gsapCommitMutation) {
|
|
378
|
+
if (STUDIO_GSAP_DRAG_INTERCEPT_ENABLED && gsapCommitMutation) {
|
|
376
379
|
const handled = await tryGsapResizeIntercept(
|
|
377
380
|
selection,
|
|
378
381
|
next,
|
|
@@ -396,7 +399,7 @@ export function useDomEditSession({
|
|
|
396
399
|
|
|
397
400
|
const handleGsapAwareRotationCommit = useCallback(
|
|
398
401
|
async (selection: DomEditSelection, next: { angle: number }) => {
|
|
399
|
-
if (gsapCommitMutation) {
|
|
402
|
+
if (STUDIO_GSAP_DRAG_INTERCEPT_ENABLED && gsapCommitMutation) {
|
|
400
403
|
const handled = await tryGsapRotationIntercept(
|
|
401
404
|
selection,
|
|
402
405
|
next.angle,
|