freat: renaming
This commit is contained in:
@@ -1,5 +1,5 @@
|
||||
/**
|
||||
* Menubar for the canvas page: Project (Import/Export), Edit (Undo/Redo, Duplicate/Copy/Paste, Rename), View (Fit View, Minimap).
|
||||
* 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'
|
||||
@@ -24,7 +24,7 @@ export type CanvasMenubarProps = {
|
||||
onExport: () => void
|
||||
onSave?: () => void
|
||||
canSave?: boolean
|
||||
/** Shown next to project title: unsaved | saving | saved */
|
||||
/** Shown next to recollection title: unsaved | saving | saved */
|
||||
saveStatus?: SaveStatus
|
||||
undo: () => void
|
||||
redo: () => void
|
||||
@@ -64,14 +64,14 @@ export function CanvasMenubar({
|
||||
canCopy = false,
|
||||
onFitView,
|
||||
}: CanvasMenubarProps) {
|
||||
const { projectId } = useParams<{ projectId: string }>()
|
||||
const { projects, renameProject } = usePlatform()
|
||||
const projectName = useMemo(
|
||||
() => (projectId ? projects.find((p) => p.id === projectId)?.name ?? null : null),
|
||||
[projectId, projects]
|
||||
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 [isRenamingProject, setIsRenamingProject] = useState(false)
|
||||
const [isRenamingRecollection, setIsRenamingRecollection] = useState(false)
|
||||
const [renameValue, setRenameValue] = useState('')
|
||||
const [showSavedBriefly, setShowSavedBriefly] = useState(false)
|
||||
const renameInputRef = useRef<HTMLInputElement>(null)
|
||||
@@ -107,27 +107,27 @@ export function CanvasMenubar({
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
if (isRenamingProject) {
|
||||
setRenameValue(projectName ?? '')
|
||||
if (isRenamingRecollection) {
|
||||
setRenameValue(recollectionName ?? '')
|
||||
ignoreNextBlurRef.current = true
|
||||
// Delay focus so the Project dropdown can close first and not steal focus back (which would trigger blur)
|
||||
// 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)
|
||||
}
|
||||
}, [isRenamingProject, projectName])
|
||||
}, [isRenamingRecollection, recollectionName])
|
||||
|
||||
const applyRename = useCallback(() => {
|
||||
if (!projectId || !renameProject) return
|
||||
if (!recollectionId || !renameRecollection) return
|
||||
const trimmed = renameValue.trim()
|
||||
if (trimmed) renameProject(projectId, trimmed)
|
||||
setIsRenamingProject(false)
|
||||
}, [projectId, renameProject, renameValue])
|
||||
if (trimmed) renameRecollection(recollectionId, trimmed)
|
||||
setIsRenamingRecollection(false)
|
||||
}, [recollectionId, renameRecollection, renameValue])
|
||||
|
||||
const cancelRename = useCallback(() => {
|
||||
setIsRenamingProject(false)
|
||||
setIsRenamingRecollection(false)
|
||||
}, [])
|
||||
|
||||
const handleRenameBlur = useCallback(() => {
|
||||
@@ -172,16 +172,16 @@ export function CanvasMenubar({
|
||||
<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="/projects"
|
||||
aria-label="Back to projects"
|
||||
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">Project</MenubarTrigger>
|
||||
<MenubarTrigger className="font-normal text-muted-foreground">Recollection</MenubarTrigger>
|
||||
<MenubarContent>
|
||||
{projectId && (
|
||||
{recollectionId && (
|
||||
<>
|
||||
{onSave != null && (
|
||||
<>
|
||||
@@ -198,7 +198,7 @@ export function CanvasMenubar({
|
||||
</>
|
||||
)}
|
||||
<MenubarItem
|
||||
onClick={() => setIsRenamingProject(true)}
|
||||
onClick={() => setIsRenamingRecollection(true)}
|
||||
className="gap-2"
|
||||
>
|
||||
<Pencil className="h-4 w-4" />
|
||||
@@ -290,9 +290,9 @@ export function CanvasMenubar({
|
||||
</MenubarContent>
|
||||
</MenubarMenu>
|
||||
</Menubar>
|
||||
{projectId && (
|
||||
{recollectionId && (
|
||||
<div className="absolute left-1/2 -translate-x-1/2 flex items-center justify-center gap-2 max-w-[50%] min-w-[120px]">
|
||||
{isRenamingProject ? (
|
||||
{isRenamingRecollection ? (
|
||||
<Input
|
||||
ref={renameInputRef}
|
||||
type="text"
|
||||
@@ -309,12 +309,12 @@ export function CanvasMenubar({
|
||||
}}
|
||||
onBlur={handleRenameBlur}
|
||||
className="h-7 text-sm font-medium text-center font-serif"
|
||||
aria-label="Project name"
|
||||
aria-label="Recollection name"
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
<span className="pointer-events-none truncate text-sm font-medium text-foreground font-serif">
|
||||
{projectName ?? 'Untitled'}
|
||||
{recollectionName ?? 'Untitled'}
|
||||
</span>
|
||||
<TooltipProvider delayDuration={300}>
|
||||
<Tooltip>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
/**
|
||||
* Canvas page: the graph editor (React Flow) with nodes, edges, context menu, import/export.
|
||||
* Rendered inside the platform when a project is selected.
|
||||
* Rendered inside the platform when a recollection is selected.
|
||||
*/
|
||||
|
||||
import React, { useCallback, useEffect, useMemo, useRef } from 'react'
|
||||
@@ -66,7 +66,7 @@ import {
|
||||
} from '@/lib/graph/nodeRegistry'
|
||||
import type { AppNode, AppEdge } from '@/lib/graph/nodeTypes'
|
||||
import { toast } from 'sonner'
|
||||
import { PROJECT_FILE_EXT, PROJECT_VERSION } from '@/app/pleroma/projectGraphStorage'
|
||||
import { RECOLLECTION_FILE_EXT, RECOLLECTION_VERSION } from '@/app/recollections/recollectionGraphStorage'
|
||||
|
||||
const SNAP_GRID: [number, number] = [15, 15]
|
||||
const DUPLICATE_OFFSET = { x: 30, y: 30 }
|
||||
@@ -149,11 +149,11 @@ function FullscreenNodeContent({ node }: { node: AppNode }) {
|
||||
}
|
||||
|
||||
export type CanvasPageProps = {
|
||||
/** Optional project id for future per-project graph loading */
|
||||
projectId?: string
|
||||
/** Optional recollection id for per-recollection graph loading */
|
||||
recollectionId?: string
|
||||
}
|
||||
|
||||
export function CanvasPage({ projectId }: CanvasPageProps) {
|
||||
export function CanvasPage({ recollectionId }: CanvasPageProps) {
|
||||
const { theme } = useTheme()
|
||||
const { showMinimap } = usePlatform()
|
||||
const {
|
||||
@@ -172,7 +172,7 @@ export function CanvasPage({ projectId }: CanvasPageProps) {
|
||||
setStateImmediate,
|
||||
save,
|
||||
saveStatus,
|
||||
} = useCanvasGraph(projectId)
|
||||
} = useCanvasGraph(recollectionId)
|
||||
|
||||
const importInputRef = useRef<HTMLInputElement | null>(null)
|
||||
const [rfInstance, setRfInstance] = React.useState<unknown>(null)
|
||||
@@ -323,19 +323,19 @@ export function CanvasPage({ projectId }: CanvasPageProps) {
|
||||
const onNodeDragStart = useCallback(() => saveForDragEnd(), [saveForDragEnd])
|
||||
const onNodeDragStop = useCallback(() => commitDragEnd(), [commitDragEnd])
|
||||
|
||||
const handleExportProject = useCallback(() => {
|
||||
const state = { version: PROJECT_VERSION, nodes, edges }
|
||||
const handleExportRecollection = useCallback(() => {
|
||||
const state = { version: RECOLLECTION_VERSION, nodes, edges }
|
||||
const blob = new Blob([JSON.stringify(state, null, 2)], { type: 'application/json' })
|
||||
const url = URL.createObjectURL(blob)
|
||||
const a = document.createElement('a')
|
||||
a.href = url
|
||||
a.download = `project${PROJECT_FILE_EXT}`
|
||||
a.download = `recollection${RECOLLECTION_FILE_EXT}`
|
||||
a.click()
|
||||
URL.revokeObjectURL(url)
|
||||
toast.success('Project exported')
|
||||
toast.success('Recollection exported')
|
||||
}, [nodes, edges])
|
||||
|
||||
const handleImportProject = useCallback(() => importInputRef.current?.click(), [])
|
||||
const handleImportRecollection = useCallback(() => importInputRef.current?.click(), [])
|
||||
|
||||
const handleLoadExample = useCallback(() => {
|
||||
const { nodes: exampleNodes, edges: exampleEdges } = getExampleGraph()
|
||||
@@ -360,10 +360,10 @@ export function CanvasPage({ projectId }: CanvasPageProps) {
|
||||
const nodes = state.nodes as AppNode[]
|
||||
const edges = backfillEdgeTargetTypes(nodes, state.edges as AppEdge[])
|
||||
setStateImmediate({ nodes, edges })
|
||||
if (state.version != null && state.version > PROJECT_VERSION) {
|
||||
toast.error('Project was created with a newer app version')
|
||||
if (state.version != null && state.version > RECOLLECTION_VERSION) {
|
||||
toast.error('Recollection was created with a newer app version')
|
||||
} else {
|
||||
toast.success('Project loaded')
|
||||
toast.success('Recollection loaded')
|
||||
}
|
||||
} catch {
|
||||
toast.error('Invalid file: not valid JSON')
|
||||
@@ -534,8 +534,9 @@ export function CanvasPage({ projectId }: CanvasPageProps) {
|
||||
const clientX = click?.clientX ?? window.innerWidth / 2
|
||||
const clientY = click?.clientY ?? window.innerHeight / 2
|
||||
try {
|
||||
const inst = rfInstance as { screenToFlowPosition?: (p: { x: number; y: number }) => { x: number; y: number }; project?: (p: { x: number; y: number }) => { x: number; y: number } }
|
||||
const screenToFlow = inst.screenToFlowPosition ?? inst.project
|
||||
type ScreenToFlow = (p: { x: number; y: number }) => { x: number; y: number }
|
||||
const inst = rfInstance as { screenToFlowPosition?: ScreenToFlow; [k: string]: unknown }
|
||||
const screenToFlow = inst.screenToFlowPosition ?? (inst['project'] as ScreenToFlow | undefined)
|
||||
const p = screenToFlow?.call(rfInstance, { x: clientX, y: clientY })
|
||||
return p ? snapToGrid(p.x, p.y) : null
|
||||
} catch {
|
||||
@@ -646,17 +647,17 @@ export function CanvasPage({ projectId }: CanvasPageProps) {
|
||||
aria-hidden
|
||||
/>
|
||||
<CanvasMenubar
|
||||
onImport={handleImportProject}
|
||||
onExport={handleExportProject}
|
||||
onImport={handleImportRecollection}
|
||||
onExport={handleExportRecollection}
|
||||
onSave={
|
||||
projectId
|
||||
recollectionId
|
||||
? () => {
|
||||
save()
|
||||
toast.success('Saved')
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
canSave={Boolean(projectId)}
|
||||
canSave={Boolean(recollectionId)}
|
||||
saveStatus={saveStatus}
|
||||
undo={undo}
|
||||
redo={redo}
|
||||
@@ -694,11 +695,11 @@ export function CanvasPage({ projectId }: CanvasPageProps) {
|
||||
<EmptyTitle>Start adding a new node!</EmptyTitle>
|
||||
<EmptyDescription>
|
||||
Right‑click to add nodes. <br />
|
||||
Import a project or paste a node.
|
||||
Import a recollection or paste a node.
|
||||
</EmptyDescription>
|
||||
</EmptyHeader>
|
||||
<EmptyContent className="flex-row flex-wrap justify-center gap-2">
|
||||
<Button onClick={handleImportProject} variant="outline" size="sm">
|
||||
<Button onClick={handleImportRecollection} variant="outline" size="sm">
|
||||
<FolderOpen className="size-4" />
|
||||
Import…
|
||||
</Button>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
/**
|
||||
* Route wrapper for the canvas: resolves projectId from URL and updates lastEditedAt on open.
|
||||
* Route wrapper for the canvas: resolves recollectionId from URL and updates lastEditedAt on open.
|
||||
*/
|
||||
|
||||
import React, { useEffect, useRef } from 'react'
|
||||
@@ -8,29 +8,29 @@ import { CanvasPage } from './CanvasPage'
|
||||
import { usePlatform } from '@/app/kosmos/KosmosContext'
|
||||
|
||||
export function CanvasRoute() {
|
||||
const { projectId } = useParams<{ projectId: string }>()
|
||||
const { projects, updateLastEdited } = usePlatform()
|
||||
const { recollectionId } = useParams<{ recollectionId: string }>()
|
||||
const { recollections, updateLastEdited } = usePlatform()
|
||||
const updateLastEditedRef = useRef(updateLastEdited)
|
||||
updateLastEditedRef.current = updateLastEdited
|
||||
|
||||
const project = projects.find((p) => p.id === projectId)
|
||||
const recollection = recollections.find((p) => p.id === recollectionId)
|
||||
|
||||
useEffect(() => {
|
||||
if (projectId) updateLastEditedRef.current(projectId)
|
||||
}, [projectId])
|
||||
if (recollectionId) updateLastEditedRef.current(recollectionId)
|
||||
}, [recollectionId])
|
||||
|
||||
if (!projectId) return null
|
||||
if (!project) {
|
||||
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">Project not found.</p>
|
||||
<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={projectId} projectId={projectId} />
|
||||
<CanvasPage key={recollectionId} recollectionId={recollectionId} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
|
||||
import type { AppNode, AppEdge } from '@/lib/graph/nodeTypes'
|
||||
import { DEFAULT_NODE_STYLE } from '@/lib/graph/flowUtils'
|
||||
import { loadGraphFromStorage } from '@/app/pleroma/projectGraphStorage'
|
||||
import { loadGraphFromStorage } from '@/app/recollections/recollectionGraphStorage'
|
||||
|
||||
/** Ensure each edge has data.targetType from the target node (for labels without depending on nodes in edgesForFlow). */
|
||||
export function backfillEdgeTargetTypes(
|
||||
@@ -70,9 +70,9 @@ export function getExampleGraph(): { nodes: AppNode[]; edges: AppEdge[] } {
|
||||
return { nodes, edges }
|
||||
}
|
||||
|
||||
export function getInitialGraph(projectId: string | undefined): { nodes: AppNode[]; edges: AppEdge[] } {
|
||||
if (projectId) {
|
||||
const stored = loadGraphFromStorage(projectId)
|
||||
export function getInitialGraph(recollectionId: string | undefined): { nodes: AppNode[]; edges: AppEdge[] } {
|
||||
if (recollectionId) {
|
||||
const stored = loadGraphFromStorage(recollectionId)
|
||||
if (stored && (stored.nodes.length > 0 || stored.edges.length > 0)) {
|
||||
const nodes = stored.nodes as AppNode[]
|
||||
const edges = backfillEdgeTargetTypes(nodes, stored.edges as AppEdge[])
|
||||
|
||||
@@ -1,25 +1,25 @@
|
||||
/**
|
||||
* Hook for canvas graph state and persistence. Wraps useGraphStateWithHistory
|
||||
* with initial graph from project storage (or example). Save is explicit via save().
|
||||
* with initial graph from recollection storage (or example). Save is explicit via save().
|
||||
*/
|
||||
|
||||
import { useCallback, useMemo, useRef, useState } from 'react'
|
||||
import { useGraphStateWithHistory } from '@/hooks/useGraphStateWithHistory'
|
||||
import { getInitialGraph } from '@/app/canvas/canvasGraphUtils'
|
||||
import { saveGraphToStorage, PROJECT_VERSION } from '@/app/pleroma/projectGraphStorage'
|
||||
import { saveGraphToStorage, RECOLLECTION_VERSION } from '@/app/recollections/recollectionGraphStorage'
|
||||
import type { AppNode, AppEdge } from '@/lib/graph/nodeTypes'
|
||||
|
||||
export type SaveStatus = 'saved' | 'unsaved' | 'saving'
|
||||
|
||||
export type UseCanvasGraphResult = ReturnType<typeof useGraphStateWithHistory> & {
|
||||
/** Persist current nodes/edges to storage. No-op when projectId is missing. */
|
||||
/** Persist current nodes/edges to storage. No-op when recollectionId is missing. */
|
||||
save: () => void
|
||||
/** For menubar: show "Unsaved changes" | "Saving…" | "All changes saved". */
|
||||
saveStatus: SaveStatus
|
||||
}
|
||||
|
||||
export function useCanvasGraph(projectId: string | undefined): UseCanvasGraphResult {
|
||||
const initialGraph = useMemo(() => getInitialGraph(projectId), [projectId])
|
||||
export function useCanvasGraph(recollectionId: string | undefined): UseCanvasGraphResult {
|
||||
const initialGraph = useMemo(() => getInitialGraph(recollectionId), [recollectionId])
|
||||
const result = useGraphStateWithHistory(initialGraph.nodes, initialGraph.edges)
|
||||
const { nodes, edges } = result
|
||||
|
||||
@@ -41,14 +41,14 @@ export function useCanvasGraph(projectId: string | undefined): UseCanvasGraphRes
|
||||
const saveStatus: SaveStatus = isSaving ? 'saving' : isDirty ? 'unsaved' : 'saved'
|
||||
|
||||
const save = useCallback(() => {
|
||||
if (!projectId) return
|
||||
if (!recollectionId) return
|
||||
const snapshot = JSON.stringify({
|
||||
nodes: nodesRef.current,
|
||||
edges: edgesRef.current,
|
||||
})
|
||||
setIsSaving(true)
|
||||
saveGraphToStorage(projectId, {
|
||||
version: PROJECT_VERSION,
|
||||
saveGraphToStorage(recollectionId, {
|
||||
version: RECOLLECTION_VERSION,
|
||||
nodes: nodesRef.current,
|
||||
edges: edgesRef.current,
|
||||
})
|
||||
@@ -57,7 +57,7 @@ export function useCanvasGraph(projectId: string | undefined): UseCanvasGraphRes
|
||||
setLastSavedSerialized(snapshot)
|
||||
setIsSaving(false)
|
||||
}, SAVING_DISPLAY_MS)
|
||||
}, [projectId])
|
||||
}, [recollectionId])
|
||||
|
||||
return { ...result, save, saveStatus }
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user