feat: full screen node

This commit is contained in:
2026-03-11 20:22:32 +01:00
parent 0b8c18a43f
commit 86d264e83a
11 changed files with 151 additions and 12 deletions

View File

@@ -51,6 +51,11 @@ import {
EmptyTitle, EmptyTitle,
} from '@/components/ui/empty' } from '@/components/ui/empty'
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
import {
Dialog,
DialogContent,
DialogTitle,
} from '@/components/ui/dialog'
import { ClipboardPaste, FolderOpen, FileStack, CircleDotDashed } from 'lucide-react' import { ClipboardPaste, FolderOpen, FileStack, CircleDotDashed } from 'lucide-react'
import { DEFAULT_NODE_STYLE, getDefaultDataForType, getNextNodeId } from '@/lib/flowUtils' import { DEFAULT_NODE_STYLE, getDefaultDataForType, getNextNodeId } from '@/lib/flowUtils'
import { import {
@@ -58,6 +63,7 @@ import {
getRegisteredNodeTypesGroupedByClassification, getRegisteredNodeTypesGroupedByClassification,
getRegisteredNodeTypeIds, getRegisteredNodeTypeIds,
getDefaultStyle, getDefaultStyle,
getNodeType,
isConnectionAllowed, isConnectionAllowed,
} from '@/lib/nodeRegistry' } from '@/lib/nodeRegistry'
import type { AppNode, AppEdge } from '@/lib/nodeTypes' import type { AppNode, AppEdge } from '@/lib/nodeTypes'
@@ -131,6 +137,70 @@ function FlowFitViewOnLoad() {
return null return null
} }
type FullscreenNodeOverlayProps = {
nodeId: string
nodes: AppNode[]
onClose: () => void
}
function FullscreenNodeOverlay({ nodeId, nodes, onClose }: FullscreenNodeOverlayProps) {
const open = nodeId != null
const node = nodes.find((n) => n.id === nodeId)
if (!nodeId) return null
return (
<Dialog open={open} onOpenChange={(isOpen) => { if (!isOpen) onClose() }}>
<DialogContent
className="max-w-[94vw] w-[94vw] max-h-[90vh] h-[90vh] p-0 gap-0 overflow-hidden flex flex-col border rounded-lg shadow-xl bg-card"
aria-describedby={undefined}
hideCloseButton
>
<DialogTitle className="sr-only">Node: {node?.id ?? nodeId}</DialogTitle>
{node && node.type && (
<FullscreenNodeContent node={node} />
)}
</DialogContent>
</Dialog>
)
}
function FullscreenNodeContent({ node }: { node: AppNode }) {
const descriptor = getNodeType(node.type ?? '')
const NodeComponent = descriptor?.component as React.ComponentType<{
id: string
data: Record<string, unknown>
type?: string
selected?: boolean
width?: number
height?: number
}> | undefined
if (!NodeComponent) return null
const w = typeof window !== 'undefined' ? Math.round(window.innerWidth * 0.94) : 1200
const h = typeof window !== 'undefined' ? Math.round(window.innerHeight * 0.9) : 800
const fullscreenNodeForStore: AppNode = {
...node,
position: node.position ?? { x: 0, y: 0 },
style: { ...(node.style as object), width: w, height: h },
}
return (
<div className="flex-1 min-h-0 overflow-hidden flex">
<ReactFlowProvider initialNodes={[fullscreenNodeForStore]} initialEdges={[]}>
<NodeComponent
id={node.id}
data={(node.data as Record<string, unknown>) ?? {}}
type={node.type}
selected={false}
width={w}
height={h}
/>
</ReactFlowProvider>
</div>
)
}
export type CanvasPageProps = { export type CanvasPageProps = {
/** Optional project id for future per-project graph loading */ /** Optional project id for future per-project graph loading */
projectId?: string projectId?: string
@@ -193,6 +263,7 @@ export function CanvasPage({ projectId }: CanvasPageProps) {
const [isPanning, setIsPanning] = React.useState(false) const [isPanning, setIsPanning] = React.useState(false)
const [isSelecting, setIsSelecting] = React.useState(false) const [isSelecting, setIsSelecting] = React.useState(false)
const [ariaAnnouncement, setAriaAnnouncement] = React.useState<string | null>(null) const [ariaAnnouncement, setAriaAnnouncement] = React.useState<string | null>(null)
const [fullscreenNodeId, setFullscreenNodeId] = React.useState<string | null>(null)
const nodesRef = useRef(nodes) const nodesRef = useRef(nodes)
nodesRef.current = nodes nodesRef.current = nodes
@@ -412,6 +483,8 @@ export function CanvasPage({ projectId }: CanvasPageProps) {
setConnectionFrom, setConnectionFrom,
isValidConnection, isValidConnection,
flowActionsRef, flowActionsRef,
fullscreenNodeId,
setFullscreenNodeId,
}), }),
[ [
nodes, nodes,
@@ -424,6 +497,8 @@ export function CanvasPage({ projectId }: CanvasPageProps) {
setConnectionFrom, setConnectionFrom,
isValidConnection, isValidConnection,
flowActionsRef, flowActionsRef,
fullscreenNodeId,
setFullscreenNodeId,
] ]
) )
@@ -707,6 +782,13 @@ export function CanvasPage({ projectId }: CanvasPageProps) {
</ContextMenuGroup> </ContextMenuGroup>
</ContextMenuContent> </ContextMenuContent>
</ContextMenu> </ContextMenu>
{fullscreenNodeId && (
<FullscreenNodeOverlay
nodeId={fullscreenNodeId}
nodes={nodes}
onClose={() => setFullscreenNodeId(null)}
/>
)}
</FlowContext.Provider> </FlowContext.Provider>
</div> </div>
</div> </div>

View File

@@ -122,19 +122,37 @@ export function BaseNodeHeaderRow({
title, title,
right, right,
className, className,
onHeaderDoubleClick,
}: { }: {
icon: ReactNode; icon: ReactNode;
title: ReactNode; title: ReactNode;
/** Optional right-side content (e.g. type selector). */ /** Optional right-side content (e.g. type selector). */
right?: ReactNode; right?: ReactNode;
className?: string; className?: string;
/** When provided, double-click on the header triggers this (e.g. fullscreen). Header gets cursor-pointer and nodrag. */
onHeaderDoubleClick?: () => void;
}) { }) {
return ( return (
<header <header
role={onHeaderDoubleClick ? 'button' : undefined}
tabIndex={onHeaderDoubleClick ? 0 : undefined}
onDoubleClick={onHeaderDoubleClick}
onKeyDown={
onHeaderDoubleClick
? (e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
onHeaderDoubleClick();
}
}
: undefined
}
className={cn( className={cn(
"shrink-0 mx-0 my-0 flex flex-row items-center justify-between gap-2 px-3 py-2", "shrink-0 mx-0 my-0 flex flex-row items-center justify-between gap-2 px-3 py-2",
onHeaderDoubleClick && "cursor-pointer",
className, className,
)} )}
title={onHeaderDoubleClick ? 'Double-click for full screen' : undefined}
> >
{icon} {icon}
<h3 <h3

View File

@@ -80,6 +80,8 @@ export function FlowKeyboardShortcuts() {
useEffect(() => { useEffect(() => {
const onKeyDown = (ev: KeyboardEvent) => { const onKeyDown = (ev: KeyboardEvent) => {
if (ev.key === 'Escape') { if (ev.key === 'Escape') {
const openDialog = document.querySelector('[role="dialog"]')
if (openDialog && ev.target instanceof Node && openDialog.contains(ev.target)) return
setConnectionFrom?.(null) setConnectionFrom?.(null)
setNodes?.((nds) => nds.map((n) => ({ ...n, selected: false }))) setNodes?.((nds) => nds.map((n) => ({ ...n, selected: false })))
ev.preventDefault() ev.preventDefault()

View File

@@ -1,4 +1,4 @@
import React, { useCallback, useMemo, useState } from 'react' import React, { useCallback, useContext, useMemo, useState } from 'react'
import { import {
AbstractNodeProps, AbstractNodeProps,
createAbstractNodeComponent, createAbstractNodeComponent,
@@ -15,6 +15,8 @@ import { NodeMenubar } from '@/components/base/NodeMenubar'
import { NodeFooterEdgeIndicators } from '@/components/base/NodeFooterEdgeIndicators' import { NodeFooterEdgeIndicators } from '@/components/base/NodeFooterEdgeIndicators'
import { NodeHeaderTitle } from '@/components/base/NodeHeaderTitle' import { NodeHeaderTitle } from '@/components/base/NodeHeaderTitle'
import { InputHandle, OutputHandle } from '@/components/base/NodeHandles' import { InputHandle, OutputHandle } from '@/components/base/NodeHandles'
import FlowContext from '@/lib/flowContext'
import { getNodeType } from '@/lib/nodeRegistry'
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
import { usePlatform } from '@/app/kosmos/KosmosContext' import { usePlatform } from '@/app/kosmos/KosmosContext'
import { Bot, Play, Loader2 } from 'lucide-react' import { Bot, Play, Loader2 } from 'lucide-react'
@@ -51,6 +53,9 @@ function serializeNodeForContext(nodes: { id: string; type?: string; data?: unkn
} }
function AgentNodeComponent({ id, data, width, height, selected }: Props) { function AgentNodeComponent({ id, data, width, height, selected }: Props) {
const flowContext = useContext(FlowContext)
const setFullscreenNodeId = flowContext?.setFullscreenNodeId
const supportsFullscreen = getNodeType('agent')?.supportsFullscreen
const { nodes, sourceIds, updateData } = useAbstractNode<AgentNodeData>(id, data ?? {}) const { nodes, sourceIds, updateData } = useAbstractNode<AgentNodeData>(id, data ?? {})
const { aiConnection } = usePlatform() const { aiConnection } = usePlatform()
const [running, setRunning] = useState(false) const [running, setRunning] = useState(false)
@@ -137,6 +142,7 @@ function AgentNodeComponent({ id, data, width, height, selected }: Props) {
<BaseNodeHeaderRow <BaseNodeHeaderRow
icon={<Bot className="size-4" />} icon={<Bot className="size-4" />}
title={<NodeHeaderTitle nodeId={id} displayTitle={id} />} title={<NodeHeaderTitle nodeId={id} displayTitle={id} />}
onHeaderDoubleClick={supportsFullscreen && setFullscreenNodeId ? () => setFullscreenNodeId(id) : undefined}
/> />
<BaseNodeContent> <BaseNodeContent>
<div className="shrink-0 w-full"> <div className="shrink-0 w-full">

View File

@@ -1,4 +1,4 @@
import React, { useCallback, useMemo, useRef } from 'react' import React, { useCallback, useContext, useMemo, useRef } from 'react'
import { autocompletion } from '@codemirror/autocomplete' import { autocompletion } from '@codemirror/autocomplete'
import CodeMirror from '@uiw/react-codemirror' import CodeMirror from '@uiw/react-codemirror'
import { javascript } from '@codemirror/lang-javascript' import { javascript } from '@codemirror/lang-javascript'
@@ -42,12 +42,17 @@ import { InputHandle, OutputHandle } from '../base/NodeHandles'
import { NodeFooterEdgeIndicators } from '../base/NodeFooterEdgeIndicators' import { NodeFooterEdgeIndicators } from '../base/NodeFooterEdgeIndicators'
import { NodeHeaderTitle } from '../base/NodeHeaderTitle' import { NodeHeaderTitle } from '../base/NodeHeaderTitle'
import { NodeMenubar } from '../base/NodeMenubar' import { NodeMenubar } from '../base/NodeMenubar'
import FlowContext from '../../lib/flowContext'
import { getNodeType } from '../../lib/nodeRegistry'
export type ConfigNodeData = { configType?: ConfigTypeId; content?: string; title?: string; /** @deprecated use content */ plantuml?: string } export type ConfigNodeData = { configType?: ConfigTypeId; content?: string; title?: string; /** @deprecated use content */ plantuml?: string }
type Props = AbstractNodeProps<ConfigNodeData> type Props = AbstractNodeProps<ConfigNodeData>
function ConfigNodeComponent({ id, data, width, height, selected }: Props) { function ConfigNodeComponent({ id, data, width, height, selected }: Props) {
const flowContext = useContext(FlowContext)
const setFullscreenNodeId = flowContext?.setFullscreenNodeId
const supportsFullscreen = getNodeType('config')?.supportsFullscreen
const configTypeId = getConfigTypeId(data ?? {}) const configTypeId = getConfigTypeId(data ?? {})
const configType = getConfigType(configTypeId) const configType = getConfigType(configTypeId)
const content = getConfigContent(data ?? {}) const content = getConfigContent(data ?? {})
@@ -262,6 +267,7 @@ function ConfigNodeComponent({ id, data, width, height, selected }: Props) {
</SelectContent> </SelectContent>
</Select> </Select>
} }
onHeaderDoubleClick={supportsFullscreen && setFullscreenNodeId ? () => setFullscreenNodeId(id) : undefined}
/> />
<BaseNodeContent> <BaseNodeContent>

View File

@@ -1,4 +1,4 @@
import React, { useCallback, useMemo, useRef } from 'react' import React, { useCallback, useContext, useMemo, useRef } from 'react'
import CodeMirror from '@uiw/react-codemirror' import CodeMirror from '@uiw/react-codemirror'
import { javascript } from '@codemirror/lang-javascript' import { javascript } from '@codemirror/lang-javascript'
import { import {
@@ -19,6 +19,8 @@ import { InputHandle, OutputHandle } from '../base/NodeHandles'
import { NodeFooterEdgeIndicators } from '../base/NodeFooterEdgeIndicators' import { NodeFooterEdgeIndicators } from '../base/NodeFooterEdgeIndicators'
import { NodeHeaderTitle } from '../base/NodeHeaderTitle' import { NodeHeaderTitle } from '../base/NodeHeaderTitle'
import { NodeMenubar } from '../base/NodeMenubar' import { NodeMenubar } from '../base/NodeMenubar'
import FlowContext from '../../lib/flowContext'
import { getNodeType } from '../../lib/nodeRegistry'
import { MenubarItem, MenubarShortcut } from '../ui/menubar' import { MenubarItem, MenubarShortcut } from '../ui/menubar'
import { Kbd } from '../ui/kbd' import { Kbd } from '../ui/kbd'
import { Code2, Variable } from 'lucide-react' import { Code2, Variable } from 'lucide-react'
@@ -28,6 +30,9 @@ export type FunctionNodeData = { body?: string }
type Props = AbstractNodeProps<FunctionNodeData> type Props = AbstractNodeProps<FunctionNodeData>
function FunctionNodeComponent({ id, data, width, height, selected }: Props) { function FunctionNodeComponent({ id, data, width, height, selected }: Props) {
const flowContext = useContext(FlowContext)
const setFullscreenNodeId = flowContext?.setFullscreenNodeId
const supportsFullscreen = getNodeType('function')?.supportsFullscreen
const bodyValue = data?.body ?? '' const bodyValue = data?.body ?? ''
const { theme } = useTheme() const { theme } = useTheme()
const { nodes, sourceIds, updateData } = useAbstractNode<FunctionNodeData>(id, data ?? {}) const { nodes, sourceIds, updateData } = useAbstractNode<FunctionNodeData>(id, data ?? {})
@@ -92,7 +97,11 @@ function FunctionNodeComponent({ id, data, width, height, selected }: Props) {
return ( return (
<BaseNode className="min-w-72 min-h-[260px]" dimensions={dimensions} resizable nodeId={id} selected={selected} handles={<><InputHandle id="in" nodeId={id} /><OutputHandle id="out" /></>}> <BaseNode className="min-w-72 min-h-[260px]" dimensions={dimensions} resizable nodeId={id} selected={selected} handles={<><InputHandle id="in" nodeId={id} /><OutputHandle id="out" /></>}>
<BaseNodeHeaderRow icon={<Code2 className="size-4" />} title={<NodeHeaderTitle nodeId={id} displayTitle={id} />} /> <BaseNodeHeaderRow
icon={<Code2 className="size-4" />}
title={<NodeHeaderTitle nodeId={id} displayTitle={id} />}
onHeaderDoubleClick={supportsFullscreen && setFullscreenNodeId ? () => setFullscreenNodeId(id) : undefined}
/>
<BaseNodeContent> <BaseNodeContent>
<div className="shrink-0 w-full"> <div className="shrink-0 w-full">

View File

@@ -1,4 +1,4 @@
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react' import React, { useCallback, useContext, useEffect, useMemo, useRef, useState } from 'react'
import nunjucks from 'nunjucks' import nunjucks from 'nunjucks'
import CodeMirror from '@uiw/react-codemirror' import CodeMirror from '@uiw/react-codemirror'
import { javascript } from '@codemirror/lang-javascript' import { javascript } from '@codemirror/lang-javascript'
@@ -19,7 +19,8 @@ import {
BaseNodeHeaderRow, BaseNodeHeaderRow,
} from '../base/BaseNode' } from '../base/BaseNode'
import { getDefaultDataForType, getNextNodeId } from '../../lib/flowUtils' import { getDefaultDataForType, getNextNodeId } from '../../lib/flowUtils'
import { getDefaultStyle } from '../../lib/nodeRegistry' import FlowContext from '../../lib/flowContext'
import { getDefaultStyle, getNodeType } from '../../lib/nodeRegistry'
import { NodeFooterEdgeIndicators } from '../base/NodeFooterEdgeIndicators' import { NodeFooterEdgeIndicators } from '../base/NodeFooterEdgeIndicators'
import { NodeHeaderTitle } from '../base/NodeHeaderTitle' import { NodeHeaderTitle } from '../base/NodeHeaderTitle'
import { NodeMenubar } from '../base/NodeMenubar' import { NodeMenubar } from '../base/NodeMenubar'
@@ -47,6 +48,9 @@ type Props = AbstractNodeProps<RenderingNodeData>
type ViewMode = 'preview' | 'raw' type ViewMode = 'preview' | 'raw'
function RenderingNodeComponent({ id, data, width, height, selected }: Props) { function RenderingNodeComponent({ id, data, width, height, selected }: Props) {
const flowContext = useContext(FlowContext)
const setFullscreenNodeId = flowContext?.setFullscreenNodeId
const supportsFullscreen = getNodeType('render')?.supportsFullscreen
const [renderedContent, setRenderedContent] = useState<string | null>(null) const [renderedContent, setRenderedContent] = useState<string | null>(null)
const [resolvedContent, setResolvedContent] = useState<string | null>(null) const [resolvedContent, setResolvedContent] = useState<string | null>(null)
const [error, setError] = useState<null | { kind: string; message: string }>(null) const [error, setError] = useState<null | { kind: string; message: string }>(null)
@@ -675,6 +679,7 @@ function RenderingNodeComponent({ id, data, width, height, selected }: Props) {
<BaseNodeHeaderRow <BaseNodeHeaderRow
icon={<Sparkles className="size-4" />} icon={<Sparkles className="size-4" />}
title={<NodeHeaderTitle nodeId={id} displayTitle={id} />} title={<NodeHeaderTitle nodeId={id} displayTitle={id} />}
onHeaderDoubleClick={supportsFullscreen && setFullscreenNodeId ? () => setFullscreenNodeId(id) : undefined}
right={ right={
incomingIds.length > 0 ? ( incomingIds.length > 0 ? (
<ToggleGroup <ToggleGroup

View File

@@ -29,8 +29,8 @@ DialogOverlay.displayName = DialogPrimitive.Overlay.displayName
const DialogContent = React.forwardRef< const DialogContent = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Content>, React.ElementRef<typeof DialogPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content> React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content> & { hideCloseButton?: boolean }
>(({ className, children, ...props }, ref) => ( >(({ className, children, hideCloseButton, ...props }, ref) => (
<DialogPortal> <DialogPortal>
<DialogOverlay /> <DialogOverlay />
<DialogPrimitive.Content <DialogPrimitive.Content
@@ -42,10 +42,12 @@ const DialogContent = React.forwardRef<
{...props} {...props}
> >
{children} {children}
<DialogPrimitive.Close className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-accent data-[state=open]:text-muted-foreground"> {!hideCloseButton && (
<X className="h-4 w-4" /> <DialogPrimitive.Close className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-accent data-[state=open]:text-muted-foreground">
<span className="sr-only">Close</span> <X className="h-4 w-4" />
</DialogPrimitive.Close> <span className="sr-only">Close</span>
</DialogPrimitive.Close>
)}
</DialogPrimitive.Content> </DialogPrimitive.Content>
</DialogPortal> </DialogPortal>
)) ))

View File

@@ -22,6 +22,9 @@ export type FlowContextValue = {
isValidConnection: (connection: Connection) => boolean isValidConnection: (connection: Connection) => boolean
/** Set by FlowKeyboardShortcuts so Node menubar can trigger paste / fit view. */ /** Set by FlowKeyboardShortcuts so Node menubar can trigger paste / fit view. */
flowActionsRef: React.MutableRefObject<FlowActions | null> flowActionsRef: React.MutableRefObject<FlowActions | null>
/** When set, graph centers on this node and a fullscreen dialog shows the node. Cleared on close. */
fullscreenNodeId: string | null
setFullscreenNodeId: (id: string | null) => void
} }
const FlowContext = React.createContext<FlowContextValue | null>(null) const FlowContext = React.createContext<FlowContextValue | null>(null)

View File

@@ -45,6 +45,8 @@ export type NodeTypeDescriptor = {
getResetData?: (nodeId?: string) => Record<string, unknown> getResetData?: (nodeId?: string) => Record<string, unknown>
/** Optional: label shown on edge when this type is the target (e.g. "render", "add input"). */ /** Optional: label shown on edge when this type is the target (e.g. "render", "add input"). */
connectionLabel?: string connectionLabel?: string
/** When true, double-clicking the node header opens a fullscreen dialog for this node. */
supportsFullscreen?: boolean
} }
const registry = new Map<string, NodeTypeDescriptor>() const registry = new Map<string, NodeTypeDescriptor>()

View File

@@ -42,6 +42,7 @@ export function registerBuiltinNodes(): void {
title: nodeId ?? '', title: nodeId ?? '',
}), }),
connectionLabel: 'adding input', connectionLabel: 'adding input',
supportsFullscreen: true,
}) })
registerNodeType({ registerNodeType({
@@ -58,6 +59,7 @@ export function registerBuiltinNodes(): void {
menuLabel: 'Renderer', menuLabel: 'Renderer',
menuIcon: <Sparkles className={ICON_CLASS} />, menuIcon: <Sparkles className={ICON_CLASS} />,
connectionLabel: 'rendering', connectionLabel: 'rendering',
supportsFullscreen: true,
}) })
registerNodeType({ registerNodeType({
@@ -75,6 +77,7 @@ export function registerBuiltinNodes(): void {
menuLabel: 'Agent', menuLabel: 'Agent',
menuIcon: <Bot className={ICON_CLASS} />, menuIcon: <Bot className={ICON_CLASS} />,
connectionLabel: 'prompt/context', connectionLabel: 'prompt/context',
supportsFullscreen: true,
}) })
registerNodeType({ registerNodeType({
@@ -128,5 +131,6 @@ export function registerBuiltinNodes(): void {
menuIcon: <Code2 className={ICON_CLASS} />, menuIcon: <Code2 className={ICON_CLASS} />,
getResetData: () => ({ body: '' }), getResetData: () => ({ body: '' }),
connectionLabel: 'adding input', connectionLabel: 'adding input',
supportsFullscreen: true,
}) })
} }