feat: optimize event capturing and playback accuracy
This commit is contained in:
@@ -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}
|
||||
|
||||
Reference in New Issue
Block a user