Skip to content

Commit a8c1a94

Browse files
committed
feat(studio): gesture recording core
Add gesture recording engine with RAF sampling, modifier key property mapping (Shift→rotationXY, Alt→rotation, Cmd→opacity), Ramer-Douglas-Peucker simplification, and ghost trail SVG overlay.
1 parent 78da2c7 commit a8c1a94

3 files changed

Lines changed: 655 additions & 0 deletions

File tree

Lines changed: 132 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,132 @@
1+
import { memo, useMemo } from "react";
2+
import type { GestureSample } from "../../hooks/useGestureRecording";
3+
4+
interface GestureTrailOverlayProps {
5+
samples: GestureSample[];
6+
sampleCount?: number;
7+
trail?: Array<{ x: number; y: number }>;
8+
simplifiedPoints?: Map<number, Record<string, number>>;
9+
canvasRect: { left: number; top: number; width: number; height: number };
10+
compositionSize?: { width: number; height: number };
11+
mode: "recording" | "preview";
12+
accentColor?: string;
13+
}
14+
15+
export const GestureTrailOverlay = memo(function GestureTrailOverlay({
16+
samples,
17+
sampleCount,
18+
trail,
19+
simplifiedPoints,
20+
canvasRect,
21+
compositionSize,
22+
mode,
23+
accentColor = "#3CE6AC",
24+
}: GestureTrailOverlayProps) {
25+
const trailPoints = useMemo(() => {
26+
if (trail && trail.length > 1) {
27+
return trail.map((p) => `${p.x - canvasRect.left},${p.y - canvasRect.top}`).join(" ");
28+
}
29+
if (samples.length === 0) return "";
30+
return samples
31+
.filter((s) => s.properties.x != null && s.properties.y != null)
32+
.map((s) => `${s.properties.x},${s.properties.y}`)
33+
.join(" ");
34+
// eslint-disable-next-line react-hooks/exhaustive-deps
35+
}, [samples, trail, sampleCount, canvasRect.left, canvasRect.top]);
36+
37+
const simplifiedPath = useMemo(() => {
38+
if (!simplifiedPoints || simplifiedPoints.size === 0) return "";
39+
const pts: Array<{ x: number; y: number; pct: number }> = [];
40+
for (const [pct, props] of simplifiedPoints) {
41+
if (props.x != null && props.y != null) {
42+
pts.push({ x: props.x, y: props.y, pct });
43+
}
44+
}
45+
pts.sort((a, b) => a.pct - b.pct);
46+
if (pts.length === 0) return "";
47+
return pts.map((p) => `${p.x},${p.y}`).join(" ");
48+
}, [simplifiedPoints]);
49+
50+
const diamondPositions = useMemo(() => {
51+
if (!simplifiedPoints || simplifiedPoints.size === 0) return [];
52+
const pts: Array<{ x: number; y: number; pct: number }> = [];
53+
for (const [pct, props] of simplifiedPoints) {
54+
if (props.x != null && props.y != null) {
55+
pts.push({ x: props.x, y: props.y, pct });
56+
}
57+
}
58+
return pts.sort((a, b) => a.pct - b.pct);
59+
}, [simplifiedPoints]);
60+
61+
if (samples.length < 2 && !simplifiedPoints) return null;
62+
63+
return (
64+
<svg
65+
className="pointer-events-none fixed z-50"
66+
style={{
67+
left: canvasRect.left,
68+
top: canvasRect.top,
69+
width: canvasRect.width,
70+
height: canvasRect.height,
71+
}}
72+
viewBox={
73+
trail && trail.length > 1
74+
? `0 0 ${canvasRect.width} ${canvasRect.height}`
75+
: `0 0 ${compositionSize?.width ?? canvasRect.width} ${compositionSize?.height ?? canvasRect.height}`
76+
}
77+
>
78+
{mode === "recording" && trailPoints && (
79+
<polyline
80+
points={trailPoints}
81+
fill="none"
82+
stroke={accentColor}
83+
strokeWidth="2"
84+
strokeOpacity="0.6"
85+
strokeLinecap="round"
86+
strokeLinejoin="round"
87+
/>
88+
)}
89+
90+
{mode === "preview" && (
91+
<>
92+
{trailPoints && (
93+
<polyline
94+
points={trailPoints}
95+
fill="none"
96+
stroke={accentColor}
97+
strokeWidth="1"
98+
strokeOpacity="0.2"
99+
strokeDasharray="4 3"
100+
strokeLinecap="round"
101+
/>
102+
)}
103+
{simplifiedPath && (
104+
<polyline
105+
points={simplifiedPath}
106+
fill="none"
107+
stroke={accentColor}
108+
strokeWidth="2"
109+
strokeOpacity="0.8"
110+
strokeLinecap="round"
111+
strokeLinejoin="round"
112+
/>
113+
)}
114+
{diamondPositions.map((pt) => (
115+
<g key={pt.pct} transform={`translate(${pt.x}, ${pt.y})`}>
116+
<rect
117+
x="-4"
118+
y="-4"
119+
width="8"
120+
height="8"
121+
rx="1"
122+
transform="rotate(45)"
123+
fill={accentColor}
124+
fillOpacity="0.9"
125+
/>
126+
</g>
127+
))}
128+
</>
129+
)}
130+
</svg>
131+
);
132+
});

0 commit comments

Comments
 (0)