feat(record-mode): unify mouse tool and enhance visuals
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
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
This commit is contained in:
@@ -25,6 +25,7 @@ interface RecordModeContextType {
|
||||
reorderEvents: (startIndex: number, endIndex: number) => void;
|
||||
hoveredEventId: string | null;
|
||||
setHoveredEventId: (id: string | null) => void;
|
||||
isClicking: boolean;
|
||||
}
|
||||
|
||||
const RecordModeContext = createContext<RecordModeContextType | null>(null);
|
||||
@@ -32,29 +33,29 @@ const RecordModeContext = createContext<RecordModeContextType | null>(null);
|
||||
export function useRecordMode(): RecordModeContextType {
|
||||
const context = useContext(RecordModeContext);
|
||||
if (!context) {
|
||||
// Return a fail-safe fallback for SSR/Static generation where provider might be missing
|
||||
return {
|
||||
isActive: false,
|
||||
setIsActive: () => { },
|
||||
setIsActive: () => {},
|
||||
events: [],
|
||||
addEvent: () => { },
|
||||
updateEvent: () => { },
|
||||
removeEvent: () => { },
|
||||
clearEvents: () => { },
|
||||
addEvent: () => {},
|
||||
updateEvent: () => {},
|
||||
removeEvent: () => {},
|
||||
clearEvents: () => {},
|
||||
isPlaying: false,
|
||||
playEvents: () => { },
|
||||
stopPlayback: () => { },
|
||||
playEvents: () => {},
|
||||
stopPlayback: () => {},
|
||||
cursorPosition: { x: 0, y: 0 },
|
||||
zoomLevel: 1,
|
||||
isBlurry: false,
|
||||
currentSession: null,
|
||||
isFeedbackActive: false,
|
||||
setIsFeedbackActive: () => { },
|
||||
saveSession: () => { },
|
||||
reorderEvents: () => { },
|
||||
setIsFeedbackActive: () => {},
|
||||
saveSession: () => {},
|
||||
reorderEvents: () => {},
|
||||
hoveredEventId: null,
|
||||
setHoveredEventId: () => { },
|
||||
setEvents: () => { },
|
||||
setHoveredEventId: () => {},
|
||||
setEvents: () => {},
|
||||
isClicking: false,
|
||||
};
|
||||
}
|
||||
return context;
|
||||
@@ -69,17 +70,18 @@ export function RecordModeProvider({ children }: { children: React.ReactNode })
|
||||
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' &&
|
||||
const embedded =
|
||||
typeof window !== 'undefined' &&
|
||||
(window.location.search.includes('embedded=true') ||
|
||||
window.name === 'record-mode-iframe' ||
|
||||
window.self !== window.top);
|
||||
setIsEmbedded(embedded);
|
||||
}, []);
|
||||
|
||||
// Synchronous mutually exclusive setters
|
||||
const setIsActive = (active: boolean) => {
|
||||
setIsActiveState(active);
|
||||
if (active) setIsFeedbackActiveState(false);
|
||||
@@ -90,52 +92,39 @@ export function RecordModeProvider({ children }: { children: React.ReactNode })
|
||||
if (active) setIsActiveState(false);
|
||||
};
|
||||
|
||||
const playbackRequestRef = useRef<number>(0);
|
||||
const isPlayingRef = useRef(false);
|
||||
const isLoadedRef = 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));
|
||||
isLoadedRef.current = true;
|
||||
}, []);
|
||||
|
||||
// Sync events to localStorage
|
||||
useEffect(() => {
|
||||
if (!isLoadedRef.current) return;
|
||||
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]);
|
||||
}, [isActive]);
|
||||
|
||||
useEffect(() => {
|
||||
if (isFeedbackActive && isActive) {
|
||||
setIsActive(false);
|
||||
}
|
||||
}, [isFeedbackActive, isActive]);
|
||||
|
||||
// GUEST LISTENERS: Execute events coming from host
|
||||
useEffect(() => {
|
||||
if (!isEmbedded) return;
|
||||
|
||||
const handlePlaybackMessage = (e: MessageEvent) => {
|
||||
if (e.data.type === 'PLAY_EVENT') {
|
||||
const { event } = e.data;
|
||||
if (event.selector) {
|
||||
const el = document.querySelector(event.selector) as HTMLElement;
|
||||
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();
|
||||
|
||||
// Calculate Point based on Origin
|
||||
let targetX = currentRect.left + currentRect.width / 2;
|
||||
let targetY = currentRect.top + currentRect.height / 2;
|
||||
|
||||
@@ -153,30 +142,30 @@ export function RecordModeProvider({ children }: { children: React.ReactNode })
|
||||
targetY = currentRect.bottom - 5;
|
||||
}
|
||||
|
||||
const eventCoords = {
|
||||
clientX: targetX,
|
||||
clientY: targetY
|
||||
};
|
||||
|
||||
const eventCoords = { clientX: targetX, clientY: targetY };
|
||||
const dispatchMouse = (type: string) => {
|
||||
el.dispatchEvent(new MouseEvent(type, {
|
||||
view: window,
|
||||
bubbles: true,
|
||||
cancelable: true,
|
||||
...eventCoords
|
||||
}));
|
||||
el.dispatchEvent(
|
||||
new MouseEvent(type, {
|
||||
view: window,
|
||||
bubbles: true,
|
||||
cancelable: true,
|
||||
...eventCoords,
|
||||
}),
|
||||
);
|
||||
};
|
||||
|
||||
if (event.interactionType === 'click') {
|
||||
setIsClicking(true);
|
||||
dispatchMouse('mousedown');
|
||||
dispatchMouse('mouseup');
|
||||
dispatchMouse('click');
|
||||
|
||||
if (event.realClick) {
|
||||
el.click();
|
||||
}
|
||||
setTimeout(() => {
|
||||
dispatchMouse('mouseup');
|
||||
if (event.realClick) {
|
||||
dispatchMouse('click');
|
||||
el.click();
|
||||
}
|
||||
setIsClicking(false);
|
||||
}, 150);
|
||||
} else {
|
||||
// Hover Interaction
|
||||
dispatchMouse('mousemove');
|
||||
dispatchMouse('mouseover');
|
||||
dispatchMouse('mouseenter');
|
||||
@@ -184,36 +173,27 @@ export function RecordModeProvider({ children }: { children: React.ReactNode })
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener('message', handlePlaybackMessage);
|
||||
return () => window.removeEventListener('message', handlePlaybackMessage);
|
||||
};
|
||||
window.addEventListener('message', handlePlaybackMessage);
|
||||
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 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
|
||||
}, '*');
|
||||
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 = {
|
||||
realClick: false,
|
||||
...event,
|
||||
id: Math.random().toString(36).substr(2, 9),
|
||||
timestamp: Date.now(),
|
||||
@@ -223,74 +203,72 @@ export function RecordModeProvider({ children }: { children: React.ReactNode })
|
||||
|
||||
const updateEvent = (id: string, updatedFields: Partial<RecordEvent>) => {
|
||||
setEvents((prev) =>
|
||||
prev.map((event) => (event.id === id ? { ...event, ...updatedFields } : event))
|
||||
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([]);
|
||||
}
|
||||
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 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 || isPlayingRef.current) return;
|
||||
|
||||
setIsPlaying(true);
|
||||
isPlayingRef.current = true;
|
||||
|
||||
// Sort events by timestamp just in case
|
||||
const sortedEvents = [...events].sort((a, b) => (a.timestamp || 0) - (b.timestamp || 0));
|
||||
|
||||
for (const event of sortedEvents) {
|
||||
if (!isPlayingRef.current) break;
|
||||
|
||||
// 1. Move Cursor (Host logic)
|
||||
if (event.rect && !isEmbedded) {
|
||||
const iframe = document.querySelector('iframe[name="record-mode-iframe"]') as HTMLIFrameElement;
|
||||
const iframe = document.querySelector(
|
||||
'iframe[name="record-mode-iframe"]',
|
||||
) as HTMLIFrameElement;
|
||||
const iframeRect = iframe?.getBoundingClientRect();
|
||||
|
||||
// Add iframe offset to guest-relative coordinates
|
||||
setCursorPosition({
|
||||
x: (iframeRect?.left || 0) + event.rect.x + event.rect.width / 2,
|
||||
y: (iframeRect?.top || 0) + event.rect.y + event.rect.height / 2,
|
||||
});
|
||||
}
|
||||
|
||||
// 2. Handle Action
|
||||
if (event.selector) {
|
||||
if (!isEmbedded) {
|
||||
// HOST: Delegate to Iframe
|
||||
const iframe = document.querySelector('iframe[name="record-mode-iframe"]') as HTMLIFrameElement;
|
||||
if (iframe?.contentWindow) {
|
||||
const iframe = document.querySelector(
|
||||
'iframe[name="record-mode-iframe"]',
|
||||
) as HTMLIFrameElement;
|
||||
if (iframe?.contentWindow)
|
||||
iframe.contentWindow.postMessage({ type: 'PLAY_EVENT', event }, '*');
|
||||
}
|
||||
} else {
|
||||
// GUEST (Self-execution failsafe)
|
||||
// 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') {
|
||||
if (event.type === 'scroll') el.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
||||
else if (event.type === 'mouse') {
|
||||
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;
|
||||
|
||||
@@ -308,30 +286,30 @@ export function RecordModeProvider({ children }: { children: React.ReactNode })
|
||||
targetY = currentRect.bottom - 5;
|
||||
}
|
||||
|
||||
const eventCoords = {
|
||||
clientX: targetX,
|
||||
clientY: targetY
|
||||
};
|
||||
|
||||
const eventCoords = { clientX: targetX, clientY: targetY };
|
||||
const dispatchMouse = (type: string) => {
|
||||
el.dispatchEvent(new MouseEvent(type, {
|
||||
view: window,
|
||||
bubbles: true,
|
||||
cancelable: true,
|
||||
...eventCoords
|
||||
}));
|
||||
el.dispatchEvent(
|
||||
new MouseEvent(type, {
|
||||
view: window,
|
||||
bubbles: true,
|
||||
cancelable: true,
|
||||
...eventCoords,
|
||||
}),
|
||||
);
|
||||
};
|
||||
|
||||
if (event.interactionType === 'click') {
|
||||
setIsClicking(true);
|
||||
dispatchMouse('mousedown');
|
||||
dispatchMouse('mouseup');
|
||||
dispatchMouse('click');
|
||||
|
||||
if (event.realClick) {
|
||||
el.click();
|
||||
}
|
||||
setTimeout(() => {
|
||||
dispatchMouse('mouseup');
|
||||
if (event.realClick) {
|
||||
dispatchMouse('click');
|
||||
el.click();
|
||||
}
|
||||
setIsClicking(false);
|
||||
}, 150);
|
||||
} else {
|
||||
// Hover Interaction
|
||||
dispatchMouse('mousemove');
|
||||
dispatchMouse('mouseover');
|
||||
dispatchMouse('mouseenter');
|
||||
@@ -341,15 +319,11 @@ export function RecordModeProvider({ children }: { children: React.ReactNode })
|
||||
}
|
||||
}
|
||||
|
||||
// 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);
|
||||
@@ -386,6 +360,7 @@ export function RecordModeProvider({ children }: { children: React.ReactNode })
|
||||
reorderEvents,
|
||||
hoveredEventId,
|
||||
setHoveredEventId,
|
||||
isClicking,
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
|
||||
Reference in New Issue
Block a user