Adapt layout to group node

This commit is contained in:
Guilhem
2025-11-07 11:20:44 +01:00
parent 82e140eee6
commit a425ab5b3e
3 changed files with 368 additions and 56 deletions
@@ -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<string, number>()
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 ?? []),
@@ -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<FlowNote, 'position' | 'size'> {
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
}
}
}
@@ -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)
</script>
@@ -179,31 +239,34 @@
{/if}
<!-- Note content -->
<div class="h-full rounded-md">
<div class="w-full min-h-[60px] max-h-[400px] rounded-md">
{#if editMode}
<!-- Edit mode: show textarea -->
<textarea
bind:this={textareaElement}
bind:value={textContent}
class={twMerge(
'windmillapp w-full h-full min-h-0 shadow-none resize-none text-xs overflow-y-auto border-none rounded-md bg-transparent transition-colors p-4',
'windmillapp w-full min-h-[60px] max-h-[400px] shadow-none resize-none text-xs overflow-y-auto border-none rounded-md bg-transparent transition-colors p-4',
colorConfig.text
)}
placeholder="Add your note here... (Markdown supported)"
onblur={handleTextSave}
oninput={calculateContentHeight}
spellcheck="false"
style="height: auto;"
></textarea>
{:else}
<!-- Render mode: show markdown or empty state -->
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div
bind:this={contentElement}
class={twMerge(
'w-full h-full overflow-auto cursor-pointer flex items-start justify-center rounded-md p-4'
'w-full min-h-[60px] max-h-[400px] overflow-auto cursor-pointer flex items-start justify-start rounded-md p-4'
)}
ondblclick={handleDoubleClick}
>
{#if data.text}
<div class={twMerge('w-full h-full text-xs rounded-md', colorConfig.text)}>
<div class={twMerge('w-full text-xs rounded-md', colorConfig.text)}>
<GfmMarkdown md={data.text} noPadding />
</div>
{:else}
@@ -220,7 +283,7 @@
<NodeResizer
isVisible={selected && !dragging}
minWidth={200}
minHeight={100}
minHeight={60}
lineClass="!border-4 !border-transparent !rounded-md"
handleClass="!bg-transparent !w-4 !h-4 !border-none !rounded-md"
onResizeEnd={(_, params) => {