diff --git a/frontend/src/app/canvas/CanvasPage.tsx b/frontend/src/app/canvas/CanvasPage.tsx index 353edec..10af3f3 100644 --- a/frontend/src/app/canvas/CanvasPage.tsx +++ b/frontend/src/app/canvas/CanvasPage.tsx @@ -4,7 +4,6 @@ */ import React, { useCallback, useEffect, useMemo, useRef } from 'react' -import { useLocation } from 'react-router-dom' import { ReactFlow, ReactFlowProvider, @@ -37,8 +36,8 @@ import { ContextMenu, ContextMenuTrigger } from '@/components/ui/context-menu' import { CanvasContextMenuContent } from '@/app/canvas/CanvasContextMenuContent' import { useCanvasConnectionPathFromStore } from '@/app/canvas/useCanvasConnectionPathFromStore' import { dispatchCanvasCommand } from '@/app/canvas/canvasStore' -import { useRecollectionTitleData } from '@/app/recollections/RecollectionTitleDataContext' -import { FluxMenubarContent } from '@/app/recollections/flux/FluxMenubarContent' +import { useRecollectionActions } from '@/app/recollections/RecollectionActionsContext' +import type { StoredGraphState } from '@/app/recollections/recollectionStore' import { createContextualNode } from '@/app/canvas/ContextualZoomNode' import { ViewportDisplayProvider } from '@/app/canvas/ViewportDisplayContext' import { FlowKeyboardShortcuts } from '@/components/graph/FlowKeyboardShortcuts' @@ -68,7 +67,7 @@ import { } from '@/lib/graph/nodeRegistry' import type { AppNode, AppEdge } from '@/lib/graph/nodeTypes' import { toast } from 'sonner' -import { RECOLLECTION_FILE_EXT, RECOLLECTION_VERSION } from '@/app/recollections/recollectionGraphStorage' +import { RECOLLECTION_VERSION } from '@/app/recollections/recollectionGraphStorage' const SNAP_GRID: [number, number] = [15, 15] const DUPLICATE_OFFSET = { x: 30, y: 30 } @@ -176,7 +175,6 @@ export function CanvasPage({ recollectionId }: CanvasPageProps) { saveStatus, } = useCanvasGraph(recollectionId) - const importInputRef = useRef(null) const [rfInstance, setRfInstance] = React.useState(null) const [renamingNodeId, setRenamingNodeId] = React.useState(null) const [connectionFrom, setConnectionFrom] = React.useState<{ nodeId: string; sourceHandle?: string } | null>(null) @@ -325,57 +323,12 @@ export function CanvasPage({ recollectionId }: CanvasPageProps) { const onNodeDragStart = useCallback(() => saveForDragEnd(), [saveForDragEnd]) const onNodeDragStop = useCallback(() => commitDragEnd(), [commitDragEnd]) - const handleExportRecollection = useCallback(() => { - const state = { version: RECOLLECTION_VERSION, nodes, edges } - const blob = new Blob([JSON.stringify(state, 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') - }, [nodes, edges]) - - const handleImportRecollection = useCallback(() => importInputRef.current?.click(), []) - const handleLoadExample = useCallback(() => { const { nodes: exampleNodes, edges: exampleEdges } = getExampleGraph() setStateImmediate({ nodes: exampleNodes, edges: exampleEdges }) toast.success('Example loaded') }, [setStateImmediate]) - const onImportFileChange = useCallback( - (e: React.ChangeEvent) => { - const file = e.target.files?.[0] - e.target.value = '' - if (!file) return - const reader = new FileReader() - reader.onload = () => { - try { - const text = reader.result as string - const state = JSON.parse(text) as { version?: number; nodes?: unknown[]; edges?: unknown[] } - if (!state || !Array.isArray(state.nodes) || !Array.isArray(state.edges)) { - toast.error('Invalid file: expected nodes and edges arrays') - return - } - const nodes = state.nodes as AppNode[] - const edges = backfillEdgeTargetTypes(nodes, state.edges as AppEdge[]) - setStateImmediate({ nodes, edges }) - if (state.version != null && state.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) - }, - [setStateImmediate] - ) - const selectedNodes = useMemo( () => nodes.filter((n) => (n as Node & { selected?: boolean }).selected), [nodes] @@ -429,56 +382,56 @@ export function CanvasPage({ recollectionId }: CanvasPageProps) { flowActionsRef.current?.pasteAtViewportCenter?.() }, []) - const { pathname } = useLocation() - const isFluxActive = pathname.endsWith('/flux') - const { setTitleData } = useRecollectionTitleData() + const { setFluxSlot, onImport } = useRecollectionActions() + + const onRefreshFromStore = useCallback( + (graph: StoredGraphState) => { + const nodes = graph.nodes as AppNode[] + const edges = backfillEdgeTargetTypes(nodes, graph.edges as AppEdge[]) + setStateImmediate({ nodes, edges }) + }, + [setStateImmediate] + ) + useEffect(() => { - setTitleData({ + const slot = { saveStatus, onSave: recollectionId ? () => { save() toast.success('Saved') } - : undefined, + : () => {}, canSave: Boolean(recollectionId), - onImport: handleImportRecollection, - onExport: handleExportRecollection, - }) - return () => setTitleData(null) - }, [ - setTitleData, - saveStatus, - recollectionId, - save, - handleImportRecollection, - handleExportRecollection, - ]) - - const fluxMenubarProps = useMemo( - () => ({ undo, redo, canUndo, canRedo, + onRefreshFromStore, onDuplicate: handleDuplicate, onCopy: handleCopy, onPaste: handlePaste, canDuplicate: selectedNodes.length > 0, canCopy: selectedNodes.length === 1, onFitView: () => flowActionsRef.current?.fitView?.(), - }), - [ - canUndo, - canRedo, - selectedNodes.length, - undo, - redo, - handleDuplicate, - handleCopy, - handlePaste, - ] - ) + } + setFluxSlot(slot) + return () => setFluxSlot(null) + }, [ + setFluxSlot, + saveStatus, + recollectionId, + save, + undo, + redo, + canUndo, + canRedo, + onRefreshFromStore, + handleDuplicate, + handleCopy, + handlePaste, + selectedNodes.length, + ]) const graphContextValue = useMemo( () => ({ setNodes, setEdges, graphRef, edges }), @@ -688,18 +641,9 @@ export function CanvasPage({ recollectionId }: CanvasPageProps) { onContextMenu={onCanvasContextMenu} style={{ position: 'relative' }} > - {isFluxActive && }
{ariaAnnouncement}
-
@@ -729,7 +673,7 @@ export function CanvasPage({ recollectionId }: CanvasPageProps) { - diff --git a/frontend/src/app/recollections/RecollectionActionsContext.tsx b/frontend/src/app/recollections/RecollectionActionsContext.tsx new file mode 100644 index 0000000..bf1422d --- /dev/null +++ b/frontend/src/app/recollections/RecollectionActionsContext.tsx @@ -0,0 +1,224 @@ +/** + * 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, + RECOLLECTION_FILE_EXT, + RECOLLECTION_VERSION, + type StoredGraphState, + type StoredLogosContent, +} from './recollectionStore' +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 +} + +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 + } + } + 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.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 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.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, + }), + [flux, logos, setFluxSlot, setLogosSlot, isFluxActive, isLogosActive, activeSlot, onImport, onExport] + ) + + return ( + + {children} + + + ) +} + +export function useRecollectionActions(): RecollectionActionsContextValue { + const ctx = useContext(RecollectionActionsContext) + if (!ctx) throw new Error('useRecollectionActions must be used within RecollectionActionsProvider') + return ctx +} diff --git a/frontend/src/app/recollections/RecollectionEditViewMenus.tsx b/frontend/src/app/recollections/RecollectionEditViewMenus.tsx new file mode 100644 index 0000000..087d5f4 --- /dev/null +++ b/frontend/src/app/recollections/RecollectionEditViewMenus.tsx @@ -0,0 +1,145 @@ +/** + * Shared Edit and View menus for recollections. Always rendered in the menubar; + * uses the active slot (flux or logos by pathname) for undo, redo, and Flux-only actions. + */ + +import React, { useEffect, useMemo } from 'react' +import { + Menubar, + MenubarContent, + MenubarItem, + MenubarMenu, + MenubarSeparator, + MenubarTrigger, +} from '@/components/ui/menubar' +import { Kbd, KbdGroup } from '@/components/ui/kbd' +import { useRecollectionActions } from './RecollectionActionsContext' +import { ClipboardPaste, Copy, CopyPlus, Redo2, Undo2 } from 'lucide-react' + +const UNDO_KEYS = { key: 'z', shiftKey: false } +const REDO_KEYS = { key: 'z', shiftKey: true } + +function matchKey(ev: KeyboardEvent, want: { key: string; shiftKey: boolean }) { + const mod = ev.ctrlKey || ev.metaKey + return ev.key.toLowerCase() === want.key && !!mod && !!ev.shiftKey === want.shiftKey +} + +export function RecollectionEditViewMenus() { + const { activeSlot, flux, isFluxActive } = useRecollectionActions() + + const fluxSlot = isFluxActive ? flux : null + + useEffect(() => { + if (!activeSlot) return + const onKeyDown = (ev: KeyboardEvent) => { + if (matchKey(ev, UNDO_KEYS) && activeSlot.canUndo) { + ev.preventDefault() + ev.stopPropagation() + activeSlot.undo() + } else if (matchKey(ev, REDO_KEYS) && activeSlot.canRedo) { + ev.preventDefault() + ev.stopPropagation() + activeSlot.redo() + } + } + window.addEventListener('keydown', onKeyDown, true) + return () => window.removeEventListener('keydown', onKeyDown, true) + }, [activeSlot]) + + const menus = useMemo(() => { + if (!activeSlot) return null + const hasFluxOnly = + fluxSlot && + (fluxSlot.onDuplicate != null || fluxSlot.onCopy != null || fluxSlot.onPaste != null) + return ( + + + Edit + + + + Undo + + + ⌘ + Z + + + + + + Redo + + + ⌘ + ⇧ + Z + + + + {hasFluxOnly && ( + <> + + {fluxSlot!.onDuplicate != null && ( + + + Duplicate + + + ⌘D + + + + )} + {fluxSlot!.onCopy != null && ( + + + Copy + + + ⌘C + + + + )} + {fluxSlot!.onPaste != null && ( + + + Paste + + + ⌘V + + + + )} + + )} + + + + View + + {fluxSlot?.onFitView && ( + + Fit View + + + ⌘0 + + + + )} + + + + ) + }, [activeSlot, fluxSlot]) + + return menus +} diff --git a/frontend/src/app/recollections/RecollectionLayout.tsx b/frontend/src/app/recollections/RecollectionLayout.tsx index 3e13370..bbbe184 100644 --- a/frontend/src/app/recollections/RecollectionLayout.tsx +++ b/frontend/src/app/recollections/RecollectionLayout.tsx @@ -6,7 +6,7 @@ import React, { useEffect } from 'react' import { Link, useParams } from 'react-router-dom' import { usePlatform } from '@/app/kosmos/KosmosContext' import { RecollectionMenubarProvider } from './RecollectionMenubarContext' -import { RecollectionTitleDataProvider } from './RecollectionTitleDataContext' +import { RecollectionActionsProvider } from './RecollectionActionsContext' import { RecollectionTitleContent } from './RecollectionTitleContent' import { RecollectionMenubar } from './RecollectionMenubar' import { FlippingCardView } from './FlippingCardView' @@ -35,7 +35,7 @@ export function RecollectionLayout() { return ( - +
@@ -43,7 +43,7 @@ export function RecollectionLayout() {
- + ) } diff --git a/frontend/src/app/recollections/RecollectionMenubar.tsx b/frontend/src/app/recollections/RecollectionMenubar.tsx index 6e7dcfb..9a729a8 100644 --- a/frontend/src/app/recollections/RecollectionMenubar.tsx +++ b/frontend/src/app/recollections/RecollectionMenubar.tsx @@ -8,11 +8,12 @@ import { usePlatform } from '@/app/kosmos/KosmosContext' import { ArrowLeft } from 'lucide-react' import { FluxIcon, LogosIcon } from '@/lib/icons' import { useRecollectionMenubar } from './RecollectionMenubarContext' +import { RecollectionEditViewMenus } from './RecollectionEditViewMenus' export function RecollectionMenubar() { const { recollectionId } = useParams<{ recollectionId: string }>() const { recollections } = usePlatform() - const { titleContent, customContent } = useRecollectionMenubar() + const { titleContent } = useRecollectionMenubar() const recollection = recollectionId ? recollections.find((p) => p.id === recollectionId) : null const base = recollectionId ? `/recollections/${recollectionId}` : '' @@ -33,7 +34,7 @@ export function RecollectionMenubar() { {titleContent ?? defaultTitle}
- {customContent} +
{base && (