From f67c491deb0d6480f3126391444724b79b59f3d5 Mon Sep 17 00:00:00 2001 From: dtoro Date: Sun, 15 Mar 2026 23:41:10 +0100 Subject: [PATCH] feat: flux to logos --- .../RecollectionActionsContext.tsx | 21 ++- .../src/app/recollections/logos/LogosPage.tsx | 42 +++++- .../logos/blocks/fluxOutputBlock.tsx | 140 ++++++++++++++++++ .../app/recollections/logos/logosSchema.ts | 17 +++ .../app/recollections/recollectionStore.ts | 47 +++++- .../nodes/render/useRenderingNodeState.ts | 29 +++- 6 files changed, 289 insertions(+), 7 deletions(-) create mode 100644 frontend/src/app/recollections/logos/blocks/fluxOutputBlock.tsx create mode 100644 frontend/src/app/recollections/logos/logosSchema.ts diff --git a/frontend/src/app/recollections/RecollectionActionsContext.tsx b/frontend/src/app/recollections/RecollectionActionsContext.tsx index bf1422d..8137ae6 100644 --- a/frontend/src/app/recollections/RecollectionActionsContext.tsx +++ b/frontend/src/app/recollections/RecollectionActionsContext.tsx @@ -11,11 +11,15 @@ import { setGraph, getLogosContent, setLogosContent, + upsertRenderOutputEntry, RECOLLECTION_FILE_EXT, RECOLLECTION_VERSION, type StoredGraphState, type StoredLogosContent, + type RenderOutputCacheEntry, } from './recollectionStore' + +export type { RenderOutputCacheEntry } import type { AppNode, AppEdge } from '@/lib/graph/nodeTypes' import { backfillEdgeTargetTypes } from '@/app/canvas/canvasGraphUtils' import { toast } from 'sonner' @@ -70,6 +74,8 @@ export type RecollectionActionsContextValue = { activeSlot: FluxSlot | LogosSlot | null onImport: () => void onExport: () => void + /** Upsert a rendering node's output into the cache so Logos "Insert from Flux" block can show it. */ + upsertRenderOutputToLogos: (entry: RenderOutputCacheEntry) => void } const RecollectionActionsContext = createContext(null) @@ -147,6 +153,13 @@ export function RecollectionActionsProvider({ children }: { children: React.Reac importInputRef.current?.click() }, []) + const upsertRenderOutputToLogos = useCallback( + (entry: RenderOutputCacheEntry) => { + if (recollectionId) upsertRenderOutputEntry(recollectionId, entry) + }, + [recollectionId] + ) + const onImportFileChange = useCallback( (e: React.ChangeEvent) => { const file = e.target.files?.[0] @@ -198,8 +211,9 @@ export function RecollectionActionsProvider({ children }: { children: React.Reac activeSlot, onImport, onExport, + upsertRenderOutputToLogos, }), - [flux, logos, setFluxSlot, setLogosSlot, isFluxActive, isLogosActive, activeSlot, onImport, onExport] + [flux, logos, setFluxSlot, setLogosSlot, isFluxActive, isLogosActive, activeSlot, onImport, onExport, upsertRenderOutputToLogos] ) return ( @@ -222,3 +236,8 @@ export function useRecollectionActions(): RecollectionActionsContextValue { if (!ctx) throw new Error('useRecollectionActions must be used within RecollectionActionsProvider') return ctx } + +/** Returns the context value or null when outside RecollectionActionsProvider. Use when the consumer may render outside recollection layout. */ +export function useOptionalRecollectionActions(): RecollectionActionsContextValue | null { + return useContext(RecollectionActionsContext) +} diff --git a/frontend/src/app/recollections/logos/LogosPage.tsx b/frontend/src/app/recollections/logos/LogosPage.tsx index e2700bb..6ee54de 100644 --- a/frontend/src/app/recollections/logos/LogosPage.tsx +++ b/frontend/src/app/recollections/logos/LogosPage.tsx @@ -5,11 +5,13 @@ import React, { useCallback, useEffect, useMemo, useRef, useState, forwardRef } from 'react' import { useParams } from 'react-router-dom' -import { useCreateBlockNote } from '@blocknote/react' +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 '../RecollectionActionsContext' import { getLogosContent, setLogosContent, type StoredLogosContent } from '../recollectionStore' +import { logosSchema } from './logosSchema' import { toast } from 'sonner' /** Wraps BlockNoteView so refs go to a div, not the function component (avoids ref warning). */ @@ -58,7 +60,10 @@ export function LogosPage() { [recollectionId, reloadKey] ) - const editor = useCreateBlockNote({ initialContent }, [recollectionId, reloadKey]) + const editor = useCreateBlockNote( + { schema: logosSchema, initialContent }, + [recollectionId, reloadKey] + ) const persistContent = useCallback(() => { if (!recollectionId || !editor) return @@ -137,14 +142,43 @@ export function LogosPage() { patchBlockNoteRefWarning() + const getSlashMenuItems = useCallback( + async (query: string) => { + const defaultItems = getDefaultReactSlashMenuItems(editor) + const fluxItem = { + title: 'Insert from Flux', + subtext: 'Insert output from a Flux rendering node', + icon: , + onItemClick: () => { + const pos = editor.getTextCursorPosition() + editor.replaceBlocks([pos.block.id], [{ type: 'fluxOutput', props: {} }]) + }, + aliases: ['flux', 'output', 'render'] as const, + group: 'Flux', + } + 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] + ) + return (
+ slashMenu={false} + > + +
) diff --git a/frontend/src/app/recollections/logos/blocks/fluxOutputBlock.tsx b/frontend/src/app/recollections/logos/blocks/fluxOutputBlock.tsx new file mode 100644 index 0000000..946a722 --- /dev/null +++ b/frontend/src/app/recollections/logos/blocks/fluxOutputBlock.tsx @@ -0,0 +1,140 @@ +/** + * BlockNote block "Flux output": insert output from Flux rendering nodes into Logos. + * Empty state: placeholder + picker over getRenderOutputCache(recollectionId). + * Filled state: when nodeId is set, display is live from the cache (updates when the rendering node changes in Flux); + * otherwise or when cache entry is missing, show stored content (static). + */ + +import React, { useCallback, useState } from 'react' +import { useParams } from 'react-router-dom' +import { createReactBlockSpec } from '@blocknote/react' +import type { ReactCustomBlockRenderProps } from '@blocknote/react' +import { getRenderOutputCache, type RenderOutputCacheEntry } from '../../recollectionStore' +import { Button } from '@/components/ui/button' +import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover' + +const fluxOutputBlockConfig = { + type: 'fluxOutput' as const, + propSchema: { + nodeId: { + default: '', + }, + contentType: { + default: 'image' as const, + values: ['image', 'html'] as const, + }, + content: { + default: '', + }, + label: { + default: '', + }, + }, + content: 'none' as const, +} + +function FluxOutputBlockContent({ + block, + editor, + contentRef, +}: ReactCustomBlockRenderProps<'fluxOutput', typeof fluxOutputBlockConfig.propSchema, 'none'>) { + const { recollectionId } = useParams<{ recollectionId: string }>() + const [pickerOpen, setPickerOpen] = useState(false) + const nodeId = block.props.nodeId ?? '' + const storedContent = block.props.content ?? '' + const storedContentType = block.props.contentType ?? 'image' + const storedLabel = block.props.label ?? '' + + const handleSelect = useCallback( + (entry: RenderOutputCacheEntry) => { + editor.updateBlock(block.id, { + props: { + nodeId: entry.nodeId, + contentType: entry.type, + content: entry.content, + label: entry.label, + }, + }) + setPickerOpen(false) + }, + [editor, block.id] + ) + + const entries = recollectionId ? getRenderOutputCache(recollectionId) : [] + + // Resolve display from cache when block is tied to a node (live); otherwise use stored props (static). + const liveEntry = nodeId && recollectionId ? entries.find((e) => e.nodeId === nodeId) : null + const content = liveEntry ? liveEntry.content : storedContent + const rawContentType = liveEntry ? liveEntry.type : storedContentType + const label = liveEntry ? liveEntry.label : storedLabel + // If content is SVG but type was stored as html, show as image (fixes incorrect cache or legacy data). + const isSvgContent = Boolean(content?.trim() && /]/i.test(content.trim())) + const contentType = rawContentType === 'image' || isSvgContent ? 'image' : 'html' + + // Empty: show placeholder + picker when nothing inserted yet + if (!content) { + return ( +
+ + + + + + {entries.length === 0 ? ( +
+ No Flux outputs yet. In Flux, run a graph with a rendering node; its output will appear here automatically. +
+ ) : ( +
    + {entries.map((entry) => ( +
  • + +
  • + ))} +
+ )} +
+
+
+ ) + } + + // Filled: show image or HTML + if (contentType === 'image') { + const src = + content.startsWith('data:') ? content : `data:image/svg+xml;utf8,${encodeURIComponent(content)}` + return ( +
+ {label ?

{label}

: null} + {label +
+ ) + } + + // HTML: render in iframe to limit script execution (XSS safety) + return ( +
+ {label ?

{label}

: null} +