modify layout based on cached text height

This commit is contained in:
Guilhem
2025-11-11 10:34:33 +01:00
parent 5083ba61bd
commit 53fa1c4db0
6 changed files with 204 additions and 238 deletions
@@ -45,8 +45,6 @@
console.log('Context menu event detected:', event)
// Let Melt UI handle this
}
$inspect('dbg context menu', $open)
</script>
<div
@@ -71,7 +71,7 @@
import type { AssetWithAltAccessType } from '../assets/lib'
import type { AIModuleAction } from '../copilot/chat/flow/core'
import { setGraphContext } from './graphContext'
import { computeGroupNoteSpacing } from './groupNoteSpacing'
import { buildNodeSpacingMap } from './groupNoteUtils'
let useDataflow: Writable<boolean | undefined> = writable<boolean | undefined>(false)
let showAssets: Writable<boolean | undefined> = writable<boolean | undefined>(true)
@@ -264,7 +264,11 @@
type NodeDep = { id: string; parentIds?: string[]; offset?: number }
type NodePos = { position: { x: number; y: number } }
let lastNodes: [NodeDep[], (NodeDep & NodePos)[]] | undefined = undefined
function layoutNodes(nodes: NodeDep[]): (NodeDep & NodePos)[] {
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')
@@ -283,6 +287,9 @@
const nodes2: (NodeDep & NodePos)[] = nodes.map((n) => {
return { ...n, position: { x: 0, y: 0 } }
})
// Build spacing map for group notes - this integrates with D3's layout algorithm
const nodeSpacingMap = buildNodeSpacingMap(nodes, groupNotes, noteTextHeights, noteManager)
for (const n of topologicalSort(nodes)) {
const endId = n.id + '-end'
@@ -304,9 +311,12 @@
.decross(nodes.length > 20 ? decrossTwoLayer() : decrossOpt())
.coord(coordCenter())
.nodeSize((d) => {
const nodeId = d?.data?.['id'] ?? ''
const baseHeight = NODE.height + NODE.gap.vertical
const extraSpacing = nodeSpacingMap[nodeId] ?? 0
return [
(nodeWidths[d?.data?.['id'] ?? ''] ?? 1) * (NODE.width + NODE.gap.horizontal * 1),
NODE.height + NODE.gap.vertical
(nodeWidths[nodeId] ?? 1) * (NODE.width + NODE.gap.horizontal * 1),
baseHeight + extraSpacing
] as readonly [number, number]
})
boxSize = layout(dag as any)
@@ -314,7 +324,12 @@
const layout = sugiyama()
.decross(decrossTwoLayer())
.coord(coordCenter())
.nodeSize(() => [NODE.width + NODE.gap.horizontal, NODE.height + NODE.gap.vertical])
.nodeSize((d) => {
const nodeId = d?.data?.['id'] ?? ''
const baseHeight = NODE.height + NODE.gap.vertical
const extraSpacing = nodeSpacingMap[nodeId] ?? 0
return [NODE.width + NODE.gap.horizontal, baseHeight + extraSpacing]
})
boxSize = layout(dag as any)
}
@@ -332,7 +347,7 @@
NODE.width / 2 -
(width - fullWidth) / 2
: 0,
y: (des.y || 0) + yOffset
y: (des.y || 0) + yOffset + (nodeSpacingMap[des.data.id] ?? 0) / 2
}
}))
}
@@ -465,20 +480,11 @@
id: n.id,
parentIds: n.parentIds,
offset: n.data.offset ?? 0
}))
)
let newNodes: (Node & NodeLayout)[] = layoutedNodes.map((n) => ({ ...n, ...graph.nodes[n.id] }))
// Apply group note spacing adjustments
const groupNoteSpacingResult = computeGroupNoteSpacing(
newNodes.map((n) => ({ id: n.id, position: n.position })),
notes ?? [],
})),
notes?.filter((note) => note.type === 'group') ?? [],
noteTextHeights
)
newNodes = newNodes.map((n) => ({
...n,
position: groupNoteSpacingResult.newNodePositions[n.id] || n.position
}))
let newNodes: (Node & NodeLayout)[] = layoutedNodes.map((n) => ({ ...n, ...graph.nodes[n.id] }))
let assetNodesResult = $showAssets
? computeAssetNodes(
@@ -691,8 +697,6 @@
export function zoomOut() {
viewportSynchronizer?.zoomOut()
}
$inspect('dbg notes & nodes', notes, nodes)
</script>
{#if insertable}
@@ -1,184 +0,0 @@
import type { FlowNote } from '../../gen'
import { deepEqual } from 'fast-equals'
import { clone } from '../../utils'
export type NodePosition = {
id: string
position: { x: number; y: number }
}
export type GroupNoteSpacingResult = {
// Nodes need to be offset on the y axis to make space for group notes
newNodePositions: Record<string, { x: number; y: number }>
}
export type TextHeightCache = {
content: string
height: number
}
// Cache for expensive group note spacing calculations
let computeGroupNoteSpacingCache:
| [NodePosition[], FlowNote[], Record<string, number>, GroupNoteSpacingResult]
| undefined
// Text height cache to avoid remeasuring unchanged note text
let textHeightCache: Record<string, TextHeightCache> = {}
const GROUP_NOTE_PADDING = 20
/**
* Caches text height measurements based on content hash
*/
export function cacheTextHeight(noteId: string, content: string, height: number): void {
textHeightCache[noteId] = { content, height }
}
/**
* Gets cached text height if content hasn't changed, otherwise returns undefined
*/
export function getCachedTextHeight(noteId: string, content: string): number | undefined {
const cached = textHeightCache[noteId]
if (cached && cached.content === content) {
return cached.height
}
return undefined
}
/**
* Gets the text height for a note, using cache if available or fallback
*/
function getTextHeight(note: FlowNote, noteTextHeights: Record<string, number>): number {
// Try cached height first
const cachedHeight = getCachedTextHeight(note.id, note.text)
if (cachedHeight !== undefined) {
return cachedHeight
}
// Fall back to runtime heights
const runtimeHeight = noteTextHeights[note.id]
if (runtimeHeight !== undefined) {
// Update cache for future use
cacheTextHeight(note.id, note.text, runtimeHeight)
return runtimeHeight
}
// Default fallback
return 60
}
/**
* Finds the topmost node in a group by Y position
*/
function findTopmostNodeInGroup(
groupNote: FlowNote,
nodes: NodePosition[]
): NodePosition | undefined {
if (!groupNote.contained_node_ids?.length) {
return undefined
}
const containedNodes = nodes.filter((node) => groupNote.contained_node_ids?.includes(node.id))
if (containedNodes.length === 0) {
return undefined
}
return containedNodes.reduce((topMost, node) =>
node.position.y < topMost.position.y ? node : topMost
)
}
/**
* Computes vertical spacing adjustments for nodes to accommodate group notes.
*/
export function computeGroupNoteSpacing(
nodes: NodePosition[],
notes: FlowNote[],
noteTextHeights: Record<string, number>
): GroupNoteSpacingResult {
// Check cache first
if (
computeGroupNoteSpacingCache &&
deepEqual(nodes, computeGroupNoteSpacingCache[0]) &&
deepEqual(notes, computeGroupNoteSpacingCache[1]) &&
deepEqual(noteTextHeights, computeGroupNoteSpacingCache[2])
) {
return computeGroupNoteSpacingCache[3]
}
// Filter to only group notes
const groupNotes = notes.filter((note) => note.type === 'group')
if (groupNotes.length === 0) {
const result: GroupNoteSpacingResult = {
newNodePositions: Object.fromEntries(nodes.map((n) => [n.id, n.position]))
}
computeGroupNoteSpacingCache = [clone(nodes), clone(notes), clone(noteTextHeights), result]
return result
}
// Map Y positions to required spacing
const ySpacingMap = new Map<number, number>()
// For each group note, determine the spacing needed at the topmost node's Y position
for (const groupNote of groupNotes) {
const topmostNode = findTopmostNodeInGroup(groupNote, nodes)
if (topmostNode) {
const textHeight = getTextHeight(groupNote, noteTextHeights)
const requiredSpacing = textHeight + GROUP_NOTE_PADDING
// If multiple group notes affect the same Y position, take the maximum spacing needed
const currentSpacing = ySpacingMap.get(topmostNode.position.y) || 0
ySpacingMap.set(topmostNode.position.y, Math.max(currentSpacing, requiredSpacing))
}
}
// Sort nodes by Y position to apply cumulative spacing
const sortedNodes = [...nodes].sort((a, b) => a.position.y - b.position.y)
// Apply cumulative spacing
let cumulativeOffset = 0
let lastYPosition = -Infinity
const adjustedNodes = sortedNodes.map((node) => {
// When we encounter a new Y position, check if it needs additional spacing
if (node.position.y > lastYPosition) {
const spacingAtThisY = ySpacingMap.get(node.position.y)
if (spacingAtThisY !== undefined) {
cumulativeOffset += spacingAtThisY
}
lastYPosition = node.position.y
}
return {
...node,
position: {
...node.position,
y: node.position.y + cumulativeOffset
}
}
})
const result: GroupNoteSpacingResult = {
newNodePositions: Object.fromEntries(adjustedNodes.map((n) => [n.id, n.position]))
}
// Cache the result
computeGroupNoteSpacingCache = [clone(nodes), clone(notes), clone(noteTextHeights), result]
return result
}
/**
* Clears the text height cache (useful for testing or when needed)
*/
export function clearTextHeightCache(): void {
textHeightCache = {}
}
/**
* Clears the group note spacing cache (useful when nodes structure changes dramatically)
*/
export function clearGroupNoteSpacingCache(): void {
computeGroupNoteSpacingCache = undefined
}
@@ -1,6 +1,11 @@
import type { Node } from '@xyflow/svelte'
import type { FlowNote } from '$lib/gen'
import { NODE } from './util'
import type { FlowNote } from '../../gen'
import type { NoteManager } from './noteManager.svelte'
type NodeDep = { id: string; parentIds?: string[]; offset?: number }
const GROUP_NOTE_PADDING = 20
export interface GroupNoteBounds {
x: number
@@ -18,7 +23,7 @@ export function computeGroupNoteBounds(
nodes: Node[],
textHeight: number = 60
): GroupNoteBounds {
const containedNodes = nodes.filter(node => containedNodeIds.includes(node.id))
const containedNodes = nodes.filter((node) => containedNodeIds.includes(node.id))
if (containedNodes.length === 0) {
throw new Error('No nodes contained in group note')
@@ -30,7 +35,7 @@ export function computeGroupNoteBounds(
let minY = Infinity
let maxY = -Infinity
containedNodes.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)
@@ -43,39 +48,106 @@ export function computeGroupNoteBounds(
return {
x: minX - padding,
y: minY - padding - textHeight, // Position text above the nodes
width: maxX - minX + (padding * 2),
height: maxY - minY + (padding * 2) + textHeight
width: maxX - minX + padding * 2,
height: maxY - minY + padding * 2 + textHeight
}
}
/**
* Gets the topmost node from a list of contained nodes
* Finds the topmost node in a group based on topological ordering
* Uses parent-child relationships to determine hierarchy
*/
export function getTopMostNode(containedNodeIds: string[], nodes: Node[]): Node | null {
const containedNodes = nodes.filter(node => containedNodeIds.includes(node.id))
function findTopmostNodeInGroup(groupNote: FlowNote, nodes: NodeDep[]): NodeDep | undefined {
if (!groupNote.contained_node_ids?.length) {
return undefined
}
const containedNodes = nodes.filter((node) => groupNote.contained_node_ids?.includes(node.id))
if (containedNodes.length === 0) {
return null
return undefined
}
return containedNodes.reduce((topMost, node) =>
node.position.y < topMost.position.y ? node : topMost
)
// Find the node with the fewest parents (or no parents) - this will be topmost in the flow
const nodesByParentCount = containedNodes.map((node) => ({
node,
parentCount: node.parentIds?.length || 0
}))
// Sort by parent count, then by appearance in nodes array to ensure deterministic results
nodesByParentCount.sort((a, b) => {
if (a.parentCount !== b.parentCount) {
return a.parentCount - b.parentCount
}
// If same parent count, use original node order as tiebreaker
const aIndex = nodes.findIndex((n) => n.id === a.node.id)
const bIndex = nodes.findIndex((n) => n.id === b.node.id)
return aIndex - bIndex
})
return nodesByParentCount[0]?.node
}
/**
* Validates that all contained nodes exist in the provided nodes array
* Gets the extra spacing needed for a specific node due to group notes
* Returns 0 if the node doesn't need extra spacing
*/
export function validateGroupNote(note: FlowNote, nodes: Node[]): FlowNote {
if (note.type !== 'group' || !note.contained_node_ids) {
return note
export function getNodeGroupNoteSpacing(
nodeId: string,
groupNotes: FlowNote[],
nodes: NodeDep[],
noteTextHeights: Record<string, number>,
noteManager: NoteManager
): number {
for (const groupNote of groupNotes) {
if (groupNote.contained_node_ids?.includes(nodeId)) {
const topmostNode = findTopmostNodeInGroup(groupNote, nodes)
// Only the topmost node gets the spacing
if (topmostNode?.id === nodeId) {
const textHeight = noteManager.getTextHeight(
groupNote.id,
groupNote.text,
noteTextHeights,
60
)
return textHeight + GROUP_NOTE_PADDING
}
}
}
return 0
}
/**
* Builds a map of node IDs to their required extra spacing for group notes
* This is called during the layout process to integrate with D3's nodeSize function
*/
export function buildNodeSpacingMap(
nodes: NodeDep[],
groupNotes: FlowNote[],
noteTextHeights: Record<string, number>,
noteManager: NoteManager
): Record<string, number> {
const spacingMap: Record<string, number> = {}
// Only process if we have group notes
if (groupNotes.length === 0) {
return spacingMap
}
const validNodeIds = nodes.map(node => node.id)
const validContainedNodeIds = note.contained_node_ids.filter(id => validNodeIds.includes(id))
return {
...note,
contained_node_ids: validContainedNodeIds
// For each node, calculate if it needs extra spacing
for (const node of nodes) {
const extraSpacing = getNodeGroupNoteSpacing(
node.id,
groupNotes,
nodes,
noteTextHeights,
noteManager
)
if (extraSpacing > 0) {
spacingMap[node.id] = extraSpacing
}
}
}
return spacingMap
}
@@ -2,20 +2,30 @@ import type { FlowNote } from '$lib/gen'
import type { Node } from '@xyflow/svelte'
import type { NoteColor } from './noteColors'
import { calculateNodesBounds } from './util'
import { cacheTextHeight } from './groupNoteSpacing'
export type NodePosition = {
id: string
position: { x: number; y: number }
}
export type TextHeightCacheEntry = {
content: string
height: number
}
/**
* Utility class for managing flow notes including regular and group notes
* This is now a stateless utility that operates on passed note data
*/
export class NoteManager {
#cache: Record<string, TextHeightCacheEntry> = $state({})
constructor() {}
getCache(): Record<string, TextHeightCacheEntry> {
return this.#cache
}
/**
* Add a new note from the note tool
*/
@@ -126,7 +136,8 @@ export class NoteManager {
// Find bounds of all contained nodes
const bounds = calculateNodesBounds(containedNodes)
const padding = 20
const padding = 16
return {
position: {
x: bounds.minX - padding,
@@ -178,7 +189,7 @@ export class NoteManager {
onTextHeightChange: (textHeight: number) => {
onTextHeightChange(note.id, textHeight)
// Cache the text height for improved performance
cacheTextHeight(note.id, note.text, textHeight)
this.cacheTextHeight(note.id, note.text, textHeight)
}
}
}
@@ -215,4 +226,63 @@ export class NoteManager {
}
})
}
/**
* Caches text height measurements based on content hash
*/
cacheTextHeight(noteId: string, content: string, height: number): void {
this.#cache[noteId] = { content, height }
}
/**
* Gets cached text height if content hasn't changed, otherwise returns undefined
*/
getCachedTextHeight(noteId: string, content: string): number | undefined {
const cached = this.#cache[noteId]
if (cached && cached.content === content) {
return cached.height
}
return undefined
}
/**
* Gets the text height for a note, using cache if available or fallback
*/
getTextHeight(
noteId: string,
content: string,
runtimeHeights: Record<string, number>,
defaultHeight: number = 60
): number {
// Try cached height first
const cachedHeight = this.getCachedTextHeight(noteId, content)
if (cachedHeight !== undefined) {
return cachedHeight
}
// Fall back to runtime heights
const runtimeHeight = runtimeHeights[noteId]
if (runtimeHeight !== undefined) {
// Update cache for future use
this.cacheTextHeight(noteId, content, runtimeHeight)
return runtimeHeight
}
// Default fallback
return defaultHeight
}
/**
* Clears the entire text height cache (useful for testing or when needed)
*/
clearTextHeightCache(): void {
this.#cache = {}
}
/**
* Removes a specific note from the cache
*/
removeTextHeight(noteId: string): void {
delete this.#cache[noteId]
}
}
@@ -92,8 +92,11 @@
})
// Track content height and notify parent
let previousContainerHeight = $state(0)
$effect(() => {
if (containerHeight > 0) {
if (containerHeight > 0 && containerHeight !== previousContainerHeight) {
console.log('dbg containerHeight', containerHeight)
previousContainerHeight = containerHeight
data.onTextHeightChange?.(containerHeight)
}
})
@@ -188,8 +191,10 @@
<!-- Note content -->
<div
bind:clientHeight={containerHeight}
class="w-full min-h-[60px] max-h-[400px] h-fit rounded-md"
class={twMerge(
'w-full min-h-[60px] max-h-[400px] rounded-md ',
data.isGroupNote ? '' : 'h-full'
)}
>
{#if editMode}
<!-- Edit mode: show textarea -->
@@ -197,22 +202,23 @@
bind:this={textareaElement}
bind:value={textContent}
class={twMerge(
'windmillapp w-full h-auto 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',
'windmillapp w-full 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}
spellcheck="false"
style="field-sizing: content;"
style:height={data.isGroupNote ? `${containerHeight > 0 ? containerHeight : 60}px` : '100%'}
></textarea>
{:else}
<!-- Render mode: show markdown or empty state -->
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div
class={twMerge(
'w-full h-fit min-h-[60px] max-h-[400px] overflow-auto cursor-pointer flex items-start justify-start rounded-md p-4'
'w-full h-fit overflow-auto cursor-pointer flex items-start justify-start rounded-md p-4'
)}
ondblclick={handleDoubleClick}
bind:clientHeight={containerHeight}
>
{#if data.text}
<div