244 lines
8.3 KiB
TypeScript
244 lines
8.3 KiB
TypeScript
/**
|
|
* 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, useRef, useState } from 'react'
|
|
import { useLocation, useParams } from 'react-router-dom'
|
|
import type { SaveStatus } from '@/app/canvas/useCanvasGraph'
|
|
import {
|
|
getGraph,
|
|
setGraph,
|
|
getLogosContent,
|
|
setLogosContent,
|
|
upsertRenderOutputEntry,
|
|
RECOLLECTION_FILE_EXT,
|
|
RECOLLECTION_VERSION,
|
|
type StoredGraphState,
|
|
type StoredLogosContent,
|
|
type RenderOutputCacheEntry,
|
|
} 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
|
|
}
|
|
|
|
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
|
|
}
|
|
|
|
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<RecollectionActionsContextValue | null>(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
|
|
}
|
|
}
|
|
return result
|
|
}
|
|
|
|
export function RecollectionActionsProvider({ children }: { children: React.ReactNode }) {
|
|
const { recollectionId } = useParams<{ recollectionId: string }>()
|
|
const { pathname } = useLocation()
|
|
const [flux, setFluxSlotState] = useState<FluxSlot | null>(null)
|
|
const [logos, setLogosSlotState] = useState<LogosSlot | null>(null)
|
|
const importInputRef = useRef<HTMLInputElement>(null)
|
|
const fluxRef = useRef<FluxSlot | null>(null)
|
|
const logosRef = useRef<LogosSlot | null>(null)
|
|
const pathnameRef = useRef(pathname)
|
|
fluxRef.current = flux
|
|
logosRef.current = logos
|
|
pathnameRef.current = pathname
|
|
|
|
const isFluxActive = pathname.endsWith('/flux')
|
|
const isLogosActive = pathname.endsWith('/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)
|
|
}, [])
|
|
|
|
const onExport = useCallback(() => {
|
|
if (!recollectionId) return
|
|
const graph = getGraph(recollectionId)
|
|
const logosContent = getLogosContent(recollectionId)
|
|
const payload: RecollectionFilePayload = {
|
|
version: RECOLLECTION_VERSION,
|
|
...(graph && { graph: { nodes: graph.nodes, edges: graph.edges } }),
|
|
...(logosContent && { logos: logosContent }),
|
|
}
|
|
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<HTMLInputElement>) => {
|
|
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.endsWith('/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 (
|
|
<RecollectionActionsContext.Provider value={value}>
|
|
{children}
|
|
<input
|
|
ref={importInputRef}
|
|
type="file"
|
|
accept=".json,application/json"
|
|
className="hidden"
|
|
aria-hidden
|
|
onChange={onImportFileChange}
|
|
/>
|
|
</RecollectionActionsContext.Provider>
|
|
)
|
|
}
|
|
|
|
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)
|
|
}
|