feat: improvements

This commit is contained in:
2026-03-15 22:08:31 +01:00
parent 4d673ab556
commit 45c387c9e6
12 changed files with 598 additions and 420 deletions

View File

@@ -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<HTMLInputElement | null>(null)
const [rfInstance, setRfInstance] = React.useState<unknown>(null)
const [renamingNodeId, setRenamingNodeId] = React.useState<string | null>(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<HTMLInputElement>) => {
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,
}
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 && <FluxMenubarContent {...fluxMenubarProps} />}
<div role="status" aria-live="polite" aria-atomic className="sr-only">
{ariaAnnouncement}
</div>
<input
ref={importInputRef}
type="file"
accept=".json,.zui.json,application/json"
className="hidden"
onChange={onImportFileChange}
aria-hidden
/>
<div className="flex-1 min-h-0 relative flex flex-col">
<div className="flex-1 min-h-0 flex flex-col">
<GraphContext.Provider value={graphContextValue}>
@@ -729,7 +673,7 @@ export function CanvasPage({ recollectionId }: CanvasPageProps) {
</EmptyDescription>
</EmptyHeader>
<EmptyContent className="flex-row flex-wrap justify-center gap-2">
<Button onClick={handleImportRecollection} variant="outline" size="sm">
<Button onClick={onImport} variant="outline" size="sm">
<FolderOpen className="size-4" />
Import
</Button>

View File

@@ -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<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 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,
}),
[flux, logos, setFluxSlot, setLogosSlot, isFluxActive, isLogosActive, activeSlot, onImport, onExport]
)
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
}

View File

@@ -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 (
<Menubar className="shrink-0 rounded-none border-0 bg-transparent p-0 shadow-none">
<MenubarMenu>
<MenubarTrigger className="font-normal text-muted-foreground">Edit</MenubarTrigger>
<MenubarContent>
<MenubarItem onClick={activeSlot.undo} disabled={!activeSlot.canUndo} className="gap-2">
<Undo2 className="h-4 w-4" />
Undo
<span className="ml-auto pl-4">
<KbdGroup>
<Kbd> + Z</Kbd>
</KbdGroup>
</span>
</MenubarItem>
<MenubarItem onClick={activeSlot.redo} disabled={!activeSlot.canRedo} className="gap-2">
<Redo2 className="h-4 w-4" />
Redo
<span className="ml-auto pl-4">
<KbdGroup>
<Kbd> + + Z</Kbd>
</KbdGroup>
</span>
</MenubarItem>
{hasFluxOnly && (
<>
<MenubarSeparator />
{fluxSlot!.onDuplicate != null && (
<MenubarItem
onClick={fluxSlot!.onDuplicate}
disabled={!fluxSlot!.canDuplicate}
className="gap-2"
>
<CopyPlus className="h-4 w-4" />
Duplicate
<span className="ml-auto pl-4">
<KbdGroup>
<Kbd>D</Kbd>
</KbdGroup>
</span>
</MenubarItem>
)}
{fluxSlot!.onCopy != null && (
<MenubarItem
onClick={fluxSlot!.onCopy}
disabled={!fluxSlot!.canCopy}
className="gap-2"
>
<Copy className="h-4 w-4" />
Copy
<span className="ml-auto pl-4">
<KbdGroup>
<Kbd>C</Kbd>
</KbdGroup>
</span>
</MenubarItem>
)}
{fluxSlot!.onPaste != null && (
<MenubarItem onClick={fluxSlot!.onPaste} className="gap-2">
<ClipboardPaste className="h-4 w-4" />
Paste
<span className="ml-auto pl-4">
<KbdGroup>
<Kbd>V</Kbd>
</KbdGroup>
</span>
</MenubarItem>
)}
</>
)}
</MenubarContent>
</MenubarMenu>
<MenubarMenu>
<MenubarTrigger className="font-normal text-muted-foreground">View</MenubarTrigger>
<MenubarContent>
{fluxSlot?.onFitView && (
<MenubarItem onClick={fluxSlot.onFitView} className="gap-2">
Fit View
<span className="ml-auto pl-4">
<KbdGroup>
<Kbd>0</Kbd>
</KbdGroup>
</span>
</MenubarItem>
)}
</MenubarContent>
</MenubarMenu>
</Menubar>
)
}, [activeSlot, fluxSlot])
return menus
}

