/** * Context for shared recollection actions: two slots (flux and logos) and layout-level import/export. * Consumers use pathname to pick the active slot for title (save status, Save) and Edit/View menus (undo, redo, etc.). */ import React, { createContext, useCallback, useContext, useEffect, useRef, useState } from 'react' import { useLocation, useParams } from 'react-router-dom' import type { SaveStatus } from '@/app/canvas/useCanvasGraph' import { getGraph, setGraph, getLogosContent, setLogosContent, upsertRenderOutputEntry, getFluxSceneTree, setFluxSceneTree, getSceneGraph, setSceneGraph, RECOLLECTION_FILE_EXT, RECOLLECTION_VERSION, type StoredGraphState, type StoredLogosContent, type RenderOutputCacheEntry, type FluxSceneMeta, } from '../state/recollectionStore' export type { RenderOutputCacheEntry } import type { AppNode, AppEdge } from '@/lib/graph/nodeTypes' import { backfillEdgeTargetTypes } from '@/app/canvas/canvasGraphUtils' import { toast } from 'sonner' export type { SaveStatus } /** Parsed recollection file format (import/export). */ export type RecollectionFilePayload = { version?: number graph?: { nodes: unknown[]; edges: unknown[] } logos?: StoredLogosContent /** Flux scenes (Phase 2+). Each entry has scene metadata + graph. */ scenes?: Array<{ id: string; title: string; position: number; graph: { nodes: unknown[]; edges: unknown[] } }> } export type FluxSlot = { saveStatus: SaveStatus onSave: () => void canSave: boolean undo: () => void redo: () => void canUndo: boolean canRedo: boolean onRefreshFromStore: (graph: StoredGraphState) => void onDuplicate?: () => void onCopy?: () => void onPaste?: () => void canDuplicate?: boolean canCopy?: boolean onFitView?: () => void onRun?: () => void } export type LogosSlot = { saveStatus: SaveStatus onSave: () => void canSave: boolean undo: () => void redo: () => void canUndo: boolean canRedo: boolean onRefreshFromStore: () => void } export type RecollectionActionsContextValue = { flux: FluxSlot | null logos: LogosSlot | null setFluxSlot: (slot: FluxSlot | null) => void setLogosSlot: (slot: LogosSlot | null) => void /** Whether the Flux view is active (pathname ends with /flux). */ isFluxActive: boolean /** Whether the Logos view is active. */ isLogosActive: boolean /** Active slot (flux or logos by pathname). */ activeSlot: FluxSlot | LogosSlot | null onImport: () => void onExport: () => void /** Upsert a rendering node's output into the cache so Logos "Insert from Flux" block can show it. */ upsertRenderOutputToLogos: (entry: RenderOutputCacheEntry) => void } const RecollectionActionsContext = createContext(null) function validateAndWritePayload( recollectionId: string, payload: RecollectionFilePayload, backfillEdges: (nodes: AppNode[], edges: AppEdge[]) => AppEdge[] ): { graph?: StoredGraphState; logos?: StoredLogosContent } { const result: { graph?: StoredGraphState; logos?: StoredLogosContent } = {} if (payload.graph && Array.isArray(payload.graph.nodes) && Array.isArray(payload.graph.edges)) { const nodes = payload.graph.nodes as AppNode[] const edges = backfillEdges(nodes, payload.graph.edges as AppEdge[]) const graphState: StoredGraphState = { version: payload.version ?? RECOLLECTION_VERSION, nodes, edges, } setGraph(recollectionId, graphState) result.graph = graphState } if (payload.logos != null && Array.isArray(payload.logos)) { if (payload.logos.every((item) => item != null && typeof item === 'object')) { setLogosContent(recollectionId, payload.logos as StoredLogosContent) result.logos = payload.logos as StoredLogosContent } } // Import scenes if (payload.scenes && Array.isArray(payload.scenes)) { const sceneMetas: FluxSceneMeta[] = [] for (const s of payload.scenes) { if (!s || typeof s.id !== 'string' || typeof s.title !== 'string') continue sceneMetas.push({ id: s.id, title: s.title, position: s.position ?? 0 }) if (s.graph && Array.isArray(s.graph.nodes) && Array.isArray(s.graph.edges)) { const nodes = s.graph.nodes as AppNode[] const edges = backfillEdges(nodes, s.graph.edges as AppEdge[]) setSceneGraph(recollectionId, s.id, { version: payload.version ?? RECOLLECTION_VERSION, nodes, edges, }) } } if (sceneMetas.length > 0) { setFluxSceneTree(recollectionId, sceneMetas) } } return result } export function RecollectionActionsProvider({ children }: { children: React.ReactNode }) { const { recollectionId } = useParams<{ recollectionId: string }>() const { pathname } = useLocation() const [flux, setFluxSlotState] = useState(null) const [logos, setLogosSlotState] = useState(null) const importInputRef = useRef(null) const fluxRef = useRef(null) const logosRef = useRef(null) const pathnameRef = useRef(pathname) fluxRef.current = flux logosRef.current = logos pathnameRef.current = pathname const isFluxActive = pathname.endsWith('/flux') const isLogosActive = pathname.includes('/logos') || /\/recollections\/[^/]+\/?$/.test(pathname) const activeSlot = isFluxActive ? flux : isLogosActive ? logos : null const setFluxSlot = useCallback((slot: FluxSlot | null) => { setFluxSlotState(() => slot) }, []) const setLogosSlot = useCallback((slot: LogosSlot | null) => { setLogosSlotState(() => slot) }, []) useEffect(() => { const onKeyDown = (ev: KeyboardEvent) => { const mod = ev.ctrlKey || ev.metaKey if (mod && ev.key.toLowerCase() === 's') { const slot = activeSlot if (slot?.onSave && slot?.canSave) { ev.preventDefault() ev.stopPropagation() slot.onSave() } } } window.addEventListener('keydown', onKeyDown, true) return () => window.removeEventListener('keydown', onKeyDown, true) }, [activeSlot]) const onExport = useCallback(() => { if (!recollectionId) return const graph = getGraph(recollectionId) const logosContent = getLogosContent(recollectionId) // Export scenes const sceneTree = getFluxSceneTree(recollectionId) const scenes = sceneTree.map((s) => { const sg = getSceneGraph(recollectionId, s.id) return { ...s, graph: sg ? { nodes: sg.nodes, edges: sg.edges } : { nodes: [], edges: [] } } }) const payload: RecollectionFilePayload = { version: RECOLLECTION_VERSION, ...(graph && { graph: { nodes: graph.nodes, edges: graph.edges } }), ...(logosContent && { logos: logosContent }), ...(scenes.length > 0 && { scenes }), } const blob = new Blob([JSON.stringify(payload, null, 2)], { type: 'application/json' }) const url = URL.createObjectURL(blob) const a = document.createElement('a') a.href = url a.download = `recollection${RECOLLECTION_FILE_EXT}` a.click() URL.revokeObjectURL(url) toast.success('Recollection exported') }, [recollectionId]) const onImport = useCallback(() => { importInputRef.current?.click() }, []) const upsertRenderOutputToLogos = useCallback( (entry: RenderOutputCacheEntry) => { if (recollectionId) upsertRenderOutputEntry(recollectionId, entry) }, [recollectionId] ) const onImportFileChange = useCallback( (e: React.ChangeEvent) => { const file = e.target.files?.[0] e.target.value = '' if (!file || !recollectionId) return const reader = new FileReader() reader.onload = () => { try { const text = reader.result as string const payload = JSON.parse(text) as RecollectionFilePayload if (!payload || typeof payload !== 'object') { toast.error('Invalid file: not valid JSON') return } const written = validateAndWritePayload(recollectionId, payload, (nodes, edges) => backfillEdgeTargetTypes(nodes, edges) ) const currentPath = pathnameRef.current const fluxActive = currentPath.endsWith('/flux') const logosActive = currentPath.includes('/logos') || /\/recollections\/[^/]+\/?$/.test(currentPath) if (written.graph && fluxActive && fluxRef.current?.onRefreshFromStore) { fluxRef.current.onRefreshFromStore(written.graph) } if ((written.graph != null || written.logos != null) && logosActive && logosRef.current?.onRefreshFromStore) { logosRef.current.onRefreshFromStore() } if (payload.version != null && payload.version > RECOLLECTION_VERSION) { toast.error('Recollection was created with a newer app version') } else { toast.success('Recollection loaded') } } catch { toast.error('Invalid file: not valid JSON') } } reader.readAsText(file) }, [recollectionId] ) const value: RecollectionActionsContextValue = React.useMemo( () => ({ flux, logos, setFluxSlot, setLogosSlot, isFluxActive, isLogosActive, activeSlot, onImport, onExport, upsertRenderOutputToLogos, }), [flux, logos, setFluxSlot, setLogosSlot, isFluxActive, isLogosActive, activeSlot, onImport, onExport, upsertRenderOutputToLogos] ) return ( {children} ) } export function useRecollectionActions(): RecollectionActionsContextValue { const ctx = useContext(RecollectionActionsContext) if (!ctx) throw new Error('useRecollectionActions must be used within RecollectionActionsProvider') return ctx } /** Returns the context value or null when outside RecollectionActionsProvider. Use when the consumer may render outside recollection layout. */ export function useOptionalRecollectionActions(): RecollectionActionsContextValue | null { return useContext(RecollectionActionsContext) }