feat: logos and flux
This commit is contained in:
@@ -1,359 +0,0 @@
|
||||
/**
|
||||
* Menubar for the canvas page: Recollection (Import/Export), Edit (Undo/Redo, Duplicate/Copy/Paste, Rename), View (Fit View, Minimap).
|
||||
*/
|
||||
|
||||
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { Link, useParams } from 'react-router-dom'
|
||||
import {
|
||||
Menubar,
|
||||
MenubarContent,
|
||||
MenubarItem,
|
||||
MenubarMenu,
|
||||
MenubarSeparator,
|
||||
MenubarTrigger
|
||||
} from '@/components/ui/menubar'
|
||||
import { Kbd, KbdGroup } from '@/components/ui/kbd'
|
||||
import { usePlatform } from '@/app/kosmos/KosmosContext'
|
||||
import { ArrowLeft, CheckCircle2, CircleDot, ClipboardPaste, Copy, CopyPlus, Download, FolderOpen, Loader2, Pencil, Redo2, Save, Undo2 } from 'lucide-react'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip'
|
||||
import type { SaveStatus } from '@/app/canvas/useCanvasGraph'
|
||||
|
||||
export type CanvasMenubarProps = {
|
||||
onImport: () => void
|
||||
onExport: () => void
|
||||
onSave?: () => void
|
||||
canSave?: boolean
|
||||
/** Shown next to recollection title: unsaved | saving | saved */
|
||||
saveStatus?: SaveStatus
|
||||
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 }
|
||||
const SAVE_KEYS = { key: 's', shiftKey: false }
|
||||
|
||||
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 CanvasMenubar({
|
||||
onImport,
|
||||
onExport,
|
||||
onSave,
|
||||
canSave = true,
|
||||
saveStatus = 'saved',
|
||||
undo,
|
||||
redo,
|
||||
canUndo,
|
||||
canRedo,
|
||||
onDuplicate,
|
||||
onCopy,
|
||||
onPaste,
|
||||
canDuplicate = false,
|
||||
canCopy = false,
|
||||
onFitView,
|
||||
}: CanvasMenubarProps) {
|
||||
const { recollectionId } = useParams<{ recollectionId: string }>()
|
||||
const { recollections, renameRecollection } = usePlatform()
|
||||
const recollectionName = useMemo(
|
||||
() => (recollectionId ? recollections.find((p) => p.id === recollectionId)?.name ?? null : null),
|
||||
[recollectionId, recollections]
|
||||
)
|
||||
|
||||
const [isRenamingRecollection, setIsRenamingRecollection] = useState(false)
|
||||
const [renameValue, setRenameValue] = useState('')
|
||||
const [showSavedBriefly, setShowSavedBriefly] = useState(false)
|
||||
const renameInputRef = useRef<HTMLInputElement>(null)
|
||||
const ignoreNextBlurRef = useRef(false)
|
||||
const savedBrieflyTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
|
||||
const prevSaveStatusRef = useRef<SaveStatus>(saveStatus)
|
||||
|
||||
useEffect(() => {
|
||||
if (saveStatus === 'unsaved') {
|
||||
setShowSavedBriefly(false)
|
||||
if (savedBrieflyTimerRef.current) {
|
||||
clearTimeout(savedBrieflyTimerRef.current)
|
||||
savedBrieflyTimerRef.current = null
|
||||
}
|
||||
} else if (prevSaveStatusRef.current === 'saving' && saveStatus === 'saved') {
|
||||
setShowSavedBriefly(true)
|
||||
if (savedBrieflyTimerRef.current) clearTimeout(savedBrieflyTimerRef.current)
|
||||
savedBrieflyTimerRef.current = setTimeout(() => {
|
||||
savedBrieflyTimerRef.current = null
|
||||
setShowSavedBriefly(false)
|
||||
}, 2500)
|
||||
}
|
||||
prevSaveStatusRef.current = saveStatus
|
||||
}, [saveStatus])
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (savedBrieflyTimerRef.current) {
|
||||
clearTimeout(savedBrieflyTimerRef.current)
|
||||
savedBrieflyTimerRef.current = null
|
||||
}
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
if (isRenamingRecollection) {
|
||||
setRenameValue(recollectionName ?? '')
|
||||
ignoreNextBlurRef.current = true
|
||||
// Delay focus so the Recollection dropdown can close first and not steal focus back (which would trigger blur)
|
||||
const t = setTimeout(() => {
|
||||
renameInputRef.current?.focus()
|
||||
renameInputRef.current?.select()
|
||||
}, 100)
|
||||
return () => clearTimeout(t)
|
||||
}
|
||||
}, [isRenamingRecollection, recollectionName])
|
||||
|
||||
const applyRename = useCallback(() => {
|
||||
if (!recollectionId || !renameRecollection) return
|
||||
const trimmed = renameValue.trim()
|
||||
if (trimmed) renameRecollection(recollectionId, trimmed)
|
||||
setIsRenamingRecollection(false)
|
||||
}, [recollectionId, renameRecollection, renameValue])
|
||||
|
||||
const cancelRename = useCallback(() => {
|
||||
setIsRenamingRecollection(false)
|
||||
}, [])
|
||||
|
||||
const handleRenameBlur = useCallback(() => {
|
||||
if (ignoreNextBlurRef.current) {
|
||||
ignoreNextBlurRef.current = false
|
||||
return
|
||||
}
|
||||
applyRename()
|
||||
}, [applyRename])
|
||||
|
||||
useEffect(() => {
|
||||
const onKeyDown = (ev: KeyboardEvent) => {
|
||||
if (matchKey(ev, UNDO_KEYS)) {
|
||||
if (canUndo) {
|
||||
ev.preventDefault()
|
||||
ev.stopPropagation()
|
||||
undo()
|
||||
}
|
||||
return
|
||||
}
|
||||
if (matchKey(ev, REDO_KEYS)) {
|
||||
if (canRedo) {
|
||||
ev.preventDefault()
|
||||
ev.stopPropagation()
|
||||
redo()
|
||||
}
|
||||
return
|
||||
}
|
||||
if (matchKey(ev, SAVE_KEYS)) {
|
||||
if (onSave && canSave) {
|
||||
ev.preventDefault()
|
||||
ev.stopPropagation()
|
||||
onSave()
|
||||
}
|
||||
}
|
||||
}
|
||||
window.addEventListener('keydown', onKeyDown, true)
|
||||
return () => window.removeEventListener('keydown', onKeyDown, true)
|
||||
}, [undo, redo, canUndo, canRedo, onSave, canSave])
|
||||
|
||||
return (
|
||||
<div className="relative flex h-9 w-full shrink-0 items-center border-b border-border/40 bg-background">
|
||||
<Menubar className="flex-1 shrink-0 rounded-none border-0 border-b-0 bg-transparent p-0 shadow-none">
|
||||
<Link
|
||||
to="/recollections"
|
||||
aria-label="Back to recollections"
|
||||
className="flex shrink-0 items-center rounded-sm px-2 py-1 ml-1 text-sm outline-none focus:bg-accent focus:text-accent-foreground hover:bg-accent hover:text-accent-foreground"
|
||||
>
|
||||
<ArrowLeft className="size-4" />
|
||||
</Link>
|
||||
<MenubarMenu>
|
||||
<MenubarTrigger className="font-normal text-muted-foreground">Recollection</MenubarTrigger>
|
||||
<MenubarContent>
|
||||
{recollectionId && (
|
||||
<>
|
||||
{onSave != null && (
|
||||
<>
|
||||
<MenubarItem onClick={onSave} disabled={!canSave} className="gap-2">
|
||||
<Save className="h-4 w-4" />
|
||||
Save
|
||||
<span className="ml-auto pl-4">
|
||||
<KbdGroup>
|
||||
<Kbd>⌘S</Kbd>
|
||||
</KbdGroup>
|
||||
</span>
|
||||
</MenubarItem>
|
||||
<MenubarSeparator />
|
||||
</>
|
||||
)}
|
||||
<MenubarItem
|
||||
onClick={() => setIsRenamingRecollection(true)}
|
||||
className="gap-2"
|
||||
>
|
||||
<Pencil className="h-4 w-4" />
|
||||
Rename
|
||||
</MenubarItem>
|
||||
<MenubarSeparator />
|
||||
</>
|
||||
)}
|
||||
<MenubarItem onClick={onImport} className="gap-2">
|
||||
<FolderOpen className="h-4 w-4" />
|
||||
Import…
|
||||
</MenubarItem>
|
||||
<MenubarItem onClick={onExport} className="gap-2">
|
||||
<Download className="h-4 w-4" />
|
||||
Export…
|
||||
</MenubarItem>
|
||||
</MenubarContent>
|
||||
</MenubarMenu>
|
||||
<MenubarMenu>
|
||||
<MenubarTrigger className="font-normal text-muted-foreground">Edit</MenubarTrigger>
|
||||
<MenubarContent>
|
||||
<MenubarItem onClick={undo} disabled={!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={redo} disabled={!canRedo} className="gap-2">
|
||||
<Redo2 className="h-4 w-4" />
|
||||
Redo
|
||||
<span className="ml-auto pl-4">
|
||||
<KbdGroup>
|
||||
<Kbd>⌘ + ⇧ + Z</Kbd>
|
||||
</KbdGroup>
|
||||
</span>
|
||||
</MenubarItem>
|
||||
{(onDuplicate != null || onCopy != null || onPaste != null) && <MenubarSeparator />}
|
||||
{onDuplicate != null && (
|
||||
<MenubarItem onClick={onDuplicate} disabled={!canDuplicate} className="gap-2">
|
||||
<CopyPlus className="h-4 w-4" />
|
||||
Duplicate
|
||||
<span className="ml-auto pl-4">
|
||||
<KbdGroup>
|
||||
<Kbd>⌘D</Kbd>
|
||||
</KbdGroup>
|
||||
</span>
|
||||
</MenubarItem>
|
||||
)}
|
||||
{onCopy != null && (
|
||||
<MenubarItem onClick={onCopy} disabled={!canCopy} className="gap-2">
|
||||
<Copy className="h-4 w-4" />
|
||||
Copy
|
||||
<span className="ml-auto pl-4">
|
||||
<KbdGroup>
|
||||
<Kbd>⌘C</Kbd>
|
||||
</KbdGroup>
|
||||
</span>
|
||||
</MenubarItem>
|
||||
)}
|
||||
{onPaste != null && (
|
||||
<MenubarItem onClick={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>
|
||||
{onFitView && (
|
||||
<MenubarItem onClick={onFitView} className="gap-2">
|
||||
Fit View
|
||||
<span className="ml-auto pl-4">
|
||||
<KbdGroup>
|
||||
<Kbd>⌘0</Kbd>
|
||||
</KbdGroup>
|
||||
</span>
|
||||
</MenubarItem>
|
||||
)}
|
||||
</MenubarContent>
|
||||
</MenubarMenu>
|
||||
</Menubar>
|
||||
{recollectionId && (
|
||||
<div className="absolute left-1/2 -translate-x-1/2 flex items-center justify-center gap-2 max-w-[50%] min-w-[120px]">
|
||||
{isRenamingRecollection ? (
|
||||
<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()
|
||||
cancelRename()
|
||||
}
|
||||
}}
|
||||
onBlur={handleRenameBlur}
|
||||
className="h-7 text-sm font-medium text-center font-serif"
|
||||
aria-label="Recollection name"
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
<span className="pointer-events-none truncate text-sm font-medium text-foreground font-serif">
|
||||
{recollectionName ?? 'Untitled'}
|
||||
</span>
|
||||
<TooltipProvider delayDuration={300}>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<span
|
||||
className="shrink-0 flex items-center text-muted-foreground cursor-default"
|
||||
aria-live="polite"
|
||||
aria-label={
|
||||
saveStatus === 'saving'
|
||||
? 'Saving'
|
||||
: saveStatus === 'unsaved'
|
||||
? 'Unsaved changes'
|
||||
: 'All changes saved'
|
||||
}
|
||||
>
|
||||
{saveStatus === 'saving' && (
|
||||
<Loader2 className="size-3.5 animate-spin" aria-hidden />
|
||||
)}
|
||||
{saveStatus === 'unsaved' && (
|
||||
<CircleDot className="size-3.5" aria-hidden />
|
||||
)}
|
||||
{saveStatus !== 'unsaved' && (saveStatus === 'saved' || showSavedBriefly) && (
|
||||
<CheckCircle2 className="size-3.5 text-muted-foreground/70" aria-hidden />
|
||||
)}
|
||||
</span>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom">
|
||||
{saveStatus === 'saving'
|
||||
? 'Saving…'
|
||||
: saveStatus === 'unsaved'
|
||||
? 'Unsaved changes'
|
||||
: 'All changes saved'}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -36,7 +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 { CanvasMenubar } from '@/app/canvas/CanvasMenubar'
|
||||
import { useRecollectionMenubar } from '@/app/recollections/RecollectionMenubarContext'
|
||||
import { FluxMenubarContent } from '@/app/recollections/flux/FluxMenubarContent'
|
||||
import { createContextualNode } from '@/app/canvas/ContextualZoomNode'
|
||||
import { ViewportDisplayProvider } from '@/app/canvas/ViewportDisplayContext'
|
||||
import { FlowKeyboardShortcuts } from '@/components/graph/FlowKeyboardShortcuts'
|
||||
@@ -427,6 +428,52 @@ export function CanvasPage({ recollectionId }: CanvasPageProps) {
|
||||
flowActionsRef.current?.pasteAtViewportCenter?.()
|
||||
}, [])
|
||||
|
||||
const { setCustomContent } = useRecollectionMenubar()
|
||||
useEffect(() => {
|
||||
setCustomContent(
|
||||
<FluxMenubarContent
|
||||
onImport={handleImportRecollection}
|
||||
onExport={handleExportRecollection}
|
||||
onSave={
|
||||
recollectionId
|
||||
? () => {
|
||||
save()
|
||||
toast.success('Saved')
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
canSave={Boolean(recollectionId)}
|
||||
saveStatus={saveStatus}
|
||||
undo={undo}
|
||||
redo={redo}
|
||||
canUndo={canUndo}
|
||||
canRedo={canRedo}
|
||||
onDuplicate={handleDuplicate}
|
||||
onCopy={handleCopy}
|
||||
onPaste={handlePaste}
|
||||
canDuplicate={selectedNodes.length > 0}
|
||||
canCopy={selectedNodes.length === 1}
|
||||
onFitView={() => flowActionsRef.current?.fitView?.()}
|
||||
/>
|
||||
)
|
||||
return () => setCustomContent(null)
|
||||
}, [
|
||||
setCustomContent,
|
||||
recollectionId,
|
||||
saveStatus,
|
||||
canUndo,
|
||||
canRedo,
|
||||
selectedNodes.length,
|
||||
save,
|
||||
undo,
|
||||
redo,
|
||||
handleDuplicate,
|
||||
handleCopy,
|
||||
handlePaste,
|
||||
handleImportRecollection,
|
||||
handleExportRecollection,
|
||||
])
|
||||
|
||||
const graphContextValue = useMemo(
|
||||
() => ({ setNodes, setEdges, graphRef, edges }),
|
||||
[setNodes, setEdges, edges]
|
||||
@@ -646,30 +693,6 @@ export function CanvasPage({ recollectionId }: CanvasPageProps) {
|
||||
onChange={onImportFileChange}
|
||||
aria-hidden
|
||||
/>
|
||||
<CanvasMenubar
|
||||
onImport={handleImportRecollection}
|
||||
onExport={handleExportRecollection}
|
||||
onSave={
|
||||
recollectionId
|
||||
? () => {
|
||||
save()
|
||||
toast.success('Saved')
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
canSave={Boolean(recollectionId)}
|
||||
saveStatus={saveStatus}
|
||||
undo={undo}
|
||||
redo={redo}
|
||||
canUndo={canUndo}
|
||||
canRedo={canRedo}
|
||||
onDuplicate={handleDuplicate}
|
||||
onCopy={handleCopy}
|
||||
onPaste={handlePaste}
|
||||
canDuplicate={selectedNodes.length > 0}
|
||||
canCopy={selectedNodes.length === 1}
|
||||
onFitView={() => flowActionsRef.current?.fitView?.()}
|
||||
/>
|
||||
<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}>
|
||||
|
||||
@@ -1,36 +0,0 @@
|
||||
/**
|
||||
* Route wrapper for the canvas: resolves recollectionId from URL and updates lastEditedAt on open.
|
||||
*/
|
||||
|
||||
import React, { useEffect, useRef } from 'react'
|
||||
import { useParams } from 'react-router-dom'
|
||||
import { CanvasPage } from './CanvasPage'
|
||||
import { usePlatform } from '@/app/kosmos/KosmosContext'
|
||||
|
||||
export function CanvasRoute() {
|
||||
const { recollectionId } = useParams<{ recollectionId: string }>()
|
||||
const { recollections, updateLastEdited } = usePlatform()
|
||||
const updateLastEditedRef = useRef(updateLastEdited)
|
||||
updateLastEditedRef.current = updateLastEdited
|
||||
|
||||
const recollection = recollections.find((p) => p.id === recollectionId)
|
||||
|
||||
useEffect(() => {
|
||||
if (recollectionId) updateLastEditedRef.current(recollectionId)
|
||||
}, [recollectionId])
|
||||
|
||||
if (!recollectionId) return null
|
||||
if (!recollection) {
|
||||
return (
|
||||
<div className="flex flex-1 flex-col items-center justify-center gap-4 p-8">
|
||||
<p className="text-sm text-muted-foreground">Recollection not found.</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex min-h-0 flex-1 flex-col">
|
||||
<CanvasPage key={recollectionId} recollectionId={recollectionId} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user