diff --git a/frontend/src/lib/components/graph/FlowGraphV2.svelte b/frontend/src/lib/components/graph/FlowGraphV2.svelte index 91d97ecca5..bd13b86555 100644 --- a/frontend/src/lib/components/graph/FlowGraphV2.svelte +++ b/frontend/src/lib/components/graph/FlowGraphV2.svelte @@ -220,8 +220,15 @@ multiSelectEnabled = false }: Props = $props() - // Initialize note manager with notes function - const noteManager = new NoteManager(() => notes ?? []) + // Initialize note manager with notes function and nodes setter/getter + const noteManager = new NoteManager( + () => notes ?? [], + (newNodes) => { + nodes = newNodes + }, + () => nodes, + editMode + ) // Runtime text height tracking for notes (not stored in FlowNote) let noteTextHeights = $state>({}) diff --git a/frontend/src/lib/components/graph/noteManager.svelte.ts b/frontend/src/lib/components/graph/noteManager.svelte.ts index 9de349bb4c..77c7b0efff 100644 --- a/frontend/src/lib/components/graph/noteManager.svelte.ts +++ b/frontend/src/lib/components/graph/noteManager.svelte.ts @@ -26,24 +26,61 @@ export class NoteManager { count: 0, groupMemberships: {} }) + #previousPropertyState: Record< + string, + { + text: string + locked: boolean + color: string + position: { x: number; y: number } + size: { width: number; height: number } + } + > = $state({}) - constructor(notes: () => FlowNote[]) { + // Function to update nodes array with reactivity + #setNodes: (nodes: Node[]) => void + #getNodes: () => Node[] + #editMode: boolean = false + + constructor( + notes: () => FlowNote[], + setNodes: (nodes: Node[]) => void, + getNodes: () => Node[], + editMode: boolean + ) { this.#notes = notes + this.#setNodes = setNodes + this.#getNodes = getNodes + this.#editMode = editMode - // Effect to monitor structural changes in notes + // Effect to monitor both structural and property changes in notes $effect(() => { const currentNotes = this.#notes() - const currentState = this.#extractStructuralState(currentNotes) - const hasStructuralChanges = this.#hasStructuralChanges(currentState, this.#previousStructuralState) + const currentStructuralState = this.#extractStructuralState(currentNotes) + const currentPropertyState = this.#extractPropertyState(currentNotes) + + const hasStructuralChanges = this.#hasStructuralChanges( + currentStructuralState, + this.#previousStructuralState + ) + const propertyChanges = this.#getPropertyChanges( + currentPropertyState, + this.#previousPropertyState + ) if (hasStructuralChanges) { - this.#previousStructuralState = currentState + // Structural changes require full re-render + this.#previousStructuralState = currentStructuralState + this.#previousPropertyState = currentPropertyState this.render() + } else if (propertyChanges.length > 0) { + // Property changes can be handled with fast updates + this.#updateNodesProperties(propertyChanges) + this.#previousPropertyState = currentPropertyState } }) } - /** * Triggers a re-render of the graph by incrementing the render count */ @@ -54,7 +91,10 @@ export class NoteManager { /** * Extract structural state from notes for change detection */ - #extractStructuralState(notes: FlowNote[]): { count: number; groupMemberships: Record } { + #extractStructuralState(notes: FlowNote[]): { + count: number + groupMemberships: Record + } { const groupMemberships: Record = {} // Extract group memberships for group notes @@ -117,6 +157,178 @@ export class NoteManager { return a.every((value, index) => value === b[index]) } + /** + * Extract property state from notes for change detection + */ + #extractPropertyState( + notes: FlowNote[] + ): Record< + string, + { + text: string + locked: boolean + color: string + position: { x: number; y: number } + size: { width: number; height: number } + } + > { + const propertyState: Record< + string, + { + text: string + locked: boolean + color: string + position: { x: number; y: number } + size: { width: number; height: number } + } + > = {} + + for (const note of notes) { + propertyState[note.id] = { + text: note.text, + locked: note.locked || false, + color: note.color, + position: { ...note.position }, + size: { ...note.size } + } + } + + return propertyState + } + + /** + * Get property changes between current and previous state + */ + #getPropertyChanges( + current: Record< + string, + { + text: string + locked: boolean + color: string + position: { x: number; y: number } + size: { width: number; height: number } + } + >, + previous: Record< + string, + { + text: string + locked: boolean + color: string + position: { x: number; y: number } + size: { width: number; height: number } + } + > + ): Array<{ noteId: string; property: string; oldValue: any; newValue: any }> { + const changes: Array<{ noteId: string; property: string; oldValue: any; newValue: any }> = [] + + for (const noteId of Object.keys(current)) { + const currentNote = current[noteId] + const previousNote = previous[noteId] + + if (!previousNote) continue // New note, will be handled by structural changes + + // Check each property for changes + if (currentNote.text !== previousNote.text) { + changes.push({ + noteId, + property: 'text', + oldValue: previousNote.text, + newValue: currentNote.text + }) + } + if (currentNote.locked !== previousNote.locked) { + changes.push({ + noteId, + property: 'locked', + oldValue: previousNote.locked, + newValue: currentNote.locked + }) + } + if (currentNote.color !== previousNote.color) { + changes.push({ + noteId, + property: 'color', + oldValue: previousNote.color, + newValue: currentNote.color + }) + } + if ( + currentNote.position.x !== previousNote.position.x || + currentNote.position.y !== previousNote.position.y + ) { + changes.push({ + noteId, + property: 'position', + oldValue: previousNote.position, + newValue: currentNote.position + }) + } + if ( + currentNote.size.width !== previousNote.size.width || + currentNote.size.height !== previousNote.size.height + ) { + changes.push({ + noteId, + property: 'size', + oldValue: previousNote.size, + newValue: currentNote.size + }) + } + } + + return changes + } + + /** + * Update node properties using setter function for proper reactivity + */ + #updateNodesProperties( + changes: Array<{ noteId: string; property: string; oldValue: any; newValue: any }> + ): void { + const currentNodes = this.#getNodes() + if (currentNodes.length === 0) return + + // Create a new array with updated nodes to trigger reactivity + const updatedNodes = currentNodes.map((node) => { + const change = changes.find((c) => c.noteId === node.id) + if (!change) return node + + // Clone the node to avoid mutation + const updatedNode = { ...node, data: { ...node.data } } + + switch (change.property) { + case 'text': + if (updatedNode.data) updatedNode.data.text = change.newValue + break + case 'locked': + if (updatedNode.data) updatedNode.data.locked = change.newValue + // Update draggable property based on lock state and edit mode + updatedNode.draggable = updatedNode.data.isGroupNote + ? false + : this.#editMode && !change.newValue + break + case 'color': + if (updatedNode.data) updatedNode.data.color = change.newValue + break + case 'position': + updatedNode.position = { ...change.newValue } + break + case 'size': + updatedNode.width = change.newValue.width + updatedNode.height = change.newValue.height + updatedNode.style = `width: ${change.newValue.width}px; height: ${change.newValue.height}px;` + break + } + + return updatedNode + }) + + // Use setter function to trigger reactivity + this.#setNodes(updatedNodes) + } + getCache(): Record { return this.#cache } @@ -173,7 +385,7 @@ export class NoteManager { isGroupNote, editMode, ...(isGroupNote && { containedNodeIds: note.contained_node_ids || [] }), - // Note: Edit callbacks will be added by NoteNode when NoteEditor context is available + // Text height tracking for layout calculations onTextHeightChange: (textHeight: number) => { onTextHeightChange(note.id, textHeight) // Cache the text height for improved performance @@ -182,6 +394,37 @@ export class NoteManager { } } + /** + * Convert a single note to a SvelteFlow node + */ + convertNoteToNode( + note: FlowNote, + currentNodes: Node[], + textHeights: Record, + onTextHeightChange: (noteId: string, height: number) => void, + editMode: boolean = false + ): Node { + const isGroupNote = note.type === 'group' + + // Calculate position and size based on note type + const { position, size } = isGroupNote + ? this.calculateGroupNoteLayout(note, currentNodes, textHeights[note.id] || 60) + : { position: note.position, size: note.size } + + return { + id: note.id, + type: 'note', + position, + data: this.createNoteData(note, onTextHeightChange, isGroupNote, editMode), + style: `width: ${size.width}px; height: ${size.height}px;`, + width: size.width, + height: size.height, + zIndex: -2000, + draggable: isGroupNote ? false : editMode && !note.locked, + selectable: true + } + } + /** * Convert notes to SvelteFlow nodes (rendering-only) */ @@ -192,27 +435,9 @@ export class NoteManager { onTextHeightChange: (noteId: string, height: number) => void, editMode: boolean = false ): Node[] { - return notes.map((note) => { - const isGroupNote = note.type === 'group' - - // Calculate position and size based on note type - const { position, size } = isGroupNote - ? this.calculateGroupNoteLayout(note, currentNodes, textHeights[note.id] || 60) - : { position: note.position, size: note.size } - - return { - id: note.id, - type: 'note', - position, - data: this.createNoteData(note, onTextHeightChange, isGroupNote, editMode), - style: `width: ${size.width}px; height: ${size.height}px;`, - width: size.width, - height: size.height, - zIndex: -2000, - draggable: isGroupNote ? false : !note.locked, - selectable: true - } - }) + return notes.map((note) => + this.convertNoteToNode(note, currentNodes, textHeights, onTextHeightChange, editMode) + ) } /** diff --git a/frontend/src/lib/components/graph/renderers/nodes/NoteNode.svelte b/frontend/src/lib/components/graph/renderers/nodes/NoteNode.svelte index 968aa3e86a..a34b992a17 100644 --- a/frontend/src/lib/components/graph/renderers/nodes/NoteNode.svelte +++ b/frontend/src/lib/components/graph/renderers/nodes/NoteNode.svelte @@ -1,6 +1,6 @@
@@ -203,7 +196,7 @@ {#if hovering || selected} - {#if !editMode && data.text} + {#if !editMode && isEditModeAvailable}
- {#if textContent} + {#if textForDisplay}
- +
{:else}