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

@@ -1,55 +1,90 @@
'use client'; 'use client';
import React, { useEffect, useState } from 'react'; import React, { useEffect, useState } from 'react';
import { motion } from 'framer-motion'; import { motion, AnimatePresence } from 'framer-motion';
import { useRecordMode } from './RecordModeContext'; import { useRecordMode } from './RecordModeContext';
export function PlaybackCursor() { export function PlaybackCursor() {
const { isPlaying, cursorPosition } = useRecordMode(); const { isPlaying, cursorPosition, isClicking } = useRecordMode();
const [scrollOffset, setScrollOffset] = useState({ x: 0, y: 0 }); const [scrollOffset, setScrollOffset] = useState({ x: 0, y: 0 });
// Track scroll so cursor stays locked to the correct element // Track scroll so cursor stays locked to the correct element
useEffect(() => { useEffect(() => {
if (!isPlaying) return; if (!isPlaying) return;
const handleScroll = () => { const handleScroll = () => {
setScrollOffset({ x: window.scrollX, y: window.scrollY }); setScrollOffset({ x: window.scrollX, y: window.scrollY });
}; };
handleScroll(); // Init handleScroll(); // Init
window.addEventListener('scroll', handleScroll, { passive: true }); window.addEventListener('scroll', handleScroll, { passive: true });
return () => window.removeEventListener('scroll', handleScroll); return () => window.removeEventListener('scroll', handleScroll);
}, [isPlaying]); }, [isPlaying]);
if (!isPlaying) return null; if (!isPlaying) return null;
return ( return (
<motion.div <motion.div
className="fixed z-[10000] pointer-events-none" className="fixed z-[10000] pointer-events-none"
animate={{ animate={{
x: cursorPosition.x, x: cursorPosition.x,
y: cursorPosition.y, y: cursorPosition.y,
}} scale: isClicking ? 0.8 : 1,
transition={{ rotateX: isClicking ? 15 : 0,
type: 'spring', rotateY: isClicking ? -15 : 0,
damping: 30, }}
stiffness: 250, transition={{
mass: 0.5, x: { type: 'spring', damping: 30, stiffness: 250, mass: 0.5 },
}} y: { type: 'spring', damping: 30, stiffness: 250, mass: 0.5 },
scale: { type: 'spring', damping: 15, stiffness: 400 },
rotateX: { type: 'spring', damping: 15, stiffness: 400 },
rotateY: { type: 'spring', damping: 15, stiffness: 400 },
}}
style={{ perspective: '1000px' }}
>
<AnimatePresence>
{isClicking && (
<motion.div
initial={{ scale: 0.5, opacity: 0 }}
animate={{ scale: 2.5, opacity: 0 }}
exit={{ opacity: 0 }}
transition={{ duration: 0.4, ease: 'easeOut' }}
className="absolute inset-0 rounded-full border-2 border-[#82ed20] blur-[1px]"
/>
)}
</AnimatePresence>
{/* Outer Pulse Ring */}
<div
className={`absolute -inset-3 rounded-full bg-[#82ed20]/10 ${isClicking ? 'scale-150 opacity-0' : 'animate-ping'} transition-all duration-300`}
/>
{/* Visual Cursor */}
<div className="relative">
{/* Soft Glow */}
<div
className={`absolute -inset-2 bg-[#82ed20]/20 rounded-full blur-md transition-all ${isClicking ? 'bg-[#82ed20]/50 blur-xl' : ''}`}
/>
{/* Pointer Arrow */}
<svg
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
xmlns="http://www.w3.org/2000/svg"
className={`drop-shadow-[0_2px_8px_rgba(130,237,32,0.5)] transition-transform ${isClicking ? 'translate-y-0.5' : ''}`}
> >
{/* Outer Pulse Ring */} <path
<div className="absolute -inset-3 rounded-full bg-[#82ed20]/10 animate-ping" /> d="M3 3L10.07 19.97L12.58 12.58L19.97 10.07L3 3Z"
fill={isClicking ? '#82ed20' : 'white'}
{/* Visual Cursor */} stroke="black"
<div className="relative"> strokeWidth="1.5"
{/* Soft Glow */} strokeLinejoin="round"
<div className="absolute -inset-2 bg-[#82ed20]/20 rounded-full blur-md" /> className="transition-colors duration-150"
/>
{/* Pointer Arrow */} </svg>
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg" className="drop-shadow-[0_2px_8px_rgba(130,237,32,0.5)]"> </div>
<path d="M3 3L10.07 19.97L12.58 12.58L19.97 10.07L3 3Z" fill="white" stroke="black" strokeWidth="1.5" strokeLinejoin="round" /> </motion.div>
</svg> );
</div>
</motion.div>
);
} }

View File

