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

This commit is contained in:
2026-02-15 18:25:52 +01:00
parent 483dfabe10
commit 1baf03a84e
3 changed files with 273 additions and 221 deletions

View File

@@ -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}