import { useEffect, useMemo, useState, type RefObject } from "react"; import { useNavigate } from "react-router-dom"; import { convertFileSrc } from "@tauri-apps/api/core"; import VideoPlayer from "../../components/VideoPlayer"; import HotspotOverlay from "../../components/HotspotOverlay"; import ClickRippleOverlay from "../../components/ClickRippleOverlay"; import TerminalPlayer from "../../components/TerminalPlayer"; import Timeline from "../../components/Timeline"; import "../../components/FrameEditorLayout.css"; import EditorScriptView from "./EditorScriptView"; import { useEditor } from "./EditorContext"; type HeaderTab = "edit" | "script" | "preview" | "insights"; type InspectorSection = "design" | "metadata" | "audio" | "other"; const DEFAULT_HOTSPOT_BG = "#5E6AD2"; const DEFAULT_HOTSPOT_TEXT = "#FFFFFF"; const INSPECTOR_AUTO_COLLAPSE_MAX_WIDTH = 1480; interface EditorWorkspaceLayoutProps { videoRef: RefObject; } const formatDuration = (seconds: number): string => { if (!Number.isFinite(seconds) || seconds <= 0) return "00:00.00"; const mins = Math.floor(seconds / 60); const secs = Math.floor(seconds % 60); const millis = Math.floor((seconds % 1) * 100); return `${mins.toString().padStart(2, "0")}:${secs.toString().padStart(2, "0")}.${millis .toString() .padStart(2, "0")}`; }; const formatStepRange = (startMs: number, endMs: number): string => { const start = Math.max(0, Math.floor(startMs / 1000)); const end = Math.max(0, Math.floor(endMs / 1000)); const startMin = Math.floor(start / 60); const startSec = start % 60; const endMin = Math.floor(end / 60); const endSec = end % 60; return `${startMin}:${startSec.toString().padStart(2, "0")} - ${endMin}:${endSec .toString() .padStart(2, "0")}`; }; const formatTranscriptTimestamp = (timestampMs: number): string => { const totalSeconds = Math.max(0, Math.floor(timestampMs / 1000)); const mins = Math.floor(totalSeconds / 60); const secs = totalSeconds % 60; return `${mins}:${secs.toString().padStart(2, "0")}`; }; const formatSessionDate = (createdAt: string | undefined): string => { if (!createdAt) return "Unknown date"; const asNumber = Number(createdAt); if (!Number.isFinite(asNumber)) return createdAt; const ms = createdAt.length <= 10 ? asNumber * 1000 : asNumber; const d = new Date(ms); if (Number.isNaN(d.getTime())) return createdAt; return d.toDateString(); }; const clamp = (value: number, min: number, max: number) => Math.min(max, Math.max(min, value)); const isHexColor = (value: string): boolean => /^#[0-9a-fA-F]{6}$/.test(value.trim()); const normalizeHexColor = (value: string): string | null => { const trimmed = value.trim(); if (isHexColor(trimmed)) return trimmed; if (/^[0-9a-fA-F]{6}$/.test(trimmed)) return `#${trimmed}`; if (/^#[0-9a-fA-F]{3}$/.test(trimmed)) { const [r, g, b] = trimmed.slice(1).split(""); if (!r || !g || !b) return null; return `#${r}${r}${g}${g}${b}${b}`; } if (/^[0-9a-fA-F]{3}$/.test(trimmed)) { const [r, g, b] = trimmed.split(""); if (!r || !g || !b) return null; return `#${r}${r}${g}${g}${b}${b}`; } return null; }; const WINDOWS_ABSOLUTE_PATH = /^[a-zA-Z]:[\\/]/; const isAbsolutePath = (value: string): boolean => value.startsWith("/") || value.startsWith("\\\\") || WINDOWS_ABSOLUTE_PATH.test(value); const getSessionDirectory = (recordingPath: string | null | undefined): string | null => { if (!recordingPath) return null; const slash = Math.max(recordingPath.lastIndexOf("/"), recordingPath.lastIndexOf("\\")); if (slash < 0) return null; return recordingPath.slice(0, slash); }; const joinPath = (base: string, relative: string): string => { const normalizedBase = base.replace(/[\\/]+$/, ""); const normalizedRelative = relative.replace(/^\.?[\\/]+/, ""); return `${normalizedBase}/${normalizedRelative}`; }; const fromFileUrlToPath = (value: string): string => { try { const url = new URL(value); const decoded = decodeURIComponent(url.pathname); if (WINDOWS_ABSOLUTE_PATH.test(decoded.slice(1))) { return decoded.slice(1); } return decoded; } catch { return value.replace(/^file:\/+/, "/"); } }; const resolvePreviewSource = ( path: string | null | undefined, recordingPath: string | null | undefined ): string | null => { if (!path) return null; if ( path.startsWith("http://") || path.startsWith("https://") || path.startsWith("tauri://") || path.startsWith("asset:") || path.startsWith("blob:") || path.startsWith("data:") ) { return path; } const normalizedPath = path.startsWith("file://") ? fromFileUrlToPath(path) : path; const filePath = isAbsolutePath(normalizedPath) || !recordingPath ? normalizedPath : (() => { const sessionDir = getSessionDirectory(recordingPath); return sessionDir ? joinPath(sessionDir, normalizedPath) : normalizedPath; })(); try { return convertFileSrc(filePath); } catch { return filePath; } }; const INSPECTOR_SECTION_LABELS: Record = { design: "Design", metadata: "Metadata", audio: "Audio", other: "Other", }; const renderInspectorShortcutIcon = (section: InspectorSection) => { if (section === "design") { return ( ); } if (section === "metadata") { return ( ); } if (section === "audio") { return ( ); } return ( ); }; function EditorWorkspaceLayout({ videoRef }: EditorWorkspaceLayoutProps) { const navigate = useNavigate(); const { metadata, editorMode, setEditorMode, setSharing, activeTab, setActiveTab, steps, selectedStepId, handleSelectStep, splitSelectedStepAtCurrentFrame, handleRenameStep, updateStepConfig, moveStepUp, moveStepDown, duplicateStep, deleteStep, mergeSelectedStepWithNext, applyHotspotStylePreset, playing, setPlaying, togglePlay, currentTime, duration, currentFrame, totalFrames, fps, videoUrl, terminalUrl, hasTerminal, seekToTimestamp, seekToFrame, markers, clicks, showClickRipples, editingHotspots, setEditingHotspots, placingHotspot, setPlacingHotspot, placingHotspotStyle, setPlacingHotspotStyle, handleStageClick, hotspots, selectedHotspotId, setSelectedHotspotId, handleDeleteHotspot, handleUpdateHotspot, handleVideoLoadError, handleGenerateStepsFromActivity, handleAnalyzeSession, handleTranscribe, transcribing, transcript, transcriptError, exporting, exportFeedback, clearExportFeedback, handleExportMarkdown, handleExportMp4, undo, redo, canUndo, canRedo, } = useEditor(); const [headerTab, setHeaderTab] = useState("edit"); const [openSection, setOpenSection] = useState("design"); const [muted, setMuted] = useState(false); const [editingTitleId, setEditingTitleId] = useState(null); const [editingTitle, setEditingTitle] = useState(""); const [backgroundDraft, setBackgroundDraft] = useState(DEFAULT_HOTSPOT_BG); const [textDraft, setTextDraft] = useState(DEFAULT_HOTSPOT_TEXT); const [activatedHotspotsByStep, setActivatedHotspotsByStep] = useState< Record >({}); const [completionBlockMessage, setCompletionBlockMessage] = useState(null); const [showExportModal, setShowExportModal] = useState(false); const [exportFormat, setExportFormat] = useState<"markdown" | "mp4">("markdown"); const [exportIncludeChapterCards, setExportIncludeChapterCards] = useState(true); const [isStepRailCollapsed, setIsStepRailCollapsed] = useState(false); const [isInspectorCollapsed, setIsInspectorCollapsed] = useState(false); const [inspectorCollapseTouched, setInspectorCollapseTouched] = useState(false); const selectedStep = useMemo( () => steps.find((step) => step.id === selectedStepId) ?? null, [steps, selectedStepId] ); const selectedHotspot = useMemo( () => hotspots.find((hotspot) => hotspot.id === selectedHotspotId) ?? null, [hotspots, selectedHotspotId] ); const selectedStepNumber = useMemo(() => { const selectedIndex = steps.findIndex((step) => step.id === selectedStepId); return selectedIndex >= 0 ? selectedIndex + 1 : 0; }, [steps, selectedStepId]); const framePreviewSources = useMemo(() => { const entries: Array<[string, string | null]> = steps.map((step) => [ step.id, resolvePreviewSource(step.screenshot?.path, metadata?.recording_path), ]); return new Map(entries); }, [metadata?.recording_path, steps]); const resolvedTitle = (metadata?.title ?? "").trim() || "Untitled session"; const sessionDate = formatSessionDate(metadata?.created_at); const hotspotCountInStep = selectedStep?.hotspot_ids?.length ?? 0; useEffect(() => { if (!selectedHotspot) return; setBackgroundDraft(selectedHotspot.backgroundColor ?? DEFAULT_HOTSPOT_BG); setTextDraft(selectedHotspot.textColor ?? DEFAULT_HOTSPOT_TEXT); }, [selectedHotspot?.id, selectedHotspot?.backgroundColor, selectedHotspot?.textColor]); useEffect(() => { if (!editingHotspots) return; if (selectedHotspotId) return; if (!hotspots.length) return; setSelectedHotspotId(hotspots[0].id); }, [editingHotspots, hotspots, selectedHotspotId, setSelectedHotspotId]); useEffect(() => { setCompletionBlockMessage(null); }, [selectedStepId]); useEffect(() => { if (inspectorCollapseTouched || typeof window === "undefined") return; const mediaQuery = window.matchMedia(`(max-width: ${INSPECTOR_AUTO_COLLAPSE_MAX_WIDTH}px)`); const applyViewportPreference = () => { setIsInspectorCollapsed(mediaQuery.matches); }; applyViewportPreference(); if (typeof mediaQuery.addEventListener === "function") { mediaQuery.addEventListener("change", applyViewportPreference); return () => mediaQuery.removeEventListener("change", applyViewportPreference); } mediaQuery.addListener(applyViewportPreference); return () => mediaQuery.removeListener(applyViewportPreference); }, [inspectorCollapseTouched]); const selectHeaderTab = (tab: HeaderTab) => { setHeaderTab(tab); if (tab === "edit" || tab === "script") setEditorMode("edit"); if (tab === "preview" || tab === "insights") setEditorMode("preview"); }; const jumpBySeconds = (deltaSeconds: number) => { if (!duration) return; const next = clamp(currentTime + deltaSeconds, 0, duration); seekToTimestamp(next); }; const toggleMuted = () => { const video = videoRef.current; if (!video) return; const next = !video.muted; video.muted = next; setMuted(next); }; const insertAfterStep = (stepId: string, stepStartMs: number, stepEndMs: number) => { handleSelectStep(stepId); const insertionPointMs = Math.max(stepStartMs + 1, stepEndMs - 1); seekToTimestamp(insertionPointMs / 1000); window.requestAnimationFrame(() => splitSelectedStepAtCurrentFrame()); }; const startPlacing = (style: "pulse" | "callout") => { setEditingHotspots(true); setSelectedHotspotId(null); setPlacingHotspotStyle(style); if (placingHotspot && placingHotspotStyle === style) { setPlacingHotspot(false); return; } setPlacingHotspot(true); }; const saveTitle = (stepId: string) => { handleRenameStep(stepId, editingTitle); setEditingTitleId(null); setEditingTitle(""); }; const hotspotToolActive = editingHotspots && placingHotspot && placingHotspotStyle === "pulse"; const calloutToolActive = editingHotspots && placingHotspot && placingHotspotStyle === "callout"; const isPreviewMode = headerTab === "preview"; const isScriptMode = headerTab === "script"; const inspectorSections: InspectorSection[] = ["design", "metadata", "audio", "other"]; const showCanvasTools = !isPreviewMode && !isScriptMode && (activeTab === "video" || activeTab === "both"); const handleNavigateToStep = ( destination: | { type: "next_step" } | { type: "step"; stepId: string } | { type: "this_step" }, hotspotId: string ) => { const sourceStep = steps.find((step) => (step.hotspot_ids ?? []).includes(hotspotId)); if (!sourceStep) return; const requiredIds = (sourceStep.required_hotspot_ids ?? []).length ? (sourceStep.required_hotspot_ids ?? []) : (sourceStep.hotspot_ids ?? []); const activatedIds = new Set(activatedHotspotsByStep[sourceStep.id] ?? []); const completionMode = sourceStep.completion_mode ?? "none"; const canAdvance = completionMode === "none" || requiredIds.length === 0 || (completionMode === "any" ? requiredIds.some((id) => activatedIds.has(id)) : requiredIds.every((id) => activatedIds.has(id))); if (destination.type !== "this_step" && !canAdvance) { const progress = `${Math.min( requiredIds.filter((id) => activatedIds.has(id)).length, requiredIds.length )}/${requiredIds.length}`; setCompletionBlockMessage( completionMode === "all" ? `Complete all required interactions before advancing (${progress}).` : `Complete at least one required interaction before advancing (${progress}).` ); return; } setCompletionBlockMessage(null); if (destination.type === "this_step") { return; } if (destination.type === "step") { const step = steps.find((item) => item.id === destination.stepId); if (!step) return; handleSelectStep(step.id); seekToTimestamp(step.start_ms / 1000); return; } const currentIndex = steps.findIndex((step) => step.id === sourceStep.id); if (currentIndex < 0) return; const nextStep = steps[currentIndex + 1]; if (!nextStep) return; handleSelectStep(nextStep.id); seekToTimestamp(nextStep.start_ms / 1000); }; const updateSelectedHotspot = ( updater: ( hotspot: NonNullable ) => NonNullable ) => { if (!selectedHotspot) return; handleUpdateHotspot(selectedHotspot.id, updater); }; const commitColorDraft = (kind: "background" | "text") => { if (!selectedHotspot) return; const draft = kind === "background" ? backgroundDraft : textDraft; const normalized = normalizeHexColor(draft); if (!normalized) { if (kind === "background") { setBackgroundDraft(selectedHotspot.backgroundColor ?? DEFAULT_HOTSPOT_BG); } else { setTextDraft(selectedHotspot.textColor ?? DEFAULT_HOTSPOT_TEXT); } return; } if (kind === "background") { setBackgroundDraft(normalized); updateSelectedHotspot((current) => ({ ...current, backgroundColor: normalized })); } else { setTextDraft(normalized); updateSelectedHotspot((current) => ({ ...current, textColor: normalized })); } }; const applyCurrentColors = (scope: "step" | "session", target: "hotspot" | "callout") => { const normalizedBackground = normalizeHexColor(backgroundDraft); const normalizedText = normalizeHexColor(textDraft); applyHotspotStylePreset(scope, target, { backgroundColor: normalizedBackground ?? undefined, textColor: normalizedText ?? undefined, }); }; const currentStepCompletionMode = selectedStep?.completion_mode ?? "none"; const currentStepRequiredHotspotIds = (selectedStep?.required_hotspot_ids ?? []).length > 0 ? (selectedStep?.required_hotspot_ids ?? []) : (selectedStep?.hotspot_ids ?? []); const currentStepActivatedHotspotIds = new Set( selectedStep ? (activatedHotspotsByStep[selectedStep.id] ?? []) : [] ); const currentStepCompletionProgress = selectedStep ? `${Math.min( currentStepRequiredHotspotIds.filter((id) => currentStepActivatedHotspotIds.has(id)) .length, currentStepRequiredHotspotIds.length )}/${currentStepRequiredHotspotIds.length}` : "0/0"; const exportFeedbackTitle = exportFeedback ? exportFeedback.kind === "markdown" ? exportFeedback.status === "success" ? "Script Exported" : "Script Export Failed" : exportFeedback.status === "success" ? "MP4 Export Complete" : "MP4 Export Failed" : ""; const openExportModal = () => { setShowExportModal(true); }; const closeExportModal = () => { if (exporting) return; setShowExportModal(false); }; const runExportWithOptions = async () => { if (exporting) return; if (exportFormat === "mp4") { await handleExportMp4(exportIncludeChapterCards); } else { await handleExportMarkdown(); } setShowExportModal(false); }; const toggleStepRail = () => { setIsStepRailCollapsed((current) => !current); }; const toggleInspectorRail = () => { setInspectorCollapseTouched(true); setIsInspectorCollapsed((current) => !current); }; const handleInspectorShortcut = (section: InspectorSection) => { setOpenSection(section); if (isInspectorCollapsed) { setInspectorCollapseTouched(true); setIsInspectorCollapsed(false); } }; return (
{exportFeedback && (
{exportFeedbackTitle} {exportFeedback.path ?? exportFeedback.message}
)} {showExportModal && (
event.stopPropagation()} >

Export Options

Choose what to export and format options.

{exportFormat === "mp4" && ( )}
)}
{!isPreviewMode && !isScriptMode && ( )}
{isScriptMode ? ( ) : ( <> {showCanvasTools && (
)}
{hasTerminal && (
)}
{(activeTab === "video" || activeTab === "both") && (
setPlaying(true)} onPause={() => setPlaying(false)} onEnded={() => setPlaying(false)} onLoadError={handleVideoLoadError} onStageClick={ editorMode === "edit" && placingHotspot ? handleStageClick : undefined } > { const ownerStep = steps.find((step) => (step.hotspot_ids ?? []).includes( hotspotId ) ); if (!ownerStep) return; setActivatedHotspotsByStep( (current) => { const existing = new Set( current[ownerStep.id] ?? [] ); existing.add(hotspotId); return { ...current, [ownerStep.id]: Array.from(existing), }; } ); setCompletionBlockMessage(null); }} />
)} {(activeTab === "terminal" || activeTab === "both") && terminalUrl && (
)}
{completionBlockMessage && (
{completionBlockMessage}
)}
{!isPreviewMode && (
{formatDuration(currentTime)} /{" "} {formatDuration(duration)}
)} )}
{!isPreviewMode && !isScriptMode && (