Some checks failed
Build & Deploy / 🔍 Prepare (push) Successful in 7s
Build & Deploy / 🧪 QA (push) Failing after 1m6s
Build & Deploy / 🏗️ Build (push) Has been skipped
Build & Deploy / 🚀 Deploy (push) Has been skipped
Build & Deploy / 🧪 Smoke Test (push) Has been skipped
Build & Deploy / 🔔 Notify (push) Successful in 1s
370 lines
12 KiB
TypeScript
370 lines
12 KiB
TypeScript
'use client';
|
|
|
|
import React, { createContext, useContext, useState, useEffect, useRef } from 'react';
|
|
import { RecordEvent, RecordingSession } from '@/types/record-mode';
|
|
|
|
interface RecordModeContextType {
|
|
isActive: boolean;
|
|
setIsActive: (active: boolean) => void;
|
|
events: RecordEvent[];
|
|
addEvent: (event: Omit<RecordEvent, 'id' | 'timestamp'>) => void;
|
|
updateEvent: (id: string, event: Partial<RecordEvent>) => void;
|
|
removeEvent: (id: string) => void;
|
|
clearEvents: () => void;
|
|
setEvents: (events: RecordEvent[]) => void;
|
|
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;
|
|
reorderEvents: (startIndex: number, endIndex: number) => void;
|
|
hoveredEventId: string | null;
|
|
setHoveredEventId: (id: string | null) => void;
|
|
isClicking: boolean;
|
|
}
|
|
|
|
const RecordModeContext = createContext<RecordModeContextType | null>(null);
|
|
|
|
export function useRecordMode(): RecordModeContextType {
|
|
const context = useContext(RecordModeContext);
|
|
if (!context) {
|
|
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: () => {},
|
|
reorderEvents: () => {},
|
|
hoveredEventId: null,
|
|
setHoveredEventId: () => {},
|
|
setEvents: () => {},
|
|
isClicking: false,
|
|
};
|
|
}
|
|
return context;
|
|
}
|
|
|
|
export function RecordModeProvider({ children }: { children: React.ReactNode }) {
|
|
const [isActive, setIsActiveState] = useState(false);
|
|
const [events, setEvents] = useState<RecordEvent[]>([]);
|
|
const [isPlaying, setIsPlaying] = useState(false);
|
|
const [cursorPosition, setCursorPosition] = useState({ x: 0, y: 0 });
|
|
const [zoomLevel, setZoomLevel] = useState(1);
|
|
const [isBlurry, setIsBlurry] = useState(false);
|
|
const [isFeedbackActive, setIsFeedbackActiveState] = useState(false);
|
|
const [hoveredEventId, setHoveredEventId] = useState<string | null>(null);
|
|
const [isClicking, setIsClicking] = useState(false);
|
|
const [isEmbedded, setIsEmbedded] = useState(false);
|
|
|
|
useEffect(() => {
|
|
const embedded =
|
|
typeof window !== 'undefined' &&
|
|
(window.location.search.includes('embedded=true') ||
|
|
window.name === 'record-mode-iframe' ||
|
|
window.self !== window.top);
|
|
setIsEmbedded(embedded);
|
|
}, []);
|
|
|
|
const setIsActive = (active: boolean) => {
|
|
setIsActiveState(active);
|
|
if (active) setIsFeedbackActiveState(false);
|
|
};
|
|
|
|
const setIsFeedbackActive = (active: boolean) => {
|
|
setIsFeedbackActiveState(active);
|
|
if (active) setIsActiveState(false);
|
|
};
|
|
|
|
const isPlayingRef = useRef(false);
|
|
const isLoadedRef = useRef(false);
|
|
|
|
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));
|
|
isLoadedRef.current = true;
|
|
}, []);
|
|
|
|
useEffect(() => {
|
|
if (!isLoadedRef.current) return;
|
|
localStorage.setItem('klz-record-events', JSON.stringify(events));
|
|
}, [events]);
|
|
|
|
useEffect(() => {
|
|
localStorage.setItem('klz-record-active', JSON.stringify(isActive));
|
|
}, [isActive]);
|
|
|
|
useEffect(() => {
|
|
if (isEmbedded) {
|
|
const handlePlaybackMessage = (e: MessageEvent) => {
|
|
if (e.data.type === 'PLAY_EVENT') {
|
|
const { event } = e.data;
|
|
const el = event.selector
|
|
? (document.querySelector(event.selector) as HTMLElement)
|
|
: null;
|
|
if (el) {
|
|
if (event.type === 'scroll') {
|
|
el.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
|
} else if (event.type === 'mouse') {
|
|
const currentRect = el.getBoundingClientRect();
|
|
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,
|
|
bubbles: true,
|
|
cancelable: true,
|
|
...eventCoords,
|
|
}),
|
|
);
|
|
};
|
|
|
|
if (event.interactionType === 'click') {
|
|
setIsClicking(true);
|
|
dispatchMouse('mousedown');
|
|
setTimeout(() => {
|
|
dispatchMouse('mouseup');
|
|
if (event.realClick) {
|
|
dispatchMouse('click');
|
|
el.click();
|
|
}
|
|
setIsClicking(false);
|
|
}, 150);
|
|
} else {
|
|
dispatchMouse('mousemove');
|
|
dispatchMouse('mouseover');
|
|
dispatchMouse('mouseenter');
|
|
}
|
|
}
|
|
}
|
|
}
|
|
};
|
|
window.addEventListener('message', handlePlaybackMessage);
|
|
return () => window.removeEventListener('message', handlePlaybackMessage);
|
|
}
|
|
}, [isEmbedded]);
|
|
|
|
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 addEvent = (event: Omit<RecordEvent, 'id' | 'timestamp'>) => {
|
|
const newEvent: RecordEvent = {
|
|
realClick: false,
|
|
...event,
|
|
id: Math.random().toString(36).substr(2, 9),
|
|
timestamp: Date.now(),
|
|
};
|
|
setEvents((prev) => [...prev, newEvent]);
|
|
};
|
|
|
|
const updateEvent = (id: string, updatedFields: Partial<RecordEvent>) => {
|
|
setEvents((prev) =>
|
|
prev.map((event) => (event.id === id ? { ...event, ...updatedFields } : event)),
|
|
);
|
|
};
|
|
|
|
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 removeEvent = (id: string) => {
|
|
setEvents((prev) => prev.filter((event) => event.id !== id));
|
|
};
|
|
|
|
const clearEvents = () => {
|
|
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) => {
|
|
console.log('Saving session:', name, events);
|
|
};
|
|
|
|
const playEvents = async () => {
|
|
if (events.length === 0 || isPlayingRef.current) return;
|
|
setIsPlaying(true);
|
|
isPlayingRef.current = true;
|
|
const sortedEvents = [...events].sort((a, b) => (a.timestamp || 0) - (b.timestamp || 0));
|
|
|
|
for (const event of sortedEvents) {
|
|
if (!isPlayingRef.current) break;
|
|
if (event.rect && !isEmbedded) {
|
|
const iframe = document.querySelector(
|
|
'iframe[name="record-mode-iframe"]',
|
|
) as HTMLIFrameElement;
|
|
const iframeRect = iframe?.getBoundingClientRect();
|
|
setCursorPosition({
|
|
x: (iframeRect?.left || 0) + event.rect.x + event.rect.width / 2,
|
|
y: (iframeRect?.top || 0) + event.rect.y + event.rect.height / 2,
|
|
});
|
|
}
|
|
|
|
if (event.selector) {
|
|
if (!isEmbedded) {
|
|
const iframe = document.querySelector(
|
|
'iframe[name="record-mode-iframe"]',
|
|
) as HTMLIFrameElement;
|
|
if (iframe?.contentWindow)
|
|
iframe.contentWindow.postMessage({ type: 'PLAY_EVENT', event }, '*');
|
|
} else {
|
|
// Self-execution logic for guest
|
|
const el = document.querySelector(event.selector) as HTMLElement;
|
|
if (el) {
|
|
if (event.type === 'scroll') el.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
|
else if (event.type === 'mouse') {
|
|
const currentRect = el.getBoundingClientRect();
|
|
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,
|
|
bubbles: true,
|
|
cancelable: true,
|
|
...eventCoords,
|
|
}),
|
|
);
|
|
};
|
|
|
|
if (event.interactionType === 'click') {
|
|
setIsClicking(true);
|
|
dispatchMouse('mousedown');
|
|
setTimeout(() => {
|
|
dispatchMouse('mouseup');
|
|
if (event.realClick) {
|
|
dispatchMouse('click');
|
|
el.click();
|
|
}
|
|
setIsClicking(false);
|
|
}, 150);
|
|
} else {
|
|
dispatchMouse('mousemove');
|
|
dispatchMouse('mouseover');
|
|
dispatchMouse('mouseenter');
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
if (event.zoom) setZoomLevel(event.zoom);
|
|
if (event.motionBlur) setIsBlurry(true);
|
|
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);
|
|
};
|
|
|
|
return (
|
|
<RecordModeContext.Provider
|
|
value={{
|
|
isActive,
|
|
setIsActive,
|
|
events,
|
|
addEvent,
|
|
updateEvent,
|
|
removeEvent,
|
|
clearEvents,
|
|
setEvents,
|
|
isPlaying,
|
|
playEvents,
|
|
stopPlayback,
|
|
cursorPosition,
|
|
zoomLevel,
|
|
isBlurry,
|
|
currentSession,
|
|
saveSession,
|
|
isFeedbackActive,
|
|
setIsFeedbackActive,
|
|
reorderEvents,
|
|
hoveredEventId,
|
|
setHoveredEventId,
|
|
isClicking,
|
|
}}
|
|
>
|
|
{children}
|
|
</RecordModeContext.Provider>
|
|
);
|
|
}
|