This commit is contained in:
Guilhem
2025-11-10 14:37:32 +01:00
parent d01f80ef60
commit 1ae0f4822d
8 changed files with 219 additions and 331 deletions
+13
View File
@@ -196,6 +196,13 @@ pub struct FlowValue {
pub notes: Option<Vec<FlowNote>>,
}
#[derive(Deserialize, Serialize, Debug, Clone)]
#[serde(rename_all = "lowercase")]
pub enum FlowNoteType {
Free,
Group,
}
#[derive(Deserialize, Serialize, Debug, Clone)]
pub struct FlowNote {
pub id: String,
@@ -203,6 +210,12 @@ pub struct FlowNote {
pub position: FlowNotePosition,
pub size: FlowNoteSize,
pub color: String,
#[serde(rename = "type")]
pub note_type: FlowNoteType,
#[serde(default)]
pub locked: bool,
#[serde(skip_serializing_if = "Option::is_none")]
pub contained_node_ids: Option<Vec<String>>,
}
#[derive(Deserialize, Serialize, Debug, Clone)]
@@ -221,6 +221,9 @@
// Initialize note manager (now stateless)
const noteManager = new NoteManager()
// Runtime text height tracking for notes (not stored in FlowNote)
let noteTextHeights = $state<Record<string, number>>({})
// Selection manager - create one if not provided
let actualSelectionManager = selectionManager || new SelectionManager()
@@ -339,7 +342,8 @@
const groupNoteHeight = noteManager.getGroupNoteHeightForNode(
notes ?? [],
node.id,
initialNodes
initialNodes,
noteTextHeights
)
if (groupNoteHeight > 0) {
spacingMap.set(node.id, groupNoteHeight)
@@ -527,9 +531,17 @@
nodes = [
...finalNodes,
...noteManager.convertToNodes(notes ?? [], finalNodes, (newNotes) => {
notes = newNotes
})
...noteManager.convertToNodes(
notes ?? [],
finalNodes,
noteTextHeights,
(newNotes) => {
notes = newNotes
},
(noteId, height) => {
noteTextHeights[noteId] = height
}
)
]
edges = [
...(assetNodesResult?.newAssetEdges ?? []),
@@ -708,6 +720,7 @@
}
$inspect('dbg notes & nodes', notes, nodes)
$inspect('dbg noteTextHeights', noteTextHeights)
</script>
{#if insertable}
@@ -1,7 +1,7 @@
<script lang="ts">
import type { Node } from '@xyflow/svelte'
import { useSvelteFlow } from '@xyflow/svelte'
import { NODE } from './util'
import { calculateNodesBounds } from './util'
interface Props {
selectedNodes: Node[]
@@ -17,17 +17,7 @@
}
// Calculate flow coordinates bounds
let minX = Infinity
let maxX = -Infinity
let minY = Infinity
let maxY = -Infinity
selectedNodes.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)
maxY = Math.max(maxY, node.position.y + NODE.height)
})
const { minX, minY, maxX, maxY } = calculateNodesBounds(selectedNodes)
// Add padding in flow coordinates
const flowBounds = {
@@ -78,8 +68,10 @@
z-index: 10;
"
>
<div class="absolute -top-6 left-0 text-xs text-accent font-medium bg-surface px-2 py-1 rounded shadow pointer-events-none">
<div
class="absolute -top-6 left-0 text-xs text-accent font-medium bg-surface px-2 py-1 rounded shadow pointer-events-none"
>
{selectedNodes.length} nodes selected
</div>
</div>
{/if}
{/if}
@@ -9,33 +9,6 @@ export interface GroupNoteBounds {
height: number
}
// Extended types for group notes
export interface RegularNote extends FlowNote {
type: 'regular'
}
export interface GroupNote extends Omit<FlowNote, 'position' | 'size'> {
type: 'group'
containedNodeIds: string[]
// position and size will be calculated dynamically
}
export type ExtendedFlowNote = RegularNote | GroupNote
/**
* Type guard to check if a note is a group note
*/
export function isGroupNote(note: ExtendedFlowNote): note is GroupNote {
return note.type === 'group'
}
/**
* Type guard to check if a note is a regular note
*/
export function isRegularNote(note: ExtendedFlowNote): note is RegularNote {
return note.type === 'regular'
}
/**
* Computes the bounding box that wraps all contained nodes with padding
* Uses the same calculation logic as SelectionBoundingBox for consistency
@@ -90,116 +63,19 @@ export function getTopMostNode(containedNodeIds: string[], nodes: Node[]): Node
)
}
/**
* Calculates the dynamic bounds for a group note
*/
export function calculateGroupNoteBounds(
groupNote: GroupNote,
nodes: Node[],
textHeight: number = 60
): { position: { x: number; y: number }; size: { width: number; height: number } } {
const bounds = computeGroupNoteBounds(groupNote.containedNodeIds, nodes, textHeight)
return {
position: {
x: bounds.x,
y: bounds.y
},
size: {
width: bounds.width,
height: bounds.height
}
}
}
/**
* Creates a new group note with contained node IDs instead of fixed bounds
*/
export function createGroupNote(
selectedNodeIds: string[],
color: string = 'yellow'
): GroupNote {
if (selectedNodeIds.length === 0) {
throw new Error('No nodes selected for group note')
}
return {
id: `group-note-${Date.now()}`,
type: 'group',
text: `Group note for ${selectedNodeIds.length} nodes`,
containedNodeIds: [...selectedNodeIds], // Copy array to avoid mutation
color
}
}
/**
* Adds a node to an existing group note
*/
export function addNodeToGroup(groupNote: GroupNote, nodeId: string): GroupNote {
if (groupNote.containedNodeIds.includes(nodeId)) {
return groupNote // Node already in group
}
return {
...groupNote,
containedNodeIds: [...groupNote.containedNodeIds, nodeId],
text: `Group note for ${groupNote.containedNodeIds.length + 1} nodes`
}
}
/**
* Removes a node from an existing group note
*/
export function removeNodeFromGroup(groupNote: GroupNote, nodeId: string): GroupNote | null {
const updatedNodeIds = groupNote.containedNodeIds.filter(id => id !== nodeId)
if (updatedNodeIds.length === 0) {
return null // Group note should be deleted if no nodes remain
}
return {
...groupNote,
containedNodeIds: updatedNodeIds,
text: `Group note for ${updatedNodeIds.length} nodes`
}
}
/**
* Validates that all contained nodes exist in the provided nodes array
*/
export function validateGroupNote(groupNote: GroupNote, nodes: Node[]): GroupNote {
export function validateGroupNote(note: FlowNote, nodes: Node[]): FlowNote {
if (note.type !== 'group' || !note.contained_node_ids) {
return note
}
const validNodeIds = nodes.map(node => node.id)
const validContainedNodeIds = groupNote.containedNodeIds.filter(id => validNodeIds.includes(id))
const validContainedNodeIds = note.contained_node_ids.filter(id => validNodeIds.includes(id))
return {
...groupNote,
containedNodeIds: validContainedNodeIds,
text: `Group note for ${validContainedNodeIds.length} nodes`
}
}
/**
* Converts a legacy FlowNote to the new ExtendedFlowNote format
*/
export function convertToExtendedNote(note: FlowNote & { isGroupNote?: boolean; containedNodeIds?: string[] }): ExtendedFlowNote {
if (note.isGroupNote && note.containedNodeIds) {
// Convert legacy group note
return {
id: note.id,
type: 'group',
text: note.text,
color: note.color,
containedNodeIds: note.containedNodeIds
}
} else {
// Convert regular note
return {
id: note.id,
type: 'regular',
text: note.text,
position: note.position,
size: note.size,
color: note.color
}
...note,
contained_node_ids: validContainedNodeIds
}
}
@@ -1,12 +1,7 @@
import type { FlowNote } from '$lib/gen'
import type { Node } from '@xyflow/svelte'
import type { NoteColor } from './noteColors'
import {
createGroupNote,
isGroupNote,
calculateGroupNoteBounds,
convertToExtendedNote
} from './groupNoteUtils'
import { calculateNodesBounds } from './util'
type NodeDep = { id: string; parentIds?: string[]; offset?: number }
type NodePos = { position: { x: number; y: number } }
@@ -29,7 +24,9 @@ export class NoteManager {
text: newNoteFromTool.data?.text || '',
position: newNoteFromTool.position,
size: { width: newNoteFromTool.width || 300, height: newNoteFromTool.height || 100 },
color: newNoteFromTool.data?.color || 'yellow'
color: newNoteFromTool.data?.color || 'yellow',
type: 'free',
locked: false
}
return [...notes, newNote]
}
@@ -83,7 +80,7 @@ export class NoteManager {
* Update note lock state
*/
updateLock(notes: FlowNote[], noteId: string, locked: boolean): FlowNote[] {
return notes.map((note) => (note.id === noteId ? ({ ...note, locked } as any) : note))
return notes.map((note) => (note.id === noteId ? { ...note, locked } : note))
}
/**
@@ -92,46 +89,70 @@ export class NoteManager {
createGroupNote(notes: FlowNote[], selectedNodeIds: string[]): FlowNote[] {
if (selectedNodeIds.length === 0) return notes
try {
const groupNote = createGroupNote(selectedNodeIds)
// For now, we need to store group notes as FlowNote format with additional properties
// We'll add dummy position/size that will be calculated dynamically in convertNotesToNodes
const newGroupNote = {
...groupNote,
position: { x: 0, y: 0 }, // Dummy values, will be calculated dynamically
size: { width: 300, height: 100 }, // Dummy values, will be calculated dynamically
locked: false, // Group notes are not locked, just not movable/resizable
isGroupNote: true,
containedNodeIds: groupNote.containedNodeIds,
type: 'group'
} as FlowNote & {
locked: boolean
isGroupNote: boolean
containedNodeIds: string[]
type: string
const newGroupNote: FlowNote = {
id: `group-${Date.now()}`,
text: '',
position: { x: 0, y: 0 }, // Will be calculated dynamically
size: { width: 300, height: 100 }, // Will be calculated dynamically
color: 'gray',
type: 'group',
locked: false,
contained_node_ids: selectedNodeIds
}
return [...notes, newGroupNote]
}
/**
* Calculate position and size for group notes based on contained nodes
*/
calculateGroupNoteLayout(
note: FlowNote,
nodes: Node[],
textHeight: number = 60
): { position: { x: number; y: number }; size: { width: number; height: number } } {
if (note.type !== 'group' || !note.contained_node_ids?.length) {
return { position: note.position, size: note.size }
}
const containedNodes = nodes.filter((node) => note.contained_node_ids?.includes(node.id))
if (containedNodes.length === 0) {
return { position: note.position, size: note.size }
}
// Find bounds of all contained nodes
const bounds = calculateNodesBounds(containedNodes)
const padding = 20
return {
position: {
x: bounds.minX - padding,
y: bounds.minY - textHeight - padding
},
size: {
width: bounds.maxX - bounds.minX + 2 * padding,
height: bounds.maxY - bounds.minY + textHeight + 2 * padding
}
return [...notes, newGroupNote]
} catch (error) {
console.error('Failed to create group note:', error)
return notes
}
}
/**
* Helper function to determine if a node needs additional spacing above it for group notes.
* Returns the height needed above the node.
* Returns the height needed above the node (text height + padding).
*/
getGroupNoteHeightForNode(
notes: FlowNote[],
nodeId: string,
layoutedNodes: (NodeDep & NodePos)[]
layoutedNodes: (NodeDep & NodePos)[],
noteTextHeights: Record<string, number>
): number {
const PADDING = 20 // Fixed padding above and below the note text
for (const note of notes) {
const extendedNote = convertToExtendedNote(note as any)
if (isGroupNote(extendedNote) && extendedNote.containedNodeIds.includes(nodeId)) {
if (note.type === 'group' && note.contained_node_ids?.includes(nodeId)) {
// Find the topmost node in this group by Y position
const containedNodes = layoutedNodes.filter((node) =>
extendedNote.containedNodeIds.includes(node.id)
note.contained_node_ids?.includes(node.id)
)
if (containedNodes.length > 0) {
@@ -141,7 +162,9 @@ export class NoteManager {
// If this is the topmost node in the group, return the needed height
if (topmostNode.id === nodeId) {
return 60 // Height for group note text
// Use actual text height if available, otherwise default to 60
const textHeight = noteTextHeights[note.id] || 60
return textHeight + PADDING
}
}
}
@@ -149,98 +172,77 @@ export class NoteManager {
return 0
}
/**
* Create common data object for note nodes
*/
private createNoteData(
note: FlowNote,
notes: FlowNote[],
onNotesChange: (notes: FlowNote[]) => void,
onTextHeightChange: (noteId: string, height: number) => void,
isGroupNote: boolean
) {
return {
text: note.text,
color: note.color,
locked: note.locked || false,
isGroupNote,
...(isGroupNote && { containedNodeIds: note.contained_node_ids || [] }),
onUpdate: (text: string) => {
const newNotes = this.updateText(notes, note.id, text)
onNotesChange(newNotes)
},
onDelete: () => {
const newNotes = this.delete(notes, note.id)
onNotesChange(newNotes)
},
onColorChange: (color: NoteColor) => {
const newNotes = this.updateColor(notes, note.id, color)
onNotesChange(newNotes)
},
onSizeChange: (size: { width: number; height: number }) => {
const newNotes = this.updateSize(notes, note.id, size)
onNotesChange(newNotes)
},
onLockToggle: (locked: boolean) => {
const newNotes = this.updateLock(notes, note.id, locked)
onNotesChange(newNotes)
},
onTextHeightChange: (textHeight: number) => {
onTextHeightChange(note.id, textHeight)
}
}
}
/**
* Convert notes to SvelteFlow nodes
*/
convertToNodes(
notes: FlowNote[],
currentNodes: Node[],
onNotesChange: (notes: FlowNote[]) => void
textHeights: Record<string, number>,
onNotesChange: (notes: FlowNote[]) => void,
onTextHeightChange: (noteId: string, height: number) => void
): Node[] {
return notes.map((note) => {
const extendedNote = convertToExtendedNote(note as any)
const isGroupNote = note.type === 'group'
if (isGroupNote(extendedNote)) {
// Calculate dynamic bounds for group notes
const bounds = calculateGroupNoteBounds(extendedNote, currentNodes, 60)
// 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: extendedNote.id,
type: 'note',
position: bounds.position,
data: {
text: extendedNote.text,
color: extendedNote.color,
locked: false, // Group notes are not locked - they can be edited
isGroupNote: true,
containedNodeIds: extendedNote.containedNodeIds,
onUpdate: (text: string) => {
const newNotes = this.updateText(notes, extendedNote.id, text)
onNotesChange(newNotes)
},
onDelete: () => {
const newNotes = this.delete(notes, extendedNote.id)
onNotesChange(newNotes)
},
onColorChange: (color: NoteColor) => {
const newNotes = this.updateColor(notes, extendedNote.id, color)
onNotesChange(newNotes)
},
onSizeChange: (size: { width: number; height: number }) => {
const newNotes = this.updateSize(notes, extendedNote.id, size)
onNotesChange(newNotes)
},
onLockToggle: (locked: boolean) => {
const newNotes = this.updateLock(notes, extendedNote.id, locked)
onNotesChange(newNotes)
}
},
style: `width: ${bounds.size.width}px; height: ${bounds.size.height}px;`,
width: bounds.size.width,
height: bounds.size.height,
zIndex: -2000,
draggable: false, // Group notes cannot be moved - position is determined by contained nodes
selectable: true
}
} else {
// Handle regular notes
return {
id: extendedNote.id,
type: 'note',
position: extendedNote.position,
data: {
text: extendedNote.text,
color: extendedNote.color,
locked: (note as any).locked || false,
isGroupNote: false,
onUpdate: (text: string) => {
const newNotes = this.updateText(notes, extendedNote.id, text)
onNotesChange(newNotes)
},
onDelete: () => {
const newNotes = this.delete(notes, extendedNote.id)
onNotesChange(newNotes)
},
onColorChange: (color: NoteColor) => {
const newNotes = this.updateColor(notes, extendedNote.id, color)
onNotesChange(newNotes)
},
onSizeChange: (size: { width: number; height: number }) => {
const newNotes = this.updateSize(notes, extendedNote.id, size)
onNotesChange(newNotes)
},
onLockToggle: (locked: boolean) => {
const newNotes = this.updateLock(notes, extendedNote.id, locked)
onNotesChange(newNotes)
}
},
style: `width: ${extendedNote.size.width}px; height: ${extendedNote.size.height}px;`,
width: extendedNote.size.width,
height: extendedNote.size.height,
zIndex: -2000,
draggable: !(note as any).locked, // Don't allow dragging locked notes
selectable: true
}
return {
id: note.id,
type: 'note',
position,
data: this.createNoteData(note, notes, onNotesChange, onTextHeightChange, isGroupNote),
style: `width: ${size.width}px; height: ${size.height}px;`,
width: size.width,
height: size.height,
zIndex: -2000,
draggable: isGroupNote ? false : !note.locked,
selectable: true
}
})
}
@@ -19,6 +19,7 @@
onColorChange?: (color: NoteColor) => void
onSizeChange?: (size: { width: number; height: number }) => void
onLockToggle?: (locked: boolean) => void
onTextHeightChange?: (height: number) => void
}
selected?: boolean
dragging?: boolean
@@ -27,57 +28,14 @@
let { data, selected = false, dragging = false }: Props = $props()
let textareaElement: HTMLTextAreaElement | undefined = $state(undefined)
let contentElement: HTMLDivElement | undefined = $state(undefined)
let editMode = $state(false)
let hovering = $state(false)
let textContent = $state(data.text ?? '')
let contentHeight = $state(0)
function calculateContentHeight() {
let calculatedHeight = 0
let currentWidth = 300 // Default width
if (editMode && textareaElement) {
// For textarea in edit mode
textareaElement.style.height = 'auto'
const scrollHeight = textareaElement.scrollHeight
const minHeight = 60 // Minimum height in pixels
const maxHeight = 400 // Maximum height in pixels
calculatedHeight = Math.max(minHeight, Math.min(maxHeight, scrollHeight))
textareaElement.style.height = `${calculatedHeight}px`
// Get current width from parent element
const parentElement = textareaElement.closest('.svelte-flow__node')
if (parentElement) {
currentWidth = parentElement.getBoundingClientRect().width
}
} else if (!editMode && contentElement) {
// For content in display mode
const scrollHeight = contentElement.scrollHeight
const minHeight = 60
const maxHeight = 400
calculatedHeight = Math.max(minHeight, Math.min(maxHeight, scrollHeight))
// Get current width from parent element
const parentElement = contentElement.closest('.svelte-flow__node')
if (parentElement) {
currentWidth = parentElement.getBoundingClientRect().width
}
}
contentHeight = calculatedHeight
// Update note size if handler is available
if (data.onSizeChange && contentHeight > 0) {
data.onSizeChange({ width: currentWidth, height: contentHeight + 40 }) // Add extra padding for note UI
}
}
let containerHeight = $state(0)
function handleTextSave() {
// Only update parent when done editing
data.onUpdate?.(textContent)
// Recalculate height after saving
setTimeout(calculateContentHeight, 0)
}
function handleDelete(event?: Event) {
@@ -101,7 +59,6 @@
const colorConfig = $derived(NOTE_COLORS[data.color] || NOTE_COLORS[DEFAULT_NOTE_COLOR])
function handleDoubleClick(event: Event) {
console.log('Double click detected', { editMode, selected, dragging })
event.preventDefault()
event.stopPropagation()
@@ -115,7 +72,6 @@
// Focus the textarea after a short delay to ensure it's rendered
setTimeout(() => {
textareaElement?.focus()
calculateContentHeight()
}, 0)
}
@@ -135,18 +91,10 @@
}
})
// Auto-resize when content changes
// Track content height and notify parent
$effect(() => {
textContent // Track textContent changes
if (editMode || contentElement) {
setTimeout(calculateContentHeight, 0)
}
})
// Calculate initial content height
$effect(() => {
if (data.text && !editMode && contentElement) {
setTimeout(calculateContentHeight, 0)
if (containerHeight > 0) {
data.onTextHeightChange?.(containerHeight)
}
})
@@ -239,34 +187,40 @@
{/if}
<!-- Note content -->
<div class="w-full min-h-[60px] max-h-[400px] rounded-md">
<div
bind:clientHeight={containerHeight}
class="w-full min-h-[60px] max-h-[400px] h-fit rounded-md"
>
{#if editMode}
<!-- Edit mode: show textarea -->
<textarea
bind:this={textareaElement}
bind:value={textContent}
class={twMerge(
'windmillapp w-full 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 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',
colorConfig.text
)}
placeholder="Add your note here... (Markdown supported)"
onblur={handleTextSave}
oninput={calculateContentHeight}
spellcheck="false"
style="height: auto;"
style="field-sizing: content;"
></textarea>
{:else}
<!-- Render mode: show markdown or empty state -->
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div
bind:this={contentElement}
class={twMerge(
'w-full min-h-[60px] max-h-[400px] overflow-auto cursor-pointer flex items-start justify-start rounded-md p-4'
'w-full h-fit min-h-[60px] max-h-[400px] overflow-auto cursor-pointer flex items-start justify-start rounded-md p-4'
)}
ondblclick={handleDoubleClick}
>
{#if data.text}
<div class={twMerge('w-full text-xs rounded-md', colorConfig.text)}>
<div
class={twMerge(
'w-full text-xs rounded-md break-words overflow-hidden',
colorConfig.text
)}
>
<GfmMarkdown md={data.text} noPadding />
</div>
{:else}
+24
View File
@@ -126,3 +126,27 @@ export function getNodeColorClasses(state: FlowNodeState, selected: boolean): Fl
return r
}
/**
* Calculate the bounding box for a collection of nodes
* @param nodes - Array of nodes with position.x and position.y properties
* @returns The bounds { minX, minY, maxX, maxY }
*/
export function calculateNodesBounds(
nodes: Array<{ position: { x: number; y: number } }>
): { minX: number; minY: number; maxX: number; maxY: number } {
return nodes.reduce(
(acc, node) => ({
minX: Math.min(acc.minX, node.position.x),
minY: Math.min(acc.minY, node.position.y),
maxX: Math.max(acc.maxX, node.position.x + NODE.width),
maxY: Math.max(acc.maxY, node.position.y + NODE.height)
}),
{
minX: Infinity,
minY: Infinity,
maxX: -Infinity,
maxY: -Infinity
}
)
}
+14
View File
@@ -143,12 +143,26 @@ components:
color:
type: string
description: Color of the note (e.g., "yellow", "#ffff00")
type:
type: string
enum: [free, group]
description: Type of note - 'free' for standalone notes, 'group' for notes that group other nodes
locked:
type: boolean
default: false
description: Whether the note is locked and cannot be edited or moved
contained_node_ids:
type: array
items:
type: string
description: For group notes, the IDs of nodes contained within this group
required:
- id
- text
- position
- size
- color
- type
RetryIf:
type: object