feat: optimize event capturing and playback accuracy

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

View File

@@ -6,9 +6,6 @@ import { RecordEvent, RecordingSession } from '@/types/record-mode';
interface RecordModeContextType {
isActive: boolean;
setIsActive: (active: boolean) => void;
isRecording: boolean;
startRecording: () => void;
stopRecording: () => void;
events: RecordEvent[];
addEvent: (event: Omit<RecordEvent, 'id' | 'timestamp'>) => void;
updateEvent: (id: string, event: Partial<RecordEvent>) => void;
@@ -17,149 +14,209 @@ interface RecordModeContextType {
isPlaying: boolean;
playEvents: () => void;
stopPlayback: () => void;
cursorPosition: { x: number; y: number };
zoomLevel: number;
isBlurry: boolean;
currentSession: RecordingSession | null;
saveSession: (name: string) => void;
isFeedbackActive: boolean;
setIsFeedbackActive: (active: boolean) => void;
}
const RecordModeContext = createContext<RecordModeContextType | null>(null);
export function useRecordMode() {
export function useRecordMode(): RecordModeContextType {
const context = useContext(RecordModeContext);
if (!context) {
throw new Error('useRecordMode must be used within a RecordModeProvider');
// Return a fail-safe fallback for SSR/Static generation where provider might be missing
return {
isActive: false,
setIsActive: () => { },
events: [],
addEvent: () => { },
updateEvent: () => { },
removeEvent: () => { },
clearEvents: () => { },
isPlaying: false,
playEvents: () => { },
stopPlayback: () => { },
cursorPosition: { x: 0, y: 0 },
zoomLevel: 1,
isBlurry: false,
currentSession: null,
isFeedbackActive: false,
setIsFeedbackActive: () => { },
saveSession: () => { },
};
}
return context;
}
export function RecordModeProvider({ children }: { children: React.ReactNode }) {
const [isActive, setIsActive] = useState(false);
const [isRecording, setIsRecording] = useState(false);
const [isActive, setIsActiveState] = useState(false);
const [events, setEvents] = useState<RecordEvent[]>([]);
const [isPlaying, setIsPlaying] = useState(false);
const [currentSession, setCurrentSession] = useState<RecordingSession | null>(null);
const startTimeRef = useRef<number>(0);
const [cursorPosition, setCursorPosition] = useState({ x: 0, y: 0 });
const [zoomLevel, setZoomLevel] = useState(1);
const [isBlurry, setIsBlurry] = useState(false);
const [isFeedbackActive, setIsFeedbackActiveState] = useState(false);
const startRecording = () => {
setIsRecording(true);
setEvents([]);
startTimeRef.current = Date.now();
// Synchronous mutually exclusive setters
const setIsActive = (active: boolean) => {
setIsActiveState(active);
if (active) setIsFeedbackActiveState(false);
};
const stopRecording = () => {
setIsRecording(false);
const setIsFeedbackActive = (active: boolean) => {
setIsFeedbackActiveState(active);
if (active) setIsActiveState(false);
};
const addEvent = (eventData: Omit<RecordEvent, 'id' | 'timestamp'>) => {
const timestamp = Date.now() - startTimeRef.current;
const playbackRequestRef = useRef<number>(0);
const isPlayingRef = useRef(false);
// Load draft from localStorage on mount
useEffect(() => {
const savedEvents = localStorage.getItem('klz-record-events');
const savedActive = localStorage.getItem('klz-record-active');
if (savedEvents) setEvents(JSON.parse(savedEvents));
if (savedActive) setIsActive(JSON.parse(savedActive));
}, []);
// Sync events to localStorage
useEffect(() => {
localStorage.setItem('klz-record-events', JSON.stringify(events));
}, [events]);
// Sync active state to localStorage
useEffect(() => {
localStorage.setItem('klz-record-active', JSON.stringify(isActive));
if (isActive && isFeedbackActive) {
setIsFeedbackActive(false);
}
}, [isActive, isFeedbackActive]);
useEffect(() => {
if (isFeedbackActive && isActive) {
setIsActive(false);
}
}, [isFeedbackActive, isActive]);
const addEvent = (event: Omit<RecordEvent, 'id' | 'timestamp'>) => {
const newEvent: RecordEvent = {
id: crypto.randomUUID(),
timestamp,
...eventData,
...event,
id: Math.random().toString(36).substr(2, 9),
timestamp: Date.now(),
};
setEvents((prev) => [...prev, newEvent]);
};
const updateEvent = (id: string, updates: Partial<RecordEvent>) => {
setEvents((prev) => prev.map((e) => (e.id === id ? { ...e, ...updates } : e)));
const updateEvent = (id: string, updatedFields: Partial<RecordEvent>) => {
setEvents((prev) =>
prev.map((event) => (event.id === id ? { ...event, ...updatedFields } : event))
);
};
const removeEvent = (id: string) => {
setEvents((prev) => prev.filter((e) => e.id !== id));
setEvents((prev) => prev.filter((event) => event.id !== id));
};
const clearEvents = () => {
setEvents([]);
if (confirm('Clear all recorded events?')) {
setEvents([]);
}
};
const currentSession: RecordingSession | null = events.length > 0 ? {
id: 'draft',
name: 'Draft Session',
events,
createdAt: new Date().toISOString(),
} : null;
const saveSession = (name: string) => {
// In a real app, this would be an API call
console.log('Saving session:', name, events);
};
const playEvents = async () => {
if (events.length === 0) return;
setIsPlaying(true);
if (events.length === 0 || isPlayingRef.current) return;
// Simple playback logic mostly for preview
const startPlayTime = Date.now();
setIsPlaying(true);
isPlayingRef.current = true;
// Sort events by timestamp just in case
const sortedEvents = [...events].sort((a, b) => a.timestamp - b.timestamp);
const sortedEvents = [...events].sort((a, b) => (a.timestamp || 0) - (b.timestamp || 0));
for (const event of sortedEvents) {
if (!isPlaying) break; // Check if stopped
if (!isPlayingRef.current) break;
const targetTime = startPlayTime + event.timestamp;
const now = Date.now();
const delay = targetTime - now;
if (delay > 0) {
await new Promise((r) => setTimeout(r, delay));
// 1. Move Cursor
if (event.rect) {
setCursorPosition({
x: event.rect.x + event.rect.width / 2,
y: event.rect.y + event.rect.height / 2,
});
}
// Execute event visual feedback
if (document) {
if (event.selector) {
const el = document.querySelector(event.selector);
if (el) {
// Highlight or scroll to element
if (event.type === 'scroll') {
el.scrollIntoView({ behavior: 'smooth', block: 'center' });
} else if (event.type === 'click') {
// Visualize click
const rect = el.getBoundingClientRect();
const clickMarker = document.createElement('div');
clickMarker.style.position = 'fixed';
clickMarker.style.left = `${rect.left + rect.width / 2}px`;
clickMarker.style.top = `${rect.top + rect.height / 2}px`;
clickMarker.style.width = '20px';
clickMarker.style.height = '20px';
clickMarker.style.borderRadius = '50%';
clickMarker.style.backgroundColor = 'rgba(255, 0, 0, 0.5)';
clickMarker.style.transform = 'translate(-50%, -50%)';
clickMarker.style.zIndex = '99999';
document.body.appendChild(clickMarker);
setTimeout(() => clickMarker.remove(), 500);
}
// 2. Handle Action
if (event.selector) {
const el = document.querySelector(event.selector) as HTMLElement;
if (el) {
if (event.type === 'scroll') {
el.scrollIntoView({ behavior: 'smooth', block: 'center' });
} else if (event.type === 'click') {
// Precise industrial click sequence: mousedown -> mouseup -> click
const eventCoords = {
clientX: event.rect ? event.rect.x + event.rect.width / 2 : 0,
clientY: event.rect ? event.rect.y + event.rect.height / 2 : 0
};
const dispatchMouse = (type: string) => {
el.dispatchEvent(new MouseEvent(type, {
view: window,
bubbles: true,
cancelable: true,
...eventCoords
}));
};
dispatchMouse('mousedown');
dispatchMouse('mouseup');
dispatchMouse('click');
// Fallback for native elements
el.click();
}
}
}
// 3. Zoom/Blur
if (event.zoom) setZoomLevel(event.zoom);
if (event.motionBlur) setIsBlurry(true);
// 4. Wait
await new Promise((resolve) => setTimeout(resolve, event.duration || 1000));
setIsBlurry(false);
}
setIsPlaying(false);
isPlayingRef.current = false;
setZoomLevel(1);
};
const stopPlayback = () => {
setIsPlaying(false);
isPlayingRef.current = false;
setZoomLevel(1);
setIsBlurry(false);
};
const saveSession = (name: string) => {
const session: RecordingSession = {
id: crypto.randomUUID(),
name,
events,
createdAt: new Date().toISOString(),
};
setCurrentSession(session);
// Ideally save to local storage or API
localStorage.setItem('klz-record-session', JSON.stringify(session));
};
// Load session on mount
useEffect(() => {
const saved = localStorage.getItem('klz-record-session');
if (saved) {
try {
setCurrentSession(JSON.parse(saved));
} catch (e) {
console.error('Failed to load session', e);
}
}
}, []);
return (
<RecordModeContext.Provider
value={{
isActive,
setIsActive,
isRecording,
startRecording,
stopRecording,
events,
addEvent,
updateEvent,
@@ -168,8 +225,13 @@ export function RecordModeProvider({ children }: { children: React.ReactNode })
isPlaying,
playEvents,
stopPlayback,
cursorPosition,
zoomLevel,
isBlurry,
currentSession,
saveSession,
isFeedbackActive,
setIsFeedbackActive,
}}
>
{children}