feat: enhance Recording Studio with reorderable events, origin options, and hover previews

This commit is contained in:
2026-02-15 18:13:25 +01:00
parent 460eeec0bb
commit 9adbe5b9cf
4 changed files with 247 additions and 27 deletions

View File

@@ -14,6 +14,14 @@ export function PickingHelper() {
} else if (e.data.type === 'STOP_PICKING') {
setPickingMode(null);
setHoveredElement(null);
} else if (e.data.type === 'SET_HOVER_SELECTOR') {
const selector = e.data.selector;
if (selector) {
const el = document.querySelector(selector) as HTMLElement;
setHoveredElement(el || null);
} else {
setHoveredElement(null);
}
}
};
@@ -86,7 +94,9 @@ export function PickingHelper() {
};
}, [pickingMode, hoveredElement]);
if (!pickingMode || !hoveredElement) return null;
if (!hoveredElement) return null;
// Don't show highlight if we are in picking mode but NOT hovering anything (handled by logic above)
// but DO show if we have a hoveredElement (from message or mouseover)
const rect = hoveredElement.getBoundingClientRect();

View File

@@ -11,6 +11,7 @@ interface RecordModeContextType {
updateEvent: (id: string, event: Partial<RecordEvent>) => void;
removeEvent: (id: string) => void;
clearEvents: () => void;
setEvents: (events: RecordEvent[]) => void;
isPlaying: boolean;
playEvents: () => void;
stopPlayback: () => void;
@@ -21,6 +22,9 @@ interface RecordModeContextType {
saveSession: (name: string) => void;
isFeedbackActive: boolean;
setIsFeedbackActive: (active: boolean) => void;
reorderEvents: (startIndex: number, endIndex: number) => void;
hoveredEventId: string | null;
setHoveredEventId: (id: string | null) => void;
}
const RecordModeContext = createContext<RecordModeContextType | null>(null);
@@ -47,6 +51,10 @@ export function useRecordMode(): RecordModeContextType {
isFeedbackActive: false,
setIsFeedbackActive: () => { },
saveSession: () => { },
reorderEvents: () => { },
hoveredEventId: null,
setHoveredEventId: () => { },
setEvents: () => { },
};
}
return context;
@@ -60,6 +68,7 @@ export function RecordModeProvider({ children }: { children: React.ReactNode })
const [zoomLevel, setZoomLevel] = useState(1);
const [isBlurry, setIsBlurry] = useState(false);
const [isFeedbackActive, setIsFeedbackActiveState] = useState(false);
const [hoveredEventId, setHoveredEventId] = useState<string | null>(null);
const [isEmbedded, setIsEmbedded] = useState(false);
useEffect(() => {
@@ -124,13 +133,30 @@ export function RecordModeProvider({ children }: { children: React.ReactNode })
if (event.type === 'scroll') {
el.scrollIntoView({ behavior: 'smooth', block: 'center' });
} else if (event.type === 'click') {
// Get CURRENT rect for industrial precision
const currentRect = el.getBoundingClientRect();
const eventCoords = {
clientX: currentRect.left + currentRect.width / 2,
clientY: currentRect.top + currentRect.height / 2
};
// Calculate Point based on Origin
let targetX = currentRect.left + currentRect.width / 2;
let targetY = currentRect.top + currentRect.height / 2;
if (event.clickOrigin === 'top-left') {
targetX = currentRect.left + 5;
targetY = currentRect.top + 5;
} else if (event.clickOrigin === 'top-right') {
targetX = currentRect.right - 5;
targetY = currentRect.top + 5;
} else if (event.clickOrigin === 'bottom-left') {
targetX = currentRect.left + 5;
targetY = currentRect.bottom - 5;
} else if (event.clickOrigin === 'bottom-right') {
targetX = currentRect.right - 5;
targetY = currentRect.bottom - 5;
}
const eventCoords = {
clientX: targetX,
clientY: targetY
};
const dispatchMouse = (type: string) => {
el.dispatchEvent(new MouseEvent(type, {
view: window,
@@ -153,6 +179,27 @@ export function RecordModeProvider({ children }: { children: React.ReactNode })
return () => window.removeEventListener('message', handlePlaybackMessage);
}, [isEmbedded]);
// Sync Hover Preview to Iframe
useEffect(() => {
if (isEmbedded || !isActive) return;
const event = events.find(e => e.id === hoveredEventId);
const iframe = document.querySelector('iframe[name="record-mode-iframe"]') as HTMLIFrameElement;
if (iframe?.contentWindow) {
iframe.contentWindow.postMessage({
type: 'SET_HOVER_SELECTOR',
selector: event?.selector || null
}, '*');
}
}, [hoveredEventId, events, isActive, isEmbedded]);
const reorderEvents = (startIndex: number, endIndex: number) => {
const result = Array.from(events);
const [removed] = result.splice(startIndex, 1);
result.splice(endIndex, 0, removed);
setEvents(result);
};
const addEvent = (event: Omit<RecordEvent, 'id' | 'timestamp'>) => {
const newEvent: RecordEvent = {
...event,
@@ -230,9 +277,28 @@ export function RecordModeProvider({ children }: { children: React.ReactNode })
el.scrollIntoView({ behavior: 'smooth', block: 'center' });
} else if (event.type === 'click') {
const currentRect = el.getBoundingClientRect();
// Calculate Point based on Origin (same as above for parity)
let targetX = currentRect.left + currentRect.width / 2;
let targetY = currentRect.top + currentRect.height / 2;
if (event.clickOrigin === 'top-left') {
targetX = currentRect.left + 5;
targetY = currentRect.top + 5;
} else if (event.clickOrigin === 'top-right') {
targetX = currentRect.right - 5;
targetY = currentRect.top + 5;
} else if (event.clickOrigin === 'bottom-left') {
targetX = currentRect.left + 5;
targetY = currentRect.bottom - 5;
} else if (event.clickOrigin === 'bottom-right') {
targetX = currentRect.right - 5;
targetY = currentRect.bottom - 5;
}
const eventCoords = {
clientX: currentRect.left + currentRect.width / 2,
clientY: currentRect.top + currentRect.height / 2
clientX: targetX,
clientY: targetY
};
const dispatchMouse = (type: string) => {
el.dispatchEvent(new MouseEvent(type, {
@@ -282,6 +348,7 @@ export function RecordModeProvider({ children }: { children: React.ReactNode })
updateEvent,
removeEvent,
clearEvents,
setEvents,
isPlaying,
playEvents,
stopPlayback,
@@ -292,6 +359,9 @@ export function RecordModeProvider({ children }: { children: React.ReactNode })
saveSession,
isFeedbackActive,
setIsFeedbackActive,
reorderEvents,
hoveredEventId,
setHoveredEventId,
}}
>
{children}

View File

@@ -2,7 +2,7 @@
import React, { useState, useEffect } from 'react';
import { useRecordMode } from './RecordModeContext';
import { finder } from '@medv/finder';
import { Reorder, AnimatePresence } from 'framer-motion';
import {
Play,
Square,
@@ -16,6 +16,11 @@ import {
X,
Check,
Download,
Settings2,
GripVertical,
Clock,
Maximize2,
Box,
} from 'lucide-react';
import { RecordEvent } from '@/types/record-mode';
import { PlaybackCursor } from './PlaybackCursor';
@@ -32,6 +37,9 @@ export function RecordModeOverlay() {
playEvents,
saveSession,
clearEvents,
reorderEvents,
setHoveredEventId,
setEvents, // Added setEvents here
} = useRecordMode();
const [pickingMode, setPickingMode] = useState<'click' | 'scroll' | null>(null);
@@ -99,9 +107,16 @@ export function RecordModeOverlay() {
return () => {
window.removeEventListener('message', handleMessage);
};
}, [isActive, pickingMode, addEvent]);
}, [isActive, pickingMode, addEvent, mounted]);
const [showEvents, setShowEvents] = useState(false);
const saveEdit = () => {
if (editingEventId) {
updateEvent(editingEventId, editForm);
setEditingEventId(null);
}
};
const [showEvents, setShowEvents] = useState(true);
if (!mounted) return null;
@@ -273,7 +288,12 @@ export function RecordModeOverlay() {
</button>
</div>
<div className="flex-1 overflow-y-auto space-y-2 pr-2 scrollbar-hide">
<Reorder.Group
axis="y"
values={events}
onReorder={setEvents}
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} />
@@ -281,34 +301,51 @@ export function RecordModeOverlay() {
</div>
) : (
events.map((event, index) => (
<div
<Reorder.Item
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"
value={event}
className="group flex items-center gap-3 bg-white/[0.03] border border-white/5 p-3 rounded-[20px] transition-all hover:bg-white/[0.06] hover:border-white/10"
onMouseEnter={() => setHoveredEventId(event.id)}
onMouseLeave={() => setHoveredEventId(null)}
>
<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 className="cursor-grab active:cursor-grabbing text-white/10 hover:text-white/30 transition-colors">
<GripVertical size={16} />
</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-white text-[10px] font-black uppercase tracking-widest">{event.type}</span>
{event.clickOrigin && event.clickOrigin !== 'center' && (
<span className="text-[8px] bg-accent/20 text-accent px-1.5 py-0.5 rounded uppercase font-bold">{event.clickOrigin}</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">
<p className="text-[9px] 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 className="flex items-center gap-1">
<button
onClick={() => {
setEditingEventId(event.id);
setEditForm(event);
}}
className="p-2 text-white/0 group-hover:text-white/40 hover:text-white hover:bg-white/10 rounded-xl transition-all"
>
<Settings2 size={14} />
</button>
<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>
</Reorder.Item>
))
)}
</div>
</Reorder.Group>
</div>
</div>
)}
@@ -338,6 +375,108 @@ export function RecordModeOverlay() {
)}
<PlaybackCursor />
{/* 3. Event Options Panel (Sidebar-like) */}
<AnimatePresence>
{editingEventId && (
<div className="fixed inset-y-0 right-0 w-[350px] bg-black/95 backdrop-blur-3xl border-l border-white/10 z-[11000] pointer-events-auto p-8 shadow-[-40px_0_100px_rgba(0,0,0,0.9)] flex flex-col">
<div className="flex items-center justify-between mb-8">
<h3 className="text-white font-black uppercase tracking-tighter text-xl">Event Options</h3>
<button onClick={() => setEditingEventId(null)} className="p-2 text-white/40 hover:text-white transition-colors">
<X size={20} />
</button>
</div>
<div className="flex-1 space-y-8 overflow-y-auto pr-2 scrollbar-hide">
{/* Type Display */}
<div className="space-y-3">
<label className="text-[10px] uppercase tracking-[0.2em] font-bold text-white/30 leading-none">Action Type</label>
<div className="bg-white/5 p-4 rounded-2xl border border-white/5 flex items-center gap-3">
<div className="w-10 h-10 rounded-xl bg-accent/20 text-accent flex items-center justify-center">
{editForm.type === 'click' ? <MousePointer2 size={20} /> : editForm.type === 'scroll' ? <Scroll size={20} /> : <Clock size={20} />}
</div>
<span className="text-white font-black uppercase tracking-widest text-sm">{editForm.type}</span>
</div>
</div>
{/* Precise Click Origin */}
{editForm.type === 'click' && (
<div className="space-y-4">
<label className="text-[10px] uppercase tracking-[0.2em] font-bold text-white/30 leading-none">Click Origin</label>
<div className="grid grid-cols-3 gap-2 p-2 bg-white/5 rounded-2xl border border-white/5">
{[
{ id: 'top-left', label: 'TL' }, { id: 'top-right', label: 'TR' }, { id: 'center', label: 'CTR' },
{ id: 'bottom-left', label: 'BL' }, { id: 'bottom-right', label: 'BR' }
].map((origin) => (
<button
key={origin.id}
onClick={() => setEditForm(prev => ({ ...prev, clickOrigin: origin.id as any }))}
className={`py-3 rounded-xl text-[10px] font-black uppercase tracking-tighter transition-all border ${editForm.clickOrigin === origin.id ? 'bg-accent text-primary-dark border-accent' : 'bg-transparent text-white/40 border-white/5 hover:border-white/20'}`}
>
{origin.label}
</button>
))}
</div>
</div>
)}
{/* Timing */}
<div className="space-y-4">
<label className="text-[10px] uppercase tracking-[0.2em] font-bold text-white/30 leading-none flex items-center justify-between">
<span>Timeline Allocation</span>
<span className="text-accent">{editForm.duration}ms</span>
</label>
<input
type="range"
min="0"
max="5000"
step="100"
value={editForm.duration || 1000}
onChange={(e) => setEditForm(prev => ({ ...prev, duration: parseInt(e.target.value) }))}
className="w-full h-1 bg-white/10 rounded-lg appearance-none cursor-pointer accent-accent"
/>
</div>
{/* Zoom & Effects */}
<div className="space-y-6">
<div className="flex items-center justify-between p-4 bg-white/5 rounded-2xl border border-white/5 group hover:border-white/20 transition-all">
<div className="flex items-center gap-3">
<Maximize2 size={18} className="text-white/40" />
<span className="text-xs font-bold text-white uppercase tracking-wider">Zoom Shift</span>
</div>
<input
type="number"
step="0.1"
min="1"
max="3"
value={editForm.zoom || 1}
onChange={(e) => setEditForm(prev => ({ ...prev, zoom: parseFloat(e.target.value) }))}
className="w-16 bg-white/10 border border-white/10 rounded-lg px-2 py-1 text-xs text-white text-center font-mono"
/>
</div>
<button
onClick={() => setEditForm(prev => ({ ...prev, motionBlur: !prev.motionBlur }))}
className={`flex items-center justify-between w-full p-4 rounded-2xl border transition-all ${editForm.motionBlur ? 'bg-accent/10 border-accent/30 text-accent' : 'bg-white/5 border-white/5 text-white/40'}`}
>
<div className="flex items-center gap-3">
<Box size={18} />
<span className="text-xs font-bold uppercase tracking-wider">Motion Blur</span>
</div>
{editForm.motionBlur ? <Check size={18} /> : <div className="w-[18px]" />}
</button>
</div>
</div>
<button
onClick={saveEdit}
className="mt-8 py-5 bg-accent text-primary-dark text-xs font-black uppercase tracking-[0.2em] rounded-2xl shadow-2xl shadow-accent/20 hover:scale-[1.02] transition-all"
>
Commit Changes
</button>
</div>
)}
</AnimatePresence>
</div>
);
}

View File

@@ -3,11 +3,12 @@ export interface RecordEvent {
type: 'click' | 'scroll' | 'wait' | 'hover';
selector?: string; // CSS selector
timestamp: number; // Time in ms since start of recording
duration: number; // Duration of the action (e.g. scroll duration)
duration: number; // Duration allocated for this action in playback
zoom?: number; // Zoom level during event
description?: string; // Optional label
motionBlur?: boolean; // Enable motion blur effect
rect?: { x: number; y: number; width: number; height: number }; // Element position for rendering
clickOrigin?: 'center' | 'top-left' | 'top-right' | 'bottom-left' | 'bottom-right';
}
export interface RecordingSession {