Skip to content

Commit c66ee48

Browse files
committed
feat(studio): on-canvas motion-path overlay
SVG overlay with draggable keyframe-diamond nodes, hover-add, right-click remove, and context menu; mounted in the preview area.
1 parent 7f979c5 commit c66ee48

5 files changed

Lines changed: 694 additions & 2 deletions

File tree

packages/studio/src/components/StudioPreviewArea.tsx

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,13 +3,16 @@ import { NLELayout } from "./nle/NLELayout";
33
import { CaptionOverlay } from "../captions/components/CaptionOverlay";
44
import { CaptionTimeline } from "../captions/components/CaptionTimeline";
55
import { DomEditOverlay } from "./editor/DomEditOverlay";
6+
import { MotionPathOverlay } from "./editor/MotionPathOverlay";
7+
import { useCompositionDimensions } from "../hooks/useCompositionDimensions";
68
import { SnapToolbar } from "./editor/SnapToolbar";
79
import { StudioFeedbackBar } from "./StudioFeedbackBar";
810
import type { TimelineElement } from "../player";
911
import { usePlayerStore } from "../player/store/playerStore";
1012
import type { BlockedTimelineEditIntent } from "../player/components/timelineEditing";
1113
import {
1214
STUDIO_INSPECTOR_PANELS_ENABLED,
15+
STUDIO_KEYFRAMES_ENABLED,
1316
STUDIO_PREVIEW_MANUAL_EDITING_ENABLED,
1417
STUDIO_PREVIEW_SELECTION_ENABLED,
1518
} from "./editor/manualEditingAvailability";
@@ -108,6 +111,7 @@ export function StudioPreviewArea({
108111
isPlaying,
109112
refreshPreviewDocumentVersion,
110113
} = useStudioPlaybackContext();
114+
const compositionDimensions = useCompositionDimensions();
111115

112116
const {
113117
domEditHoverSelection,
@@ -337,6 +341,14 @@ export function StudioPreviewArea({
337341
onToggleRecording={onToggleRecording}
338342
/>
339343
<SnapToolbar onSnapChange={setSnapPrefs} />
344+
{STUDIO_KEYFRAMES_ENABLED && (
345+
<MotionPathOverlay
346+
iframeRef={previewIframeRef}
347+
selection={shouldShowSelectedDomBounds ? domEditSelection : null}
348+
compositionSize={compositionDimensions}
349+
isPlaying={isPlaying}
350+
/>
351+
)}
340352
{gestureOverlay}
341353
</>
342354
) : null

packages/studio/src/components/editor/DomEditOverlay.tsx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -243,6 +243,7 @@ export const DomEditOverlay = memo(function DomEditOverlay({
243243
if (!selection) return "none";
244244
return `${selection.sourceFile}:${selection.id ?? selection.selector ?? selection.label}:${selection.selectorIndex ?? 0}`;
245245
}, [selection]);
246+
246247
const groupBounds = useMemo(
247248
() => resolveDomEditGroupOverlayRect(groupOverlayItems.map((item) => item.rect)),
248249
[groupOverlayItems],
Lines changed: 98 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,98 @@
1+
import type React from "react";
2+
3+
// Editor primary color (themeable via --hf-accent). Applied through inline
4+
// style because CSS var() isn't valid in SVG presentation attributes.
5+
export const ACCENT = "var(--hf-accent, #3CE6AC)";
6+
7+
/** One path node: a diamond (matching the timeline keyframe), a wider transparent
8+
* grab target (when editable), and a hover-revealed × delete badge (when removable). */
9+
export function MotionPathNode(props: {
10+
cx: number;
11+
cy: number;
12+
r: number;
13+
interactive: boolean;
14+
removable: boolean;
15+
grabbing: boolean;
16+
selected: boolean;
17+
onEnter: () => void;
18+
onLeave: () => void;
19+
onPointerDown: (e: React.PointerEvent) => void;
20+
onPointerMove: (e: React.PointerEvent) => void;
21+
onPointerUp: (e: React.PointerEvent) => void;
22+
onRemove: (e: React.PointerEvent) => void;
23+
onContextMenu?: (e: React.MouseEvent) => void;
24+
}) {
25+
const { cx, cy, r, interactive, removable, grabbing, selected } = props;
26+
const bx = cx + r * 1.8;
27+
const by = cy - r * 1.8;
28+
const k = r * 0.55;
29+
// Diamond matching the timeline keyframe (a 45°-rotated rounded square).
30+
// `side` is chosen so the diamond's points reach ~`r` from center, matching the
31+
// old dot's footprint; selection is shown by enlarging it (no extra shape).
32+
const side = (selected ? r * 1.5 : r) * 1.414;
33+
return (
34+
<g onPointerEnter={props.onEnter} onPointerLeave={props.onLeave}>
35+
<rect
36+
x={cx - side / 2}
37+
y={cy - side / 2}
38+
width={side}
39+
height={side}
40+
rx={side * 0.17}
41+
transform={`rotate(45 ${cx} ${cy})`}
42+
stroke="#0b0f1a"
43+
strokeWidth={1.5}
44+
vectorEffect="non-scaling-stroke"
45+
style={{ fill: ACCENT }}
46+
/>
47+
{interactive && (
48+
<circle
49+
cx={cx}
50+
cy={cy}
51+
r={r * 2.4}
52+
fill="transparent"
53+
className="pointer-events-auto"
54+
style={{ cursor: grabbing ? "grabbing" : "grab" }}
55+
onPointerDown={props.onPointerDown}
56+
onPointerMove={props.onPointerMove}
57+
onPointerUp={props.onPointerUp}
58+
onContextMenu={props.onContextMenu}
59+
/>
60+
)}
61+
{removable && (
62+
<g
63+
className="pointer-events-auto"
64+
style={{ cursor: "pointer" }}
65+
onPointerDown={props.onRemove}
66+
>
67+
<circle
68+
cx={bx}
69+
cy={by}
70+
r={r * 1.3}
71+
stroke="#0b0f1a"
72+
strokeWidth={1}
73+
vectorEffect="non-scaling-stroke"
74+
style={{ fill: ACCENT }}
75+
/>
76+
<line
77+
x1={bx - k}
78+
y1={by - k}
79+
x2={bx + k}
80+
y2={by + k}
81+
stroke="#0b0f1a"
82+
strokeWidth={1.5}
83+
vectorEffect="non-scaling-stroke"
84+
/>
85+
<line
86+
x1={bx + k}
87+
y1={by - k}
88+
x2={bx - k}
89+
y2={by + k}
90+
stroke="#0b0f1a"
91+
strokeWidth={1.5}
92+
vectorEffect="non-scaling-stroke"
93+
/>
94+
</g>
95+
)}
96+
</g>
97+
);
98+
}

0 commit comments

Comments
 (0)