feat: refactor menus
This commit is contained in:
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -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}>
|
||||||
|
|||||||
220
frontend/src/app/recollections/RecollectionTitleContent.tsx
Normal file
220
frontend/src/app/recollections/RecollectionTitleContent.tsx
Normal 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
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
Reference in New Issue
Block a user