feat: refactoeing
This commit is contained in:
@@ -0,0 +1,243 @@
|
||||
/**
|
||||
* 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)
|
||||
}
|
||||
Reference in New Issue
Block a user