215 lines
7.7 KiB
TypeScript
215 lines
7.7 KiB
TypeScript
/**
|
|
* Logos page: BlockNote editor for the recollection. Content only; sidebar is at layout level.
|
|
* Content persisted in recollection store.
|
|
*/
|
|
|
|
import React, { useCallback, useEffect, useMemo, useRef, useState, forwardRef } from 'react'
|
|
import { useParams, useLocation, useSearchParams } from 'react-router-dom'
|
|
import { usePlatform } from '@/app/kosmos/KosmosContext'
|
|
import { FluxIcon } from '@/lib/icons'
|
|
import { useCreateBlockNote, getDefaultReactSlashMenuItems, SuggestionMenuController } from '@blocknote/react'
|
|
import { BlockNoteView } from '@blocknote/shadcn'
|
|
import { useTheme } from '@/lib/themeContext'
|
|
import { useRecollectionActions } from '../layout/RecollectionActionsContext'
|
|
import { useRecollectionSidebar } from '../layout/WorkspaceTreeContext'
|
|
import {
|
|
getLogosContentForPage,
|
|
setLogosContentForPage,
|
|
type StoredLogosContent,
|
|
} from '../state/recollectionStore'
|
|
import { logosSchema } from './logosSchema'
|
|
import { KatalogosPage } from '../katalogos/KatalogosPage'
|
|
import { toast } from 'sonner'
|
|
|
|
/** Wraps BlockNoteView so refs go to a div, not the function component (avoids ref warning). */
|
|
const BlockNoteViewWrapper = forwardRef<HTMLDivElement, React.ComponentProps<typeof BlockNoteView>>(
|
|
function BlockNoteViewWrapper(props, ref) {
|
|
const { className, ref: _ref, ...rest } = props as React.ComponentProps<typeof BlockNoteView> & { ref?: unknown }
|
|
return (
|
|
<div ref={ref} className={`logos-blocknote ${className ?? ''}`} style={{ minHeight: '100%', width: '100%' }}>
|
|
<BlockNoteView {...rest} />
|
|
</div>
|
|
)
|
|
}
|
|
)
|
|
const SAVE_DEBOUNCE_MS = 400
|
|
|
|
/** Known React ref warning from BlockNote/Radix internals; we can't fix it in our code. Suppress once at load so it's active before first BlockNote render. */
|
|
function isBlockNoteRefWarning(args: unknown[]): boolean {
|
|
const s = args.map((a) => (typeof a === 'string' ? a : String(a))).join(' ')
|
|
return (
|
|
s.includes('Function components cannot be given refs') &&
|
|
s.includes('ForwardRef') &&
|
|
s.includes('blocknote')
|
|
)
|
|
}
|
|
let blockNoteRefWarningPatched = false
|
|
function patchBlockNoteRefWarning() {
|
|
if (blockNoteRefWarningPatched) return
|
|
blockNoteRefWarningPatched = true
|
|
const orig = console.error
|
|
console.error = (...args: unknown[]) => {
|
|
if (isBlockNoteRefWarning(args)) return
|
|
orig.apply(console, args)
|
|
}
|
|
}
|
|
|
|
export function LogosPage() {
|
|
const { recollectionId } = useParams<{ recollectionId: string }>()
|
|
const { pathname } = useLocation()
|
|
const [searchParams, setSearchParams] = useSearchParams()
|
|
const { recollections } = usePlatform()
|
|
const { theme } = useTheme()
|
|
const { setLogosSlot } = useRecollectionActions()
|
|
const { tree, activePageId } = useRecollectionSidebar()
|
|
const recollection = recollectionId ? recollections.find((p) => p.id === recollectionId) : null
|
|
const [reloadKey, setReloadKey] = useState(0)
|
|
const saveTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null)
|
|
const [saveStatus, setSaveStatus] = useState<'saved' | 'unsaved' | 'saving'>('saved')
|
|
|
|
const isKatalogosView = pathname.endsWith('/logos/katalogos')
|
|
|
|
// When on Logos (not Katalogos) with a selected page but no page param, sync URL so refresh keeps the page.
|
|
useEffect(() => {
|
|
if (isKatalogosView || !activePageId || searchParams.get('page') === activePageId) return
|
|
setSearchParams({ page: activePageId }, { replace: true })
|
|
}, [isKatalogosView, activePageId, searchParams, setSearchParams])
|
|
|
|
const initialContent = useMemo(() => {
|
|
if (!recollectionId || !activePageId) return undefined
|
|
const content = getLogosContentForPage(recollectionId, activePageId)
|
|
if (!content || !Array.isArray(content) || content.length === 0) return undefined
|
|
return content
|
|
}, [recollectionId, activePageId, reloadKey])
|
|
|
|
const editor = useCreateBlockNote(
|
|
{ schema: logosSchema, initialContent },
|
|
[recollectionId, activePageId, reloadKey]
|
|
)
|
|
|
|
const persistContent = useCallback(() => {
|
|
if (!recollectionId || !activePageId || !editor) return
|
|
setSaveStatus('saving')
|
|
try {
|
|
const doc = editor.document
|
|
const serialized = JSON.parse(JSON.stringify(doc)) as StoredLogosContent
|
|
setLogosContentForPage(recollectionId, activePageId, serialized)
|
|
setSaveStatus('saved')
|
|
} catch {
|
|
setSaveStatus('unsaved')
|
|
}
|
|
}, [recollectionId, activePageId, editor])
|
|
|
|
const onSave = useCallback(() => {
|
|
persistContent()
|
|
toast.success('Saved')
|
|
}, [persistContent])
|
|
|
|
useEffect(() => {
|
|
if (!editor || !recollectionId || !activePageId) return
|
|
const handleChange = () => {
|
|
setSaveStatus('unsaved')
|
|
if (saveTimeoutRef.current) clearTimeout(saveTimeoutRef.current)
|
|
saveTimeoutRef.current = setTimeout(() => {
|
|
saveTimeoutRef.current = null
|
|
persistContent()
|
|
}, SAVE_DEBOUNCE_MS)
|
|
}
|
|
editor.onChange(handleChange)
|
|
return () => {
|
|
if (saveTimeoutRef.current) {
|
|
clearTimeout(saveTimeoutRef.current)
|
|
saveTimeoutRef.current = null
|
|
}
|
|
}
|
|
}, [editor, recollectionId, activePageId, persistContent])
|
|
|
|
useEffect(() => {
|
|
if (isKatalogosView || !editor || !recollectionId || !activePageId) {
|
|
if (isKatalogosView) setLogosSlot(null)
|
|
return
|
|
}
|
|
const slot = {
|
|
saveStatus,
|
|
onSave,
|
|
canSave: true,
|
|
undo: () => (editor as { undo?: () => void }).undo?.(),
|
|
redo: () => (editor as { redo?: () => void }).redo?.(),
|
|
canUndo: true,
|
|
canRedo: true,
|
|
onRefreshFromStore: () => setReloadKey((k) => k + 1),
|
|
}
|
|
setLogosSlot(slot)
|
|
return () => setLogosSlot(null)
|
|
}, [isKatalogosView, setLogosSlot, saveStatus, onSave, editor, recollectionId, activePageId])
|
|
|
|
const activePage = tree.find((p) => p.id === activePageId)
|
|
|
|
patchBlockNoteRefWarning()
|
|
|
|
const getSlashMenuItems = useCallback(
|
|
async (query: string) => {
|
|
const defaultItems = getDefaultReactSlashMenuItems(editor)
|
|
const fluxItem = {
|
|
title: 'Insert Artifact',
|
|
subtext: 'Insert an Artifact produced by a Flux rendering node',
|
|
icon: <FluxIcon className="size-4" />,
|
|
onItemClick: () => {
|
|
const pos = editor.getTextCursorPosition()
|
|
editor.replaceBlocks([pos.block.id], [{ type: 'fluxOutput', props: {} }])
|
|
},
|
|
aliases: ['artifact', 'flux', 'output', 'render'] as const,
|
|
group: 'Artifacts',
|
|
}
|
|
const all = [...defaultItems, fluxItem]
|
|
const q = query.trim().toLowerCase()
|
|
if (!q) return all
|
|
return all.filter(
|
|
(item) =>
|
|
item.title.toLowerCase().includes(q) ||
|
|
(item.aliases && item.aliases.some((a: string) => a.toLowerCase().includes(q)))
|
|
)
|
|
},
|
|
[editor]
|
|
)
|
|
|
|
if (!recollectionId) {
|
|
return (
|
|
<div className="flex h-full min-h-0 flex-1 flex-col items-center justify-center p-8 text-muted-foreground">
|
|
<p className="text-sm">No recollection selected.</p>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
if (isKatalogosView) {
|
|
return (
|
|
<div className="flex h-full min-h-0 flex-1 flex-col overflow-auto bg-background">
|
|
<KatalogosPage />
|
|
</div>
|
|
)
|
|
}
|
|
|
|
if (activePageId == null || !editor) {
|
|
return (
|
|
<div className="flex h-full min-h-0 flex-1 flex-col items-center justify-center bg-background text-sm text-muted-foreground">
|
|
{tree.length === 0 ? 'Loading…' : 'Select a page'}
|
|
</div>
|
|
)
|
|
}
|
|
|
|
return (
|
|
<div className="flex h-full min-h-0 flex-1 flex-col overflow-auto bg-background">
|
|
<div className="mx-auto w-full max-w-3xl p-4">
|
|
<BlockNoteViewWrapper
|
|
editor={editor as any}
|
|
theme={theme}
|
|
className="min-h-full w-full"
|
|
slashMenu={false}
|
|
>
|
|
<SuggestionMenuController triggerCharacter="/" getItems={getSlashMenuItems} />
|
|
</BlockNoteViewWrapper>
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|