From a425ab5b3e73f384e15f027aa72666ea1dcbe70f Mon Sep 17 00:00:00 2001 From: Guilhem Date: Fri, 7 Nov 2025 10:53:39 +0100 Subject: [PATCH] Adapt layout to group node --- .../lib/components/graph/FlowGraphV2.svelte | 183 ++++++++++++++---- .../lib/components/graph/groupNoteUtils.ts | 168 ++++++++++++++-- .../graph/renderers/nodes/NoteNode.svelte | 73 ++++++- 3 files changed, 368 insertions(+), 56 deletions(-) diff --git a/frontend/src/lib/components/graph/FlowGraphV2.svelte b/frontend/src/lib/components/graph/FlowGraphV2.svelte index 2ac8395b5f..10e29a3870 100644 --- a/frontend/src/lib/components/graph/FlowGraphV2.svelte +++ b/frontend/src/lib/components/graph/FlowGraphV2.svelte @@ -65,7 +65,12 @@ import NodeContextMenu from './NodeContextMenu.svelte' import { SelectionManager } from './selectionUtils.svelte' import { ChangeTracker } from '$lib/svelte5Utils.svelte' - import { createGroupNote } from './groupNoteUtils' + import { + createGroupNote, + isGroupNote, + calculateGroupNoteBounds, + convertToExtendedNote + } from './groupNoteUtils' import type { ModulesTestStates } from '../modulesTest.svelte' import { deepEqual } from 'fast-equals' import type { AssetWithAltAccessType } from '../assets/lib' @@ -314,7 +319,7 @@ } const yOffset = insertable ? 100 : 0 - const newNodes = dag.descendants().map((des) => ({ + const initialNodes = dag.descendants().map((des) => ({ id: des.data.id, position: { x: des.x @@ -331,8 +336,39 @@ } })) - lastNodes = [nodes, newNodes] - return newNodes + // Apply group note spacing adjustments + // First, collect all nodes that need spacing above them + const spacingMap = new Map() + for (const node of initialNodes) { + const groupNoteHeight = getGroupNoteHeightForNode(node.id, initialNodes) + if (groupNoteHeight > 0) { + spacingMap.set(node.id, groupNoteHeight) + } + } + + // Apply spacing - move nodes down by the cumulative spacing above them + const adjustedNodes = initialNodes.map(node => { + let totalSpacingAbove = 0 + + // Calculate total spacing needed above this node from all group notes above it + for (const [spacingNodeId, spacing] of spacingMap) { + const spacingNode = initialNodes.find(n => n.id === spacingNodeId) + if (spacingNode && spacingNode.position.y <= node.position.y) { + totalSpacingAbove += spacing + } + } + + return { + ...node, + position: { + ...node.position, + y: node.position.y + totalSpacingAbove + } + } + }) + + lastNodes = [nodes, adjustedNodes] + return adjustedNodes } let eventHandler = { @@ -493,9 +529,23 @@ if (selectedNodeIds.length === 0 || !onNotesChange) return try { - const groupNote = createGroupNote(selectedNodeIds, nodes) - // Group notes are locked by default - const lockedGroupNote = { ...groupNote, locked: true, isGroupNote: true } + const groupNote = createGroupNote(selectedNodeIds) + // For now, we need to store group notes as FlowNote format with additional properties + // We'll add dummy position/size that will be calculated dynamically in convertNotesToNodes + const lockedGroupNote = { + ...groupNote, + position: { x: 0, y: 0 }, // Dummy values, will be calculated dynamically + size: { width: 300, height: 100 }, // Dummy values, will be calculated dynamically + locked: true, + isGroupNote: true, + containedNodeIds: groupNote.containedNodeIds, + type: 'group' + } as FlowNote & { + locked: boolean; + isGroupNote: boolean; + containedNodeIds: string[]; + type: string; + } onNotesChange([...notes, lockedGroupNote]) nextNoteId += 1 } catch (error) { @@ -504,29 +554,92 @@ updateStores() } - function convertNotesToNodes(): Node[] { - return notes.map((note) => ({ - id: note.id, - type: 'note', - position: note.position, - data: { - text: note.text, - color: note.color, - locked: (note as any).locked || false, - isGroupNote: note.id.startsWith('group-note-'), - onUpdate: (text: string) => updateNoteText(note.id, text), - onDelete: () => deleteNote(note.id), - onColorChange: (color: NoteColor) => updateNoteColor(note.id, color), - onSizeChange: (size: { width: number; height: number }) => updateNoteSize(note.id, size), - onLockToggle: (locked: boolean) => updateNoteLock(note.id, locked) - }, - style: `width: ${note.size.width}px; height: ${note.size.height}px;`, - width: note.size.width, - height: note.size.height, - zIndex: -2000, - draggable: !(note as any).locked, // Don't allow dragging locked notes - selectable: true - })) + + /** + * Helper function to determine if a node needs additional spacing above it for group notes. + * Returns the height needed above the node. + */ + function getGroupNoteHeightForNode(nodeId: string, layoutedNodes: (NodeDep & NodePos)[]): number { + for (const note of notes) { + const extendedNote = convertToExtendedNote(note as any) + if (isGroupNote(extendedNote) && extendedNote.containedNodeIds.includes(nodeId)) { + // Find the topmost node in this group by Y position + const containedNodes = layoutedNodes.filter(node => + extendedNote.containedNodeIds.includes(node.id) + ) + + if (containedNodes.length > 0) { + const topmostNode = containedNodes.reduce((topMost, node) => + node.position.y < topMost.position.y ? node : topMost + ) + + // If this is the topmost node in the group, return the needed height + if (topmostNode.id === nodeId) { + return 60 // Height for group note text + } + } + } + } + return 0 + } + + function convertNotesToNodes(currentNodes: Node[]): Node[] { + return notes.map((note) => { + const extendedNote = convertToExtendedNote(note as any) + + if (isGroupNote(extendedNote)) { + // Calculate dynamic bounds for group notes + const bounds = calculateGroupNoteBounds(extendedNote, currentNodes, 60) + + return { + id: extendedNote.id, + type: 'note', + position: bounds.position, + data: { + text: extendedNote.text, + color: extendedNote.color, + locked: (note as any).locked || true, // Group notes are locked by default + isGroupNote: true, + containedNodeIds: extendedNote.containedNodeIds, + onUpdate: (text: string) => updateNoteText(extendedNote.id, text), + onDelete: () => deleteNote(extendedNote.id), + onColorChange: (color: NoteColor) => updateNoteColor(extendedNote.id, color), + onSizeChange: (size: { width: number; height: number }) => updateNoteSize(extendedNote.id, size), + onLockToggle: (locked: boolean) => updateNoteLock(extendedNote.id, locked) + }, + style: `width: ${bounds.size.width}px; height: ${bounds.size.height}px;`, + width: bounds.size.width, + height: bounds.size.height, + zIndex: -2000, + draggable: !(note as any).locked, // Don't allow dragging locked notes + selectable: true + } + } else { + // Handle regular notes + return { + id: extendedNote.id, + type: 'note', + position: extendedNote.position, + data: { + text: extendedNote.text, + color: extendedNote.color, + locked: (note as any).locked || false, + isGroupNote: false, + onUpdate: (text: string) => updateNoteText(extendedNote.id, text), + onDelete: () => deleteNote(extendedNote.id), + onColorChange: (color: NoteColor) => updateNoteColor(extendedNote.id, color), + onSizeChange: (size: { width: number; height: number }) => updateNoteSize(extendedNote.id, size), + onLockToggle: (locked: boolean) => updateNoteLock(extendedNote.id, locked) + }, + style: `width: ${extendedNote.size.width}px; height: ${extendedNote.size.height}px;`, + width: extendedNote.size.width, + height: extendedNote.size.height, + zIndex: -2000, + draggable: !(note as any).locked, // Don't allow dragging locked notes + selectable: true + } + } + }) } async function updateStores() { @@ -560,11 +673,15 @@ })) } let aiToolNodesResult = computeAIToolNodes(newNodes, eventHandler, insertable, flowModuleStates) - nodes = [ + const finalNodes = [ ...newNodes.map((n) => ({ ...n, position: aiToolNodesResult.newNodePositions[n.id] })), ...(assetNodesResult?.newAssetNodes ?? []), - ...aiToolNodesResult.toolNodes, - ...convertNotesToNodes() + ...aiToolNodesResult.toolNodes + ] + + nodes = [ + ...finalNodes, + ...convertNotesToNodes(finalNodes) ] edges = [ ...(assetNodesResult?.newAssetEdges ?? []), diff --git a/frontend/src/lib/components/graph/groupNoteUtils.ts b/frontend/src/lib/components/graph/groupNoteUtils.ts index 9596f6b088..0a3ed7e1d7 100644 --- a/frontend/src/lib/components/graph/groupNoteUtils.ts +++ b/frontend/src/lib/components/graph/groupNoteUtils.ts @@ -9,18 +9,46 @@ export interface GroupNoteBounds { height: number } +// Extended types for group notes +export interface RegularNote extends FlowNote { + type: 'regular' +} + +export interface GroupNote extends Omit { + type: 'group' + containedNodeIds: string[] + // position and size will be calculated dynamically +} + +export type ExtendedFlowNote = RegularNote | GroupNote + /** - * Computes the bounding box that wraps all selected nodes with padding + * Type guard to check if a note is a group note + */ +export function isGroupNote(note: ExtendedFlowNote): note is GroupNote { + return note.type === 'group' +} + +/** + * Type guard to check if a note is a regular note + */ +export function isRegularNote(note: ExtendedFlowNote): note is RegularNote { + return note.type === 'regular' +} + +/** + * Computes the bounding box that wraps all contained nodes with padding * Uses the same calculation logic as SelectionBoundingBox for consistency */ export function computeGroupNoteBounds( - selectedNodeIds: string[], - nodes: Node[] + containedNodeIds: string[], + nodes: Node[], + textHeight: number = 60 ): GroupNoteBounds { - const selectedNodes = nodes.filter(node => selectedNodeIds.includes(node.id)) + const containedNodes = nodes.filter(node => containedNodeIds.includes(node.id)) - if (selectedNodes.length === 0) { - throw new Error('No nodes selected for group note') + if (containedNodes.length === 0) { + throw new Error('No nodes contained in group note') } // Calculate flow coordinates bounds using same logic as SelectionBoundingBox @@ -29,7 +57,7 @@ export function computeGroupNoteBounds( let minY = Infinity let maxY = -Infinity - selectedNodes.forEach(node => { + containedNodes.forEach(node => { minX = Math.min(minX, node.position.x) maxX = Math.max(maxX, node.position.x + NODE.width) minY = Math.min(minY, node.position.y) @@ -41,25 +69,38 @@ export function computeGroupNoteBounds( return { x: minX - padding, - y: minY - padding, + y: minY - padding - textHeight, // Position text above the nodes width: maxX - minX + (padding * 2), - height: maxY - minY + (padding * 2) + height: maxY - minY + (padding * 2) + textHeight } } /** - * Creates a new group note with computed position and size + * Gets the topmost node from a list of contained nodes */ -export function createGroupNote( - selectedNodeIds: string[], +export function getTopMostNode(containedNodeIds: string[], nodes: Node[]): Node | null { + const containedNodes = nodes.filter(node => containedNodeIds.includes(node.id)) + + if (containedNodes.length === 0) { + return null + } + + return containedNodes.reduce((topMost, node) => + node.position.y < topMost.position.y ? node : topMost + ) +} + +/** + * Calculates the dynamic bounds for a group note + */ +export function calculateGroupNoteBounds( + groupNote: GroupNote, nodes: Node[], - color: string = 'yellow' -): FlowNote { - const bounds = computeGroupNoteBounds(selectedNodeIds, nodes) + textHeight: number = 60 +): { position: { x: number; y: number }; size: { width: number; height: number } } { + const bounds = computeGroupNoteBounds(groupNote.containedNodeIds, nodes, textHeight) return { - id: `group-note-${Date.now()}`, - text: `Group note for ${selectedNodeIds.length} nodes`, position: { x: bounds.x, y: bounds.y @@ -67,7 +108,98 @@ export function createGroupNote( size: { width: bounds.width, height: bounds.height - }, + } + } +} + +/** + * Creates a new group note with contained node IDs instead of fixed bounds + */ +export function createGroupNote( + selectedNodeIds: string[], + color: string = 'yellow' +): GroupNote { + if (selectedNodeIds.length === 0) { + throw new Error('No nodes selected for group note') + } + + return { + id: `group-note-${Date.now()}`, + type: 'group', + text: `Group note for ${selectedNodeIds.length} nodes`, + containedNodeIds: [...selectedNodeIds], // Copy array to avoid mutation color } +} + +/** + * Adds a node to an existing group note + */ +export function addNodeToGroup(groupNote: GroupNote, nodeId: string): GroupNote { + if (groupNote.containedNodeIds.includes(nodeId)) { + return groupNote // Node already in group + } + + return { + ...groupNote, + containedNodeIds: [...groupNote.containedNodeIds, nodeId], + text: `Group note for ${groupNote.containedNodeIds.length + 1} nodes` + } +} + +/** + * Removes a node from an existing group note + */ +export function removeNodeFromGroup(groupNote: GroupNote, nodeId: string): GroupNote | null { + const updatedNodeIds = groupNote.containedNodeIds.filter(id => id !== nodeId) + + if (updatedNodeIds.length === 0) { + return null // Group note should be deleted if no nodes remain + } + + return { + ...groupNote, + containedNodeIds: updatedNodeIds, + text: `Group note for ${updatedNodeIds.length} nodes` + } +} + +/** + * Validates that all contained nodes exist in the provided nodes array + */ +export function validateGroupNote(groupNote: GroupNote, nodes: Node[]): GroupNote { + const validNodeIds = nodes.map(node => node.id) + const validContainedNodeIds = groupNote.containedNodeIds.filter(id => validNodeIds.includes(id)) + + return { + ...groupNote, + containedNodeIds: validContainedNodeIds, + text: `Group note for ${validContainedNodeIds.length} nodes` + } +} + +/** + * Converts a legacy FlowNote to the new ExtendedFlowNote format + */ +export function convertToExtendedNote(note: FlowNote & { isGroupNote?: boolean; containedNodeIds?: string[] }): ExtendedFlowNote { + if (note.isGroupNote && note.containedNodeIds) { + // Convert legacy group note + return { + id: note.id, + type: 'group', + text: note.text, + color: note.color, + containedNodeIds: note.containedNodeIds + } + } else { + // Convert regular note + return { + id: note.id, + type: 'regular', + text: note.text, + position: note.position, + size: note.size, + color: note.color + } + } } \ No newline at end of file diff --git a/frontend/src/lib/components/graph/renderers/nodes/NoteNode.svelte b/frontend/src/lib/components/graph/renderers/nodes/NoteNode.svelte index 752fc36c7b..d003939abf 100644 --- a/frontend/src/lib/components/graph/renderers/nodes/NoteNode.svelte +++ b/frontend/src/lib/components/graph/renderers/nodes/NoteNode.svelte @@ -27,13 +27,57 @@ let { data, selected = false, dragging = false }: Props = $props() let textareaElement: HTMLTextAreaElement | undefined = $state(undefined) + let contentElement: HTMLDivElement | undefined = $state(undefined) let editMode = $state(false) let hovering = $state(false) let textContent = $state(data.text ?? '') + let contentHeight = $state(0) + + function calculateContentHeight() { + let calculatedHeight = 0 + let currentWidth = 300 // Default width + + if (editMode && textareaElement) { + // For textarea in edit mode + textareaElement.style.height = 'auto' + const scrollHeight = textareaElement.scrollHeight + const minHeight = 60 // Minimum height in pixels + const maxHeight = 400 // Maximum height in pixels + calculatedHeight = Math.max(minHeight, Math.min(maxHeight, scrollHeight)) + textareaElement.style.height = `${calculatedHeight}px` + + // Get current width from parent element + const parentElement = textareaElement.closest('.svelte-flow__node') + if (parentElement) { + currentWidth = parentElement.getBoundingClientRect().width + } + } else if (!editMode && contentElement) { + // For content in display mode + const scrollHeight = contentElement.scrollHeight + const minHeight = 60 + const maxHeight = 400 + calculatedHeight = Math.max(minHeight, Math.min(maxHeight, scrollHeight)) + + // Get current width from parent element + const parentElement = contentElement.closest('.svelte-flow__node') + if (parentElement) { + currentWidth = parentElement.getBoundingClientRect().width + } + } + + contentHeight = calculatedHeight + + // Update note size if handler is available + if (data.onSizeChange && contentHeight > 0) { + data.onSizeChange({ width: currentWidth, height: contentHeight + 40 }) // Add extra padding for note UI + } + } function handleTextSave() { // Only update parent when done editing data.onUpdate?.(textContent) + // Recalculate height after saving + setTimeout(calculateContentHeight, 0) } function handleDelete(event?: Event) { @@ -71,6 +115,7 @@ // Focus the textarea after a short delay to ensure it's rendered setTimeout(() => { textareaElement?.focus() + calculateContentHeight() }, 0) } @@ -90,6 +135,21 @@ } }) + // Auto-resize when content changes + $effect(() => { + textContent // Track textContent changes + if (editMode || contentElement) { + setTimeout(calculateContentHeight, 0) + } + }) + + // Calculate initial content height + $effect(() => { + if (data.text && !editMode && contentElement) { + setTimeout(calculateContentHeight, 0) + } + }) + let colorPickerIsOpen = $state(false) @@ -179,31 +239,34 @@ {/if} -
+
{#if editMode} {:else}
{#if data.text} -
+
{:else} @@ -220,7 +283,7 @@ {