feat: refactor menus

This commit is contained in:
2026-03-15 21:11:57 +01:00
parent a8bd579043
commit 929359b4bc
8 changed files with 406 additions and 290 deletions

View File

@@ -4,6 +4,7 @@
*/ */
import React, { useCallback, useEffect, useMemo, useRef } from 'react' import React, { useCallback, useEffect, useMemo, useRef } from 'react'
import { useLocation } from 'react-router-dom'
import { import {
ReactFlow, ReactFlow,
ReactFlowProvider, ReactFlowProvider,
@@ -36,7 +37,7 @@ import { ContextMenu, ContextMenuTrigger } from '@/components/ui/context-menu'
import { CanvasContextMenuContent } from '@/app/canvas/CanvasContextMenuContent' import { CanvasContextMenuContent } from '@/app/canvas/CanvasContextMenuContent'
import { useCanvasConnectionPathFromStore } from '@/app/canvas/useCanvasConnectionPathFromStore' import { useCanvasConnectionPathFromStore } from '@/app/canvas/useCanvasConnectionPathFromStore'
import { dispatchCanvasCommand } from '@/app/canvas/canvasStore' import { dispatchCanvasCommand } from '@/app/canvas/canvasStore'
import { useRecollectionMenubar } from '@/app/recollections/RecollectionMenubarContext' import { useRecollectionTitleData } from '@/app/recollections/RecollectionTitleDataContext'
import { FluxMenubarContent } from '@/app/recollections/flux/FluxMenubarContent' import { FluxMenubarContent } from '@/app/recollections/flux/FluxMenubarContent'
import { createContextualNode } from '@/app/canvas/ContextualZoomNode' import { createContextualNode } from '@/app/canvas/ContextualZoomNode'
import { ViewportDisplayProvider } from '@/app/canvas/ViewportDisplayContext' import { ViewportDisplayProvider } from '@/app/canvas/ViewportDisplayContext'
@@ -428,52 +429,57 @@ export function CanvasPage({ recollectionId }: CanvasPageProps) {
flowActionsRef.current?.pasteAtViewportCenter?.() flowActionsRef.current?.pasteAtViewportCenter?.()
}, []) }, [])
const { setCustomContent } = useRecollectionMenubar() const { pathname } = useLocation()
const isFluxActive = pathname.endsWith('/flux')
const { setTitleData } = useRecollectionTitleData()
useEffect(() => { useEffect(() => {
setCustomContent( setTitleData({
<FluxMenubarContent saveStatus,
onImport={handleImportRecollection} onSave: recollectionId
onExport={handleExportRecollection} ? () => {
onSave={ save()
recollectionId toast.success('Saved')
? () => { }
save() : undefined,
toast.success('Saved') canSave: Boolean(recollectionId),
} onImport: handleImportRecollection,
: undefined onExport: handleExportRecollection,
} })
canSave={Boolean(recollectionId)} return () => setTitleData(null)
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, setTitleData,
recollectionId,
saveStatus, saveStatus,
canUndo, recollectionId,
canRedo,
selectedNodes.length,
save, save,
undo,
redo,
handleDuplicate,
handleCopy,
handlePaste,
handleImportRecollection, handleImportRecollection,
handleExportRecollection, handleExportRecollection,
]) ])
const fluxMenubarProps = useMemo(
() => ({
undo,
redo,
canUndo,
canRedo,
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,
]
)
const graphContextValue = useMemo( const graphContextValue = useMemo(
() => ({ setNodes, setEdges, graphRef, edges }), () => ({ setNodes, setEdges, graphRef, edges }),
[setNodes, setEdges, edges] [setNodes, setEdges, edges]
@@ -682,6 +688,7 @@ export function CanvasPage({ recollectionId }: CanvasPageProps) {
onContextMenu={onCanvasContextMenu} onContextMenu={onCanvasContextMenu}
style={{ position: 'relative' }} style={{ position: 'relative' }}
> >
{isFluxActive && <FluxMenubarContent {...fluxMenubarProps} />}
<div role="status" aria-live="polite" aria-atomic className="sr-only"> <div role="status" aria-live="polite" aria-atomic className="sr-only">
{ariaAnnouncement} {ariaAnnouncement}
</div> </div>

View File

@@ -6,6 +6,8 @@ import React, { useEffect } from 'react'
import { Link, useParams } from 'react-router-dom' import { Link, useParams } from 'react-router-dom'
import { usePlatform } from '@/app/kosmos/KosmosContext' import { usePlatform } from '@/app/kosmos/KosmosContext'
import { RecollectionMenubarProvider } from './RecollectionMenubarContext' import { RecollectionMenubarProvider } from './RecollectionMenubarContext'
import { RecollectionTitleDataProvider } from './RecollectionTitleDataContext'
import { RecollectionTitleContent } from './RecollectionTitleContent'
import { RecollectionMenubar } from './RecollectionMenubar' import { RecollectionMenubar } from './RecollectionMenubar'
import { FlippingCardView } from './FlippingCardView' import { FlippingCardView } from './FlippingCardView'
@@ -33,12 +35,15 @@ export function RecollectionLayout() {
return ( return (
<RecollectionMenubarProvider> <RecollectionMenubarProvider>
<div className="flex min-h-0 flex-1 flex-col"> <RecollectionTitleDataProvider>
<RecollectionMenubar /> <RecollectionTitleContent />
<div className="flex min-h-0 flex-1 flex-col overflow-hidden"> <div className="flex min-h-0 flex-1 flex-col">
<FlippingCardView /> <RecollectionMenubar />
<div className="flex min-h-0 flex-1 flex-col overflow-hidden">
<FlippingCardView />
</div>
</div> </div>
</div> </RecollectionTitleDataProvider>
</RecollectionMenubarProvider> </RecollectionMenubarProvider>
) )
} }

View File

@@ -1,9 +1,9 @@
/** /**
* Shared base menubar for a recollection: Back, optional custom content (from context), Logos/Flux switcher. * Recollection menubar: Back | title + save status | registered menus (middle) | Logos | Flux.
*/ */
import React from 'react' import React from 'react'
import { Link, NavLink, useParams, useLocation } from 'react-router-dom' import { Link, NavLink, useParams } from 'react-router-dom'
import { usePlatform } from '@/app/kosmos/KosmosContext' import { usePlatform } from '@/app/kosmos/KosmosContext'
import { ArrowLeft } from 'lucide-react' import { ArrowLeft } from 'lucide-react'
import { FluxIcon, LogosIcon } from '@/lib/icons' import { FluxIcon, LogosIcon } from '@/lib/icons'
@@ -11,20 +11,17 @@ import { useRecollectionMenubar } from './RecollectionMenubarContext'
export function RecollectionMenubar() { export function RecollectionMenubar() {
const { recollectionId } = useParams<{ recollectionId: string }>() const { recollectionId } = useParams<{ recollectionId: string }>()
const { pathname } = useLocation()
const { recollections } = usePlatform() const { recollections } = usePlatform()
const { customContent } = useRecollectionMenubar() const { titleContent, customContent } = useRecollectionMenubar()
const isFlux = pathname.endsWith('/flux')
const recollection = recollectionId ? recollections.find((p) => p.id === recollectionId) : null const recollection = recollectionId ? recollections.find((p) => p.id === recollectionId) : null
const base = recollectionId ? `/recollections/${recollectionId}` : '' const base = recollectionId ? `/recollections/${recollectionId}` : ''
const defaultMiddle = recollection ? ( const defaultTitle = recollection ? (
<span className="truncate text-sm font-medium text-muted-foreground">{recollection.name}</span> <span className="truncate text-sm font-medium text-muted-foreground">{recollection.name}</span>
) : null ) : null
const middleContent = isFlux ? (customContent ?? defaultMiddle) : defaultMiddle
return ( return (
<div className="flex h-9 w-full shrink-0 items-center gap-2 border-b border-border/40 bg-background px-2"> <div className="flex h-9 w-full shrink-0 items-center gap-2 overflow-visible border-b border-border/40 bg-background px-2">
<Link <Link
to="/recollections" to="/recollections"
aria-label="Back to recollections" aria-label="Back to recollections"
@@ -32,8 +29,11 @@ export function RecollectionMenubar() {
> >
<ArrowLeft className="size-4" /> <ArrowLeft className="size-4" />
</Link> </Link>
<div className="flex min-w-0 flex-1 items-center"> <div className="flex min-w-0 shrink-0 items-center gap-1.5">
{middleContent} {titleContent ?? defaultTitle}
</div>
<div className="flex min-w-0 flex-1 items-center gap-1 overflow-visible">
{customContent}
</div> </div>
{base && ( {base && (
<nav className="flex shrink-0 items-center gap-0.5" aria-label="Recollection pages"> <nav className="flex shrink-0 items-center gap-0.5" aria-label="Recollection pages">

View File

@@ -1,12 +1,16 @@
/** /**
* Context for the recollection menubar: pages (Logos, Flux) can register custom content * Context for the recollection menubar:
* to show in the middle of the shared 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.
*/ */
import React, { createContext, useCallback, useContext, useState } from 'react' import React, { createContext, useCallback, useContext, useState } from 'react'
export type RecollectionMenubarContextValue = { export type RecollectionMenubarContextValue = {
/** Content to show in the middle of the menubar (menus, title, etc.). Set to null to use default (recollection name only). */ /** 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 customContent: React.ReactNode
setCustomContent: (content: React.ReactNode) => void setCustomContent: (content: React.ReactNode) => void
} }
@@ -14,13 +18,17 @@ export type RecollectionMenubarContextValue = {
const RecollectionMenubarContext = createContext<RecollectionMenubarContextValue | null>(null) const RecollectionMenubarContext = createContext<RecollectionMenubarContextValue | null>(null)
export function RecollectionMenubarProvider({ children }: { children: React.ReactNode }) { export function RecollectionMenubarProvider({ children }: { children: React.ReactNode }) {
const [titleContent, setTitleContentState] = useState<React.ReactNode>(null)
const [customContent, setCustomContentState] = 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) => { const setCustomContent = useCallback((content: React.ReactNode) => {
setCustomContentState(() => content) setCustomContentState(() => content)
}, []) }, [])
const value: RecollectionMenubarContextValue = React.useMemo( const value: RecollectionMenubarContextValue = React.useMemo(
() => ({ customContent, setCustomContent }), () => ({ titleContent, setTitleContent, customContent, setCustomContent }),
[customContent, setCustomContent] [titleContent, setTitleContent, customContent, setCustomContent]
) )
return ( return (
<RecollectionMenubarContext.Provider value={value}> <RecollectionMenubarContext.Provider value={value}>

View File

@@ -0,0 +1,220 @@
/**
* 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).
*/
import React, { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'
import { useParams } from 'react-router-dom'
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuPortal,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu'
import { Kbd, KbdGroup } from '@/components/ui/kbd'
import { usePlatform } from '@/app/kosmos/KosmosContext'
import { useRecollectionMenubar } from './RecollectionMenubarContext'
import { useRecollectionTitleData } from './RecollectionTitleDataContext'
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 }
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
}
/** Registers the shared title + save dropdown as titleContent. Renders nothing. */
export function RecollectionTitleContent() {
const { setTitleContent } = useRecollectionMenubar()
const { titleData } = useRecollectionTitleData()
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 [isRenaming, setIsRenaming] = useState(false)
const [renameValue, setRenameValue] = useState('')
const renameInputRef = useRef<HTMLInputElement>(null)
const ignoreNextBlurRef = useRef(false)
const [showSavedBriefly, setShowSavedBriefly] = useState(false)
const savedBrieflyTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
const prevSaveStatusRef = useRef<string>('saved')
const saveStatus = titleData?.saveStatus ?? 'saved'
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)
}
}, [])
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)) {
ev.preventDefault()
ev.stopPropagation()
titleData.onSave()
}
}
window.addEventListener('keydown', onKeyDown, true)
return () => window.removeEventListener('keydown', onKeyDown, true)
}, [titleData?.onSave, titleData?.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 (
<span className="flex items-center gap-1.5 truncate">
<span className="truncate text-sm font-medium font-serif max-w-[180px]">
{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>
</span>
)
}, [recollectionId, isRenaming, renameValue, recollectionName, saveStatus, showSavedBriefly, applyRename, handleRenameBlur])
const dropdownContent = useMemo(() => (
<>
{recollectionId && (
<>
{titleData?.onSave != null && (
<>
<DropdownMenuItem
onClick={titleData.onSave}
disabled={titleData.canSave === false}
className="gap-2"
>
<Save className="h-4 w-4" />
Save
<span className="ml-auto pl-4"><KbdGroup><Kbd>S</Kbd></KbdGroup></span>
</DropdownMenuItem>
<DropdownMenuSeparator />
</>
)}
<DropdownMenuItem onClick={() => setIsRenaming(true)} className="gap-2">
<Pencil className="h-4 w-4" />
Rename
</DropdownMenuItem>
<DropdownMenuSeparator />
</>
)}
<DropdownMenuItem onClick={titleData?.onImport} className="gap-2" disabled={!titleData?.onImport}>
<FolderOpen className="h-4 w-4" />
Import
</DropdownMenuItem>
<DropdownMenuItem onClick={titleData?.onExport} className="gap-2" disabled={!titleData?.onExport}>
<Download className="h-4 w-4" />
Export
</DropdownMenuItem>
</>
), [recollectionId, titleData?.onSave, titleData?.canSave, titleData?.onImport, titleData?.onExport])
const titleNode = useMemo(() => (
<DropdownMenu>
<DropdownMenuTrigger className="flex items-center gap-1.5 rounded-sm px-2 py-1 text-sm outline-none focus:bg-accent focus:text-accent-foreground hover:bg-accent hover:text-accent-foreground data-[state=open]:bg-accent">
{trigger}
<ChevronDown className="size-3.5 shrink-0 text-muted-foreground" />
</DropdownMenuTrigger>
<DropdownMenuPortal>
<DropdownMenuContent align="start" className="z-[200] min-w-[12rem]">
{dropdownContent}
</DropdownMenuContent>
</DropdownMenuPortal>
</DropdownMenu>
), [trigger, dropdownContent])
useLayoutEffect(() => {
setTitleContent(titleNode)
return () => setTitleContent(null)
}, [titleNode, setTitleContent])
return null
}

View File

@@ -0,0 +1,44 @@
/**
* 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

@@ -1,10 +1,9 @@
/** /**
* Flux-specific menubar content: Recollection/Edit/View menus and centered title with save status. * Flux-specific menubar: registers Edit and View menus in the middle slot.
* Registered with the recollection menubar when the Flux page is active. * Title + save status are shared and registered by the layout (RecollectionTitleContent).
*/ */
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react' import React, { useEffect, useMemo } from 'react'
import { useParams } from 'react-router-dom'
import { import {
Menubar, Menubar,
MenubarContent, MenubarContent,
@@ -14,18 +13,10 @@ import {
MenubarTrigger MenubarTrigger
} from '@/components/ui/menubar' } from '@/components/ui/menubar'
import { Kbd, KbdGroup } from '@/components/ui/kbd' import { Kbd, KbdGroup } from '@/components/ui/kbd'
import { usePlatform } from '@/app/kosmos/KosmosContext' import { useRecollectionMenubar } from '@/app/recollections/RecollectionMenubarContext'
import { CheckCircle2, CircleDot, ClipboardPaste, Copy, CopyPlus, Download, FolderOpen, Loader2, Pencil, Redo2, Save, Undo2 } from 'lucide-react' import { ClipboardPaste, Copy, CopyPlus, Redo2, 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 FluxMenubarContentProps = { export type FluxMenubarContentProps = {
onImport: () => void
onExport: () => void
onSave?: () => void
canSave?: boolean
saveStatus?: SaveStatus
undo: () => void undo: () => void
redo: () => void redo: () => void
canUndo: boolean canUndo: boolean
@@ -40,198 +31,48 @@ export type FluxMenubarContentProps = {
const UNDO_KEYS = { key: 'z', shiftKey: false } const UNDO_KEYS = { key: 'z', shiftKey: false }
const REDO_KEYS = { key: 'z', shiftKey: true } const REDO_KEYS = { key: 'z', shiftKey: true }
const SAVE_KEYS = { key: 's', shiftKey: false }
function matchKey(ev: KeyboardEvent, want: { key: string; shiftKey: boolean }) { function matchKey(ev: KeyboardEvent, want: { key: string; shiftKey: boolean }) {
const mod = ev.ctrlKey || ev.metaKey const mod = ev.ctrlKey || ev.metaKey
return ev.key.toLowerCase() === want.key && !!mod && !!ev.shiftKey === want.shiftKey return ev.key.toLowerCase() === want.key && !!mod && !!ev.shiftKey === want.shiftKey
} }
export function FluxMenubarContent({ export function FluxMenubarContent(props: FluxMenubarContentProps) {
onImport, const { setCustomContent } = useRecollectionMenubar()
onExport,
onSave,
canSave = true,
saveStatus = 'saved',
undo,
redo,
canUndo,
canRedo,
onDuplicate,
onCopy,
onPaste,
canDuplicate = false,
canCopy = false,
onFitView,
}: FluxMenubarContentProps) {
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 menus = useMemo(
const [renameValue, setRenameValue] = useState('') () => (
const [showSavedBriefly, setShowSavedBriefly] = useState(false) <Menubar className="shrink-0 rounded-none border-0 bg-transparent p-0 shadow-none">
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
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 flex-1 min-w-0 items-center">
<Menubar className="flex-1 shrink-0 rounded-none border-0 border-b-0 bg-transparent p-0 shadow-none min-w-0">
<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> <MenubarMenu>
<MenubarTrigger className="font-normal text-muted-foreground">Edit</MenubarTrigger> <MenubarTrigger className="font-normal text-muted-foreground">Edit</MenubarTrigger>
<MenubarContent> <MenubarContent>
<MenubarItem onClick={undo} disabled={!canUndo} className="gap-2"> <MenubarItem onClick={props.undo} disabled={!props.canUndo} className="gap-2">
<Undo2 className="h-4 w-4" /> <Undo2 className="h-4 w-4" />
Undo Undo
<span className="ml-auto pl-4"><KbdGroup><Kbd> + Z</Kbd></KbdGroup></span> <span className="ml-auto pl-4"><KbdGroup><Kbd> + Z</Kbd></KbdGroup></span>
</MenubarItem> </MenubarItem>
<MenubarItem onClick={redo} disabled={!canRedo} className="gap-2"> <MenubarItem onClick={props.redo} disabled={!props.canRedo} className="gap-2">
<Redo2 className="h-4 w-4" /> <Redo2 className="h-4 w-4" />
Redo Redo
<span className="ml-auto pl-4"><KbdGroup><Kbd> + + Z</Kbd></KbdGroup></span> <span className="ml-auto pl-4"><KbdGroup><Kbd> + + Z</Kbd></KbdGroup></span>
</MenubarItem> </MenubarItem>
{(onDuplicate != null || onCopy != null || onPaste != null) && <MenubarSeparator />} {(props.onDuplicate != null || props.onCopy != null || props.onPaste != null) && <MenubarSeparator />}
{onDuplicate != null && ( {props.onDuplicate != null && (
<MenubarItem onClick={onDuplicate} disabled={!canDuplicate} className="gap-2"> <MenubarItem onClick={props.onDuplicate} disabled={!props.canDuplicate} className="gap-2">
<CopyPlus className="h-4 w-4" /> <CopyPlus className="h-4 w-4" />
Duplicate Duplicate
<span className="ml-auto pl-4"><KbdGroup><Kbd>D</Kbd></KbdGroup></span> <span className="ml-auto pl-4"><KbdGroup><Kbd>D</Kbd></KbdGroup></span>
</MenubarItem> </MenubarItem>
)} )}
{onCopy != null && ( {props.onCopy != null && (
<MenubarItem onClick={onCopy} disabled={!canCopy} className="gap-2"> <MenubarItem onClick={props.onCopy} disabled={!props.canCopy} className="gap-2">
<Copy className="h-4 w-4" /> <Copy className="h-4 w-4" />
Copy Copy
<span className="ml-auto pl-4"><KbdGroup><Kbd>C</Kbd></KbdGroup></span> <span className="ml-auto pl-4"><KbdGroup><Kbd>C</Kbd></KbdGroup></span>
</MenubarItem> </MenubarItem>
)} )}
{onPaste != null && ( {props.onPaste != null && (
<MenubarItem onClick={onPaste} className="gap-2"> <MenubarItem onClick={props.onPaste} className="gap-2">
<ClipboardPaste className="h-4 w-4" /> <ClipboardPaste className="h-4 w-4" />
Paste Paste
<span className="ml-auto pl-4"><KbdGroup><Kbd>V</Kbd></KbdGroup></span> <span className="ml-auto pl-4"><KbdGroup><Kbd>V</Kbd></KbdGroup></span>
@@ -242,8 +83,8 @@ export function FluxMenubarContent({
<MenubarMenu> <MenubarMenu>
<MenubarTrigger className="font-normal text-muted-foreground">View</MenubarTrigger> <MenubarTrigger className="font-normal text-muted-foreground">View</MenubarTrigger>
<MenubarContent> <MenubarContent>
{onFitView && ( {props.onFitView && (
<MenubarItem onClick={onFitView} className="gap-2"> <MenubarItem onClick={props.onFitView} className="gap-2">
Fit View Fit View
<span className="ml-auto pl-4"><KbdGroup><Kbd>0</Kbd></KbdGroup></span> <span className="ml-auto pl-4"><KbdGroup><Kbd>0</Kbd></KbdGroup></span>
</MenubarItem> </MenubarItem>
@@ -251,55 +92,41 @@ export function FluxMenubarContent({
</MenubarContent> </MenubarContent>
</MenubarMenu> </MenubarMenu>
</Menubar> </Menubar>
{recollectionId && ( ),
<div className="absolute left-1/2 top-0 flex h-full -translate-x-1/2 items-center justify-center gap-2 max-w-[50%] min-w-[120px] pointer-events-none"> [
<div className="pointer-events-auto flex items-center justify-center gap-2"> props.undo,
{isRenamingRecollection ? ( props.redo,
<Input props.canUndo,
ref={renameInputRef} props.canRedo,
type="text" props.onDuplicate,
value={renameValue} props.onCopy,
onChange={(e) => setRenameValue(e.target.value)} props.onPaste,
onKeyDown={(e) => { props.canDuplicate,
if (e.key === 'Enter') { e.preventDefault(); applyRename() } props.canCopy,
else if (e.key === 'Escape') { e.preventDefault(); cancelRename() } props.onFitView,
}} ]
onBlur={handleRenameBlur}
className="h-7 text-sm font-medium text-center font-serif"
aria-label="Recollection name"
/>
) : (
<>
<span className="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>
)}
</div>
) )
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,14 +3,19 @@
*/ */
import React, { useEffect } from 'react' import React, { useEffect } from 'react'
import { useLocation } from 'react-router-dom'
import { useRecollectionMenubar } from '../RecollectionMenubarContext' import { useRecollectionMenubar } from '../RecollectionMenubarContext'
export function LogosPage() { export function LogosPage() {
const { pathname } = useLocation()
const { setCustomContent } = useRecollectionMenubar() const { setCustomContent } = useRecollectionMenubar()
const isLogosActive = pathname.endsWith('/logos') || pathname.match(/\/recollections\/[^/]+\/?$/)
useEffect(() => { useEffect(() => {
if (!isLogosActive) return
setCustomContent(null) setCustomContent(null)
return () => setCustomContent(null) return () => setCustomContent(null)
}, [setCustomContent]) }, [isLogosActive, setCustomContent])
return ( return (
<div className="flex h-full min-h-0 flex-1 flex-col items-center justify-center p-8 text-muted-foreground"> <div className="flex h-full min-h-0 flex-1 flex-col items-center justify-center p-8 text-muted-foreground">