View File

@@ -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 (
<RecollectionMenubarProvider>
<RecollectionTitleDataProvider>
<RecollectionActionsProvider>
<RecollectionTitleContent />
<div className="flex min-h-0 flex-1 flex-col">
<RecollectionMenubar />
@@ -43,7 +43,7 @@ export function RecollectionLayout() {
<FlippingCardView />
</div>
</div>
</RecollectionTitleDataProvider>
</RecollectionActionsProvider>
</RecollectionMenubarProvider>
)
}

View File

@@ -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}
</div>
<div className="flex min-w-0 flex-1 items-center gap-1 overflow-visible">
{customContent}
<RecollectionEditViewMenus />
</div>
{base && (
<nav className="flex shrink-0 items-center gap-0.5" aria-label="Recollection pages">

View File

@@ -1,7 +1,7 @@
/**
* Context for the recollection menubar:
* - Title (next to back): optional title + status, e.g. recollection name and save state when on Flux.
* - Middle: registrable menus (Edit, View when on Flux). Set via customContent.
* - Title (next to back): optional title + status, e.g. recollection name and save state.
* - Middle: shared Edit/View menus (RecollectionEditViewMenus), no longer registrable.
*/
import React, { createContext, useCallback, useContext, useState } from 'react'
@@ -10,25 +10,18 @@ export type RecollectionMenubarContextValue = {
/** Content next to the back button (e.g. title + save status, with optional dropdown). Null = use default recollection name. */
titleContent: React.ReactNode
setTitleContent: (content: React.ReactNode) => void
/** Registrable menus in the middle of the bar (e.g. Edit, View when on Flux). */
customContent: React.ReactNode
setCustomContent: (content: React.ReactNode) => void
}
const RecollectionMenubarContext = createContext<RecollectionMenubarContextValue | null>(null)
export function RecollectionMenubarProvider({ children }: { children: React.ReactNode }) {
const [titleContent, setTitleContentState] = useState<React.ReactNode>(null)
const [customContent, setCustomContentState] = useState<React.ReactNode>(null)
const setTitleContent = useCallback((content: React.ReactNode) => {
setTitleContentState(() => content)
}, [])
const setCustomContent = useCallback((content: React.ReactNode) => {
setCustomContentState(() => content)
}, [])
const value: RecollectionMenubarContextValue = React.useMemo(
() => ({ titleContent, setTitleContent, customContent, setCustomContent }),
[titleContent, setTitleContent, customContent, setCustomContent]
() => ({ titleContent, setTitleContent }),
[titleContent, setTitleContent]
)
return (
<RecollectionMenubarContext.Provider value={value}>

View File

@@ -1,6 +1,6 @@
/**
* Shared title + save status block (same for Logos and Flux). Registered with the menubar
* from the layout; gets save/import/export from RecollectionTitleDataContext (set by CanvasPage).
* Shared title + save status block (same for Logos and Flux). Gets save status and Save from
* the active slot (RecollectionActionsContext); Import/Export from layout-level handlers.
*/
import React, { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'
@@ -14,11 +14,11 @@ import {
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu'
import { Kbd, KbdGroup } from '@/components/ui/kbd'
import { RenameRecollectionDialog } from './RenameRecollectionDialog'
import { usePlatform } from '@/app/kosmos/KosmosContext'
import { useRecollectionMenubar } from './RecollectionMenubarContext'
import { useRecollectionTitleData } from './RecollectionTitleDataContext'
import { useRecollectionActions } from './RecollectionActionsContext'
import { CheckCircle2, ChevronDown, CircleDot, Download, FolderOpen, Loader2, Pencil, Save } from 'lucide-react'
import { Input } from '@/components/ui/input'
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip'
const SAVE_KEYS = { key: 's', shiftKey: false }
@@ -31,7 +31,7 @@ function matchKey(ev: KeyboardEvent, want: { key: string; shiftKey: boolean }) {
/** Registers the shared title + save dropdown as titleContent. Renders nothing. */
export function RecollectionTitleContent() {
const { setTitleContent } = useRecollectionMenubar()
const { titleData } = useRecollectionTitleData()
const { activeSlot, onImport, onExport } = useRecollectionActions()
const { recollectionId } = useParams<{ recollectionId: string }>()
const { recollections, renameRecollection } = usePlatform()
const recollectionName = useMemo(
@@ -39,14 +39,13 @@ export function RecollectionTitleContent() {
[recollectionId, recollections]
)
const [isRenaming, setIsRenaming] = useState(false)
const [renameValue, setRenameValue] = useState('')
const renameInputRef = useRef<HTMLInputElement>(null)
const ignoreNextBlurRef = useRef(false)
const [renameModalOpen, setRenameModalOpen] = useState(false)
const [showSavedBriefly, setShowSavedBriefly] = useState(false)
const savedBrieflyTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
const prevSaveStatusRef = useRef<string>('saved')
const saveStatus = titleData?.saveStatus ?? 'saved'
const saveStatus = activeSlot?.saveStatus ?? 'saved'
const onSave = activeSlot?.onSave
const canSave = activeSlot?.canSave ?? false
useEffect(() => {
if (saveStatus === 'unsaved') {
@@ -72,65 +71,20 @@ export function RecollectionTitleContent() {
}
}, [])
useEffect(() => {
if (isRenaming) {
setRenameValue(recollectionName ?? '')
ignoreNextBlurRef.current = true
const t = setTimeout(() => {
renameInputRef.current?.focus()
renameInputRef.current?.select()
}, 100)
return () => clearTimeout(t)
}
}, [isRenaming, recollectionName])
const applyRename = useCallback(() => {
if (!recollectionId || !renameRecollection) return
const trimmed = renameValue.trim()
if (trimmed) renameRecollection(recollectionId, trimmed)
setIsRenaming(false)
}, [recollectionId, renameRecollection, renameValue])
const handleRenameBlur = useCallback(() => {
if (ignoreNextBlurRef.current) {
ignoreNextBlurRef.current = false
return
}
applyRename()
}, [applyRename])
useEffect(() => {
const onKeyDown = (ev: KeyboardEvent) => {
if (matchKey(ev, SAVE_KEYS) && titleData?.onSave && (titleData?.canSave !== false)) {
if (matchKey(ev, SAVE_KEYS) && onSave && canSave) {
ev.preventDefault()
ev.stopPropagation()
titleData.onSave()
onSave()
}
}
window.addEventListener('keydown', onKeyDown, true)
return () => window.removeEventListener('keydown', onKeyDown, true)
}, [titleData?.onSave, titleData?.canSave])
}, [onSave, canSave])
const trigger = useMemo(() => {
if (recollectionId && isRenaming) {
return (
<Input
ref={renameInputRef}
type="text"
value={renameValue}
onChange={(e) => setRenameValue(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter') { e.preventDefault(); applyRename() }
else if (e.key === 'Escape') { e.preventDefault(); setIsRenaming(false) }
}}
onBlur={handleRenameBlur}
className="h-7 text-sm font-medium font-serif w-[140px]"
aria-label="Recollection name"
onClick={(e) => e.stopPropagation()}
/>
)
}
return (
const trigger = useMemo(
() => (
<span className="flex items-center gap-1.5 truncate">
<span className="truncate text-sm font-medium font-serif max-w-[180px]">
{recollectionName ?? 'Untitled'}
@@ -158,18 +112,19 @@ export function RecollectionTitleContent() {
</Tooltip>
</TooltipProvider>
</span>
),
[recollectionName, saveStatus, showSavedBriefly]
)
}, [recollectionId, isRenaming, renameValue, recollectionName, saveStatus, showSavedBriefly, applyRename, handleRenameBlur])
const dropdownContent = useMemo(() => (
<>
{recollectionId && (
<>
{titleData?.onSave != null && (
{onSave != null && (
<>
<DropdownMenuItem
onClick={titleData.onSave}
disabled={titleData.canSave === false}
onClick={onSave}
disabled={!canSave}
className="gap-2"
>
<Save className="h-4 w-4" />
@@ -179,23 +134,23 @@ export function RecollectionTitleContent() {
<DropdownMenuSeparator />
</>
)}
<DropdownMenuItem onClick={() => setIsRenaming(true)} className="gap-2">
<DropdownMenuItem onClick={() => setRenameModalOpen(true)} className="gap-2">
<Pencil className="h-4 w-4" />
Rename
</DropdownMenuItem>
<DropdownMenuSeparator />
</>
)}
<DropdownMenuItem onClick={titleData?.onImport} className="gap-2" disabled={!titleData?.onImport}>
<DropdownMenuItem onClick={onImport} className="gap-2">
<FolderOpen className="h-4 w-4" />
Import
</DropdownMenuItem>
<DropdownMenuItem onClick={titleData?.onExport} className="gap-2" disabled={!titleData?.onExport}>
<DropdownMenuItem onClick={onExport} className="gap-2">
<Download className="h-4 w-4" />
Export
</DropdownMenuItem>
</>
), [recollectionId, titleData?.onSave, titleData?.canSave, titleData?.onImport, titleData?.onExport])
), [recollectionId, onSave, canSave, onImport, onExport])
const titleNode = useMemo(() => (
<DropdownMenu>
@@ -216,5 +171,17 @@ export function RecollectionTitleContent() {
return () => setTitleContent(null)
}, [titleNode, setTitleContent])
return null
return (
<RenameRecollectionDialog
open={renameModalOpen}
onOpenChange={setRenameModalOpen}
recollectionId={recollectionId ?? ''}
initialName={recollectionName ?? ''}
recollections={recollections}
onRename={(id, newName) => {
renameRecollection(id, newName)
setRenameModalOpen(false)
}}
/>
)
}

View File

@@ -1,44 +0,0 @@
/**
* Context for the shared recollection title + save block (used by both Logos and Flux).
* CanvasPage sets save status and actions; layout consumes them to render the title slot.
*/
import React, { createContext, useCallback, useContext, useState } from 'react'
import type { SaveStatus } from '@/app/canvas/useCanvasGraph'
export type RecollectionTitleData = {
saveStatus: SaveStatus
onSave?: () => void
canSave?: boolean
onImport: () => void
onExport: () => void
}
export type RecollectionTitleDataContextValue = {
titleData: RecollectionTitleData | null
setTitleData: (data: RecollectionTitleData | null) => void
}
const RecollectionTitleDataContext = createContext<RecollectionTitleDataContextValue | null>(null)
export function RecollectionTitleDataProvider({ children }: { children: React.ReactNode }) {
const [titleData, setTitleDataState] = useState<RecollectionTitleData | null>(null)
const setTitleData = useCallback((data: RecollectionTitleData | null) => {
setTitleDataState(() => data)
}, [])
const value = React.useMemo(
() => ({ titleData, setTitleData }),
[titleData, setTitleData]
)
return (
<RecollectionTitleDataContext.Provider value={value}>
{children}
</RecollectionTitleDataContext.Provider>
)
}
export function useRecollectionTitleData(): RecollectionTitleDataContextValue {
const ctx = useContext(RecollectionTitleDataContext)
if (!ctx) throw new Error('useRecollectionTitleData must be used within RecollectionTitleDataProvider')
return ctx
}

View File

@@ -68,6 +68,7 @@ import { getLogosContent, setLogosContent } from './recollectionStore'
import { toast } from 'sonner'
import { NewRecollectionDialog } from '@/app/kosmos/NewRecollectionDialog'
import { RecollectionsPageBackground } from './RecollectionsPageBackground'
import { RenameRecollectionDialog } from './RenameRecollectionDialog'
import type { Recollection } from '@/app/kosmos/types'
const PAGE_SIZE_OPTIONS = [10, 25, 50] as const
@@ -269,8 +270,6 @@ export function RecollectionsPage() {
const [sortKey, setSortKey] = useState<SortKey>('lastEdited')
const [sortDir, setSortDir] = useState<SortDir>('desc')
const [renameTarget, setRenameTarget] = useState<Recollection | null>(null)
const [renameValue, setRenameValue] = useState('')
const renameInputRef = React.useRef<HTMLInputElement>(null)
const [deleteTarget, setDeleteTarget] = useState<Recollection | null>(null)
const [duplicateTarget, setDuplicateTarget] = useState<Recollection | null>(null)
const [duplicateName, setDuplicateName] = useState('')
@@ -321,16 +320,8 @@ export function RecollectionsPage() {
const handleRenameOpen = useCallback((recollection: Recollection) => {
setRenameTarget(recollection)
setRenameValue(recollection.name)
}, [])
React.useEffect(() => {
if (renameTarget) {
const t = setTimeout(() => renameInputRef.current?.focus(), 0)
return () => clearTimeout(t)
}
}, [renameTarget])
React.useEffect(() => {
if (duplicateTarget) {
const t = setTimeout(() => duplicateInputRef.current?.focus(), 0)
@@ -338,14 +329,6 @@ export function RecollectionsPage() {
}
}, [duplicateTarget])
const handleRenameSubmit = useCallback(() => {
if (renameTarget && renameValue.trim()) {
renameRecollection(renameTarget.id, renameValue.trim())
setRenameTarget(null)
setRenameValue('')
}
}, [renameTarget, renameValue, renameRecollection])
const handleDeleteOpen = useCallback((recollection: Recollection) => {
setDeleteTarget(recollection)
}, [])
@@ -929,37 +912,17 @@ export function RecollectionsPage() {
)}
</div>
{/* Rename dialog */}
<Dialog open={!!renameTarget} onOpenChange={(open) => !open && setRenameTarget(null)}>
<DialogContent onCloseAutoFocus={(e) => e.preventDefault()}>
<DialogHeader>
<DialogTitle>Rename recollection</DialogTitle>
<DialogDescription>Enter a new name for this recollection.</DialogDescription>
</DialogHeader>
<Input
ref={renameInputRef}
value={renameValue}
onChange={(e) => setRenameValue(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter') handleRenameSubmit()
if (e.key === 'Escape') setRenameTarget(null)
<RenameRecollectionDialog
open={!!renameTarget}
onOpenChange={(open) => !open && setRenameTarget(null)}
recollectionId={renameTarget?.id ?? ''}
initialName={renameTarget?.name ?? ''}
recollections={sorted}
onRename={(id, newName) => {
renameRecollection(id, newName)
setRenameTarget(null)
}}
placeholder="Recollection name"
aria-label="Recollection name"
/>
{renameTarget && renameValue.trim() && sorted.some((p) => p.id !== renameTarget.id && p.name.toLowerCase() === renameValue.trim().toLowerCase()) && (
<p className="text-xs text-amber-600 dark:text-amber-500">An recollection with this name already exists.</p>
)}
<DialogFooter>
<Button variant="outline" onClick={() => setRenameTarget(null)}>
Cancel
</Button>
<Button onClick={handleRenameSubmit} disabled={!renameValue.trim()}>
Save
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{/* Duplicate dialog */}
<Dialog open={!!duplicateTarget} onOpenChange={(open) => { if (!open) { setDuplicateTarget(null); setDuplicateName('') } }}>

View File

@@ -0,0 +1,94 @@
/**
* Shared modal for renaming a recollection. Used by RecollectionsPage and RecollectionTitleContent.
*/
import React, { useCallback, useEffect, useRef, useState } from 'react'
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
export type RenameRecollectionDialogProps = {
open: boolean
onOpenChange: (open: boolean) => void
recollectionId: string
initialName: string
recollections: { id: string; name: string }[]
onRename: (id: string, newName: string) => void
}
export function RenameRecollectionDialog({
open,
onOpenChange,
recollectionId,
initialName,
recollections,
onRename,
}: RenameRecollectionDialogProps) {
const [value, setValue] = useState(initialName)
const inputRef = useRef<HTMLInputElement>(null)
useEffect(() => {
if (open) {
setValue(initialName)
const t = setTimeout(() => inputRef.current?.focus(), 0)
return () => clearTimeout(t)
}
}, [open, initialName])
const handleClose = useCallback(() => onOpenChange(false), [onOpenChange])
const handleSubmit = useCallback(() => {
const trimmed = value.trim()
if (!trimmed) return
onRename(recollectionId, trimmed)
onOpenChange(false)
}, [recollectionId, value, onRename, onOpenChange])
const isDuplicateName =
!!value.trim() &&
recollections.some(
(p) => p.id !== recollectionId && p.name.toLowerCase() === value.trim().toLowerCase()
)
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent onCloseAutoFocus={(e) => e.preventDefault()}>
<DialogHeader>
<DialogTitle>Rename recollection</DialogTitle>
<DialogDescription>Enter a new name for this recollection.</DialogDescription>
</DialogHeader>
<Input
ref={inputRef}
value={value}
onChange={(e) => setValue(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter') handleSubmit()
if (e.key === 'Escape') handleClose()
}}
placeholder="Recollection name"
aria-label="Recollection name"
/>
{isDuplicateName && (
<p className="text-xs text-amber-600 dark:text-amber-500">
A recollection with this name already exists.
</p>
)}
<DialogFooter>
<Button variant="outline" onClick={handleClose}>
Cancel
</Button>
<Button onClick={handleSubmit} disabled={!value.trim()}>
Save
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
)
}

View File

@@ -1,132 +0,0 @@
/**
* Flux-specific menubar: registers Edit and View menus in the middle slot.
* Title + save status are shared and registered by the layout (RecollectionTitleContent).
*/
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 { useRecollectionMenubar } from '@/app/recollections/RecollectionMenubarContext'
import { ClipboardPaste, Copy, CopyPlus, Redo2, Undo2 } from 'lucide-react'
export type FluxMenubarContentProps = {
undo: () => void
redo: () => void
canUndo: boolean
canRedo: boolean
onDuplicate?: () => void
onCopy?: () => void
onPaste?: () => void
canDuplicate?: boolean
canCopy?: boolean
onFitView?: () => void
}
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 FluxMenubarContent(props: FluxMenubarContentProps) {
const { setCustomContent } = useRecollectionMenubar()
const menus = useMemo(
() => (
<Menubar className="shrink-0 rounded-none border-0 bg-transparent p-0 shadow-none">
<MenubarMenu>
<MenubarTrigger className="font-normal text-muted-foreground">Edit</MenubarTrigger>
<MenubarContent>
<MenubarItem onClick={props.undo} disabled={!props.canUndo} className="gap-2">
<Undo2 className="h-4 w-4" />
Undo
<span className="ml-auto pl-4"><KbdGroup><Kbd> + Z</Kbd></KbdGroup></span>
</MenubarItem>
<MenubarItem onClick={props.redo} disabled={!props.canRedo} className="gap-2">
<Redo2 className="h-4 w-4" />
Redo
<span className="ml-auto pl-4"><KbdGroup><Kbd> + + Z</Kbd></KbdGroup></span>
</MenubarItem>
{(props.onDuplicate != null || props.onCopy != null || props.onPaste != null) && <MenubarSeparator />}
{props.onDuplicate != null && (
<MenubarItem onClick={props.onDuplicate} disabled={!props.canDuplicate} className="gap-2">
<CopyPlus className="h-4 w-4" />
Duplicate
<span className="ml-auto pl-4"><KbdGroup><Kbd>D</Kbd></KbdGroup></span>
</MenubarItem>
)}
{props.onCopy != null && (
<MenubarItem onClick={props.onCopy} disabled={!props.canCopy} className="gap-2">
<Copy className="h-4 w-4" />
Copy
<span className="ml-auto pl-4"><KbdGroup><Kbd>C</Kbd></KbdGroup></span>
</MenubarItem>
)}
{props.onPaste != null && (
<MenubarItem onClick={props.onPaste} className="gap-2">
<ClipboardPaste className="h-4 w-4" />
Paste
<span className="ml-auto pl-4"><KbdGroup><Kbd>V</Kbd></KbdGroup></span>
</MenubarItem>
)}
</MenubarContent>
</MenubarMenu>
<MenubarMenu>
<MenubarTrigger className="font-normal text-muted-foreground">View</MenubarTrigger>
<MenubarContent>
{props.onFitView && (
<MenubarItem onClick={props.onFitView} className="gap-2">
Fit View
<span className="ml-auto pl-4"><KbdGroup><Kbd>0</Kbd></KbdGroup></span>
</MenubarItem>
)}
</MenubarContent>
</MenubarMenu>
</Menubar>
),
[
props.undo,
props.redo,
props.canUndo,
props.canRedo,
props.onDuplicate,
props.onCopy,
props.onPaste,
props.canDuplicate,
props.canCopy,
props.onFitView,
]
)
useEffect(() => {
const onKeyDown = (ev: KeyboardEvent) => {
if (matchKey(ev, UNDO_KEYS) && props.canUndo) {
ev.preventDefault()
ev.stopPropagation()
props.undo()
} else if (matchKey(ev, REDO_KEYS) && props.canRedo) {
ev.preventDefault()
ev.stopPropagation()
props.redo()
}
}
window.addEventListener('keydown', onKeyDown, true)
return () => window.removeEventListener('keydown', onKeyDown, true)
}, [props.undo, props.redo, props.canUndo, props.canRedo])
useEffect(() => {
setCustomContent(menus)
return () => setCustomContent(null)
}, [menus, setCustomContent])
return null
}

View File

@@ -3,11 +3,14 @@
* Layout and styling aligned with Flux (same flex/overflow, bg-background, theme).
*/
import React, { useCallback, useEffect, useMemo, useRef, forwardRef } from 'react'
import { useLocation, useParams } from 'react-router-dom'
import React, { useCallback, useEffect, useMemo, useRef, useState, forwardRef } from 'react'
import { useParams } from 'react-router-dom'
import { useCreateBlockNote } from '@blocknote/react'
import { BlockNoteView } from '@blocknote/shadcn'
import { useTheme } from '@/lib/themeContext'
import { useRecollectionActions } from '../RecollectionActionsContext'
import { getLogosContent, setLogosContent, type StoredLogosContent } from '../recollectionStore'
import { toast } from 'sonner'
/** Wraps BlockNoteView so refs go to a div, not the function component (avoids ref warning). */
const BlockNoteViewWrapper = forwardRef<HTMLDivElement, React.ComponentProps<typeof BlockNoteView>>(
@@ -20,9 +23,6 @@ const BlockNoteViewWrapper = forwardRef<HTMLDivElement, React.ComponentProps<typ
)
}
)
import { useRecollectionMenubar } from '../RecollectionMenubarContext'
import { getLogosContent, setLogosContent, type StoredLogosContent } from '../recollectionStore'
const SAVE_DEBOUNCE_MS = 400
/** Known React ref warning from BlockNote/Radix internals; we can't fix it in our code. Suppress once at load so it's active before first BlockNote render. */
@@ -46,41 +46,42 @@ function patchBlockNoteRefWarning() {
}
export function LogosPage() {
const { pathname } = useLocation()
const { recollectionId } = useParams<{ recollectionId: string }>()
const { theme } = useTheme()
const { setCustomContent } = useRecollectionMenubar()
const isLogosActive = pathname.endsWith('/logos') || pathname.match(/\/recollections\/[^/]+\/?$/)
const { setLogosSlot } = useRecollectionActions()
const [reloadKey, setReloadKey] = useState(0)
const saveTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null)
const scrollContainerRef = useRef<HTMLDivElement>(null)
const [saveStatus, setSaveStatus] = useState<'saved' | 'unsaved' | 'saving'>('saved')
const initialContent = useMemo(
() => (recollectionId ? getLogosContent(recollectionId) ?? undefined : undefined),
[recollectionId]
[recollectionId, reloadKey]
)
const editor = useCreateBlockNote({ initialContent }, [recollectionId])
const editor = useCreateBlockNote({ initialContent }, [recollectionId, reloadKey])
const persistContent = useCallback(() => {
if (!recollectionId || !editor) return
setSaveStatus('saving')
try {
const doc = editor.document
const serialized = JSON.parse(JSON.stringify(doc)) as StoredLogosContent
setLogosContent(recollectionId, serialized)
setSaveStatus('saved')
} catch {
// ignore serialize errors
setSaveStatus('unsaved')
}
}, [recollectionId, editor])
useEffect(() => {
if (!isLogosActive) return
setCustomContent(null)
return () => setCustomContent(null)
}, [isLogosActive, setCustomContent])
const onSave = useCallback(() => {
persistContent()
toast.success('Saved')
}, [persistContent])
useEffect(() => {
if (!editor || !recollectionId) return
const handleChange = () => {
setSaveStatus('unsaved')
if (saveTimeoutRef.current) clearTimeout(saveTimeoutRef.current)
saveTimeoutRef.current = setTimeout(() => {
saveTimeoutRef.current = null
@@ -96,6 +97,28 @@ export function LogosPage() {
}
}, [editor, recollectionId, persistContent])
useEffect(() => {
if (!editor || !recollectionId) return
const slot = {
saveStatus,
onSave,
canSave: true,
undo: () => (editor as { undo?: () => void }).undo?.(),
redo: () => (editor as { redo?: () => void }).redo?.(),
canUndo: true,
canRedo: true,
onRefreshFromStore: () => setReloadKey((k) => k + 1),
}
setLogosSlot(slot)
return () => setLogosSlot(null)
}, [
setLogosSlot,
saveStatus,
onSave,
editor,
recollectionId,
])
if (!recollectionId) {
return (
<div className="flex h-full min-h-0 flex-1 flex-col items-center justify-center p-8 text-muted-foreground">