simplify note state managment

This commit is contained in:
Guilhem
2025-11-11 15:36:16 +01:00
parent 1e616b8258
commit 2f1a3dc37a
3 changed files with 281 additions and 56 deletions
@@ -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<Record<string, number>>({})
@@ -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<string, string[]> } {
#extractStructuralState(notes: FlowNote[]): {
count: number
groupMemberships: Record<string, string[]>
} {
const groupMemberships: Record<string, string[]> = {}
// 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<string, TextHeightCacheEntry> {
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<string, number>,
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)
)
}
/**
@@ -1,6 +1,6 @@
<script lang="ts">
import { NodeResizer } from '@xyflow/svelte'
import { X, Lock, Unlock } from 'lucide-svelte'
import { X, Lock, LockOpen } from 'lucide-svelte'
import { twMerge } from 'tailwind-merge'
import GfmMarkdown from '$lib/components/GfmMarkdown.svelte'
import { fade } from 'svelte/transition'
@@ -8,8 +8,6 @@
import { NoteColor, NOTE_COLORS, DEFAULT_NOTE_COLOR } from '../../noteColors'
import { Button } from '$lib/components/common'
import { getNoteEditorContext } from '../../noteEditor.svelte'
import { getContext } from 'svelte'
import type { FlowEditorContext } from '$lib/components/flows/types'
interface Props {
data: {
@@ -32,25 +30,27 @@
const noteEditorContext = getNoteEditorContext()
const isEditModeAvailable = $derived(!!noteEditorContext?.noteEditor && data.editMode)
const { flowStore } = getContext<FlowEditorContext>('FlowEditorContext') || {}
const note = $derived(
flowStore?.val.value?.notes?.find((note) => note.id === data.noteId) ?? undefined
)
let textareaElement: HTMLTextAreaElement | undefined = $state(undefined)
let editMode = $state(false)
let hovering = $state(false)
let textContent = $state(data.text ?? '')
let containerHeight = $state(0)
// Derived values. If in edit mode, use the note text from the flow store. If not, use the data text.
let textContent = $derived(note?.text ?? data.text ?? '')
const color = $derived((note?.color as NoteColor) ?? data.color ?? DEFAULT_NOTE_COLOR)
const locked = $derived(note?.locked ?? data.locked ?? false)
// Use data props directly - they're kept in sync by NoteManager observer
const color = $derived(data.color ?? DEFAULT_NOTE_COLOR)
const locked = $derived(data.locked ?? false)
const textForDisplay = $derived(data.text ?? '')
// Sync textContent with data.text when not in edit mode
$effect(() => {
if (!editMode) {
textContent = data.text ?? ''
}
})
function handleTextSave() {
// Only update parent when done editing
// Only update when done editing
if (isEditModeAvailable && noteEditorContext?.noteEditor) {
// Use NoteEditor context in edit mode
noteEditorContext.noteEditor.updateText(data.noteId, textContent)
}
}
@@ -59,14 +59,12 @@
event?.preventDefault?.()
event?.stopPropagation?.()
if (isEditModeAvailable && noteEditorContext?.noteEditor) {
// Use NoteEditor context in edit mode
noteEditorContext.noteEditor.deleteNote(data.noteId)
}
}
function handleColorChange(color: NoteColor) {
if (isEditModeAvailable && noteEditorContext?.noteEditor) {
// Use NoteEditor context in edit mode
noteEditorContext.noteEditor.updateColor(data.noteId, color)
}
}
@@ -75,7 +73,6 @@
event?.preventDefault?.()
event?.stopPropagation?.()
if (isEditModeAvailable && noteEditorContext?.noteEditor) {
// Use NoteEditor context in edit mode
noteEditorContext.noteEditor.updateLock(data.noteId, !locked)
}
}
@@ -92,7 +89,6 @@
return
}
textContent = data.text ?? '' // Initialize local state with current data
editMode = true
// Focus the textarea after a short delay to ensure it's rendered
setTimeout(() => {
@@ -127,9 +123,6 @@
})
let colorPickerIsOpen = $state(false)
$inspect('dbg note', note)
$inspect('dbg textContent', textContent)
</script>
<div
@@ -170,7 +163,7 @@
unifiedSize="sm"
title={locked ? 'Unlock note' : 'Lock note'}
aria-label={locked ? 'Unlock note' : 'Lock note'}
startIcon={{ icon: locked ? Lock : Unlock }}
startIcon={{ icon: locked ? Lock : LockOpen }}
onClick={handleLockToggle}
iconOnly
/>
@@ -203,7 +196,7 @@
<!-- Hover help text -->
{#if hovering || selected}
{#if !editMode && data.text}
{#if !editMode && isEditModeAvailable}
<div
in:fade={{ duration: 200 }}
class="absolute -top-5 h-5 left-0 text-2xs text-secondary rounded-md z-10 transition-opacity duration-300"
@@ -254,14 +247,14 @@
ondblclick={handleDoubleClick}
bind:clientHeight={containerHeight}
>
{#if textContent}
{#if textForDisplay}
<div
class={twMerge(
'w-full text-xs rounded-md break-words overflow-hidden',
colorConfig.text
)}
>
<GfmMarkdown md={textContent} noPadding />
<GfmMarkdown md={textForDisplay} noPadding />
</div>
{:else}
<div class={twMerge('text-xs italic opacity-60', colorConfig.text)}>