feat: optimize event capturing and playback accuracy

This commit is contained in:
2026-02-15 18:06:50 +01:00
parent e9ce406a31
commit 4e762ebfdf
38 changed files with 2847 additions and 523 deletions

View File

@@ -15,16 +15,15 @@ import {
Edit2,
X,
Check,
Download,
} from 'lucide-react';
import { RecordEvent } from '@/types/record-mode';
import { PlaybackCursor } from './PlaybackCursor';
export function RecordModeOverlay() {
const {
isActive,
setIsActive,
isRecording,
startRecording,
stopRecording,
events,
addEvent,
updateEvent,
@@ -41,26 +40,18 @@ export function RecordModeOverlay() {
// Edit form state
const [editForm, setEditForm] = useState<Partial<RecordEvent>>({});
const [mounted, setMounted] = useState(false);
useEffect(() => {
if (!isActive) return;
setMounted(true);
}, []);
const handleMouseOver = (e: MouseEvent) => {
if (pickingMode) {
e.stopPropagation();
e.preventDefault();
const target = e.target as HTMLElement;
if (target.closest('.record-mode-ui')) return;
setHoveredElement(target);
}
};
useEffect(() => {
if (!mounted || !isActive) return;
const handleClick = (e: MouseEvent) => {
if (pickingMode && hoveredElement) {
e.stopPropagation();
e.preventDefault();
const selector = finder(hoveredElement);
const handleMessage = (e: MessageEvent) => {
if (e.data.type === 'ELEMENT_SELECTED') {
const { selector, rect, tagName } = e.data;
if (pickingMode === 'click') {
addEvent({
@@ -68,285 +59,285 @@ export function RecordModeOverlay() {
selector,
duration: 1000,
zoom: 1,
description: `Click on ${hoveredElement.tagName.toLowerCase()}`,
description: `Click on ${tagName}`,
motionBlur: false,
rect,
});
} else if (pickingMode === 'scroll') {
addEvent({
type: 'scroll',
selector,
duration: 1000,
duration: 1500,
zoom: 1,
description: `Scroll to ${hoveredElement.tagName.toLowerCase()}`,
description: `Scroll to ${tagName}`,
motionBlur: false,
rect,
});
}
setPickingMode(null);
setHoveredElement(null);
} else if (e.data.type === 'PICKING_CANCELLED') {
setPickingMode(null);
}
};
window.addEventListener('message', handleMessage);
if (pickingMode) {
window.addEventListener('mouseover', handleMouseOver);
window.addEventListener('click', handleClick, true);
// Find the iframe and signal start picking
const iframe = document.querySelector('iframe');
if (iframe?.contentWindow) {
iframe.contentWindow.postMessage({ type: 'START_PICKING', mode: pickingMode }, '*');
}
} else {
// Signal stop picking
const iframe = document.querySelector('iframe');
if (iframe?.contentWindow) {
iframe.contentWindow.postMessage({ type: 'STOP_PICKING' }, '*');
}
}
return () => {
window.removeEventListener('mouseover', handleMouseOver);
window.removeEventListener('click', handleClick, true);
window.removeEventListener('message', handleMessage);
};
}, [isActive, pickingMode, hoveredElement, addEvent]);
}, [isActive, pickingMode, addEvent]);
const startEditing = (event: RecordEvent) => {
setEditingEventId(event.id);
setEditForm({ ...event });
};
const [showEvents, setShowEvents] = useState(false);
const saveEdit = () => {
if (editingEventId && editForm) {
updateEvent(editingEventId, editForm);
setEditingEventId(null);
setEditForm({});
}
};
const cancelEdit = () => {
setEditingEventId(null);
setEditForm({});
};
if (!mounted) return null;
if (!isActive) {
// Failsafe: Never render host toggle in embedded mode
if (typeof window !== 'undefined' && (window.self !== window.top || window.name === 'record-mode-iframe' || window.location.search.includes('embedded=true'))) {
return null;
}
return (
<button
onClick={() => setIsActive(true)}
className="fixed bottom-4 right-4 z-[9999] bg-red-600 text-white p-3 rounded-full shadow-lg hover:scale-110 transition-transform record-mode-ui"
className="fixed bottom-6 left-6 z-[9999] bg-[#82ed20]/20 hover:bg-[#82ed20]/30 text-[#82ed20] p-4 rounded-full shadow-2xl transition-all hover:scale-110 record-mode-ignore border border-[#82ed20]/30 backdrop-blur-md animate-pulse"
>
<div className="w-4 h-4 rounded-full bg-white" />
<div className="w-5 h-5 rounded-[4px] border-2 border-[#82ed20]" />
</button>
);
}
return (
<div className="fixed inset-0 z-[9999] pointer-events-none">
{/* Hover Highlighter */}
{pickingMode && hoveredElement && (
<div
className="fixed pointer-events-none border-2 border-red-500 bg-red-500/20 transition-all z-[9998]"
style={{
top: hoveredElement.getBoundingClientRect().top,
left: hoveredElement.getBoundingClientRect().left,
width: hoveredElement.getBoundingClientRect().width,
height: hoveredElement.getBoundingClientRect().height,
}}
/>
)}
<div className="fixed inset-0 z-[9998] pointer-events-none font-sans">
{/* 1. Global Toolbar - Slim Industrial Bar */}
<div className="fixed bottom-6 left-1/2 -translate-x-1/2 z-[10000] pointer-events-auto">
<div className="bg-black/80 backdrop-blur-2xl border border-white/10 p-2 rounded-[24px] shadow-[0_32px_80px_rgba(0,0,0,0.8)] flex items-center gap-2">
{/* Control Panel */}
<div className="fixed bottom-8 left-1/2 -translate-x-1/2 bg-black/80 backdrop-blur-md text-white p-4 rounded-xl shadow-2xl pointer-events-auto record-mode-ui border border-white/10 w-[600px] max-h-[80vh] flex flex-col">
<div className="flex items-center justify-between mb-4">
<h3 className="font-bold flex items-center gap-2">
<div
className={`w-3 h-3 rounded-full ${isRecording ? 'bg-red-500 animate-pulse' : 'bg-gray-500'}`}
/>
Record Mode
</h3>
<button onClick={() => setIsActive(false)} className="text-white/50 hover:text-white">
Close
</button>
</div>
<div className="flex items-center gap-2 mb-4 overflow-x-auto pb-2">
{!isRecording ? (
<button
onClick={startRecording}
className="flex items-center gap-2 bg-red-600 px-4 py-2 rounded-lg hover:bg-red-700 whitespace-nowrap"
>
<div className="w-3 h-3 rounded-full bg-white" />
Start Rec
</button>
) : (
<button
onClick={stopRecording}
className="flex items-center gap-2 bg-gray-700 px-4 py-2 rounded-lg hover:bg-gray-600 whitespace-nowrap"
>
<Square size={16} fill="currentColor" />
Stop Rec
</button>
)}
<div className="w-px h-8 bg-white/20 mx-2" />
<button
disabled={!isRecording}
onClick={() => setPickingMode('click')}
className={`flex items-center gap-2 px-3 py-2 rounded-lg transition-colors whitespace-nowrap ${pickingMode === 'click' ? 'bg-blue-600' : 'hover:bg-white/10 disabled:opacity-50'}`}
>
<MousePointer2 size={16} />
Add Click
</button>
<button
disabled={!isRecording}
onClick={() => setPickingMode('scroll')}
className={`flex items-center gap-2 px-3 py-2 rounded-lg transition-colors whitespace-nowrap ${pickingMode === 'scroll' ? 'bg-blue-600' : 'hover:bg-white/10 disabled:opacity-50'}`}
>
<Scroll size={16} />
Add Scroll
</button>
<div className="w-px h-8 bg-white/20 mx-2" />
<button
onClick={playEvents}
disabled={isRecording || events.length === 0}
className="p-2 hover:bg-white/10 rounded-lg disabled:opacity-50"
>
<Play size={20} />
</button>
<button
onClick={() => saveSession('Session 1')}
disabled={events.length === 0}
className="p-2 hover:bg-white/10 rounded-lg disabled:opacity-50"
>
<Save size={20} />
</button>
<button
onClick={clearEvents}
disabled={events.length === 0}
className="p-2 hover:bg-red-500/20 text-red-400 rounded-lg disabled:opacity-50"
>
<Trash2 size={20} />
</button>
</div>
{/* Edit Form */}
{editingEventId && (
<div className="bg-blue-900/40 p-3 rounded-lg mb-4 border border-blue-500/30">
<div className="flex justify-between items-center mb-2">
<span className="font-bold text-sm text-blue-300">Edit Event</span>
<div className="flex gap-1">
<button
onClick={saveEdit}
className="p-1 hover:bg-green-500/20 text-green-400 rounded"
>
<Check size={14} />
</button>
<button
onClick={cancelEdit}
className="p-1 hover:bg-red-500/20 text-red-400 rounded"
>
<X size={14} />
</button>
</div>
</div>
<div className="grid grid-cols-2 gap-2 text-xs">
<div>
<label className="block text-white/50 mb-1">Type</label>
<select
value={editForm.type}
onChange={(e) => setEditForm({ ...editForm, type: e.target.value as any })}
className="w-full bg-black/40 border border-white/10 rounded p-1"
>
<option value="click">Click</option>
<option value="scroll">Scroll</option>
<option value="wait">Wait</option>
</select>
</div>
<div>
<label className="block text-white/50 mb-1">Duration (ms)</label>
<input
type="number"
value={editForm.duration}
onChange={(e) => setEditForm({ ...editForm, duration: parseInt(e.target.value) })}
className="w-full bg-black/40 border border-white/10 rounded p-1"
/>
</div>
<div>
<label className="block text-white/50 mb-1">Zoom (x)</label>
<input
type="number"
step="0.1"
value={editForm.zoom}
onChange={(e) => setEditForm({ ...editForm, zoom: parseFloat(e.target.value) })}
className="w-full bg-black/40 border border-white/10 rounded p-1"
/>
</div>
<div>
<label className="block text-white/50 mb-1">Motion Blur</label>
<button
onClick={() => setEditForm({ ...editForm, motionBlur: !editForm.motionBlur })}
className={`w-full p-1 rounded text-center border ${editForm.motionBlur ? 'bg-blue-500/20 border-blue-500 text-blue-300' : 'bg-black/40 border-white/10 text-white/50'}`}
>
{editForm.motionBlur ? 'Enabled' : 'Disabled'}
</button>
</div>
<div className="col-span-2">
<label className="block text-white/50 mb-1">Description</label>
<input
type="text"
value={editForm.description || ''}
onChange={(e) => setEditForm({ ...editForm, description: e.target.value })}
className="w-full bg-black/40 border border-white/10 rounded p-1"
/>
</div>
{/* Identity Tag */}
<div className="flex items-center gap-3 px-4 py-2 bg-white/5 rounded-[16px] border border-white/5 mx-1">
<div className="w-2 h-2 rounded-full bg-accent animate-pulse" />
<div className="flex flex-col">
<span className="text-[10px] font-bold text-white uppercase tracking-wider leading-none">Event Builder</span>
<span className="text-[8px] text-white/30 uppercase tracking-widest mt-1">Manual Mode</span>
</div>
</div>
)}
{/* Event Timeline */}
<div className="bg-white/5 rounded-lg p-2 flex-1 overflow-y-auto space-y-2 min-h-[200px]">
{events.length === 0 && (
<div className="text-center text-white/30 text-sm py-4">No events recorded yet.</div>
)}
{events.map((event, index) => (
<div
key={event.id}
className={`flex items-center gap-3 bg-white/5 p-2 rounded text-sm group cursor-pointer hover:bg-white/10 border border-transparent ${editingEventId === event.id ? 'border-blue-500 bg-blue-500/10' : ''}`}
onClick={() => startEditing(event)}
<div className="w-px h-6 bg-white/10 mx-1" />
{/* Action Tools */}
<div className="flex items-center gap-1">
<button
onClick={() => setPickingMode('click')}
className={`flex items-center gap-2 px-4 py-2.5 rounded-[16px] transition-all text-xs font-bold uppercase tracking-wide ${pickingMode === 'click' ? 'bg-accent text-primary-dark shadow-lg shadow-accent/20' : 'text-white/40 hover:text-white hover:bg-white/5'}`}
>
<span className="text-white/30 w-6 text-center">{index + 1}</span>
{event.type === 'click' && <MousePointer2 size={14} className="text-blue-400" />}
{event.type === 'scroll' && <Scroll size={14} className="text-green-400" />}
<MousePointer2 size={16} />
<span>Click</span>
</button>
<div className="flex-1 truncate">
<span className="font-mono text-white/50 text-xs mr-2">{event.selector}</span>
{event.motionBlur && (
<span className="text-[10px] bg-purple-500/20 text-purple-300 px-1 rounded ml-1">
Blur
</span>
)}
{event.zoom && event.zoom !== 1 && (
<span className="text-[10px] bg-yellow-500/20 text-yellow-300 px-1 rounded ml-1">
x{event.zoom}
</span>
)}
</div>
<button
onClick={() => setPickingMode('scroll')}
className={`flex items-center gap-2 px-4 py-2.5 rounded-[16px] transition-all text-xs font-bold uppercase tracking-wide ${pickingMode === 'scroll' ? 'bg-accent text-primary-dark shadow-lg shadow-accent/20' : 'text-white/40 hover:text-white hover:bg-white/5'}`}
>
<Scroll size={16} />
<span>Scroll</span>
</button>
<span className="text-xs text-white/40">{(event.timestamp / 1000).toFixed(1)}s</span>
<button
onClick={() => addEvent({
type: 'wait',
duration: 2000,
zoom: 1,
description: 'Wait for 2s',
motionBlur: false
})}
className="flex items-center gap-2 px-4 py-2.5 rounded-[16px] text-white/40 hover:text-white hover:bg-white/5 transition-all text-xs font-bold uppercase tracking-wide"
>
<Plus size={16} />
<span>Wait</span>
</button>
</div>
<button
onClick={(e) => {
e.stopPropagation();
removeEvent(event.id);
}}
className="opacity-0 group-hover:opacity-100 p-1 hover:bg-white/10 rounded text-red-400"
>
<Trash2 size={12} />
</button>
</div>
))}
<div className="w-px h-6 bg-white/10 mx-1" />
{/* Sequence Controls */}
<div className="flex items-center gap-1 p-0.5">
<button
onClick={playEvents}
disabled={isPlaying || events.length === 0}
className="p-2.5 text-accent hover:bg-accent/10 rounded-[14px] disabled:opacity-20 transition-all"
title="Preview Sequence"
>
<Play size={18} fill="currentColor" />
</button>
<button
onClick={() => setShowEvents(!showEvents)}
className={`p-2.5 rounded-[14px] transition-all relative ${showEvents ? 'bg-white/10 text-white' : 'text-white/40 hover:text-white hover:bg-white/5'}`}
>
<Edit2 size={18} />
{events.length > 0 && (
<span className="absolute -top-1 -right-1 w-4 h-4 bg-accent text-primary-dark text-[10px] flex items-center justify-center rounded-full font-bold border-2 border-black">
{events.length}
</span>
)}
</button>
<button
onClick={async () => {
const session = { events, name: 'Recording', createdAt: new Date().toISOString() };
try {
const res = await fetch('/api/save-session', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(session),
});
if (res.ok) {
// Visual feedback could be improved, but alert is fine for dev tool
alert('Session saved to remotion/session.json');
} else {
const err = await res.json();
alert(`Failed to save: ${err.error}`);
}
} catch (e) {
console.error(e);
alert('Error saving session');
}
}}
disabled={events.length === 0}
className="p-3 bg-white/5 hover:bg-green-500/20 rounded-2xl disabled:opacity-30 transition-all text-green-400"
title="Save to Project (Dev)"
>
<Save size={20} />
</button>
<button
onClick={() => {
const data = JSON.stringify({ events, name: 'Recording', createdAt: new Date().toISOString() }, null, 2);
const blob = new Blob([data], { type: 'application/json' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = 'remotion-session.json';
a.click();
URL.revokeObjectURL(url);
}}
disabled={events.length === 0}
className="p-3 bg-white/5 hover:bg-blue-500/20 rounded-2xl disabled:opacity-30 transition-all text-blue-400"
title="Download JSON"
>
<Download size={20} />
</button>
</div>
<div className="w-px h-6 bg-white/10 mx-1" />
<button
onClick={() => setIsActive(false)}
className="p-2.5 text-red-500 hover:bg-red-500/10 rounded-[14px] transition-all mx-1"
title="Exit Studio"
>
<X size={20} />
</button>
</div>
</div>
{/* Picking Instructions */}
{pickingMode && (
<div className="fixed top-8 left-1/2 -translate-x-1/2 bg-blue-600 text-white px-6 py-2 rounded-full shadow-xl z-[10000] animate-bounce">
Select element to {pickingMode}
{/* 2. Event Timeline Popover */}
{showEvents && (
<div className="fixed bottom-[100px] left-1/2 -translate-x-1/2 w-[400px] pointer-events-auto z-[9999]">
<div className="bg-black/90 backdrop-blur-3xl border border-white/10 rounded-[32px] p-6 shadow-[0_40px_100px_rgba(0,0,0,0.9)] max-h-[500px] overflow-hidden flex flex-col scale-in">
<div className="flex items-center justify-between mb-6">
<div>
<h4 className="text-white font-bold text-lg leading-none">Recording Track</h4>
<p className="text-[10px] text-white/30 uppercase tracking-widest mt-2">{events.length} Actions Recorded</p>
</div>
<button
onClick={clearEvents}
disabled={events.length === 0}
className="text-red-400/40 hover:text-red-400 transition-colors p-2 hover:bg-red-500/10 rounded-xl disabled:opacity-10"
>
<Trash2 size={18} />
</button>
</div>
<div className="flex-1 overflow-y-auto space-y-2 pr-2 scrollbar-hide">
{events.length === 0 ? (
<div className="py-12 flex flex-col items-center justify-center text-white/10">
<Plus size={40} strokeWidth={1} />
<p className="text-xs mt-4">Timeline is empty</p>
</div>
) : (
events.map((event, index) => (
<div
key={event.id}
className="group flex items-center gap-4 bg-white/[0.03] border border-white/5 p-3 rounded-[20px] transition-all hover:bg-white/[0.06] hover:border-white/10"
>
<div className="w-6 h-6 rounded-full bg-white/5 border border-white/5 flex items-center justify-center text-[9px] font-bold text-white/30">
{index + 1}
</div>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<span className="text-white text-xs font-bold uppercase tracking-widest">{event.type}</span>
<span className="text-[8px] bg-white/10 px-1.5 py-0.5 rounded text-white/40 font-mono italic">{event.duration}ms</span>
</div>
<p className="text-[10px] text-white/30 truncate font-mono mt-1 opacity-60">
{event.selector || 'system:wait'}
</p>
</div>
<button
onClick={() => removeEvent(event.id)}
className="p-2 text-white/0 group-hover:text-red-400/60 hover:text-red-400 hover:bg-red-500/10 rounded-xl transition-all"
>
<Trash2 size={14} />
</button>
</div>
))
)}
</div>
</div>
</div>
)}
{/* Industrial Selector Highlighter - handled inside iframe via PickingHelper */}
{/* Picking Tooltip */}
{pickingMode && (
<div className="fixed top-8 left-1/2 -translate-x-1/2 z-[10000] pointer-events-auto">
<div className="bg-accent text-primary-dark px-6 py-3 rounded-full flex items-center gap-4 shadow-[0_20px_40px_rgba(130,237,32,0.4)] animate-reveal border border-primary-dark/10">
<div className="flex items-center gap-2">
<div className="w-2 h-2 rounded-full bg-primary-dark animate-pulse" />
<span className="font-black uppercase tracking-widest text-xs">Assigning {pickingMode}</span>
</div>
<div className="w-px h-6 bg-primary-dark/20" />
<button
onClick={() => {
setPickingMode(null);
setHoveredElement(null);
}}
className="text-[10px] font-bold uppercase tracking-widest opacity-60 hover:opacity-100 transition-opacity bg-primary-dark/10 px-3 py-1.5 rounded-full"
>
ESC to Cancel
</button>
</div>
</div>
)}
<PlaybackCursor />
</div>
);
}