From 1baf03a84ef5d467c50cf04ef9478ba2c8219d7f Mon Sep 17 00:00:00 2001 From: Marc Mintel Date: Sun, 15 Feb 2026 18:25:52 +0100 Subject: [PATCH] feat(record-mode): unify mouse tool and enhance visuals --- components/record-mode/PlaybackCursor.tsx | 119 ++++++---- components/record-mode/RecordModeContext.tsx | 231 +++++++++---------- components/record-mode/RecordModeOverlay.tsx | 144 ++++++++---- 3 files changed, 273 insertions(+), 221 deletions(-) diff --git a/components/record-mode/PlaybackCursor.tsx b/components/record-mode/PlaybackCursor.tsx index 97cbc024..7e1fd8ca 100644 --- a/components/record-mode/PlaybackCursor.tsx +++ b/components/record-mode/PlaybackCursor.tsx @@ -1,55 +1,90 @@ 'use client'; import React, { useEffect, useState } from 'react'; -import { motion } from 'framer-motion'; +import { motion, AnimatePresence } from 'framer-motion'; import { useRecordMode } from './RecordModeContext'; export function PlaybackCursor() { - const { isPlaying, cursorPosition } = useRecordMode(); - const [scrollOffset, setScrollOffset] = useState({ x: 0, y: 0 }); + const { isPlaying, cursorPosition, isClicking } = useRecordMode(); + const [scrollOffset, setScrollOffset] = useState({ x: 0, y: 0 }); - // Track scroll so cursor stays locked to the correct element - useEffect(() => { - if (!isPlaying) return; + // Track scroll so cursor stays locked to the correct element + useEffect(() => { + if (!isPlaying) return; - const handleScroll = () => { - setScrollOffset({ x: window.scrollX, y: window.scrollY }); - }; + const handleScroll = () => { + setScrollOffset({ x: window.scrollX, y: window.scrollY }); + }; - handleScroll(); // Init - window.addEventListener('scroll', handleScroll, { passive: true }); - return () => window.removeEventListener('scroll', handleScroll); - }, [isPlaying]); + handleScroll(); // Init + window.addEventListener('scroll', handleScroll, { passive: true }); + return () => window.removeEventListener('scroll', handleScroll); + }, [isPlaying]); - if (!isPlaying) return null; + if (!isPlaying) return null; - return ( - + + {isClicking && ( + + )} + + + {/* Outer Pulse Ring */} +
+ + {/* Visual Cursor */} +
+ {/* Soft Glow */} +
+ + {/* Pointer Arrow */} + - {/* Outer Pulse Ring */} -
- - {/* Visual Cursor */} -
- {/* Soft Glow */} -
- - {/* Pointer Arrow */} - - - -
- - ); + + +
+ + ); } diff --git a/components/record-mode/RecordModeContext.tsx b/components/record-mode/RecordModeContext.tsx index a36412f9..e3741cea 100644 --- a/components/record-mode/RecordModeContext.tsx +++ b/components/record-mode/RecordModeContext.tsx @@ -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(null); @@ -32,29 +33,29 @@ const RecordModeContext = createContext(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(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(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) => { 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) => { 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} diff --git a/components/record-mode/RecordModeOverlay.tsx b/components/record-mode/RecordModeOverlay.tsx index 74e0f206..18ebeaba 100644 --- a/components/record-mode/RecordModeOverlay.tsx +++ b/components/record-mode/RecordModeOverlay.tsx @@ -70,8 +70,9 @@ export function RecordModeOverlay() { selector, duration: lastInteractionType === 'click' ? 1000 : 1500, zoom: 1, - description: `${lastInteractionType === 'click' ? 'Click' : 'Hover'} on ${tagName}`, + description: `Mouse ${lastInteractionType === 'click' ? '(Click)' : '(Hover)'} on ${tagName}`, motionBlur: false, + realClick: false, rect, }); } else if (pickingMode === 'scroll') { @@ -125,7 +126,12 @@ export function RecordModeOverlay() { if (!isActive) { // 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; } @@ -144,13 +150,16 @@ export function RecordModeOverlay() { {/* 1. Global Toolbar - Slim Industrial Bar */}
- {/* Identity Tag */}
- Event Builder - Manual Mode + + Event Builder + + + Manual Mode +
@@ -163,21 +172,10 @@ export function RecordModeOverlay() { setPickingMode('mouse'); 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'}`} > - Click - - -
@@ -409,31 +428,37 @@ export function RecordModeOverlay() {
{/* Type Display */}
- +
@@ -485,7 +519,9 @@ export function RecordModeOverlay() {
- Zoom Shift + + Zoom Shift +
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" />