feat: optimize event capturing and playback accuracy
This commit is contained in:
108
components/record-mode/PickingHelper.tsx
Normal file
108
components/record-mode/PickingHelper.tsx
Normal file
@@ -0,0 +1,108 @@
|
||||
'use client';
|
||||
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { finder } from '@medv/finder';
|
||||
|
||||
export function PickingHelper() {
|
||||
const [pickingMode, setPickingMode] = useState<'click' | 'scroll' | null>(null);
|
||||
const [hoveredElement, setHoveredElement] = useState<HTMLElement | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const handleMessage = (e: MessageEvent) => {
|
||||
if (e.data.type === 'START_PICKING') {
|
||||
setPickingMode(e.data.mode);
|
||||
} else if (e.data.type === 'STOP_PICKING') {
|
||||
setPickingMode(null);
|
||||
setHoveredElement(null);
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener('message', handleMessage);
|
||||
return () => window.removeEventListener('message', handleMessage);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!pickingMode) return;
|
||||
|
||||
const handleMouseOver = (e: MouseEvent) => {
|
||||
const target = e.target as HTMLElement;
|
||||
if (target.closest('.record-mode-ignore') || target.closest('.feedback-ui-ignore')) return;
|
||||
setHoveredElement(target);
|
||||
};
|
||||
|
||||
const handleClick = (e: MouseEvent) => {
|
||||
if (hoveredElement) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
|
||||
const selector = finder(hoveredElement, {
|
||||
root: document.body,
|
||||
seedMinLength: 3,
|
||||
optimizedMinLength: 2,
|
||||
className: (name) =>
|
||||
!name.startsWith('record-mode-') &&
|
||||
!name.startsWith('feedback-') &&
|
||||
!name.includes('[') &&
|
||||
!name.includes('/') &&
|
||||
!name.match(/^[a-z]-[0-9]/) &&
|
||||
!name.match(/[0-9]{4,}/), // Avoid dynamic IDs in classnames
|
||||
idName: (name) => !name.startsWith('__next') && !name.includes(':') && !name.match(/[0-9]{5,}/),
|
||||
});
|
||||
const rect = hoveredElement.getBoundingClientRect();
|
||||
|
||||
window.parent.postMessage({
|
||||
type: 'ELEMENT_SELECTED',
|
||||
selector,
|
||||
rect: {
|
||||
x: rect.left,
|
||||
y: rect.top,
|
||||
width: rect.width,
|
||||
height: rect.height
|
||||
},
|
||||
tagName: hoveredElement.tagName.toLowerCase()
|
||||
}, '*');
|
||||
|
||||
setPickingMode(null);
|
||||
setHoveredElement(null);
|
||||
}
|
||||
};
|
||||
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') {
|
||||
setPickingMode(null);
|
||||
setHoveredElement(null);
|
||||
window.parent.postMessage({ type: 'PICKING_CANCELLED' }, '*');
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener('mouseover', handleMouseOver);
|
||||
window.addEventListener('click', handleClick, true);
|
||||
window.addEventListener('keydown', handleKeyDown);
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('mouseover', handleMouseOver);
|
||||
window.removeEventListener('click', handleClick, true);
|
||||
window.removeEventListener('keydown', handleKeyDown);
|
||||
};
|
||||
}, [pickingMode, hoveredElement]);
|
||||
|
||||
if (!pickingMode || !hoveredElement) return null;
|
||||
|
||||
const rect = hoveredElement.getBoundingClientRect();
|
||||
|
||||
return (
|
||||
<div
|
||||
className="fixed pointer-events-none border-2 border-[#82ed20] bg-[#82ed20]/5 transition-all z-[9999] shadow-[0_0_40px_rgba(130,237,32,0.4)] rounded-sm backdrop-blur-[2px]"
|
||||
style={{
|
||||
top: rect.top,
|
||||
left: rect.left,
|
||||
width: rect.width,
|
||||
height: rect.height,
|
||||
}}
|
||||
>
|
||||
<div className="absolute top-0 right-0 bg-[#82ed20] text-black text-[10px] font-black px-1.5 py-1 transform -translate-y-full uppercase tracking-tighter shadow-xl">
|
||||
{hoveredElement.tagName.toLowerCase()}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
55
components/record-mode/PlaybackCursor.tsx
Normal file
55
components/record-mode/PlaybackCursor.tsx
Normal file
@@ -0,0 +1,55 @@
|
||||
'use client';
|
||||
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { motion } from 'framer-motion';
|
||||
import { useRecordMode } from './RecordModeContext';
|
||||
|
||||
export function PlaybackCursor() {
|
||||
const { isPlaying, cursorPosition } = useRecordMode();
|
||||
const [scrollOffset, setScrollOffset] = useState({ x: 0, y: 0 });
|
||||
|
||||
// Track scroll so cursor stays locked to the correct element
|
||||
useEffect(() => {
|
||||
if (!isPlaying) return;
|
||||
|
||||
const handleScroll = () => {
|
||||
setScrollOffset({ x: window.scrollX, y: window.scrollY });
|
||||
};
|
||||
|
||||
handleScroll(); // Init
|
||||
window.addEventListener('scroll', handleScroll, { passive: true });
|
||||
return () => window.removeEventListener('scroll', handleScroll);
|
||||
}, [isPlaying]);
|
||||
|
||||
if (!isPlaying) return null;
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
className="fixed z-[10000] pointer-events-none"
|
||||
animate={{
|
||||
x: cursorPosition.x,
|
||||
y: cursorPosition.y,
|
||||
}}
|
||||
transition={{
|
||||
type: 'spring',
|
||||
damping: 30,
|
||||
stiffness: 250,
|
||||
mass: 0.5,
|
||||
}}
|
||||
>
|
||||
{/* Outer Pulse Ring */}
|
||||
<div className="absolute -inset-3 rounded-full bg-[#82ed20]/10 animate-ping" />
|
||||
|
||||
{/* Visual Cursor */}
|
||||
<div className="relative">
|
||||
{/* Soft Glow */}
|
||||
<div className="absolute -inset-2 bg-[#82ed20]/20 rounded-full blur-md" />
|
||||
|
||||
{/* 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)]">
|
||||
<path d="M3 3L10.07 19.97L12.58 12.58L19.97 10.07L3 3Z" fill="white" stroke="black" strokeWidth="1.5" strokeLinejoin="round" />
|
||||
</svg>
|
||||
</div>
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
@@ -6,9 +6,6 @@ import { RecordEvent, RecordingSession } from '@/types/record-mode';
|
||||
interface RecordModeContextType {
|
||||
isActive: boolean;
|
||||
setIsActive: (active: boolean) => void;
|
||||
isRecording: boolean;
|
||||
startRecording: () => void;
|
||||
stopRecording: () => void;
|
||||
events: RecordEvent[];
|
||||
addEvent: (event: Omit<RecordEvent, 'id' | 'timestamp'>) => void;
|
||||
updateEvent: (id: string, event: Partial<RecordEvent>) => void;
|
||||
@@ -17,149 +14,209 @@ interface RecordModeContextType {
|
||||
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;
|
||||
}
|
||||
|
||||
const RecordModeContext = createContext<RecordModeContextType | null>(null);
|
||||
|
||||
export function useRecordMode() {
|
||||
export function useRecordMode(): RecordModeContextType {
|
||||
const context = useContext(RecordModeContext);
|
||||
if (!context) {
|
||||
throw new Error('useRecordMode must be used within a RecordModeProvider');
|
||||
// Return a fail-safe fallback for SSR/Static generation where provider might be missing
|
||||
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: () => { },
|
||||
};
|
||||
}
|
||||
return context;
|
||||
}
|
||||
|
||||
export function RecordModeProvider({ children }: { children: React.ReactNode }) {
|
||||
const [isActive, setIsActive] = useState(false);
|
||||
const [isRecording, setIsRecording] = useState(false);
|
||||
const [isActive, setIsActiveState] = useState(false);
|
||||
const [events, setEvents] = useState<RecordEvent[]>([]);
|
||||
const [isPlaying, setIsPlaying] = useState(false);
|
||||
const [currentSession, setCurrentSession] = useState<RecordingSession | null>(null);
|
||||
const startTimeRef = useRef<number>(0);
|
||||
const [cursorPosition, setCursorPosition] = useState({ x: 0, y: 0 });
|
||||
const [zoomLevel, setZoomLevel] = useState(1);
|
||||
const [isBlurry, setIsBlurry] = useState(false);
|
||||
const [isFeedbackActive, setIsFeedbackActiveState] = useState(false);
|
||||
|
||||
const startRecording = () => {
|
||||
setIsRecording(true);
|
||||
setEvents([]);
|
||||
startTimeRef.current = Date.now();
|
||||
// Synchronous mutually exclusive setters
|
||||
const setIsActive = (active: boolean) => {
|
||||
setIsActiveState(active);
|
||||
if (active) setIsFeedbackActiveState(false);
|
||||
};
|
||||
|
||||
const stopRecording = () => {
|
||||
setIsRecording(false);
|
||||
const setIsFeedbackActive = (active: boolean) => {
|
||||
setIsFeedbackActiveState(active);
|
||||
if (active) setIsActiveState(false);
|
||||
};
|
||||
|
||||
const addEvent = (eventData: Omit<RecordEvent, 'id' | 'timestamp'>) => {
|
||||
const timestamp = Date.now() - startTimeRef.current;
|
||||
const playbackRequestRef = useRef<number>(0);
|
||||
const isPlayingRef = 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));
|
||||
}, []);
|
||||
|
||||
// Sync events to localStorage
|
||||
useEffect(() => {
|
||||
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]);
|
||||
|
||||
useEffect(() => {
|
||||
if (isFeedbackActive && isActive) {
|
||||
setIsActive(false);
|
||||
}
|
||||
}, [isFeedbackActive, isActive]);
|
||||
|
||||
const addEvent = (event: Omit<RecordEvent, 'id' | 'timestamp'>) => {
|
||||
const newEvent: RecordEvent = {
|
||||
id: crypto.randomUUID(),
|
||||
timestamp,
|
||||
...eventData,
|
||||
...event,
|
||||
id: Math.random().toString(36).substr(2, 9),
|
||||
timestamp: Date.now(),
|
||||
};
|
||||
setEvents((prev) => [...prev, newEvent]);
|
||||
};
|
||||
|
||||
const updateEvent = (id: string, updates: Partial<RecordEvent>) => {
|
||||
setEvents((prev) => prev.map((e) => (e.id === id ? { ...e, ...updates } : e)));
|
||||
const updateEvent = (id: string, updatedFields: Partial<RecordEvent>) => {
|
||||
setEvents((prev) =>
|
||||
prev.map((event) => (event.id === id ? { ...event, ...updatedFields } : event))
|
||||
);
|
||||
};
|
||||
|
||||
const removeEvent = (id: string) => {
|
||||
setEvents((prev) => prev.filter((e) => e.id !== id));
|
||||
setEvents((prev) => prev.filter((event) => event.id !== id));
|
||||
};
|
||||
|
||||
const clearEvents = () => {
|
||||
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 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) return;
|
||||
setIsPlaying(true);
|
||||
if (events.length === 0 || isPlayingRef.current) return;
|
||||
|
||||
// Simple playback logic mostly for preview
|
||||
const startPlayTime = Date.now();
|
||||
setIsPlaying(true);
|
||||
isPlayingRef.current = true;
|
||||
|
||||
// Sort events by timestamp just in case
|
||||
const sortedEvents = [...events].sort((a, b) => a.timestamp - b.timestamp);
|
||||
const sortedEvents = [...events].sort((a, b) => (a.timestamp || 0) - (b.timestamp || 0));
|
||||
|
||||
for (const event of sortedEvents) {
|
||||
if (!isPlaying) break; // Check if stopped
|
||||
if (!isPlayingRef.current) break;
|
||||
|
||||
const targetTime = startPlayTime + event.timestamp;
|
||||
const now = Date.now();
|
||||
const delay = targetTime - now;
|
||||
|
||||
if (delay > 0) {
|
||||
await new Promise((r) => setTimeout(r, delay));
|
||||
// 1. Move Cursor
|
||||
if (event.rect) {
|
||||
setCursorPosition({
|
||||
x: event.rect.x + event.rect.width / 2,
|
||||
y: event.rect.y + event.rect.height / 2,
|
||||
});
|
||||
}
|
||||
|
||||
// Execute event visual feedback
|
||||
if (document) {
|
||||
if (event.selector) {
|
||||
const el = document.querySelector(event.selector);
|
||||
if (el) {
|
||||
// Highlight or scroll to element
|
||||
if (event.type === 'scroll') {
|
||||
el.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
||||
} else if (event.type === 'click') {
|
||||
// Visualize click
|
||||
const rect = el.getBoundingClientRect();
|
||||
const clickMarker = document.createElement('div');
|
||||
clickMarker.style.position = 'fixed';
|
||||
clickMarker.style.left = `${rect.left + rect.width / 2}px`;
|
||||
clickMarker.style.top = `${rect.top + rect.height / 2}px`;
|
||||
clickMarker.style.width = '20px';
|
||||
clickMarker.style.height = '20px';
|
||||
clickMarker.style.borderRadius = '50%';
|
||||
clickMarker.style.backgroundColor = 'rgba(255, 0, 0, 0.5)';
|
||||
clickMarker.style.transform = 'translate(-50%, -50%)';
|
||||
clickMarker.style.zIndex = '99999';
|
||||
document.body.appendChild(clickMarker);
|
||||
setTimeout(() => clickMarker.remove(), 500);
|
||||
}
|
||||
// 2. Handle Action
|
||||
if (event.selector) {
|
||||
const el = document.querySelector(event.selector) as HTMLElement;
|
||||
if (el) {
|
||||
if (event.type === 'scroll') {
|
||||
el.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
||||
} else if (event.type === 'click') {
|
||||
// Precise industrial click sequence: mousedown -> mouseup -> click
|
||||
const eventCoords = {
|
||||
clientX: event.rect ? event.rect.x + event.rect.width / 2 : 0,
|
||||
clientY: event.rect ? event.rect.y + event.rect.height / 2 : 0
|
||||
};
|
||||
|
||||
const dispatchMouse = (type: string) => {
|
||||
el.dispatchEvent(new MouseEvent(type, {
|
||||
view: window,
|
||||
bubbles: true,
|
||||
cancelable: true,
|
||||
...eventCoords
|
||||
}));
|
||||
};
|
||||
|
||||
dispatchMouse('mousedown');
|
||||
dispatchMouse('mouseup');
|
||||
dispatchMouse('click');
|
||||
|
||||
// Fallback for native elements
|
||||
el.click();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 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);
|
||||
};
|
||||
|
||||
const stopPlayback = () => {
|
||||
setIsPlaying(false);
|
||||
isPlayingRef.current = false;
|
||||
setZoomLevel(1);
|
||||
setIsBlurry(false);
|
||||
};
|
||||
|
||||
const saveSession = (name: string) => {
|
||||
const session: RecordingSession = {
|
||||
id: crypto.randomUUID(),
|
||||
name,
|
||||
events,
|
||||
createdAt: new Date().toISOString(),
|
||||
};
|
||||
setCurrentSession(session);
|
||||
// Ideally save to local storage or API
|
||||
localStorage.setItem('klz-record-session', JSON.stringify(session));
|
||||
};
|
||||
|
||||
// Load session on mount
|
||||
useEffect(() => {
|
||||
const saved = localStorage.getItem('klz-record-session');
|
||||
if (saved) {
|
||||
try {
|
||||
setCurrentSession(JSON.parse(saved));
|
||||
} catch (e) {
|
||||
console.error('Failed to load session', e);
|
||||
}
|
||||
}
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<RecordModeContext.Provider
|
||||
value={{
|
||||
isActive,
|
||||
setIsActive,
|
||||
isRecording,
|
||||
startRecording,
|
||||
stopRecording,
|
||||
events,
|
||||
addEvent,
|
||||
updateEvent,
|
||||
@@ -168,8 +225,13 @@ export function RecordModeProvider({ children }: { children: React.ReactNode })
|
||||
isPlaying,
|
||||
playEvents,
|
||||
stopPlayback,
|
||||
cursorPosition,
|
||||
zoomLevel,
|
||||
isBlurry,
|
||||
currentSession,
|
||||
saveSession,
|
||||
isFeedbackActive,
|
||||
setIsFeedbackActive,
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
|
||||
@@ -15,16 +15,15 @@ import {
|
||||
Edit2,
|
||||
X,
|
||||
Check,
|
||||
Download,
|
||||
} from 'lucide-react';
|
||||
import { RecordEvent } from '@/types/record-mode';
|
||||
import { PlaybackCursor } from './PlaybackCursor';
|
||||
|
||||
export function RecordModeOverlay() {
|
||||
const {
|
||||
isActive,
|
||||
setIsActive,
|
||||
isRecording,
|
||||
startRecording,
|
||||
stopRecording,
|
||||
events,
|
||||
addEvent,
|
||||
updateEvent,
|
||||
@@ -41,26 +40,18 @@ export function RecordModeOverlay() {
|
||||
|
||||
// Edit form state
|
||||
const [editForm, setEditForm] = useState<Partial<RecordEvent>>({});
|
||||
const [mounted, setMounted] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isActive) return;
|
||||
setMounted(true);
|
||||
}, []);
|
||||
|
||||
const handleMouseOver = (e: MouseEvent) => {
|
||||
if (pickingMode) {
|
||||
e.stopPropagation();
|
||||
e.preventDefault();
|
||||
const target = e.target as HTMLElement;
|
||||
if (target.closest('.record-mode-ui')) return;
|
||||
setHoveredElement(target);
|
||||
}
|
||||
};
|
||||
useEffect(() => {
|
||||
if (!mounted || !isActive) return;
|
||||
|
||||
const handleClick = (e: MouseEvent) => {
|
||||
if (pickingMode && hoveredElement) {
|
||||
e.stopPropagation();
|
||||
e.preventDefault();
|
||||
|
||||
const selector = finder(hoveredElement);
|
||||
const handleMessage = (e: MessageEvent) => {
|
||||
if (e.data.type === 'ELEMENT_SELECTED') {
|
||||
const { selector, rect, tagName } = e.data;
|
||||
|
||||
if (pickingMode === 'click') {
|
||||
addEvent({
|
||||
@@ -68,285 +59,285 @@ export function RecordModeOverlay() {
|
||||
selector,
|
||||
duration: 1000,
|
||||
zoom: 1,
|
||||
description: `Click on ${hoveredElement.tagName.toLowerCase()}`,
|
||||
description: `Click on ${tagName}`,
|
||||
motionBlur: false,
|
||||
rect,
|
||||
});
|
||||
} else if (pickingMode === 'scroll') {
|
||||
addEvent({
|
||||
type: 'scroll',
|
||||
selector,
|
||||
duration: 1000,
|
||||
duration: 1500,
|
||||
zoom: 1,
|
||||
description: `Scroll to ${hoveredElement.tagName.toLowerCase()}`,
|
||||
description: `Scroll to ${tagName}`,
|
||||
motionBlur: false,
|
||||
rect,
|
||||
});
|
||||
}
|
||||
|
||||
setPickingMode(null);
|
||||
setHoveredElement(null);
|
||||
} else if (e.data.type === 'PICKING_CANCELLED') {
|
||||
setPickingMode(null);
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener('message', handleMessage);
|
||||
|
||||
if (pickingMode) {
|
||||
window.addEventListener('mouseover', handleMouseOver);
|
||||
window.addEventListener('click', handleClick, true);
|
||||
// Find the iframe and signal start picking
|
||||
const iframe = document.querySelector('iframe');
|
||||
if (iframe?.contentWindow) {
|
||||
iframe.contentWindow.postMessage({ type: 'START_PICKING', mode: pickingMode }, '*');
|
||||
}
|
||||
} else {
|
||||
// Signal stop picking
|
||||
const iframe = document.querySelector('iframe');
|
||||
if (iframe?.contentWindow) {
|
||||
iframe.contentWindow.postMessage({ type: 'STOP_PICKING' }, '*');
|
||||
}
|
||||
}
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('mouseover', handleMouseOver);
|
||||
window.removeEventListener('click', handleClick, true);
|
||||
window.removeEventListener('message', handleMessage);
|
||||
};
|
||||
}, [isActive, pickingMode, hoveredElement, addEvent]);
|
||||
}, [isActive, pickingMode, addEvent]);
|
||||
|
||||
const startEditing = (event: RecordEvent) => {
|
||||
setEditingEventId(event.id);
|
||||
setEditForm({ ...event });
|
||||
};
|
||||
const [showEvents, setShowEvents] = useState(false);
|
||||
|
||||
const saveEdit = () => {
|
||||
if (editingEventId && editForm) {
|
||||
updateEvent(editingEventId, editForm);
|
||||
setEditingEventId(null);
|
||||
setEditForm({});
|
||||
}
|
||||
};
|
||||
|
||||
const cancelEdit = () => {
|
||||
setEditingEventId(null);
|
||||
setEditForm({});
|
||||
};
|
||||
if (!mounted) return null;
|
||||
|
||||
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'))) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<button
|
||||
onClick={() => setIsActive(true)}
|
||||
className="fixed bottom-4 right-4 z-[9999] bg-red-600 text-white p-3 rounded-full shadow-lg hover:scale-110 transition-transform record-mode-ui"
|
||||
className="fixed bottom-6 left-6 z-[9999] bg-[#82ed20]/20 hover:bg-[#82ed20]/30 text-[#82ed20] p-4 rounded-full shadow-2xl transition-all hover:scale-110 record-mode-ignore border border-[#82ed20]/30 backdrop-blur-md animate-pulse"
|
||||
>
|
||||
<div className="w-4 h-4 rounded-full bg-white" />
|
||||
<div className="w-5 h-5 rounded-[4px] border-2 border-[#82ed20]" />
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-[9999] pointer-events-none">
|
||||
{/* Hover Highlighter */}
|
||||
{pickingMode && hoveredElement && (
|
||||
<div
|
||||
className="fixed pointer-events-none border-2 border-red-500 bg-red-500/20 transition-all z-[9998]"
|
||||
style={{
|
||||
top: hoveredElement.getBoundingClientRect().top,
|
||||
left: hoveredElement.getBoundingClientRect().left,
|
||||
width: hoveredElement.getBoundingClientRect().width,
|
||||
height: hoveredElement.getBoundingClientRect().height,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
<div className="fixed inset-0 z-[9998] pointer-events-none font-sans">
|
||||
{/* 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="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">
|
||||
|
||||
{/* Control Panel */}
|
||||
<div className="fixed bottom-8 left-1/2 -translate-x-1/2 bg-black/80 backdrop-blur-md text-white p-4 rounded-xl shadow-2xl pointer-events-auto record-mode-ui border border-white/10 w-[600px] max-h-[80vh] flex flex-col">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h3 className="font-bold flex items-center gap-2">
|
||||
<div
|
||||
className={`w-3 h-3 rounded-full ${isRecording ? 'bg-red-500 animate-pulse' : 'bg-gray-500'}`}
|
||||
/>
|
||||
Record Mode
|
||||
</h3>
|
||||
<button onClick={() => setIsActive(false)} className="text-white/50 hover:text-white">
|
||||
Close
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2 mb-4 overflow-x-auto pb-2">
|
||||
{!isRecording ? (
|
||||
<button
|
||||
onClick={startRecording}
|
||||
className="flex items-center gap-2 bg-red-600 px-4 py-2 rounded-lg hover:bg-red-700 whitespace-nowrap"
|
||||
>
|
||||
<div className="w-3 h-3 rounded-full bg-white" />
|
||||
Start Rec
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
onClick={stopRecording}
|
||||
className="flex items-center gap-2 bg-gray-700 px-4 py-2 rounded-lg hover:bg-gray-600 whitespace-nowrap"
|
||||
>
|
||||
<Square size={16} fill="currentColor" />
|
||||
Stop Rec
|
||||
</button>
|
||||
)}
|
||||
|
||||
<div className="w-px h-8 bg-white/20 mx-2" />
|
||||
|
||||
<button
|
||||
disabled={!isRecording}
|
||||
onClick={() => setPickingMode('click')}
|
||||
className={`flex items-center gap-2 px-3 py-2 rounded-lg transition-colors whitespace-nowrap ${pickingMode === 'click' ? 'bg-blue-600' : 'hover:bg-white/10 disabled:opacity-50'}`}
|
||||
>
|
||||
<MousePointer2 size={16} />
|
||||
Add Click
|
||||
</button>
|
||||
|
||||
<button
|
||||
disabled={!isRecording}
|
||||
onClick={() => setPickingMode('scroll')}
|
||||
className={`flex items-center gap-2 px-3 py-2 rounded-lg transition-colors whitespace-nowrap ${pickingMode === 'scroll' ? 'bg-blue-600' : 'hover:bg-white/10 disabled:opacity-50'}`}
|
||||
>
|
||||
<Scroll size={16} />
|
||||
Add Scroll
|
||||
</button>
|
||||
|
||||
<div className="w-px h-8 bg-white/20 mx-2" />
|
||||
|
||||
<button
|
||||
onClick={playEvents}
|
||||
disabled={isRecording || events.length === 0}
|
||||
className="p-2 hover:bg-white/10 rounded-lg disabled:opacity-50"
|
||||
>
|
||||
<Play size={20} />
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={() => saveSession('Session 1')}
|
||||
disabled={events.length === 0}
|
||||
className="p-2 hover:bg-white/10 rounded-lg disabled:opacity-50"
|
||||
>
|
||||
<Save size={20} />
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={clearEvents}
|
||||
disabled={events.length === 0}
|
||||
className="p-2 hover:bg-red-500/20 text-red-400 rounded-lg disabled:opacity-50"
|
||||
>
|
||||
<Trash2 size={20} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Edit Form */}
|
||||
{editingEventId && (
|
||||
<div className="bg-blue-900/40 p-3 rounded-lg mb-4 border border-blue-500/30">
|
||||
<div className="flex justify-between items-center mb-2">
|
||||
<span className="font-bold text-sm text-blue-300">Edit Event</span>
|
||||
<div className="flex gap-1">
|
||||
<button
|
||||
onClick={saveEdit}
|
||||
className="p-1 hover:bg-green-500/20 text-green-400 rounded"
|
||||
>
|
||||
<Check size={14} />
|
||||
</button>
|
||||
<button
|
||||
onClick={cancelEdit}
|
||||
className="p-1 hover:bg-red-500/20 text-red-400 rounded"
|
||||
>
|
||||
<X size={14} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-2 text-xs">
|
||||
<div>
|
||||
<label className="block text-white/50 mb-1">Type</label>
|
||||
<select
|
||||
value={editForm.type}
|
||||
onChange={(e) => setEditForm({ ...editForm, type: e.target.value as any })}
|
||||
className="w-full bg-black/40 border border-white/10 rounded p-1"
|
||||
>
|
||||
<option value="click">Click</option>
|
||||
<option value="scroll">Scroll</option>
|
||||
<option value="wait">Wait</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-white/50 mb-1">Duration (ms)</label>
|
||||
<input
|
||||
type="number"
|
||||
value={editForm.duration}
|
||||
onChange={(e) => setEditForm({ ...editForm, duration: parseInt(e.target.value) })}
|
||||
className="w-full bg-black/40 border border-white/10 rounded p-1"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-white/50 mb-1">Zoom (x)</label>
|
||||
<input
|
||||
type="number"
|
||||
step="0.1"
|
||||
value={editForm.zoom}
|
||||
onChange={(e) => setEditForm({ ...editForm, zoom: parseFloat(e.target.value) })}
|
||||
className="w-full bg-black/40 border border-white/10 rounded p-1"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-white/50 mb-1">Motion Blur</label>
|
||||
<button
|
||||
onClick={() => setEditForm({ ...editForm, motionBlur: !editForm.motionBlur })}
|
||||
className={`w-full p-1 rounded text-center border ${editForm.motionBlur ? 'bg-blue-500/20 border-blue-500 text-blue-300' : 'bg-black/40 border-white/10 text-white/50'}`}
|
||||
>
|
||||
{editForm.motionBlur ? 'Enabled' : 'Disabled'}
|
||||
</button>
|
||||
</div>
|
||||
<div className="col-span-2">
|
||||
<label className="block text-white/50 mb-1">Description</label>
|
||||
<input
|
||||
type="text"
|
||||
value={editForm.description || ''}
|
||||
onChange={(e) => setEditForm({ ...editForm, description: e.target.value })}
|
||||
className="w-full bg-black/40 border border-white/10 rounded p-1"
|
||||
/>
|
||||
</div>
|
||||
{/* 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="w-2 h-2 rounded-full bg-accent animate-pulse" />
|
||||
<div className="flex flex-col">
|
||||
<span className="text-[10px] font-bold text-white uppercase tracking-wider leading-none">Event Builder</span>
|
||||
<span className="text-[8px] text-white/30 uppercase tracking-widest mt-1">Manual Mode</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Event Timeline */}
|
||||
<div className="bg-white/5 rounded-lg p-2 flex-1 overflow-y-auto space-y-2 min-h-[200px]">
|
||||
{events.length === 0 && (
|
||||
<div className="text-center text-white/30 text-sm py-4">No events recorded yet.</div>
|
||||
)}
|
||||
{events.map((event, index) => (
|
||||
<div
|
||||
key={event.id}
|
||||
className={`flex items-center gap-3 bg-white/5 p-2 rounded text-sm group cursor-pointer hover:bg-white/10 border border-transparent ${editingEventId === event.id ? 'border-blue-500 bg-blue-500/10' : ''}`}
|
||||
onClick={() => startEditing(event)}
|
||||
<div className="w-px h-6 bg-white/10 mx-1" />
|
||||
|
||||
{/* Action Tools */}
|
||||
<div className="flex items-center gap-1">
|
||||
<button
|
||||
onClick={() => setPickingMode('click')}
|
||||
className={`flex items-center gap-2 px-4 py-2.5 rounded-[16px] transition-all text-xs font-bold uppercase tracking-wide ${pickingMode === 'click' ? 'bg-accent text-primary-dark shadow-lg shadow-accent/20' : 'text-white/40 hover:text-white hover:bg-white/5'}`}
|
||||
>
|
||||
<span className="text-white/30 w-6 text-center">{index + 1}</span>
|
||||
{event.type === 'click' && <MousePointer2 size={14} className="text-blue-400" />}
|
||||
{event.type === 'scroll' && <Scroll size={14} className="text-green-400" />}
|
||||
<MousePointer2 size={16} />
|
||||
<span>Click</span>
|
||||
</button>
|
||||
|
||||
<div className="flex-1 truncate">
|
||||
<span className="font-mono text-white/50 text-xs mr-2">{event.selector}</span>
|
||||
{event.motionBlur && (
|
||||
<span className="text-[10px] bg-purple-500/20 text-purple-300 px-1 rounded ml-1">
|
||||
Blur
|
||||
</span>
|
||||
)}
|
||||
{event.zoom && event.zoom !== 1 && (
|
||||
<span className="text-[10px] bg-yellow-500/20 text-yellow-300 px-1 rounded ml-1">
|
||||
x{event.zoom}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setPickingMode('scroll')}
|
||||
className={`flex items-center gap-2 px-4 py-2.5 rounded-[16px] transition-all text-xs font-bold uppercase tracking-wide ${pickingMode === 'scroll' ? 'bg-accent text-primary-dark shadow-lg shadow-accent/20' : 'text-white/40 hover:text-white hover:bg-white/5'}`}
|
||||
>
|
||||
<Scroll size={16} />
|
||||
<span>Scroll</span>
|
||||
</button>
|
||||
|
||||
<span className="text-xs text-white/40">{(event.timestamp / 1000).toFixed(1)}s</span>
|
||||
<button
|
||||
onClick={() => addEvent({
|
||||
type: 'wait',
|
||||
duration: 2000,
|
||||
zoom: 1,
|
||||
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"
|
||||
>
|
||||
<Plus size={16} />
|
||||
<span>Wait</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
removeEvent(event.id);
|
||||
}}
|
||||
className="opacity-0 group-hover:opacity-100 p-1 hover:bg-white/10 rounded text-red-400"
|
||||
>
|
||||
<Trash2 size={12} />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
<div className="w-px h-6 bg-white/10 mx-1" />
|
||||
|
||||
{/* Sequence Controls */}
|
||||
<div className="flex items-center gap-1 p-0.5">
|
||||
<button
|
||||
onClick={playEvents}
|
||||
disabled={isPlaying || events.length === 0}
|
||||
className="p-2.5 text-accent hover:bg-accent/10 rounded-[14px] disabled:opacity-20 transition-all"
|
||||
title="Preview Sequence"
|
||||
>
|
||||
<Play size={18} fill="currentColor" />
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={() => setShowEvents(!showEvents)}
|
||||
className={`p-2.5 rounded-[14px] transition-all relative ${showEvents ? 'bg-white/10 text-white' : 'text-white/40 hover:text-white hover:bg-white/5'}`}
|
||||
>
|
||||
<Edit2 size={18} />
|
||||
{events.length > 0 && (
|
||||
<span className="absolute -top-1 -right-1 w-4 h-4 bg-accent text-primary-dark text-[10px] flex items-center justify-center rounded-full font-bold border-2 border-black">
|
||||
{events.length}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={async () => {
|
||||
const session = { events, name: 'Recording', createdAt: new Date().toISOString() };
|
||||
try {
|
||||
const res = await fetch('/api/save-session', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(session),
|
||||
});
|
||||
if (res.ok) {
|
||||
// Visual feedback could be improved, but alert is fine for dev tool
|
||||
alert('Session saved to remotion/session.json');
|
||||
} else {
|
||||
const err = await res.json();
|
||||
alert(`Failed to save: ${err.error}`);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
alert('Error saving session');
|
||||
}
|
||||
}}
|
||||
disabled={events.length === 0}
|
||||
className="p-3 bg-white/5 hover:bg-green-500/20 rounded-2xl disabled:opacity-30 transition-all text-green-400"
|
||||
title="Save to Project (Dev)"
|
||||
>
|
||||
<Save size={20} />
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={() => {
|
||||
const data = JSON.stringify({ events, name: 'Recording', createdAt: new Date().toISOString() }, null, 2);
|
||||
const blob = new Blob([data], { type: 'application/json' });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = 'remotion-session.json';
|
||||
a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
}}
|
||||
disabled={events.length === 0}
|
||||
className="p-3 bg-white/5 hover:bg-blue-500/20 rounded-2xl disabled:opacity-30 transition-all text-blue-400"
|
||||
title="Download JSON"
|
||||
>
|
||||
<Download size={20} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="w-px h-6 bg-white/10 mx-1" />
|
||||
|
||||
<button
|
||||
onClick={() => setIsActive(false)}
|
||||
className="p-2.5 text-red-500 hover:bg-red-500/10 rounded-[14px] transition-all mx-1"
|
||||
title="Exit Studio"
|
||||
>
|
||||
<X size={20} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Picking Instructions */}
|
||||
{pickingMode && (
|
||||
<div className="fixed top-8 left-1/2 -translate-x-1/2 bg-blue-600 text-white px-6 py-2 rounded-full shadow-xl z-[10000] animate-bounce">
|
||||
Select element to {pickingMode}
|
||||
{/* 2. Event Timeline Popover */}
|
||||
{showEvents && (
|
||||
<div className="fixed bottom-[100px] left-1/2 -translate-x-1/2 w-[400px] pointer-events-auto z-[9999]">
|
||||
<div className="bg-black/90 backdrop-blur-3xl border border-white/10 rounded-[32px] p-6 shadow-[0_40px_100px_rgba(0,0,0,0.9)] max-h-[500px] overflow-hidden flex flex-col scale-in">
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<div>
|
||||
<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>
|
||||
</div>
|
||||
<button
|
||||
onClick={clearEvents}
|
||||
disabled={events.length === 0}
|
||||
className="text-red-400/40 hover:text-red-400 transition-colors p-2 hover:bg-red-500/10 rounded-xl disabled:opacity-10"
|
||||
>
|
||||
<Trash2 size={18} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 overflow-y-auto space-y-2 pr-2 scrollbar-hide">
|
||||
{events.length === 0 ? (
|
||||
<div className="py-12 flex flex-col items-center justify-center text-white/10">
|
||||
<Plus size={40} strokeWidth={1} />
|
||||
<p className="text-xs mt-4">Timeline is empty</p>
|
||||
</div>
|
||||
) : (
|
||||
events.map((event, index) => (
|
||||
<div
|
||||
key={event.id}
|
||||
className="group flex items-center gap-4 bg-white/[0.03] border border-white/5 p-3 rounded-[20px] transition-all hover:bg-white/[0.06] hover:border-white/10"
|
||||
>
|
||||
<div className="w-6 h-6 rounded-full bg-white/5 border border-white/5 flex items-center justify-center text-[9px] font-bold text-white/30">
|
||||
{index + 1}
|
||||
</div>
|
||||
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-white text-xs font-bold uppercase tracking-widest">{event.type}</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>
|
||||
<p className="text-[10px] text-white/30 truncate font-mono mt-1 opacity-60">
|
||||
{event.selector || 'system:wait'}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={() => removeEvent(event.id)}
|
||||
className="p-2 text-white/0 group-hover:text-red-400/60 hover:text-red-400 hover:bg-red-500/10 rounded-xl transition-all"
|
||||
>
|
||||
<Trash2 size={14} />
|
||||
</button>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Industrial Selector Highlighter - handled inside iframe via PickingHelper */}
|
||||
|
||||
{/* Picking Tooltip */}
|
||||
{pickingMode && (
|
||||
<div className="fixed top-8 left-1/2 -translate-x-1/2 z-[10000] pointer-events-auto">
|
||||
<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="w-2 h-2 rounded-full bg-primary-dark animate-pulse" />
|
||||
<span className="font-black uppercase tracking-widest text-xs">Assigning {pickingMode}</span>
|
||||
</div>
|
||||
<div className="w-px h-6 bg-primary-dark/20" />
|
||||
<button
|
||||
onClick={() => {
|
||||
setPickingMode(null);
|
||||
setHoveredElement(null);
|
||||
}}
|
||||
className="text-[10px] font-bold uppercase tracking-widest opacity-60 hover:opacity-100 transition-opacity bg-primary-dark/10 px-3 py-1.5 rounded-full"
|
||||
>
|
||||
ESC to Cancel
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<PlaybackCursor />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
189
components/record-mode/RecordModeVisuals.tsx
Normal file
189
components/record-mode/RecordModeVisuals.tsx
Normal file
@@ -0,0 +1,189 @@
|
||||
'use client';
|
||||
|
||||
import React from 'react';
|
||||
import { useRecordMode } from './RecordModeContext';
|
||||
|
||||
export function RecordModeVisuals({ children }: { children: React.ReactNode }) {
|
||||
const { isActive, isPlaying, zoomLevel, cursorPosition, isBlurry } = useRecordMode();
|
||||
const [mounted, setMounted] = React.useState(false);
|
||||
const [isEmbedded, setIsEmbedded] = React.useState(false);
|
||||
const [iframeUrl, setIframeUrl] = React.useState<string | null>(null);
|
||||
|
||||
React.useEffect(() => {
|
||||
setMounted(true);
|
||||
// Explicit non-magical detection
|
||||
const embedded = window.location.search.includes('embedded=true') || window.name === 'record-mode-iframe';
|
||||
setIsEmbedded(embedded);
|
||||
|
||||
if (!embedded) {
|
||||
const url = new URL(window.location.href);
|
||||
url.searchParams.set('embedded', 'true');
|
||||
setIframeUrl(url.toString());
|
||||
}
|
||||
}, [isEmbedded]);
|
||||
|
||||
// Hydration Guard: Match server on first render
|
||||
if (!mounted) return <>{children}</>;
|
||||
|
||||
// Recursion Guard: If we are already in an embedded iframe,
|
||||
// strictly return just the children to prevent Inception.
|
||||
if (isEmbedded) {
|
||||
return (
|
||||
<>
|
||||
<style dangerouslySetInnerHTML={{
|
||||
__html: `
|
||||
/* Harder Isolation: Hide ALL potentially duplicate overlays and DEV TOOLS */
|
||||
#nextjs-portal,
|
||||
#nextjs-portal-root,
|
||||
[data-nextjs-toast-wrapper],
|
||||
.nextjs-static-indicator,
|
||||
[data-nextjs-indicator],
|
||||
[class*="nextjs-"],
|
||||
[id*="nextjs-"],
|
||||
nextjs-portal,
|
||||
#feedback-overlay,
|
||||
.feedback-ui-root,
|
||||
.feedback-ui-ignore,
|
||||
[class*="z-[9999]"],
|
||||
[class*="z-[10000]"],
|
||||
[style*="z-index: 9999"],
|
||||
[style*="z-index: 10000"],
|
||||
.fixed.bottom-6.left-6,
|
||||
.fixed.bottom-6.left-1\/2,
|
||||
.feedback-ui-overlay,
|
||||
[id^="feedback-"],
|
||||
[class^="feedback-"] {
|
||||
display: none !important;
|
||||
opacity: 0 !important;
|
||||
visibility: hidden !important;
|
||||
pointer-events: none !important;
|
||||
z-index: -10000 !important;
|
||||
}
|
||||
|
||||
/* Nuclear Option 2.0: Kill ALL scrollbars on ALL elements */
|
||||
* {
|
||||
scrollbar-width: none !important;
|
||||
-ms-overflow-style: none !important;
|
||||
}
|
||||
*::-webkit-scrollbar {
|
||||
display: none !important;
|
||||
width: 0 !important;
|
||||
height: 0 !important;
|
||||
}
|
||||
|
||||
html, body {
|
||||
border-radius: 3rem;
|
||||
background: #050505 !important;
|
||||
color: white !important;
|
||||
overflow-x: hidden !important;
|
||||
overflow-y: auto !important;
|
||||
}
|
||||
`}} />
|
||||
{children}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Global Style for Body Lock */}
|
||||
{isActive && (
|
||||
<style dangerouslySetInnerHTML={{
|
||||
__html: `
|
||||
html, body {
|
||||
overflow: hidden !important;
|
||||
height: 100vh !important;
|
||||
position: fixed !important;
|
||||
width: 100vw !important;
|
||||
}
|
||||
/* Kill Next.js Dev tools on host while Studio is active */
|
||||
#nextjs-portal,
|
||||
[data-nextjs-toast-wrapper],
|
||||
.nextjs-static-indicator {
|
||||
display: none !important;
|
||||
}
|
||||
`}} />
|
||||
)}
|
||||
|
||||
<div className={`transition-all duration-1000 ${isActive ? 'fixed inset-0 z-[9997] bg-[#020202] flex items-center justify-center p-6 md:p-12 lg:p-20' : 'relative w-full'}`}>
|
||||
{/* Studio Background - Only visible when active */}
|
||||
{isActive && (
|
||||
<div className="absolute inset-0 z-0 pointer-events-none overflow-hidden">
|
||||
<div className="absolute inset-0 bg-gradient-to-br from-[#03110a] via-[#020202] to-[#030a11] animate-pulse duration-[10s]" />
|
||||
<div className="absolute -top-[60%] -left-[50%] w-[140%] h-[140%] rounded-full opacity-[0.7]"
|
||||
style={{ background: 'radial-gradient(circle, #10b981 0%, transparent 70%)', filter: 'blur(160px)', animation: 'mesh-float-1 18s ease-in-out infinite' }} />
|
||||
<div className="absolute -bottom-[60%] -right-[50%] w-[130%] h-[130%] rounded-full opacity-[0.55]"
|
||||
style={{ background: 'radial-gradient(circle, #06b6d4 0%, transparent 70%)', filter: 'blur(150px)', animation: 'mesh-float-2 22s ease-in-out infinite' }} />
|
||||
<div className="absolute -top-[30%] -right-[40%] w-[100%] h-[100%] rounded-full opacity-[0.5]"
|
||||
style={{ background: 'radial-gradient(circle, #82ed20 0%, transparent 70%)', filter: 'blur(130px)', animation: 'mesh-float-3 14s ease-in-out infinite' }} />
|
||||
<div className="absolute -bottom-[50%] -left-[40%] w-[110%] h-[110%] rounded-full opacity-[0.45]"
|
||||
style={{ background: 'radial-gradient(circle, #2563eb 0%, transparent 70%)', filter: 'blur(140px)', animation: 'mesh-float-4 20s ease-in-out infinite' }} />
|
||||
<div className="absolute inset-0 opacity-[0.12] mix-blend-overlay" style={{ backgroundImage: `url("data:image/svg+xml,%3Csvg viewBox='0 0 256 256' xmlns='http://www.w3.org/2000/svg'%3E%3Cfilter id='noiseFilter'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.7' numOctaves='4' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23noiseFilter)'/%3E%3C/svg%3E")`, backgroundSize: '128px 128px' }} />
|
||||
<div className="absolute inset-0 opacity-[0.06]" style={{ backgroundImage: 'repeating-linear-gradient(0deg, transparent, transparent 2px, rgba(255,255,255,0.03) 2px, rgba(255,255,255,0.03) 4px)' }} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div
|
||||
className={`transition-all duration-700 ease-in-out relative z-10 w-full ${isActive ? 'h-full max-h-[1000px] max-w-[1600px] drop-shadow-[0_60px_150px_rgba(0,0,0,1)] scale-in' : 'h-full'}`}
|
||||
style={{
|
||||
transform: isPlaying ? `scale(${zoomLevel})` : undefined,
|
||||
transformOrigin: isPlaying ? `${cursorPosition.x}px ${cursorPosition.y}px` : 'center',
|
||||
filter: isBlurry ? 'blur(4px)' : 'none',
|
||||
willChange: 'transform, filter',
|
||||
WebkitBackfaceVisibility: 'hidden',
|
||||
backfaceVisibility: 'hidden',
|
||||
}}
|
||||
>
|
||||
<div className={isActive ? 'relative h-full w-full rounded-[3rem] overflow-hidden bg-[#050505] isolate' : 'w-full h-full'}
|
||||
style={{ transform: isActive ? 'translateZ(0)' : 'none' }}>
|
||||
|
||||
{isActive && (
|
||||
<>
|
||||
<div className="absolute inset-0 rounded-[3rem] border border-white/[0.08] pointer-events-none z-50" />
|
||||
<div className="absolute inset-[-2px] rounded-[3rem] pointer-events-none z-20"
|
||||
style={{ background: 'linear-gradient(135deg, rgba(16,185,129,0.15), rgba(130,237,32,0.15))', animation: 'pulse-ring 4s ease-in-out infinite' }} />
|
||||
<div className="absolute inset-0 bg-gradient-to-b from-transparent via-[#82ed20]/[0.05] to-transparent h-[15%] w-full top-[-15%] animate-scan-slow z-50 pointer-events-none opacity-20" />
|
||||
</>
|
||||
)}
|
||||
|
||||
<div className={isActive ? "w-full h-full rounded-[3rem] overflow-hidden relative" : "w-full h-full relative"}
|
||||
style={{
|
||||
WebkitMaskImage: isActive ? '-webkit-radial-gradient(white, black)' : 'none',
|
||||
transform: isActive ? 'translateZ(0)' : 'none'
|
||||
}}>
|
||||
{isActive && iframeUrl ? (
|
||||
<iframe
|
||||
src={iframeUrl}
|
||||
name="record-mode-iframe"
|
||||
className="w-full h-full border-0 block"
|
||||
style={{
|
||||
backgroundColor: '#050505',
|
||||
scrollbarWidth: 'none',
|
||||
msOverflowStyle: 'none',
|
||||
height: '100%',
|
||||
width: '100%'
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<div className={isActive ? 'blur-2xl opacity-20 pointer-events-none scale-95 transition-all duration-700' : 'transition-all duration-700'}>
|
||||
{children}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style jsx global>{`
|
||||
@keyframes mesh-float-1 { 0%, 100% { transform: translate(0, 0) scale(1) rotate(0deg); } 33% { transform: translate(15%, 10%) scale(1.1) rotate(5deg); } 66% { transform: translate(-10%, 20%) scale(0.9) rotate(-3deg); } }
|
||||
@keyframes mesh-float-2 { 0%, 100% { transform: translate(0, 0) scale(1) rotate(0deg); } 33% { transform: translate(-20%, -15%) scale(1.2) rotate(-8deg); } 66% { transform: translate(15%, -10%) scale(0.8) rotate(4deg); } }
|
||||
@keyframes mesh-float-3 { 0%, 100% { transform: translate(0, 0) scale(1.2); } 50% { transform: translate(20%, -25%) scale(0.7); } }
|
||||
@keyframes mesh-float-4 { 0%, 100% { transform: translate(0, 0) scale(1); } 50% { transform: translate(-15%, 25%) scale(1.1); } }
|
||||
@keyframes pulse-ring { 0%, 100% { opacity: 0.15; transform: scale(1); } 50% { opacity: 0.4; transform: scale(1.005); } }
|
||||
@keyframes scan-slow { 0% { transform: translateY(-100%); opacity: 0; } 5% { opacity: 0.2; } 95% { opacity: 0.2; } 100% { transform: translateY(800%); opacity: 0; } }
|
||||
@keyframes scale-in { 0% { transform: scale(0.95); opacity: 0; } 100% { transform: scale(1); opacity: 1; } }
|
||||
.scale-in { animation: scale-in 0.7s cubic-bezier(0.16, 1, 0.3, 1) forwards; }
|
||||
`}</style>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
63
components/record-mode/ToolCoordinator.tsx
Normal file
63
components/record-mode/ToolCoordinator.tsx
Normal file
@@ -0,0 +1,63 @@
|
||||
'use client';
|
||||
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { useRecordMode } from './RecordModeContext';
|
||||
import { useSearchParams } from 'next/navigation';
|
||||
import { FeedbackOverlay } from '@mintel/next-feedback';
|
||||
import { RecordModeOverlay } from './RecordModeOverlay';
|
||||
import { PickingHelper } from './PickingHelper';
|
||||
import { config } from '@/lib/config';
|
||||
|
||||
export function ToolCoordinator({ isEmbedded: isEmbeddedProp }: { isEmbedded?: boolean }) {
|
||||
const { isActive, setIsActive, isFeedbackActive, setIsFeedbackActive } = useRecordMode();
|
||||
const [isEmbedded, setIsEmbedded] = useState(false);
|
||||
const [mounted, setMounted] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
setMounted(true);
|
||||
const embedded =
|
||||
isEmbeddedProp ||
|
||||
window.location.search.includes('embedded=true') ||
|
||||
window.name === 'record-mode-iframe' ||
|
||||
(window.self !== window.top);
|
||||
setIsEmbedded(embedded);
|
||||
}, [isEmbeddedProp]);
|
||||
|
||||
if (!mounted) return null;
|
||||
|
||||
// ABSOLUTE Priority 1: Inside Iframe - ONLY Rendering PickingHelper
|
||||
if (isEmbedded) {
|
||||
return <PickingHelper />;
|
||||
}
|
||||
|
||||
// ABSOLUTE Priority 2: Record Mode Studio Active - NO OTHER TOOLS ALLOWED
|
||||
if (isActive) {
|
||||
return <RecordModeOverlay />;
|
||||
}
|
||||
|
||||
// Priority 3: Feedback Tool Active - NO OTHER TOOLS ALLOWED
|
||||
if (isFeedbackActive) {
|
||||
return (
|
||||
<FeedbackOverlay
|
||||
isActive={isFeedbackActive}
|
||||
onActiveChange={(active) => setIsFeedbackActive(active)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
// Baseline: Both toggle buttons (inactive state)
|
||||
// Only render if neither is active to prevent any overlapping residues
|
||||
// IMPORTANT: FeedbackOverlay must be rendered with isActive={false} to provide the toggle button,
|
||||
// but only if Record Mode is not active.
|
||||
return (
|
||||
<div className="feedback-ui-ignore">
|
||||
{config.feedbackEnabled && (
|
||||
<FeedbackOverlay
|
||||
isActive={false}
|
||||
onActiveChange={(active) => setIsFeedbackActive(active)}
|
||||
/>
|
||||
)}
|
||||
<RecordModeOverlay />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user