feat: page interactive, démos Play/Step et simulateur Carnot
Ajoute le pipeline PageSpec (validation, rendu, publication /p/{slug}),
les démos TipTap /demo, et le simulateur Carnot (modes frigo/PAC/moteur,
énergie kJ vs puissance W, unités K/°C/°F) avec correctifs d’équations KaTeX.
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
231
memento-note/components/simulators/anim-player-shell.tsx
Normal file
231
memento-note/components/simulators/anim-player-shell.tsx
Normal file
@@ -0,0 +1,231 @@
|
||||
'use client'
|
||||
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import { Pause, Play, RotateCcw, StepForward } from 'lucide-react'
|
||||
import { PageMd } from '@/components/interactive-page/page-md'
|
||||
import type { AnimBeat, SimI18n } from '@/lib/simulators'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
const WPM = 220
|
||||
const STEP_FLOOR_MS = 1500
|
||||
const STEP_CEIL_MS = 6000
|
||||
const SPEEDS = [0.5, 1, 2, 4]
|
||||
|
||||
function stepDurationMs(speak: string, speed: number): number {
|
||||
const words = speak.trim().split(/\s+/).filter(Boolean).length
|
||||
const raw = words * (60_000 / WPM)
|
||||
return Math.min(STEP_CEIL_MS, Math.max(STEP_FLOOR_MS, raw)) / speed
|
||||
}
|
||||
|
||||
function usePrefersReducedMotion(): boolean {
|
||||
const [reduced, setReduced] = useState(false)
|
||||
useEffect(() => {
|
||||
const mq = window.matchMedia('(prefers-reduced-motion: reduce)')
|
||||
setReduced(mq.matches)
|
||||
const onChange = () => setReduced(mq.matches)
|
||||
mq.addEventListener('change', onChange)
|
||||
return () => mq.removeEventListener('change', onChange)
|
||||
}, [])
|
||||
return reduced
|
||||
}
|
||||
|
||||
export type AnimPlayerShellProps = {
|
||||
beats: AnimBeat[]
|
||||
lang: string
|
||||
disclaimer?: SimI18n
|
||||
/** Scene renderer — receives the current beat index (0-based). */
|
||||
children: (stepIndex: number) => React.ReactNode
|
||||
}
|
||||
|
||||
/**
|
||||
* Shared chrome for curated animation plugins: Play/Pause/Step/Reset/Speed
|
||||
* + narration panel, same pacing rules as InteractiveDemoPlayer.
|
||||
*/
|
||||
export function AnimPlayerShell({
|
||||
beats,
|
||||
lang,
|
||||
disclaimer,
|
||||
children,
|
||||
}: AnimPlayerShellProps) {
|
||||
const fr = lang.startsWith('fr')
|
||||
const reducedMotion = usePrefersReducedMotion()
|
||||
const [stepIndex, setStepIndex] = useState(0)
|
||||
const [playing, setPlaying] = useState(false)
|
||||
const [speed, setSpeed] = useState(1)
|
||||
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
|
||||
/** Keyboard shortcuts only fire for the hovered/focused player. */
|
||||
const activeRef = useRef(false)
|
||||
|
||||
const clearTimer = useCallback(() => {
|
||||
if (timerRef.current) {
|
||||
clearTimeout(timerRef.current)
|
||||
timerRef.current = null
|
||||
}
|
||||
}, [])
|
||||
|
||||
const atLast = stepIndex >= beats.length - 1
|
||||
|
||||
useEffect(() => {
|
||||
clearTimer()
|
||||
if (!playing) return
|
||||
const beat = beats[stepIndex]
|
||||
if (!beat) return
|
||||
const speak = fr ? beat.speak.fr : beat.speak.en
|
||||
timerRef.current = setTimeout(
|
||||
() => {
|
||||
if (stepIndex >= beats.length - 1) {
|
||||
setPlaying(false)
|
||||
return
|
||||
}
|
||||
setStepIndex((s) => s + 1)
|
||||
},
|
||||
reducedMotion ? 250 : stepDurationMs(speak, speed)
|
||||
)
|
||||
return clearTimer
|
||||
}, [playing, stepIndex, beats, speed, fr, reducedMotion, clearTimer])
|
||||
|
||||
useEffect(() => {
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
const tag = (e.target as HTMLElement)?.tagName
|
||||
if (tag === 'INPUT' || tag === 'TEXTAREA') return
|
||||
// Multi-player pages: only the hovered/focused player answers the keyboard
|
||||
if (!activeRef.current) return
|
||||
if (e.code === 'Space') {
|
||||
e.preventDefault()
|
||||
setPlaying((p) => !p)
|
||||
} else if (e.code === 'ArrowRight') {
|
||||
e.preventDefault()
|
||||
setPlaying(false)
|
||||
setStepIndex((s) => Math.min(beats.length - 1, s + 1))
|
||||
} else if (e.code === 'ArrowLeft') {
|
||||
e.preventDefault()
|
||||
setPlaying(false)
|
||||
setStepIndex((s) => Math.max(0, s - 1))
|
||||
} else if (e.code === 'KeyR') {
|
||||
e.preventDefault()
|
||||
setPlaying(false)
|
||||
setStepIndex(0)
|
||||
}
|
||||
}
|
||||
window.addEventListener('keydown', onKey)
|
||||
return () => window.removeEventListener('keydown', onKey)
|
||||
}, [beats.length])
|
||||
|
||||
const beat = beats[stepIndex]
|
||||
const speak = beat ? (fr ? beat.speak.fr : beat.speak.en) : ''
|
||||
|
||||
return (
|
||||
<div
|
||||
onPointerEnter={() => {
|
||||
activeRef.current = true
|
||||
}}
|
||||
onPointerLeave={() => {
|
||||
activeRef.current = false
|
||||
}}
|
||||
onFocusCapture={() => {
|
||||
activeRef.current = true
|
||||
}}
|
||||
onBlurCapture={() => {
|
||||
activeRef.current = false
|
||||
}}
|
||||
>
|
||||
<div className="overflow-hidden rounded-xl border" style={{ borderColor: 'var(--pp-line)' }}>
|
||||
{children(stepIndex)}
|
||||
</div>
|
||||
|
||||
<div
|
||||
className="mt-2 flex flex-wrap items-center gap-1 rounded-lg border px-2 py-1.5"
|
||||
style={{ borderColor: 'var(--pp-line)', background: 'var(--pp-card)' }}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setPlaying((p) => !p)}
|
||||
aria-label={playing ? 'Pause' : 'Play'}
|
||||
className="inline-flex h-8 w-8 items-center justify-center rounded-md hover:bg-black/5 dark:hover:bg-white/10"
|
||||
style={{ color: 'var(--pp-plum)' }}
|
||||
>
|
||||
{playing ? <Pause className="h-4 w-4" /> : <Play className="h-4 w-4" />}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setPlaying(false)
|
||||
setStepIndex((s) => Math.min(beats.length - 1, s + 1))
|
||||
}}
|
||||
aria-label={fr ? 'Étape suivante' : 'Next step'}
|
||||
className="inline-flex h-8 w-8 items-center justify-center rounded-md hover:bg-black/5 dark:hover:bg-white/10"
|
||||
style={{ color: 'var(--pp-ink)' }}
|
||||
>
|
||||
<StepForward className="h-4 w-4" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setPlaying(false)
|
||||
setStepIndex(0)
|
||||
}}
|
||||
aria-label="Reset"
|
||||
title="R"
|
||||
className="inline-flex h-8 w-8 items-center justify-center rounded-md hover:bg-black/5 dark:hover:bg-white/10"
|
||||
style={{ color: 'var(--pp-ink)' }}
|
||||
>
|
||||
<RotateCcw className="h-4 w-4" />
|
||||
</button>
|
||||
|
||||
<span
|
||||
className="ml-2 text-xs tabular-nums"
|
||||
style={{ color: 'var(--pp-muted)', fontFamily: 'var(--pp-mono)' }}
|
||||
>
|
||||
{fr ? 'étape' : 'step'} {stepIndex + 1} / {beats.length}
|
||||
</span>
|
||||
|
||||
<div
|
||||
className="ml-auto flex items-center gap-0.5 rounded-md border p-0.5"
|
||||
style={{ borderColor: 'var(--pp-line)' }}
|
||||
role="group"
|
||||
aria-label={fr ? 'Vitesse' : 'Speed'}
|
||||
>
|
||||
{SPEEDS.map((s) => (
|
||||
<button
|
||||
key={s}
|
||||
type="button"
|
||||
onClick={() => setSpeed(s)}
|
||||
className={cn(
|
||||
'rounded px-1.5 py-0.5 text-[11px] tabular-nums transition-colors',
|
||||
speed === s ? 'font-semibold' : 'opacity-60 hover:opacity-100'
|
||||
)}
|
||||
style={
|
||||
speed === s
|
||||
? { background: 'var(--pp-ink)', color: 'var(--pp-paper)' }
|
||||
: { color: 'var(--pp-muted)' }
|
||||
}
|
||||
>
|
||||
{s}×
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
className="mt-2 rounded-lg border px-4 py-3"
|
||||
style={{ borderColor: 'var(--pp-line)', background: 'var(--pp-card)' }}
|
||||
>
|
||||
<PageMd md={speak} className="text-[15px] leading-relaxed [&_p]:my-0" />
|
||||
</div>
|
||||
|
||||
{disclaimer ? (
|
||||
<p className="mt-2 text-xs italic" style={{ color: 'var(--pp-muted)' }}>
|
||||
{fr ? disclaimer.fr : disclaimer.en}
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
<noscript>
|
||||
<ol className="mt-3 list-inside list-decimal space-y-1 text-sm">
|
||||
{beats.map((b) => (
|
||||
<li key={b.id}>{fr ? b.speak.fr : b.speak.en}</li>
|
||||
))}
|
||||
</ol>
|
||||
</noscript>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user