@lark-apaas/coding-registry-buildless 0.1.0 → 0.1.2-alpha.0
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/README.md +16 -11
- package/components/android-frame.jsx +189 -0
- package/components/animations.jsx +680 -0
- package/components/browser-window.jsx +122 -0
- package/components/design-canvas.jsx +1125 -0
- package/components/ios-frame.jsx +270 -0
- package/components/macos-window.jsx +197 -0
- package/components/tweaks-panel.jsx +724 -71
- package/package.json +14 -3
- package/scripts/deck-stage.js +1795 -85
|
@@ -0,0 +1,680 @@
|
|
|
1
|
+
/* BEGIN USAGE */
|
|
2
|
+
// animations.jsx
|
|
3
|
+
// Reusable animation starter: Stage, Timeline, Sprite, easing helpers.
|
|
4
|
+
// Exports (to window): Stage, Sprite, PlaybackBar, TextSprite, ImageSprite, RectSprite,
|
|
5
|
+
// useTime, useTimeline, useSprite, Easing, interpolate, animate, clamp.
|
|
6
|
+
//
|
|
7
|
+
// Usage (in an HTML file that loads React + Babel):
|
|
8
|
+
//
|
|
9
|
+
// <Stage width={1280} height={720} duration={10} background="#f6f4ef">
|
|
10
|
+
// <MyScene />
|
|
11
|
+
// </Stage>
|
|
12
|
+
//
|
|
13
|
+
// <Stage> auto-scales to the viewport and provides the scrubber, play/pause,
|
|
14
|
+
// ←/→ seek, space, and 0-to-reset controls, and persists the playhead.
|
|
15
|
+
// Inside <Stage>, any child can call useTime() to read the current
|
|
16
|
+
// playhead (seconds). Or wrap content in <Sprite start={1} end={4}>...</Sprite>
|
|
17
|
+
// to only render during that window -- children receive a `localTime` and
|
|
18
|
+
// `progress` via the useSprite() hook. Use Easing + interpolate()/animate()
|
|
19
|
+
// for tweens; TextSprite / ImageSprite / RectSprite have built-in entry/exit.
|
|
20
|
+
// Build YOUR scenes by composing Sprites inside a Stage.
|
|
21
|
+
/* END USAGE */
|
|
22
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
23
|
+
|
|
24
|
+
// ── Easing functions (hand-rolled, Popmotion-style) ─────────────────────────
|
|
25
|
+
// All easings take t ∈ [0,1] and return eased t ∈ [0,1] (may overshoot for back/elastic).
|
|
26
|
+
const Easing = {
|
|
27
|
+
linear: (t) => t,
|
|
28
|
+
|
|
29
|
+
// Quad
|
|
30
|
+
easeInQuad: (t) => t * t,
|
|
31
|
+
easeOutQuad: (t) => t * (2 - t),
|
|
32
|
+
easeInOutQuad: (t) => (t < 0.5 ? 2 * t * t : -1 + (4 - 2 * t) * t),
|
|
33
|
+
|
|
34
|
+
// Cubic
|
|
35
|
+
easeInCubic: (t) => t * t * t,
|
|
36
|
+
easeOutCubic: (t) => (--t) * t * t + 1,
|
|
37
|
+
easeInOutCubic: (t) => (t < 0.5 ? 4 * t * t * t : (t - 1) * (2 * t - 2) * (2 * t - 2) + 1),
|
|
38
|
+
|
|
39
|
+
// Quart
|
|
40
|
+
easeInQuart: (t) => t * t * t * t,
|
|
41
|
+
easeOutQuart: (t) => 1 - (--t) * t * t * t,
|
|
42
|
+
easeInOutQuart: (t) => (t < 0.5 ? 8 * t * t * t * t : 1 - 8 * (--t) * t * t * t),
|
|
43
|
+
|
|
44
|
+
// Expo
|
|
45
|
+
easeInExpo: (t) => (t === 0 ? 0 : Math.pow(2, 10 * (t - 1))),
|
|
46
|
+
easeOutExpo: (t) => (t === 1 ? 1 : 1 - Math.pow(2, -10 * t)),
|
|
47
|
+
easeInOutExpo: (t) => {
|
|
48
|
+
if (t === 0) return 0;
|
|
49
|
+
if (t === 1) return 1;
|
|
50
|
+
if (t < 0.5) return 0.5 * Math.pow(2, 20 * t - 10);
|
|
51
|
+
return 1 - 0.5 * Math.pow(2, -20 * t + 10);
|
|
52
|
+
},
|
|
53
|
+
|
|
54
|
+
// Sine
|
|
55
|
+
easeInSine: (t) => 1 - Math.cos((t * Math.PI) / 2),
|
|
56
|
+
easeOutSine: (t) => Math.sin((t * Math.PI) / 2),
|
|
57
|
+
easeInOutSine: (t) => -(Math.cos(Math.PI * t) - 1) / 2,
|
|
58
|
+
|
|
59
|
+
// Back (overshoot)
|
|
60
|
+
easeOutBack: (t) => {
|
|
61
|
+
const c1 = 1.70158, c3 = c1 + 1;
|
|
62
|
+
return 1 + c3 * Math.pow(t - 1, 3) + c1 * Math.pow(t - 1, 2);
|
|
63
|
+
},
|
|
64
|
+
easeInBack: (t) => {
|
|
65
|
+
const c1 = 1.70158, c3 = c1 + 1;
|
|
66
|
+
return c3 * t * t * t - c1 * t * t;
|
|
67
|
+
},
|
|
68
|
+
easeInOutBack: (t) => {
|
|
69
|
+
const c1 = 1.70158, c2 = c1 * 1.525;
|
|
70
|
+
return t < 0.5
|
|
71
|
+
? (Math.pow(2 * t, 2) * ((c2 + 1) * 2 * t - c2)) / 2
|
|
72
|
+
: (Math.pow(2 * t - 2, 2) * ((c2 + 1) * (t * 2 - 2) + c2) + 2) / 2;
|
|
73
|
+
},
|
|
74
|
+
|
|
75
|
+
// Elastic
|
|
76
|
+
easeOutElastic: (t) => {
|
|
77
|
+
const c4 = (2 * Math.PI) / 3;
|
|
78
|
+
if (t === 0) return 0;
|
|
79
|
+
if (t === 1) return 1;
|
|
80
|
+
return Math.pow(2, -10 * t) * Math.sin((t * 10 - 0.75) * c4) + 1;
|
|
81
|
+
},
|
|
82
|
+
};
|
|
83
|
+
|
|
84
|
+
// ── Core interpolation helpers ──────────────────────────────────────────────
|
|
85
|
+
|
|
86
|
+
// Clamp a value to [min, max]
|
|
87
|
+
const clamp = (v, min, max) => Math.max(min, Math.min(max, v));
|
|
88
|
+
|
|
89
|
+
// interpolate([0, 0.5, 1], [0, 100, 50], ease?) -> fn(t)
|
|
90
|
+
// Popmotion-style: linearly maps t across input keyframes to output values,
|
|
91
|
+
// with optional easing per segment (single fn or array of fns).
|
|
92
|
+
function interpolate(input, output, ease = Easing.linear) {
|
|
93
|
+
return (t) => {
|
|
94
|
+
if (t <= input[0]) return output[0];
|
|
95
|
+
if (t >= input[input.length - 1]) return output[output.length - 1];
|
|
96
|
+
for (let i = 0; i < input.length - 1; i++) {
|
|
97
|
+
if (t >= input[i] && t <= input[i + 1]) {
|
|
98
|
+
const span = input[i + 1] - input[i];
|
|
99
|
+
const local = span === 0 ? 0 : (t - input[i]) / span;
|
|
100
|
+
const easeFn = Array.isArray(ease) ? (ease[i] || Easing.linear) : ease;
|
|
101
|
+
const eased = easeFn(local);
|
|
102
|
+
return output[i] + (output[i + 1] - output[i]) * eased;
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
return output[output.length - 1];
|
|
106
|
+
};
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
// animate({from, to, start, end, ease})(t) — simpler single-segment tween.
|
|
110
|
+
// Returns `from` before `start`, `to` after `end`.
|
|
111
|
+
function animate({ from = 0, to = 1, start = 0, end = 1, ease = Easing.easeInOutCubic }) {
|
|
112
|
+
return (t) => {
|
|
113
|
+
if (t <= start) return from;
|
|
114
|
+
if (t >= end) return to;
|
|
115
|
+
const local = (t - start) / (end - start);
|
|
116
|
+
return from + (to - from) * ease(local);
|
|
117
|
+
};
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
// ── Timeline context ────────────────────────────────────────────────────────
|
|
121
|
+
|
|
122
|
+
const TimelineContext = React.createContext({ time: 0, duration: 10, playing: false });
|
|
123
|
+
|
|
124
|
+
const useTime = () => React.useContext(TimelineContext).time;
|
|
125
|
+
const useTimeline = () => React.useContext(TimelineContext);
|
|
126
|
+
|
|
127
|
+
// ── Sprite ──────────────────────────────────────────────────────────────────
|
|
128
|
+
// Renders children only when the playhead is inside [start, end]. Provides
|
|
129
|
+
// a sub-context with `localTime` (seconds since start) and `progress` (0..1).
|
|
130
|
+
//
|
|
131
|
+
// <Sprite start={2} end={5}>
|
|
132
|
+
// {({ localTime, progress }) => <Thing x={progress * 100} />}
|
|
133
|
+
// </Sprite>
|
|
134
|
+
//
|
|
135
|
+
// Or as a plain wrapper — children can call useSprite() themselves.
|
|
136
|
+
|
|
137
|
+
const SpriteContext = React.createContext({ localTime: 0, progress: 0, duration: 0 });
|
|
138
|
+
const useSprite = () => React.useContext(SpriteContext);
|
|
139
|
+
|
|
140
|
+
function Sprite({ start = 0, end = Infinity, children, keepMounted = false }) {
|
|
141
|
+
const { time } = useTimeline();
|
|
142
|
+
const visible = time >= start && time <= end;
|
|
143
|
+
if (!visible && !keepMounted) return null;
|
|
144
|
+
|
|
145
|
+
const duration = end - start;
|
|
146
|
+
const localTime = Math.max(0, time - start);
|
|
147
|
+
const progress = duration > 0 && isFinite(duration)
|
|
148
|
+
? clamp(localTime / duration, 0, 1)
|
|
149
|
+
: 0;
|
|
150
|
+
|
|
151
|
+
const value = { localTime, progress, duration, visible };
|
|
152
|
+
|
|
153
|
+
return (
|
|
154
|
+
<SpriteContext.Provider value={value}>
|
|
155
|
+
{typeof children === 'function' ? children(value) : children}
|
|
156
|
+
</SpriteContext.Provider>
|
|
157
|
+
);
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
// ── Sample sprite components ────────────────────────────────────────────────
|
|
161
|
+
|
|
162
|
+
// TextSprite: fades/slides text in on entry, holds, then fades out on exit.
|
|
163
|
+
// Props: text, x, y, size, color, font, entryDur, exitDur, align
|
|
164
|
+
function TextSprite({
|
|
165
|
+
text,
|
|
166
|
+
x = 0, y = 0,
|
|
167
|
+
size = 48,
|
|
168
|
+
color = '#111',
|
|
169
|
+
font = 'Inter, system-ui, sans-serif',
|
|
170
|
+
weight = 600,
|
|
171
|
+
entryDur = 0.45,
|
|
172
|
+
exitDur = 0.35,
|
|
173
|
+
entryEase = Easing.easeOutBack,
|
|
174
|
+
exitEase = Easing.easeInCubic,
|
|
175
|
+
align = 'left',
|
|
176
|
+
letterSpacing = '-0.01em',
|
|
177
|
+
}) {
|
|
178
|
+
const { localTime, duration } = useSprite();
|
|
179
|
+
const exitStart = Math.max(0, duration - exitDur);
|
|
180
|
+
|
|
181
|
+
let opacity = 1;
|
|
182
|
+
let ty = 0;
|
|
183
|
+
|
|
184
|
+
if (localTime < entryDur) {
|
|
185
|
+
const t = entryEase(clamp(localTime / entryDur, 0, 1));
|
|
186
|
+
opacity = t;
|
|
187
|
+
ty = (1 - t) * 16;
|
|
188
|
+
} else if (localTime > exitStart) {
|
|
189
|
+
const t = exitEase(clamp((localTime - exitStart) / exitDur, 0, 1));
|
|
190
|
+
opacity = 1 - t;
|
|
191
|
+
ty = -t * 8;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
const translateX = align === 'center' ? '-50%' : align === 'right' ? '-100%' : '0';
|
|
195
|
+
|
|
196
|
+
return (
|
|
197
|
+
<div style={{
|
|
198
|
+
position: 'absolute',
|
|
199
|
+
left: x, top: y,
|
|
200
|
+
transform: `translate(${translateX}, ${ty}px)`,
|
|
201
|
+
opacity,
|
|
202
|
+
fontFamily: font,
|
|
203
|
+
fontSize: size,
|
|
204
|
+
fontWeight: weight,
|
|
205
|
+
color,
|
|
206
|
+
letterSpacing,
|
|
207
|
+
whiteSpace: 'pre',
|
|
208
|
+
lineHeight: 1.1,
|
|
209
|
+
willChange: 'transform, opacity',
|
|
210
|
+
}}>
|
|
211
|
+
{text}
|
|
212
|
+
</div>
|
|
213
|
+
);
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
// ImageSprite: scales + fades in; optional Ken Burns drift during hold.
|
|
217
|
+
function ImageSprite({
|
|
218
|
+
src,
|
|
219
|
+
x = 0, y = 0,
|
|
220
|
+
width = 400, height = 300,
|
|
221
|
+
entryDur = 0.6,
|
|
222
|
+
exitDur = 0.4,
|
|
223
|
+
kenBurns = false,
|
|
224
|
+
kenBurnsScale = 1.08,
|
|
225
|
+
radius = 12,
|
|
226
|
+
fit = 'cover',
|
|
227
|
+
placeholder = null, // {label: string} for striped placeholder
|
|
228
|
+
}) {
|
|
229
|
+
const { localTime, duration } = useSprite();
|
|
230
|
+
const exitStart = Math.max(0, duration - exitDur);
|
|
231
|
+
|
|
232
|
+
let opacity = 1;
|
|
233
|
+
let scale = 1;
|
|
234
|
+
|
|
235
|
+
if (localTime < entryDur) {
|
|
236
|
+
const t = Easing.easeOutCubic(clamp(localTime / entryDur, 0, 1));
|
|
237
|
+
opacity = t;
|
|
238
|
+
scale = 0.96 + 0.04 * t;
|
|
239
|
+
} else if (localTime > exitStart) {
|
|
240
|
+
const t = Easing.easeInCubic(clamp((localTime - exitStart) / exitDur, 0, 1));
|
|
241
|
+
opacity = 1 - t;
|
|
242
|
+
scale = (kenBurns ? kenBurnsScale : 1) + 0.02 * t;
|
|
243
|
+
} else if (kenBurns) {
|
|
244
|
+
const holdSpan = exitStart - entryDur;
|
|
245
|
+
const holdT = holdSpan > 0 ? (localTime - entryDur) / holdSpan : 0;
|
|
246
|
+
scale = 1 + (kenBurnsScale - 1) * holdT;
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
const content = placeholder ? (
|
|
250
|
+
<div style={{
|
|
251
|
+
width: '100%', height: '100%',
|
|
252
|
+
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
|
253
|
+
background: 'repeating-linear-gradient(135deg, #e9e6df 0 10px, #dcd8cf 10px 20px)',
|
|
254
|
+
color: '#6b6458',
|
|
255
|
+
fontFamily: 'JetBrains Mono, ui-monospace, monospace',
|
|
256
|
+
fontSize: 13,
|
|
257
|
+
letterSpacing: '0.04em',
|
|
258
|
+
textTransform: 'uppercase',
|
|
259
|
+
}}>
|
|
260
|
+
{placeholder.label || 'image'}
|
|
261
|
+
</div>
|
|
262
|
+
) : (
|
|
263
|
+
<img src={src} alt="" style={{ width: '100%', height: '100%', objectFit: fit, display: 'block' }} />
|
|
264
|
+
);
|
|
265
|
+
|
|
266
|
+
return (
|
|
267
|
+
<div style={{
|
|
268
|
+
position: 'absolute',
|
|
269
|
+
left: x, top: y,
|
|
270
|
+
width, height,
|
|
271
|
+
opacity,
|
|
272
|
+
transform: `scale(${scale})`,
|
|
273
|
+
transformOrigin: 'center',
|
|
274
|
+
borderRadius: radius,
|
|
275
|
+
overflow: 'hidden',
|
|
276
|
+
willChange: 'transform, opacity',
|
|
277
|
+
}}>
|
|
278
|
+
{content}
|
|
279
|
+
</div>
|
|
280
|
+
);
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
// RectSprite: simple rectangle that animates position/size/color via props.
|
|
284
|
+
// Useful demo primitive — takes a `render` fn for per-frame customization.
|
|
285
|
+
function RectSprite({
|
|
286
|
+
x = 0, y = 0,
|
|
287
|
+
width = 100, height = 100,
|
|
288
|
+
color = '#111',
|
|
289
|
+
radius = 8,
|
|
290
|
+
entryDur = 0.4,
|
|
291
|
+
exitDur = 0.3,
|
|
292
|
+
render, // optional: (ctx) => style overrides
|
|
293
|
+
}) {
|
|
294
|
+
const spriteCtx = useSprite();
|
|
295
|
+
const { localTime, duration } = spriteCtx;
|
|
296
|
+
const exitStart = Math.max(0, duration - exitDur);
|
|
297
|
+
|
|
298
|
+
let opacity = 1;
|
|
299
|
+
let scale = 1;
|
|
300
|
+
|
|
301
|
+
if (localTime < entryDur) {
|
|
302
|
+
const t = Easing.easeOutBack(clamp(localTime / entryDur, 0, 1));
|
|
303
|
+
opacity = clamp(localTime / entryDur, 0, 1);
|
|
304
|
+
scale = 0.4 + 0.6 * t;
|
|
305
|
+
} else if (localTime > exitStart) {
|
|
306
|
+
const t = Easing.easeInQuad(clamp((localTime - exitStart) / exitDur, 0, 1));
|
|
307
|
+
opacity = 1 - t;
|
|
308
|
+
scale = 1 - 0.15 * t;
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
const overrides = render ? render(spriteCtx) : {};
|
|
312
|
+
|
|
313
|
+
return (
|
|
314
|
+
<div style={{
|
|
315
|
+
position: 'absolute',
|
|
316
|
+
left: x, top: y,
|
|
317
|
+
width, height,
|
|
318
|
+
background: color,
|
|
319
|
+
borderRadius: radius,
|
|
320
|
+
opacity,
|
|
321
|
+
transform: `scale(${scale})`,
|
|
322
|
+
transformOrigin: 'center',
|
|
323
|
+
willChange: 'transform, opacity',
|
|
324
|
+
...overrides,
|
|
325
|
+
}} />
|
|
326
|
+
);
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
|
|
330
|
+
function Stage({
|
|
331
|
+
width = 1280,
|
|
332
|
+
height = 720,
|
|
333
|
+
duration = 10,
|
|
334
|
+
background = '#f6f4ef',
|
|
335
|
+
fps = 60,
|
|
336
|
+
loop = true,
|
|
337
|
+
autoplay = true,
|
|
338
|
+
persistKey = 'animstage',
|
|
339
|
+
children,
|
|
340
|
+
}) {
|
|
341
|
+
const [time, setTime] = React.useState(() => {
|
|
342
|
+
try {
|
|
343
|
+
const v = parseFloat(localStorage.getItem(persistKey + ':t') || '0');
|
|
344
|
+
return isFinite(v) ? clamp(v, 0, duration) : 0;
|
|
345
|
+
} catch { return 0; }
|
|
346
|
+
});
|
|
347
|
+
const [playing, setPlaying] = React.useState(autoplay);
|
|
348
|
+
const [hoverTime, setHoverTime] = React.useState(null);
|
|
349
|
+
const [scale, setScale] = React.useState(1);
|
|
350
|
+
|
|
351
|
+
const stageRef = React.useRef(null);
|
|
352
|
+
const canvasRef = React.useRef(null);
|
|
353
|
+
const rafRef = React.useRef(null);
|
|
354
|
+
const lastTsRef = React.useRef(null);
|
|
355
|
+
|
|
356
|
+
// Persist playhead
|
|
357
|
+
React.useEffect(() => {
|
|
358
|
+
try { localStorage.setItem(persistKey + ':t', String(time)); } catch {}
|
|
359
|
+
}, [time, persistKey]);
|
|
360
|
+
|
|
361
|
+
// Auto-scale to fit viewport
|
|
362
|
+
React.useEffect(() => {
|
|
363
|
+
if (!stageRef.current) return;
|
|
364
|
+
const el = stageRef.current;
|
|
365
|
+
const measure = () => {
|
|
366
|
+
const barH = 44; // playback bar height
|
|
367
|
+
const s = Math.min(
|
|
368
|
+
el.clientWidth / width,
|
|
369
|
+
(el.clientHeight - barH) / height
|
|
370
|
+
);
|
|
371
|
+
setScale(Math.max(0.05, s));
|
|
372
|
+
};
|
|
373
|
+
measure();
|
|
374
|
+
const ro = new ResizeObserver(measure);
|
|
375
|
+
ro.observe(el);
|
|
376
|
+
window.addEventListener('resize', measure);
|
|
377
|
+
return () => {
|
|
378
|
+
ro.disconnect();
|
|
379
|
+
window.removeEventListener('resize', measure);
|
|
380
|
+
};
|
|
381
|
+
}, [width, height]);
|
|
382
|
+
|
|
383
|
+
// Animation loop
|
|
384
|
+
React.useEffect(() => {
|
|
385
|
+
if (!playing) {
|
|
386
|
+
lastTsRef.current = null;
|
|
387
|
+
return;
|
|
388
|
+
}
|
|
389
|
+
const step = (ts) => {
|
|
390
|
+
if (lastTsRef.current == null) lastTsRef.current = ts;
|
|
391
|
+
const dt = (ts - lastTsRef.current) / 1000;
|
|
392
|
+
lastTsRef.current = ts;
|
|
393
|
+
setTime((t) => {
|
|
394
|
+
let next = t + dt;
|
|
395
|
+
if (next >= duration) {
|
|
396
|
+
if (loop) next = next % duration;
|
|
397
|
+
else { next = duration; setPlaying(false); }
|
|
398
|
+
}
|
|
399
|
+
return next;
|
|
400
|
+
});
|
|
401
|
+
rafRef.current = requestAnimationFrame(step);
|
|
402
|
+
};
|
|
403
|
+
rafRef.current = requestAnimationFrame(step);
|
|
404
|
+
return () => {
|
|
405
|
+
if (rafRef.current) cancelAnimationFrame(rafRef.current);
|
|
406
|
+
lastTsRef.current = null;
|
|
407
|
+
};
|
|
408
|
+
}, [playing, duration, loop]);
|
|
409
|
+
|
|
410
|
+
// Keyboard: space = play/pause, ← → = seek
|
|
411
|
+
React.useEffect(() => {
|
|
412
|
+
const onKey = (e) => {
|
|
413
|
+
if (e.target && (e.target.tagName === 'INPUT' || e.target.tagName === 'TEXTAREA')) return;
|
|
414
|
+
if (e.code === 'Space') {
|
|
415
|
+
e.preventDefault();
|
|
416
|
+
setPlaying(p => !p);
|
|
417
|
+
} else if (e.code === 'ArrowLeft') {
|
|
418
|
+
setTime(t => clamp(t - (e.shiftKey ? 1 : 0.1), 0, duration));
|
|
419
|
+
} else if (e.code === 'ArrowRight') {
|
|
420
|
+
setTime(t => clamp(t + (e.shiftKey ? 1 : 0.1), 0, duration));
|
|
421
|
+
} else if (e.key === '0' || e.code === 'Home') {
|
|
422
|
+
setTime(0);
|
|
423
|
+
}
|
|
424
|
+
};
|
|
425
|
+
window.addEventListener('keydown', onKey);
|
|
426
|
+
return () => window.removeEventListener('keydown', onKey);
|
|
427
|
+
}, [duration]);
|
|
428
|
+
|
|
429
|
+
const displayTime = hoverTime != null ? hoverTime : time;
|
|
430
|
+
|
|
431
|
+
const ctxValue = React.useMemo(
|
|
432
|
+
() => ({ time: displayTime, duration, playing, setTime, setPlaying }),
|
|
433
|
+
[displayTime, duration, playing]
|
|
434
|
+
);
|
|
435
|
+
|
|
436
|
+
return (
|
|
437
|
+
<div
|
|
438
|
+
ref={stageRef}
|
|
439
|
+
style={{
|
|
440
|
+
position: 'absolute', inset: 0,
|
|
441
|
+
display: 'flex', flexDirection: 'column',
|
|
442
|
+
alignItems: 'center',
|
|
443
|
+
background: '#0a0a0a',
|
|
444
|
+
fontFamily: 'Inter, system-ui, sans-serif',
|
|
445
|
+
}}
|
|
446
|
+
>
|
|
447
|
+
{/* Canvas area — vertically centered in remaining space */}
|
|
448
|
+
<div style={{
|
|
449
|
+
flex: 1,
|
|
450
|
+
width: '100%',
|
|
451
|
+
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
|
452
|
+
overflow: 'hidden',
|
|
453
|
+
minHeight: 0,
|
|
454
|
+
}}>
|
|
455
|
+
<div
|
|
456
|
+
ref={canvasRef}
|
|
457
|
+
style={{
|
|
458
|
+
width, height,
|
|
459
|
+
background,
|
|
460
|
+
position: 'relative',
|
|
461
|
+
transform: `scale(${scale})`,
|
|
462
|
+
transformOrigin: 'center',
|
|
463
|
+
flexShrink: 0,
|
|
464
|
+
boxShadow: '0 20px 60px rgba(0,0,0,0.4)',
|
|
465
|
+
overflow: 'hidden',
|
|
466
|
+
}}
|
|
467
|
+
>
|
|
468
|
+
<TimelineContext.Provider value={ctxValue}>
|
|
469
|
+
{children}
|
|
470
|
+
</TimelineContext.Provider>
|
|
471
|
+
</div>
|
|
472
|
+
</div>
|
|
473
|
+
|
|
474
|
+
{/* Playback bar — stacked below canvas, never overlapping */}
|
|
475
|
+
<PlaybackBar
|
|
476
|
+
time={displayTime}
|
|
477
|
+
actualTime={time}
|
|
478
|
+
duration={duration}
|
|
479
|
+
playing={playing}
|
|
480
|
+
onPlayPause={() => setPlaying(p => !p)}
|
|
481
|
+
onReset={() => { setTime(0); }}
|
|
482
|
+
onSeek={(t) => setTime(t)}
|
|
483
|
+
onHover={(t) => setHoverTime(t)}
|
|
484
|
+
/>
|
|
485
|
+
</div>
|
|
486
|
+
);
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
// ── Playback bar ────────────────────────────────────────────────────────────
|
|
490
|
+
// Play/pause, return-to-begin, scrub track, time display.
|
|
491
|
+
// Uses fixed-width time fields so layout doesn't thrash.
|
|
492
|
+
|
|
493
|
+
function PlaybackBar({ time, duration, playing, onPlayPause, onReset, onSeek, onHover }) {
|
|
494
|
+
const trackRef = React.useRef(null);
|
|
495
|
+
const [dragging, setDragging] = React.useState(false);
|
|
496
|
+
|
|
497
|
+
const timeFromEvent = React.useCallback((e) => {
|
|
498
|
+
const rect = trackRef.current.getBoundingClientRect();
|
|
499
|
+
const x = clamp((e.clientX - rect.left) / rect.width, 0, 1);
|
|
500
|
+
return x * duration;
|
|
501
|
+
}, [duration]);
|
|
502
|
+
|
|
503
|
+
const onTrackMove = (e) => {
|
|
504
|
+
if (!trackRef.current) return;
|
|
505
|
+
const t = timeFromEvent(e);
|
|
506
|
+
if (dragging) {
|
|
507
|
+
onSeek(t);
|
|
508
|
+
} else {
|
|
509
|
+
onHover(t);
|
|
510
|
+
}
|
|
511
|
+
};
|
|
512
|
+
|
|
513
|
+
const onTrackLeave = () => {
|
|
514
|
+
if (!dragging) onHover(null);
|
|
515
|
+
};
|
|
516
|
+
|
|
517
|
+
const onTrackDown = (e) => {
|
|
518
|
+
setDragging(true);
|
|
519
|
+
const t = timeFromEvent(e);
|
|
520
|
+
onSeek(t);
|
|
521
|
+
onHover(null);
|
|
522
|
+
};
|
|
523
|
+
|
|
524
|
+
React.useEffect(() => {
|
|
525
|
+
if (!dragging) return;
|
|
526
|
+
const onUp = () => setDragging(false);
|
|
527
|
+
const onMove = (e) => {
|
|
528
|
+
if (!trackRef.current) return;
|
|
529
|
+
const t = timeFromEvent(e);
|
|
530
|
+
onSeek(t);
|
|
531
|
+
};
|
|
532
|
+
window.addEventListener('mouseup', onUp);
|
|
533
|
+
window.addEventListener('mousemove', onMove);
|
|
534
|
+
return () => {
|
|
535
|
+
window.removeEventListener('mouseup', onUp);
|
|
536
|
+
window.removeEventListener('mousemove', onMove);
|
|
537
|
+
};
|
|
538
|
+
}, [dragging, timeFromEvent, onSeek]);
|
|
539
|
+
|
|
540
|
+
const pct = duration > 0 ? (time / duration) * 100 : 0;
|
|
541
|
+
const fmt = (t) => {
|
|
542
|
+
const total = Math.max(0, t);
|
|
543
|
+
const m = Math.floor(total / 60);
|
|
544
|
+
const s = Math.floor(total % 60);
|
|
545
|
+
const cs = Math.floor((total * 100) % 100);
|
|
546
|
+
return `${String(m).padStart(1, '0')}:${String(s).padStart(2, '0')}.${String(cs).padStart(2, '0')}`;
|
|
547
|
+
};
|
|
548
|
+
|
|
549
|
+
const mono = 'JetBrains Mono, ui-monospace, SFMono-Regular, monospace';
|
|
550
|
+
|
|
551
|
+
return (
|
|
552
|
+
<div style={{
|
|
553
|
+
display: 'flex', alignItems: 'center', gap: 12,
|
|
554
|
+
padding: '8px 16px',
|
|
555
|
+
background: 'rgba(20,20,20,0.92)',
|
|
556
|
+
borderTop: '1px solid rgba(255,255,255,0.08)',
|
|
557
|
+
width: '100%',
|
|
558
|
+
maxWidth: 680,
|
|
559
|
+
alignSelf: 'center',
|
|
560
|
+
|
|
561
|
+
borderRadius: 8,
|
|
562
|
+
color: '#f6f4ef',
|
|
563
|
+
fontFamily: 'Inter, system-ui, sans-serif',
|
|
564
|
+
userSelect: 'none',
|
|
565
|
+
flexShrink: 0,
|
|
566
|
+
}}>
|
|
567
|
+
<IconButton onClick={onReset} title="Return to start (0)">
|
|
568
|
+
<svg width="14" height="14" viewBox="0 0 14 14" fill="none">
|
|
569
|
+
<path d="M3 2v10M12 2L5 7l7 5V2z" stroke="currentColor" strokeWidth="1.5" strokeLinejoin="round" strokeLinecap="round"/>
|
|
570
|
+
</svg>
|
|
571
|
+
</IconButton>
|
|
572
|
+
<IconButton onClick={onPlayPause} title="Play/pause (space)">
|
|
573
|
+
{playing ? (
|
|
574
|
+
<svg width="14" height="14" viewBox="0 0 14 14" fill="none">
|
|
575
|
+
<rect x="3" y="2" width="3" height="10" fill="currentColor"/>
|
|
576
|
+
<rect x="8" y="2" width="3" height="10" fill="currentColor"/>
|
|
577
|
+
</svg>
|
|
578
|
+
) : (
|
|
579
|
+
<svg width="14" height="14" viewBox="0 0 14 14" fill="none">
|
|
580
|
+
<path d="M3 2l9 5-9 5V2z" fill="currentColor"/>
|
|
581
|
+
</svg>
|
|
582
|
+
)}
|
|
583
|
+
</IconButton>
|
|
584
|
+
|
|
585
|
+
{/* Current time: fixed width so it doesn't thrash */}
|
|
586
|
+
<div style={{
|
|
587
|
+
fontFamily: mono,
|
|
588
|
+
fontSize: 12,
|
|
589
|
+
fontVariantNumeric: 'tabular-nums',
|
|
590
|
+
width: 64, textAlign: 'right',
|
|
591
|
+
color: '#f6f4ef',
|
|
592
|
+
}}>
|
|
593
|
+
{fmt(time)}
|
|
594
|
+
</div>
|
|
595
|
+
|
|
596
|
+
{/* Scrub track */}
|
|
597
|
+
<div
|
|
598
|
+
ref={trackRef}
|
|
599
|
+
onMouseMove={onTrackMove}
|
|
600
|
+
onMouseLeave={onTrackLeave}
|
|
601
|
+
onMouseDown={onTrackDown}
|
|
602
|
+
style={{
|
|
603
|
+
flex: 1,
|
|
604
|
+
height: 22,
|
|
605
|
+
position: 'relative',
|
|
606
|
+
cursor: 'pointer',
|
|
607
|
+
display: 'flex', alignItems: 'center',
|
|
608
|
+
}}
|
|
609
|
+
>
|
|
610
|
+
<div style={{
|
|
611
|
+
position: 'absolute',
|
|
612
|
+
left: 0, right: 0, height: 4,
|
|
613
|
+
background: 'rgba(255,255,255,0.12)',
|
|
614
|
+
borderRadius: 2,
|
|
615
|
+
}}/>
|
|
616
|
+
<div style={{
|
|
617
|
+
position: 'absolute',
|
|
618
|
+
left: 0, width: `${pct}%`, height: 4,
|
|
619
|
+
background: 'oklch(72% 0.12 250)',
|
|
620
|
+
borderRadius: 2,
|
|
621
|
+
}}/>
|
|
622
|
+
<div style={{
|
|
623
|
+
position: 'absolute',
|
|
624
|
+
left: `${pct}%`, top: '50%',
|
|
625
|
+
width: 12, height: 12,
|
|
626
|
+
marginLeft: -6, marginTop: -6,
|
|
627
|
+
background: '#fff',
|
|
628
|
+
borderRadius: 6,
|
|
629
|
+
boxShadow: '0 2px 4px rgba(0,0,0,0.4)',
|
|
630
|
+
}}/>
|
|
631
|
+
</div>
|
|
632
|
+
|
|
633
|
+
{/* Duration: fixed width */}
|
|
634
|
+
<div style={{
|
|
635
|
+
fontFamily: mono,
|
|
636
|
+
fontSize: 12,
|
|
637
|
+
fontVariantNumeric: 'tabular-nums',
|
|
638
|
+
width: 64, textAlign: 'left',
|
|
639
|
+
color: 'rgba(246,244,239,0.55)',
|
|
640
|
+
}}>
|
|
641
|
+
{fmt(duration)}
|
|
642
|
+
</div>
|
|
643
|
+
</div>
|
|
644
|
+
);
|
|
645
|
+
}
|
|
646
|
+
|
|
647
|
+
function IconButton({ children, onClick, title }) {
|
|
648
|
+
const [hover, setHover] = React.useState(false);
|
|
649
|
+
return (
|
|
650
|
+
<button
|
|
651
|
+
onClick={onClick}
|
|
652
|
+
title={title}
|
|
653
|
+
onMouseEnter={() => setHover(true)}
|
|
654
|
+
onMouseLeave={() => setHover(false)}
|
|
655
|
+
style={{
|
|
656
|
+
width: 28, height: 28,
|
|
657
|
+
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
|
658
|
+
background: hover ? 'rgba(255,255,255,0.12)' : 'rgba(255,255,255,0.04)',
|
|
659
|
+
border: '1px solid rgba(255,255,255,0.1)',
|
|
660
|
+
borderRadius: 6,
|
|
661
|
+
color: '#f6f4ef',
|
|
662
|
+
cursor: 'pointer',
|
|
663
|
+
padding: 0,
|
|
664
|
+
transition: 'background 120ms',
|
|
665
|
+
}}
|
|
666
|
+
>
|
|
667
|
+
{children}
|
|
668
|
+
</button>
|
|
669
|
+
);
|
|
670
|
+
}
|
|
671
|
+
|
|
672
|
+
|
|
673
|
+
Object.assign(window, {
|
|
674
|
+
Easing, interpolate, animate, clamp,
|
|
675
|
+
TimelineContext, useTime, useTimeline,
|
|
676
|
+
Sprite, SpriteContext, useSprite,
|
|
677
|
+
TextSprite, ImageSprite, RectSprite,
|
|
678
|
+
Stage, PlaybackBar,
|
|
679
|
+
});
|
|
680
|
+
|