mirror of
https://github.com/windmill-labs/windmill.git
synced 2026-09-09 16:05:42 +00:00
simplify note state managment
This commit is contained in:
@@ -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)}>
|
||||
|
||||
Reference in New Issue
Block a user