@@ -25,6 +25,7 @@ interface RecordModeContextType {
reorderEvents: (startIndex: number, endIndex: number) => void; reorderEvents: (startIndex: number, endIndex: number) => void;
hoveredEventId: string | null; hoveredEventId: string | null;
setHoveredEventId: (id: string | null) => void; setHoveredEventId: (id: string | null) => void;
isClicking: boolean;
} }
const RecordModeContext = createContext<RecordModeContextType | null>(null); const RecordModeContext = createContext<RecordModeContextType | null>(null);
@@ -32,29 +33,29 @@ const RecordModeContext = createContext<RecordModeContextType | null>(null);
export function useRecordMode(): RecordModeContextType { export function useRecordMode(): RecordModeContextType {
const context = useContext(RecordModeContext); const context = useContext(RecordModeContext);
if (!context) { if (!context) {
// Return a fail-safe fallback for SSR/Static generation where provider might be missing
return { return {
isActive: false, isActive: false,
setIsActive: () => { }, setIsActive: () => {},
events: [], events: [],
addEvent: () => { }, addEvent: () => {},
updateEvent: () => { }, updateEvent: () => {},
removeEvent: () => { }, removeEvent: () => {},
clearEvents: () => { }, clearEvents: () => {},
isPlaying: false, isPlaying: false,
playEvents: () => { }, playEvents: () => {},
stopPlayback: () => { }, stopPlayback: () => {},
cursorPosition: { x: 0, y: 0 }, cursorPosition: { x: 0, y: 0 },
zoomLevel: 1, zoomLevel: 1,
isBlurry: false, isBlurry: false,
currentSession: null, currentSession: null,
isFeedbackActive: false, isFeedbackActive: false,
setIsFeedbackActive: () => { }, setIsFeedbackActive: () => {},
saveSession: () => { }, saveSession: () => {},
reorderEvents: () => { }, reorderEvents: () => {},
hoveredEventId: null, hoveredEventId: null,
setHoveredEventId: () => { }, setHoveredEventId: () => {},
setEvents: () => { }, setEvents: () => {},
isClicking: false,
}; };
} }
return context; return context;
@@ -69,17 +70,18 @@ export function RecordModeProvider({ children }: { children: React.ReactNode })
const [isBlurry, setIsBlurry] = useState(false); const [isBlurry, setIsBlurry] = useState(false);
const [isFeedbackActive, setIsFeedbackActiveState] = useState(false); const [isFeedbackActive, setIsFeedbackActiveState] = useState(false);
const [hoveredEventId, setHoveredEventId] = useState<string | null>(null); const [hoveredEventId, setHoveredEventId] = useState<string | null>(null);
const [isClicking, setIsClicking] = useState(false);
const [isEmbedded, setIsEmbedded] = useState(false); const [isEmbedded, setIsEmbedded] = useState(false);
useEffect(() => { useEffect(() => {
const embedded = typeof window !== 'undefined' && const embedded =
typeof window !== 'undefined' &&
(window.location.search.includes('embedded=true') || (window.location.search.includes('embedded=true') ||
window.name === 'record-mode-iframe' || window.name === 'record-mode-iframe' ||
window.self !== window.top); window.self !== window.top);
setIsEmbedded(embedded); setIsEmbedded(embedded);
}, []); }, []);
// Synchronous mutually exclusive setters
const setIsActive = (active: boolean) => { const setIsActive = (active: boolean) => {
setIsActiveState(active); setIsActiveState(active);
if (active) setIsFeedbackActiveState(false); if (active) setIsFeedbackActiveState(false);
@@ -90,52 +92,39 @@ export function RecordModeProvider({ children }: { children: React.ReactNode })
if (active) setIsActiveState(false); if (active) setIsActiveState(false);
}; };
const playbackRequestRef = useRef<number>(0);
const isPlayingRef = useRef(false); const isPlayingRef = useRef(false);
const isLoadedRef = useRef(false);
// Load draft from localStorage on mount
useEffect(() => { useEffect(() => {
const savedEvents = localStorage.getItem('klz-record-events'); const savedEvents = localStorage.getItem('klz-record-events');
const savedActive = localStorage.getItem('klz-record-active'); const savedActive = localStorage.getItem('klz-record-active');
if (savedEvents) setEvents(JSON.parse(savedEvents)); if (savedEvents) setEvents(JSON.parse(savedEvents));
if (savedActive) setIsActive(JSON.parse(savedActive)); if (savedActive) setIsActive(JSON.parse(savedActive));
isLoadedRef.current = true;
}, []); }, []);
// Sync events to localStorage
useEffect(() => { useEffect(() => {
if (!isLoadedRef.current) return;
localStorage.setItem('klz-record-events', JSON.stringify(events)); localStorage.setItem('klz-record-events', JSON.stringify(events));
}, [events]); }, [events]);
// Sync active state to localStorage
useEffect(() => { useEffect(() => {
localStorage.setItem('klz-record-active', JSON.stringify(isActive)); localStorage.setItem('klz-record-active', JSON.stringify(isActive));
if (isActive && isFeedbackActive) { }, [isActive]);
setIsFeedbackActive(false);
}
}, [isActive, isFeedbackActive]);
useEffect(() => { useEffect(() => {
if (isFeedbackActive && isActive) { if (isEmbedded) {
setIsActive(false); const handlePlaybackMessage = (e: MessageEvent) => {
} if (e.data.type === 'PLAY_EVENT') {
}, [isFeedbackActive, isActive]); const { event } = e.data;
const el = event.selector
// GUEST LISTENERS: Execute events coming from host ? (document.querySelector(event.selector) as HTMLElement)
useEffect(() => { : null;
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 (el) { if (el) {
if (event.type === 'scroll') { if (event.type === 'scroll') {
el.scrollIntoView({ behavior: 'smooth', block: 'center' }); el.scrollIntoView({ behavior: 'smooth', block: 'center' });
} else if (event.type === 'mouse') { } else if (event.type === 'mouse') {
const currentRect = el.getBoundingClientRect(); const currentRect = el.getBoundingClientRect();
// Calculate Point based on Origin
let targetX = currentRect.left + currentRect.width / 2; let targetX = currentRect.left + currentRect.width / 2;
let targetY = currentRect.top + currentRect.height / 2; let targetY = currentRect.top + currentRect.height / 2;
@@ -153,30 +142,30 @@ export function RecordModeProvider({ children }: { children: React.ReactNode })
targetY = currentRect.bottom - 5; targetY = currentRect.bottom - 5;
} }
const eventCoords = { const eventCoords = { clientX: targetX, clientY: targetY };
clientX: targetX,
clientY: targetY
};
const dispatchMouse = (type: string) => { const dispatchMouse = (type: string) => {
el.dispatchEvent(new MouseEvent(type, { el.dispatchEvent(
view: window, new MouseEvent(type, {
bubbles: true, view: window,
cancelable: true, bubbles: true,
...eventCoords cancelable: true,
})); ...eventCoords,
}),
);
}; };
if (event.interactionType === 'click') { if (event.interactionType === 'click') {
setIsClicking(true);
dispatchMouse('mousedown'); dispatchMouse('mousedown');
dispatchMouse('mouseup'); setTimeout(() => {
dispatchMouse('click'); dispatchMouse('mouseup');
if (event.realClick) {
if (event.realClick) { dispatchMouse('click');
el.click(); el.click();
} }
setIsClicking(false);
}, 150);
} else { } else {
// Hover Interaction
dispatchMouse('mousemove'); dispatchMouse('mousemove');
dispatchMouse('mouseover'); dispatchMouse('mouseover');
dispatchMouse('mouseenter'); 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]); }, [isEmbedded]);
// Sync Hover Preview to Iframe
useEffect(() => { useEffect(() => {
if (isEmbedded || !isActive) return; 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; const iframe = document.querySelector('iframe[name="record-mode-iframe"]') as HTMLIFrameElement;
if (iframe?.contentWindow) { if (iframe?.contentWindow) {
iframe.contentWindow.postMessage({ iframe.contentWindow.postMessage(
type: 'SET_HOVER_SELECTOR', { type: 'SET_HOVER_SELECTOR', selector: event?.selector || null },
selector: event?.selector || null '*',
}, '*'); );
} }
}, [hoveredEventId, events, isActive, isEmbedded]); }, [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 addEvent = (event: Omit<RecordEvent, 'id' | 'timestamp'>) => {
const newEvent: RecordEvent = { const newEvent: RecordEvent = {
realClick: false,
...event, ...event,
id: Math.random().toString(36).substr(2, 9), id: Math.random().toString(36).substr(2, 9),
timestamp: Date.now(), timestamp: Date.now(),
@@ -223,74 +203,72 @@ export function RecordModeProvider({ children }: { children: React.ReactNode })
const updateEvent = (id: string, updatedFields: Partial<RecordEvent>) => { const updateEvent = (id: string, updatedFields: Partial<RecordEvent>) => {
setEvents((prev) => 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) => { const removeEvent = (id: string) => {
setEvents((prev) => prev.filter((event) => event.id !== id)); setEvents((prev) => prev.filter((event) => event.id !== id));
}; };
const clearEvents = () => { const clearEvents = () => {
if (confirm('Clear all recorded events?')) { if (confirm('Clear all recorded events?')) setEvents([]);
setEvents([]);
}
}; };
const currentSession: RecordingSession | null = events.length > 0 ? { const currentSession: RecordingSession | null =
id: 'draft', events.length > 0
name: 'Draft Session', ? {
events, id: 'draft',
createdAt: new Date().toISOString(), name: 'Draft Session',
} : null; events,
createdAt: new Date().toISOString(),
}
: null;
const saveSession = (name: string) => { const saveSession = (name: string) => {
// In a real app, this would be an API call
console.log('Saving session:', name, events); console.log('Saving session:', name, events);
}; };
const playEvents = async () => { const playEvents = async () => {
if (events.length === 0 || isPlayingRef.current) return; if (events.length === 0 || isPlayingRef.current) return;
setIsPlaying(true); setIsPlaying(true);
isPlayingRef.current = true; isPlayingRef.current = true;
// Sort events by timestamp just in case
const sortedEvents = [...events].sort((a, b) => (a.timestamp || 0) - (b.timestamp || 0)); const sortedEvents = [...events].sort((a, b) => (a.timestamp || 0) - (b.timestamp || 0));
for (const event of sortedEvents) { for (const event of sortedEvents) {
if (!isPlayingRef.current) break; if (!isPlayingRef.current) break;
// 1. Move Cursor (Host logic)
if (event.rect && !isEmbedded) { 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(); const iframeRect = iframe?.getBoundingClientRect();
// Add iframe offset to guest-relative coordinates
setCursorPosition({ setCursorPosition({
x: (iframeRect?.left || 0) + event.rect.x + event.rect.width / 2, x: (iframeRect?.left || 0) + event.rect.x + event.rect.width / 2,
y: (iframeRect?.top || 0) + event.rect.y + event.rect.height / 2, y: (iframeRect?.top || 0) + event.rect.y + event.rect.height / 2,
}); });
} }
// 2. Handle Action
if (event.selector) { if (event.selector) {
if (!isEmbedded) { if (!isEmbedded) {
// HOST: Delegate to Iframe const iframe = document.querySelector(
const iframe = document.querySelector('iframe[name="record-mode-iframe"]') as HTMLIFrameElement; 'iframe[name="record-mode-iframe"]',
if (iframe?.contentWindow) { ) as HTMLIFrameElement;
if (iframe?.contentWindow)
iframe.contentWindow.postMessage({ type: 'PLAY_EVENT', event }, '*'); iframe.contentWindow.postMessage({ type: 'PLAY_EVENT', event }, '*');
}
} else { } else {
// GUEST (Self-execution failsafe) // Self-execution logic for guest
const el = document.querySelector(event.selector) as HTMLElement; const el = document.querySelector(event.selector) as HTMLElement;
if (el) { if (el) {
if (event.type === 'scroll') { if (event.type === 'scroll') el.scrollIntoView({ behavior: 'smooth', block: 'center' });
el.scrollIntoView({ behavior: 'smooth', block: 'center' }); else if (event.type === 'mouse') {
} else if (event.type === 'mouse') {
const currentRect = el.getBoundingClientRect(); const currentRect = el.getBoundingClientRect();
// Calculate Point based on Origin (same as above for parity)
let targetX = currentRect.left + currentRect.width / 2; let targetX = currentRect.left + currentRect.width / 2;
let targetY = currentRect.top + currentRect.height / 2; let targetY = currentRect.top + currentRect.height / 2;
@@ -308,30 +286,30 @@ export function RecordModeProvider({ children }: { children: React.ReactNode })
targetY = currentRect.bottom - 5; targetY = currentRect.bottom - 5;
} }
const eventCoords = { const eventCoords = { clientX: targetX, clientY: targetY };
clientX: targetX,
clientY: targetY
};
const dispatchMouse = (type: string) => { const dispatchMouse = (type: string) => {
el.dispatchEvent(new MouseEvent(type, { el.dispatchEvent(
view: window, new MouseEvent(type, {
bubbles: true, view: window,
cancelable: true, bubbles: true,
...eventCoords cancelable: true,
})); ...eventCoords,
}),
);
}; };
if (event.interactionType === 'click') { if (event.interactionType === 'click') {
setIsClicking(true);
dispatchMouse('mousedown'); dispatchMouse('mousedown');
dispatchMouse('mouseup'); setTimeout(() => {
dispatchMouse('click'); dispatchMouse('mouseup');
if (event.realClick) {
if (event.realClick) { dispatchMouse('click');
el.click(); el.click();
} }
setIsClicking(false);
}, 150);
} else { } else {
// Hover Interaction
dispatchMouse('mousemove'); dispatchMouse('mousemove');
dispatchMouse('mouseover'); dispatchMouse('mouseover');
dispatchMouse('mouseenter'); dispatchMouse('mouseenter');
@@ -341,15 +319,11 @@ export function RecordModeProvider({ children }: { children: React.ReactNode })
} }
} }
// 3. Zoom/Blur
if (event.zoom) setZoomLevel(event.zoom); if (event.zoom) setZoomLevel(event.zoom);
if (event.motionBlur) setIsBlurry(true); if (event.motionBlur) setIsBlurry(true);
// 4. Wait
await new Promise((resolve) => setTimeout(resolve, event.duration || 1000)); await new Promise((resolve) => setTimeout(resolve, event.duration || 1000));
setIsBlurry(false); setIsBlurry(false);
} }
setIsPlaying(false); setIsPlaying(false);
isPlayingRef.current = false; isPlayingRef.current = false;
setZoomLevel(1); setZoomLevel(1);
@@ -386,6 +360,7 @@ export function RecordModeProvider({ children }: { children: React.ReactNode })
reorderEvents, reorderEvents,
hoveredEventId, hoveredEventId,
setHoveredEventId, setHoveredEventId,
isClicking,
}} }}
> >
{children} {children}

View File

@@ -70,8 +70,9 @@ export function RecordModeOverlay() {
selector, selector,
duration: lastInteractionType === 'click' ? 1000 : 1500, duration: lastInteractionType === 'click' ? 1000 : 1500,
zoom: 1, zoom: 1,
description: `${lastInteractionType === 'click' ? 'Click' : 'Hover'} on ${tagName}`, description: `Mouse ${lastInteractionType === 'click' ? '(Click)' : '(Hover)'} on ${tagName}`,
motionBlur: false, motionBlur: false,
realClick: false,
rect, rect,
}); });
} else if (pickingMode === 'scroll') { } else if (pickingMode === 'scroll') {
@@ -125,7 +126,12 @@ export function RecordModeOverlay() {
if (!isActive) { if (!isActive) {
// Failsafe: Never render host toggle in embedded mode // Failsafe: Never render host toggle in embedded mode
if (typeof window !== 'undefined' && (window.self !== window.top || window.name === 'record-mode-iframe' || window.location.search.includes('embedded=true'))) { if (
typeof window !== 'undefined' &&
(window.self !== window.top ||
window.name === 'record-mode-iframe' ||
window.location.search.includes('embedded=true'))
) {
return null; return null;
} }
@@ -144,13 +150,16 @@ export function RecordModeOverlay() {
{/* 1. Global Toolbar - Slim Industrial Bar */} {/* 1. Global Toolbar - Slim Industrial Bar */}
<div className="fixed bottom-6 left-1/2 -translate-x-1/2 z-[10000] pointer-events-auto"> <div className="fixed bottom-6 left-1/2 -translate-x-1/2 z-[10000] pointer-events-auto">
<div className="bg-black/80 backdrop-blur-2xl border border-white/10 p-2 rounded-[24px] shadow-[0_32px_80px_rgba(0,0,0,0.8)] flex items-center gap-2"> <div className="bg-black/80 backdrop-blur-2xl border border-white/10 p-2 rounded-[24px] shadow-[0_32px_80px_rgba(0,0,0,0.8)] flex items-center gap-2">
{/* Identity Tag */} {/* Identity Tag */}
<div className="flex items-center gap-3 px-4 py-2 bg-white/5 rounded-[16px] border border-white/5 mx-1"> <div className="flex items-center gap-3 px-4 py-2 bg-white/5 rounded-[16px] border border-white/5 mx-1">
<div className="w-2 h-2 rounded-full bg-accent animate-pulse" /> <div className="w-2 h-2 rounded-full bg-accent animate-pulse" />
<div className="flex flex-col"> <div className="flex flex-col">
<span className="text-[10px] font-bold text-white uppercase tracking-wider leading-none">Event Builder</span> <span className="text-[10px] font-bold text-white uppercase tracking-wider leading-none">
<span className="text-[8px] text-white/30 uppercase tracking-widest mt-1">Manual Mode</span> Event Builder
</span>
<span className="text-[8px] text-white/30 uppercase tracking-widest mt-1">
Manual Mode
</span>
</div> </div>
</div> </div>
@@ -163,21 +172,10 @@ export function RecordModeOverlay() {
setPickingMode('mouse'); setPickingMode('mouse');
setLastInteractionType('click'); setLastInteractionType('click');
}} }}
className={`flex items-center gap-2 px-4 py-2.5 rounded-[16px] transition-all text-xs font-bold uppercase tracking-wide ${pickingMode === 'mouse' && lastInteractionType === 'click' ? 'bg-accent text-primary-dark shadow-lg shadow-accent/20' : 'text-white/40 hover:text-white hover:bg-white/5'}`} className={`flex items-center gap-2 px-4 py-2.5 rounded-[16px] transition-all text-xs font-bold uppercase tracking-wide ${pickingMode === 'mouse' ? 'bg-accent text-primary-dark shadow-lg shadow-accent/20' : 'text-white/40 hover:text-white hover:bg-white/5'}`}
> >
<MousePointer2 size={16} /> <MousePointer2 size={16} />
<span>Click</span> <span>Mouse</span>
</button>
<button
onClick={() => {
setPickingMode('mouse');
setLastInteractionType('hover');
}}
className={`flex items-center gap-2 px-4 py-2.5 rounded-[16px] transition-all text-xs font-bold uppercase tracking-wide ${pickingMode === 'mouse' && lastInteractionType === 'hover' ? 'bg-accent text-primary-dark shadow-lg shadow-accent/20' : 'text-white/40 hover:text-white hover:bg-white/5'}`}
>
<Eye size={16} />
<span>Hover</span>
</button> </button>
<button <button
@@ -189,13 +187,15 @@ export function RecordModeOverlay() {
</button> </button>
<button <button
onClick={() => addEvent({ onClick={() =>
type: 'wait', addEvent({
duration: 2000, type: 'wait',
zoom: 1, duration: 2000,
description: 'Wait for 2s', zoom: 1,
motionBlur: false description: 'Wait for 2s',
})} motionBlur: false,
})
}
className="flex items-center gap-2 px-4 py-2.5 rounded-[16px] text-white/40 hover:text-white hover:bg-white/5 transition-all text-xs font-bold uppercase tracking-wide" className="flex items-center gap-2 px-4 py-2.5 rounded-[16px] text-white/40 hover:text-white hover:bg-white/5 transition-all text-xs font-bold uppercase tracking-wide"
> >
<Plus size={16} /> <Plus size={16} />
@@ -258,7 +258,11 @@ export function RecordModeOverlay() {
<button <button
onClick={() => { onClick={() => {
const data = JSON.stringify({ events, name: 'Recording', createdAt: new Date().toISOString() }, null, 2); const data = JSON.stringify(
{ events, name: 'Recording', createdAt: new Date().toISOString() },
null,
2,
);
const blob = new Blob([data], { type: 'application/json' }); const blob = new Blob([data], { type: 'application/json' });
const url = URL.createObjectURL(blob); const url = URL.createObjectURL(blob);
const a = document.createElement('a'); const a = document.createElement('a');
@@ -294,7 +298,9 @@ export function RecordModeOverlay() {
<div className="flex items-center justify-between mb-6"> <div className="flex items-center justify-between mb-6">
<div> <div>
<h4 className="text-white font-bold text-lg leading-none">Recording Track</h4> <h4 className="text-white font-bold text-lg leading-none">Recording Track</h4>
<p className="text-[10px] text-white/30 uppercase tracking-widest mt-2">{events.length} Actions Recorded</p> <p className="text-[10px] text-white/30 uppercase tracking-widest mt-2">
{events.length} Actions Recorded
</p>
</div> </div>
<button <button
onClick={clearEvents} onClick={clearEvents}
@@ -332,12 +338,18 @@ export function RecordModeOverlay() {
<div className="flex-1 min-w-0"> <div className="flex-1 min-w-0">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<span className="text-white text-[10px] font-black uppercase tracking-widest"> <span className="text-white text-[10px] font-black uppercase tracking-widest">
{event.type === 'mouse' ? event.interactionType : event.type} {event.type === 'mouse' ? `Mouse (${event.interactionType})` : event.type}
</span>
{event.clickOrigin &&
event.clickOrigin !== 'center' &&
event.interactionType === 'click' && (
<span className="text-[8px] bg-accent/20 text-accent px-1.5 py-0.5 rounded uppercase font-bold">
{event.clickOrigin}
</span>
)}
<span className="text-[8px] bg-white/10 px-1.5 py-0.5 rounded text-white/40 font-mono italic">
{event.duration}ms
</span> </span>
{event.clickOrigin && event.clickOrigin !== 'center' && event.interactionType === 'click' && (
<span className="text-[8px] bg-accent/20 text-accent px-1.5 py-0.5 rounded uppercase font-bold">{event.clickOrigin}</span>
)}
<span className="text-[8px] bg-white/10 px-1.5 py-0.5 rounded text-white/40 font-mono italic">{event.duration}ms</span>
</div> </div>
<p className="text-[9px] text-white/30 truncate font-mono mt-1 opacity-60"> <p className="text-[9px] text-white/30 truncate font-mono mt-1 opacity-60">
{event.selector || 'system:wait'} {event.selector || 'system:wait'}
@@ -377,7 +389,9 @@ export function RecordModeOverlay() {
<div className="bg-accent text-primary-dark px-6 py-3 rounded-full flex items-center gap-4 shadow-[0_20px_40px_rgba(130,237,32,0.4)] animate-reveal border border-primary-dark/10"> <div className="bg-accent text-primary-dark px-6 py-3 rounded-full flex items-center gap-4 shadow-[0_20px_40px_rgba(130,237,32,0.4)] animate-reveal border border-primary-dark/10">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<div className="w-2 h-2 rounded-full bg-primary-dark animate-pulse" /> <div className="w-2 h-2 rounded-full bg-primary-dark animate-pulse" />
<span className="font-black uppercase tracking-widest text-xs">Assigning {pickingMode}</span> <span className="font-black uppercase tracking-widest text-xs">
Assigning {pickingMode}
</span>
</div> </div>
<div className="w-px h-6 bg-primary-dark/20" /> <div className="w-px h-6 bg-primary-dark/20" />
<button <button
@@ -400,8 +414,13 @@ export function RecordModeOverlay() {
{editingEventId && ( {editingEventId && (
<div className="fixed inset-y-0 right-0 w-[350px] bg-black/95 backdrop-blur-3xl border-l border-white/10 z-[11000] pointer-events-auto p-8 shadow-[-40px_0_100px_rgba(0,0,0,0.9)] flex flex-col"> <div className="fixed inset-y-0 right-0 w-[350px] bg-black/95 backdrop-blur-3xl border-l border-white/10 z-[11000] pointer-events-auto p-8 shadow-[-40px_0_100px_rgba(0,0,0,0.9)] flex flex-col">
<div className="flex items-center justify-between mb-8"> <div className="flex items-center justify-between mb-8">
<h3 className="text-white font-black uppercase tracking-tighter text-xl">Event Options</h3> <h3 className="text-white font-black uppercase tracking-tighter text-xl">
<button onClick={() => setEditingEventId(null)} className="p-2 text-white/40 hover:text-white transition-colors"> Event Options
</h3>
<button
onClick={() => setEditingEventId(null)}
className="p-2 text-white/40 hover:text-white transition-colors"
>
<X size={20} /> <X size={20} />
</button> </button>
</div> </div>
@@ -409,31 +428,37 @@ export function RecordModeOverlay() {
<div className="flex-1 space-y-8 overflow-y-auto pr-2 scrollbar-hide"> <div className="flex-1 space-y-8 overflow-y-auto pr-2 scrollbar-hide">
{/* Type Display */} {/* Type Display */}
<div className="space-y-3"> <div className="space-y-3">
<label className="text-[10px] uppercase tracking-[0.2em] font-bold text-white/30 leading-none">Interaction Type</label> <label className="text-[10px] uppercase tracking-[0.2em] font-bold text-white/30 leading-none">
Interaction Type
</label>
<div className="flex gap-2 p-1 bg-white/5 rounded-2xl border border-white/5"> <div className="flex gap-2 p-1 bg-white/5 rounded-2xl border border-white/5">
<button <button
onClick={() => setEditForm(prev => ({ ...prev, type: 'mouse', interactionType: 'click' }))} onClick={() =>
setEditForm((prev) => ({ ...prev, type: 'mouse', interactionType: 'click' }))
}
className={`flex-1 flex items-center justify-center gap-2 py-3 rounded-xl transition-all border ${editForm.type === 'mouse' && editForm.interactionType === 'click' ? 'bg-accent text-primary-dark border-accent' : 'text-white/40 border-transparent hover:border-white/10'}`} className={`flex-1 flex items-center justify-center gap-2 py-3 rounded-xl transition-all border ${editForm.type === 'mouse' && editForm.interactionType === 'click' ? 'bg-accent text-primary-dark border-accent' : 'text-white/40 border-transparent hover:border-white/10'}`}
> >
<MousePointer2 size={14} /> <MousePointer2 size={14} />
<span className="text-[10px] font-black uppercase">Click</span> <span className="text-[10px] font-black uppercase">Click</span>
</button> </button>
<button <button
onClick={() => setEditForm(prev => ({ ...prev, type: 'mouse', interactionType: 'hover' }))} onClick={() =>
setEditForm((prev) => ({ ...prev, type: 'mouse', interactionType: 'hover' }))
}
className={`flex-1 flex items-center justify-center gap-2 py-3 rounded-xl transition-all border ${editForm.type === 'mouse' && editForm.interactionType === 'hover' ? 'bg-accent text-primary-dark border-accent' : 'text-white/40 border-transparent hover:border-white/10'}`} className={`flex-1 flex items-center justify-center gap-2 py-3 rounded-xl transition-all border ${editForm.type === 'mouse' && editForm.interactionType === 'hover' ? 'bg-accent text-primary-dark border-accent' : 'text-white/40 border-transparent hover:border-white/10'}`}
> >
<Eye size={14} /> <Eye size={14} />
<span className="text-[10px] font-black uppercase">Hover</span> <span className="text-[10px] font-black uppercase">Hover</span>
</button> </button>
<button <button
onClick={() => setEditForm(prev => ({ ...prev, type: 'scroll' }))} onClick={() => setEditForm((prev) => ({ ...prev, type: 'scroll' }))}
className={`flex-1 flex items-center justify-center gap-2 py-3 rounded-xl transition-all border ${editForm.type === 'scroll' ? 'bg-accent text-primary-dark border-accent' : 'text-white/40 border-transparent hover:border-white/10'}`} className={`flex-1 flex items-center justify-center gap-2 py-3 rounded-xl transition-all border ${editForm.type === 'scroll' ? 'bg-accent text-primary-dark border-accent' : 'text-white/40 border-transparent hover:border-white/10'}`}
> >
<Scroll size={14} /> <Scroll size={14} />
<span className="text-[10px] font-black uppercase">Scroll</span> <span className="text-[10px] font-black uppercase">Scroll</span>
</button> </button>
<button <button
onClick={() => setEditForm(prev => ({ ...prev, type: 'wait' }))} onClick={() => setEditForm((prev) => ({ ...prev, type: 'wait' }))}
className={`flex-1 flex items-center justify-center gap-2 py-3 rounded-xl transition-all border ${editForm.type === 'wait' ? 'bg-accent text-primary-dark border-accent' : 'text-white/40 border-transparent hover:border-white/10'}`} className={`flex-1 flex items-center justify-center gap-2 py-3 rounded-xl transition-all border ${editForm.type === 'wait' ? 'bg-accent text-primary-dark border-accent' : 'text-white/40 border-transparent hover:border-white/10'}`}
> >
<Clock size={14} /> <Clock size={14} />
@@ -445,15 +470,22 @@ export function RecordModeOverlay() {
{/* Precise Click Origin */} {/* Precise Click Origin */}
{editForm.type === 'mouse' && editForm.interactionType === 'click' && ( {editForm.type === 'mouse' && editForm.interactionType === 'click' && (
<div className="space-y-4"> <div className="space-y-4">
<label className="text-[10px] uppercase tracking-[0.2em] font-bold text-white/30 leading-none">Click Origin</label> <label className="text-[10px] uppercase tracking-[0.2em] font-bold text-white/30 leading-none">
Click Origin
</label>
<div className="grid grid-cols-3 gap-2 p-2 bg-white/5 rounded-2xl border border-white/5"> <div className="grid grid-cols-3 gap-2 p-2 bg-white/5 rounded-2xl border border-white/5">
{[ {[
{ id: 'top-left', label: 'TL' }, { id: 'top-right', label: 'TR' }, { id: 'center', label: 'CTR' }, { id: 'top-left', label: 'TL' },
{ id: 'bottom-left', label: 'BL' }, { id: 'bottom-right', label: 'BR' } { id: 'top-right', label: 'TR' },
{ id: 'center', label: 'CTR' },
{ id: 'bottom-left', label: 'BL' },
{ id: 'bottom-right', label: 'BR' },
].map((origin) => ( ].map((origin) => (
<button <button
key={origin.id} key={origin.id}
onClick={() => setEditForm(prev => ({ ...prev, clickOrigin: origin.id as any }))} onClick={() =>
setEditForm((prev) => ({ ...prev, clickOrigin: origin.id as any }))
}
className={`py-3 rounded-xl text-[10px] font-black uppercase tracking-tighter transition-all border ${editForm.clickOrigin === origin.id ? 'bg-accent text-primary-dark border-accent' : 'bg-transparent text-white/40 border-white/5 hover:border-white/20'}`} className={`py-3 rounded-xl text-[10px] font-black uppercase tracking-tighter transition-all border ${editForm.clickOrigin === origin.id ? 'bg-accent text-primary-dark border-accent' : 'bg-transparent text-white/40 border-white/5 hover:border-white/20'}`}
> >
{origin.label} {origin.label}
@@ -475,7 +507,9 @@ export function RecordModeOverlay() {
max="5000" max="5000"
step="100" step="100"
value={editForm.duration || 1000} value={editForm.duration || 1000}
onChange={(e) => setEditForm(prev => ({ ...prev, duration: parseInt(e.target.value) }))} onChange={(e) =>
setEditForm((prev) => ({ ...prev, duration: parseInt(e.target.value) }))
}
className="w-full h-1 bg-white/10 rounded-lg appearance-none cursor-pointer accent-accent" className="w-full h-1 bg-white/10 rounded-lg appearance-none cursor-pointer accent-accent"
/> />
</div> </div>
@@ -485,7 +519,9 @@ export function RecordModeOverlay() {
<div className="flex items-center justify-between p-4 bg-white/5 rounded-2xl border border-white/5 group hover:border-white/20 transition-all"> <div className="flex items-center justify-between p-4 bg-white/5 rounded-2xl border border-white/5 group hover:border-white/20 transition-all">
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<Maximize2 size={18} className="text-white/40" /> <Maximize2 size={18} className="text-white/40" />
<span className="text-xs font-bold text-white uppercase tracking-wider">Zoom Shift</span> <span className="text-xs font-bold text-white uppercase tracking-wider">
Zoom Shift
</span>
</div> </div>
<input <input
type="number" type="number"
@@ -493,13 +529,15 @@ export function RecordModeOverlay() {
min="1" min="1"
max="3" max="3"
value={editForm.zoom || 1} value={editForm.zoom || 1}
onChange={(e) => setEditForm(prev => ({ ...prev, zoom: parseFloat(e.target.value) }))} onChange={(e) =>
setEditForm((prev) => ({ ...prev, zoom: parseFloat(e.target.value) }))
}
className="w-16 bg-white/10 border border-white/10 rounded-lg px-2 py-1 text-xs text-white text-center font-mono" className="w-16 bg-white/10 border border-white/10 rounded-lg px-2 py-1 text-xs text-white text-center font-mono"
/> />
</div> </div>
<button <button
onClick={() => setEditForm(prev => ({ ...prev, motionBlur: !prev.motionBlur }))} onClick={() => setEditForm((prev) => ({ ...prev, motionBlur: !prev.motionBlur }))}
className={`flex items-center justify-between w-full p-4 rounded-2xl border transition-all ${editForm.motionBlur ? 'bg-accent/10 border-accent/30 text-accent' : 'bg-white/5 border-white/5 text-white/40'}`} className={`flex items-center justify-between w-full p-4 rounded-2xl border transition-all ${editForm.motionBlur ? 'bg-accent/10 border-accent/30 text-accent' : 'bg-white/5 border-white/5 text-white/40'}`}
> >
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
@@ -511,14 +549,18 @@ export function RecordModeOverlay() {
{editForm.type === 'mouse' && editForm.interactionType === 'click' && ( {editForm.type === 'mouse' && editForm.interactionType === 'click' && (
<button <button
onClick={() => setEditForm(prev => ({ ...prev, realClick: !prev.realClick }))} onClick={() => setEditForm((prev) => ({ ...prev, realClick: !prev.realClick }))}
className={`flex items-center justify-between w-full p-4 rounded-2xl border transition-all ${editForm.realClick ? 'bg-orange-500/10 border-orange-500/30 text-orange-400' : 'bg-white/5 border-white/5 text-white/40'}`} className={`flex items-center justify-between w-full p-4 rounded-2xl border transition-all ${editForm.realClick ? 'bg-orange-500/10 border-orange-500/30 text-orange-400' : 'bg-white/5 border-white/5 text-white/40'}`}
> >
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<ExternalLink size={18} /> <ExternalLink size={18} />
<div className="flex flex-col items-start"> <div className="flex flex-col items-start">
<span className="text-xs font-bold uppercase tracking-wider">Trigger Navigation</span> <span className="text-xs font-bold uppercase tracking-wider">
<span className="text-[8px] opacity-60">Allows URL transitions in Studio</span> Trigger Navigation
</span>
<span className="text-[8px] opacity-60">
Allows URL transitions in Studio
</span>
</div> </div>
</div> </div>
{editForm.realClick ? <Check size={18} /> : <div className="w-[18px]" />} {editForm.realClick ? <Check size={18} /> : <div className="w-[18px]" />}