/** * 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>( function BlockNoteViewWrapper(props, ref) { const { className, ref: _ref, ...rest } = props as React.ComponentProps & { ref?: unknown } return (
) } ) 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 | 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: , 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 (

No recollection selected.

) } if (isKatalogosView) { return (
) } if (activePageId == null || !editor) { return (
{tree.length === 0 ? 'Loading…' : 'Select a page'}
) } return (
) }