fix layout check

This commit is contained in:
Guilhem
2025-11-11 18:53:52 +01:00
parent 8e4985eddb
commit 999e673dd0
3 changed files with 120 additions and 258 deletions
@@ -70,7 +70,7 @@
import type { AssetWithAltAccessType } from '../assets/lib'
import type { AIModuleAction } from '../copilot/chat/flow/core'
import { setGraphContext } from './graphContext'
import { buildNodeSpacingMap } from './groupNoteUtils'
import { buildNodeSpacingMap, getNoteStateSignature } from './groupNoteUtils'
let useDataflow: Writable<boolean | undefined> = writable<boolean | undefined>(false)
let showAssets: Writable<boolean | undefined> = writable<boolean | undefined>(true)
@@ -272,16 +272,28 @@
type NodeDep = { id: string; parentIds?: string[]; offset?: number }
type NodePos = { position: { x: number; y: number } }
let lastNodes: [NodeDep[], (NodeDep & NodePos)[]] | undefined = undefined
type LayoutCacheKey = {
nodes: NodeDep[]
noteSignature: ReturnType<typeof getNoteStateSignature>
}
let lastLayoutCache: { key: LayoutCacheKey; result: (NodeDep & NodePos)[] } | undefined =
undefined
function layoutNodes(
nodes: NodeDep[],
groupNotes: FlowNote[] = [],
noteTextHeights: Record<string, number> = {}
): (NodeDep & NodePos)[] {
let lastResult = lastNodes?.[1]
if (lastResult && deepEqual(nodes, lastNodes?.[0])) {
console.debug('layoutNodes', 'same nodes')
return lastResult
// Create comprehensive cache key that includes all layout dependencies
const currentCacheKey: LayoutCacheKey = {
nodes,
noteSignature: getNoteStateSignature(groupNotes, noteTextHeights)
}
// Check if we can use cached result
if (lastLayoutCache && deepEqual(currentCacheKey, lastLayoutCache.key)) {
console.debug('layoutNodes', 'cache hit - same nodes and notes')
return lastLayoutCache.result
}
console.debug('layoutNodes', nodes.length)
let seenId: string[] = []
@@ -360,7 +372,11 @@
}
}))
lastNodes = [nodes, newNodes]
// Update cache with new results
lastLayoutCache = {
key: currentCacheKey,
result: newNodes
}
return newNodes
}
@@ -151,3 +151,60 @@ export function buildNodeSpacingMap(
return spacingMap
}
/**
* Creates a stable hash of the noteTextHeights object for cache comparison
*/
export function hashNoteTextHeights(noteTextHeights: Record<string, number>): string {
const entries = Object.entries(noteTextHeights)
.sort(([a], [b]) => a.localeCompare(b)) // Sort for stable hash
return JSON.stringify(entries)
}
/**
* Extracts note state signature for layout cache comparison
*/
export function getNoteStateSignature(groupNotes: FlowNote[], noteTextHeights: Record<string, number>) {
return {
notesCount: groupNotes.length,
noteIds: groupNotes.map(n => n.id).sort(),
textHeightHash: hashNoteTextHeights(noteTextHeights)
}
}
/**
* Extracts layout-affecting signature for change detection
* Only includes properties that affect graph layout (structure, grouping)
*/
export function getLayoutSignature(notes: FlowNote[]) {
return {
notesCount: notes.length,
noteIds: notes.map(n => n.id).sort(),
// Group memberships affect layout spacing
groupMemberships: notes
.filter(note => note.type === 'group')
.map(note => ({
id: note.id,
containedIds: note.contained_node_ids?.slice().sort() || []
}))
.sort((a, b) => a.id.localeCompare(b.id))
}
}
/**
* Extracts property-only signature for change detection
* Only includes visual/content properties that don't affect layout
*/
export function getPropertySignature(notes: FlowNote[]) {
return notes
.map(note => ({
id: note.id,
text: note.text,
color: note.color,
locked: note.locked || false,
position: { ...note.position },
size: { ...note.size }
}))
.sort((a, b) => a.id.localeCompare(b.id))
}
@@ -1,6 +1,8 @@
import type { FlowNote } from '$lib/gen'
import type { Node } from '@xyflow/svelte'
import { calculateNodesBounds } from './util'
import { getLayoutSignature, getPropertySignature } from './groupNoteUtils'
import { deepEqual } from 'fast-equals'
export type NodePosition = {
id: string
@@ -22,20 +24,12 @@ export class NoteManager {
// Track notes for layout change detection
#notes: () => FlowNote[]
#previousStructuralState: { count: number; groupMemberships: Record<string, string[]> } = $state({
count: 0,
groupMemberships: {}
#previousLayoutSignature: ReturnType<typeof getLayoutSignature> = $state({
notesCount: 0,
noteIds: [],
groupMemberships: []
})
#previousPropertyState: Record<
string,
{
text: string
locked: boolean
color: string
position: { x: number; y: number }
size: { width: number; height: number }
}
> = $state({})
#previousPropertySignature: ReturnType<typeof getPropertySignature> = $state([])
// Function to update nodes array with reactivity
#setNodes: (nodes: Node[]) => void
@@ -53,30 +47,27 @@ export class NoteManager {
this.#getNodes = getNodes
this.#editMode = editMode
// Effect to monitor both structural and property changes in notes
// Effect to monitor note changes with dual signature tracking
$effect(() => {
const currentNotes = this.#notes()
const currentStructuralState = this.#extractStructuralState(currentNotes)
const currentPropertyState = this.#extractPropertyState(currentNotes)
const currentLayoutSignature = getLayoutSignature(currentNotes)
const currentPropertySignature = getPropertySignature(currentNotes)
const hasStructuralChanges = this.#hasStructuralChanges(
currentStructuralState,
this.#previousStructuralState
)
const propertyChanges = this.#getPropertyChanges(
currentPropertyState,
this.#previousPropertyState
const hasLayoutChanges = !deepEqual(currentLayoutSignature, this.#previousLayoutSignature)
const hasPropertyChanges = !deepEqual(
currentPropertySignature,
this.#previousPropertySignature
)
if (hasStructuralChanges) {
if (hasLayoutChanges) {
// Structural changes require full re-render
this.#previousStructuralState = currentStructuralState
this.#previousPropertyState = currentPropertyState
this.#previousLayoutSignature = currentLayoutSignature
this.#previousPropertySignature = currentPropertySignature
this.render()
} else if (propertyChanges.length > 0) {
} else if (hasPropertyChanges) {
// Property changes can be handled with fast updates
this.#updateNodesProperties(propertyChanges)
this.#previousPropertyState = currentPropertyState
this.#updateNodesProperties(currentNotes)
this.#previousPropertySignature = currentPropertySignature
}
})
}
@@ -88,238 +79,40 @@ export class NoteManager {
this.renderCount++
}
/**
* Extract structural state from notes for change detection
*/
#extractStructuralState(notes: FlowNote[]): {
count: number
groupMemberships: Record<string, string[]>
} {
const groupMemberships: Record<string, string[]> = {}
// Extract group memberships for group notes
notes
.filter((note) => note.type === 'group')
.forEach((note) => {
if (note.contained_node_ids) {
groupMemberships[note.id] = [...note.contained_node_ids].sort() // Sort for consistent comparison
}
})
return {
count: notes.length,
groupMemberships
}
}
/**
* Check if there are structural changes that affect layout
*/
#hasStructuralChanges(
current: { count: number; groupMemberships: Record<string, string[]> },
previous: { count: number; groupMemberships: Record<string, string[]> }
): boolean {
// Check if note count changed
if (current.count !== previous.count) {
return true
}
// Check if group memberships changed
const currentGroups = Object.keys(current.groupMemberships)
const previousGroups = Object.keys(previous.groupMemberships)
// Different number of group notes
if (currentGroups.length !== previousGroups.length) {
return true
}
// Check each group's membership
for (const groupId of currentGroups) {
const currentMembers = current.groupMemberships[groupId]
const previousMembers = previous.groupMemberships[groupId]
// Group didn't exist before or membership changed
if (!previousMembers || !this.#arraysEqual(currentMembers, previousMembers)) {
return true
}
}
return false
}
/**
* Helper to compare two sorted arrays for equality
*/
#arraysEqual(a: string[], b: string[]): boolean {
if (a.length !== b.length) {
return false
}
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
getCache(): Record<string, TextHeightCacheEntry> {
return this.#cache
}
/**
* Update node properties using setter function for proper reactivity
* Only updates visual properties that don't affect layout
*/
#updateNodesProperties(
changes: Array<{ noteId: string; property: string; oldValue: any; newValue: any }>
): void {
#updateNodesProperties(currentNotes: FlowNote[]): 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
const note = currentNotes.find((n) => n.id === node.id)
if (!note) 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
// Update properties that don't affect layout
if (updatedNode.data) {
updatedNode.data.text = note.text
updatedNode.data.color = note.color
updatedNode.data.locked = note.locked || false
}
// Update draggable property based on lock state and edit mode
const isGroupNote = note.type === 'group'
updatedNode.draggable = isGroupNote ? false : this.#editMode && !note.locked
if (!isGroupNote) {
updatedNode.width = note.size.width
updatedNode.height = note.size.height
updatedNode.position = note.position
}
return updatedNode
@@ -329,10 +122,6 @@ export class NoteManager {
this.#setNodes(updatedNodes)
}
getCache(): Record<string, TextHeightCacheEntry> {
return this.#cache
}
/**
* Calculate position and size for group notes based on contained nodes
*/