mirror of
https://github.com/windmill-labs/windmill.git
synced 2026-09-07 00:01:49 +00:00
* Add note component
* save note size and position
* move add note button up
* nit
* Add markdown support
* wip
* fix add sticky note button
* fix text update
* Add sticky note to saved flow data
* add note color picker
* Introduce node multiselect
* Add group notes
* Adapt layout to group node
* create a note manager class
* clean reactivity
* clean
* improve adaptive layout to group note
* modify layout based on cached text height
* fined grained graph rendering for notes
* separate noteManager into editor and render
* separate noteManager into editor and render
* create a note change observer
* render note node from context
* simplify note state managment
* show note in flow viewer
* clean dirty changes
* clean selection manager
* fix layout check
* improve bg surface select
* Handle z-index for stacked group notes
* clean selection manager
* exclude notes from rect select
* Allow switch between selection modes with keyboard keys
* improve selection box styling
* prevent dragging note when editing
* nit
* Simplify selection using svelte flow built in feature
* handle note selection separately
* Add min size for notes
* improve selection toggle
* improve mode switch
* make size and position optional for group notes
* Improve initial viewport position
* Add context menu for the canevas
* nit
* Add node context menu
* improve note select
* use clickoutside for note deselect
* use pointerdown outside to close context menu
* nit
* fix selection issues
* make edges non selectable
* improve color palette
* fix backend
* fix backend check
* cargo lock restore
* Add toggle to display notes
* fix note selection
* nit
* account for css offset in for loop
* fix multiple selection pannel styling
* clear flow selection when creating note
* Improve placeholder and note default text
* Escape note edit mode when pressing Esc
* Allow note edition in local dev
* clean
* Handle subflow selection
* prevent group note resizing
* nit
* allow notes in flow expand
* Improve multi select panel
* Allow context menu in note mode
* Add event listenner to fix pane click deselect
* prevent zoom in text area in notes
* improve bounding box styling
* Use control for box selection for non mac users
* nit
* clean notes groups
* nit
* use portal for note actions
* handle assets node when computing note layout
* Simplify layout compute for notes
* use smart color choice for notes
* Switch display note when adding a new note
* clean code
* improve group note bound size calculation
* simplify AI tool nodes and asset handling
* nit
* nit
* improve flow centering
* create group note button
* Improve selection of nodes
* Revert "Improve selection of nodes"
This reverts commit d2c40d82b1.
* refert backend changes
* nit
* improve graph selection
* clean
* make backend work except job runs
* fix notSelectable
---------
Co-authored-by: Ruben Fiszel <ruben@windmill.dev>
421 lines
12 KiB
TypeScript
421 lines
12 KiB
TypeScript
import type { FlowNote } from '$lib/gen'
|
|
import type { Node } from '@xyflow/svelte'
|
|
import { deepEqual } from 'fast-equals'
|
|
import { calculateNodesBoundsWithOffset } from './util'
|
|
import { MIN_NOTE_WIDTH, MIN_NOTE_HEIGHT } from './noteColors'
|
|
import type { NodeLayout } from './graphBuilder.svelte'
|
|
import { topologicalSort } from './graphBuilder.svelte'
|
|
import type { AssetWithAltAccessType } from '../assets/lib'
|
|
import type { NoteEditorContext } from './noteEditor.svelte'
|
|
import { StickyNote } from 'lucide-svelte'
|
|
|
|
export type NodeDep = {
|
|
id: string
|
|
position: { x: number; y: number }
|
|
data?: { assets?: AssetWithAltAccessType[] }
|
|
parentIds?: string[]
|
|
offset?: number
|
|
type?: string
|
|
}
|
|
|
|
export type NoteComputeResult = {
|
|
noteNodes: (Node & NodeLayout)[]
|
|
newNodePositions: Record<string, { x: number; y: number }>
|
|
}
|
|
|
|
export type AIToolSpacingInfo = {
|
|
toolNodes: (Node & NodeLayout)[]
|
|
toolEdges: any[]
|
|
newNodePositions: Record<string, { x: number; y: number }>
|
|
}
|
|
|
|
export interface GroupNoteBounds {
|
|
x: number
|
|
y: number
|
|
width: number
|
|
height: number
|
|
}
|
|
|
|
let computeNoteNodesCache:
|
|
| [NodeDep[], FlowNote[], Record<string, number>, NoteComputeResult]
|
|
| undefined
|
|
|
|
/**
|
|
* 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))
|
|
}
|
|
|
|
/**
|
|
* Calculates z-index values for all notes
|
|
* Group notes are ordered by their topmost node's hierarchy position
|
|
* Free notes get undefined z-index to use SvelteFlow's native behavior
|
|
*/
|
|
export function calculateAllNoteZIndexes(
|
|
notes: FlowNote[],
|
|
nodes: NodeDep[]
|
|
): Record<string, number | undefined> {
|
|
const zIndexMap: Record<string, number | undefined> = {}
|
|
|
|
// Use topological sort to get proper hierarchy order based on parentIds relationships
|
|
const sortedNodes = topologicalSort(nodes).reverse()
|
|
|
|
// Create a mapping from node ID to its hierarchy position (topological order)
|
|
const nodeHierarchyMap: Record<string, number> = {}
|
|
sortedNodes.forEach((node, index) => {
|
|
nodeHierarchyMap[node.id] = index
|
|
})
|
|
|
|
// Process each note
|
|
for (const note of notes) {
|
|
if (note.type === 'free') {
|
|
// Free notes use SvelteFlow's native z-index behavior (last selected on top)
|
|
zIndexMap[note.id] = undefined
|
|
} else if (note.type === 'group') {
|
|
// Group notes get z-index based on topmost contained node's hierarchy
|
|
// Since sortedNodes is in topological order, the first matching node is the topmost
|
|
const topmostNode = sortedNodes.find((node) => note.contained_node_ids?.includes(node.id))
|
|
|
|
if (topmostNode) {
|
|
const hierarchyPosition = nodeHierarchyMap[topmostNode.id] ?? 0
|
|
// Higher hierarchy position = lower z-index (appears behind)
|
|
// Use negative values starting from -2000 to stay below other elements
|
|
zIndexMap[note.id] = hierarchyPosition - 2000
|
|
} else {
|
|
// Fallback for group notes without valid contained nodes
|
|
zIndexMap[note.id] = -2000
|
|
}
|
|
}
|
|
}
|
|
|
|
return zIndexMap
|
|
}
|
|
|
|
/**
|
|
* Calculate extra spacing needed for asset nodes of the topmost node
|
|
*/
|
|
function calculateExtraAssetSpacing(topmostNodeId: string, nodes: NodeDep[]): number {
|
|
// Find the topmost node position
|
|
const topmostNode = nodes.find((n) => n.id === topmostNodeId)
|
|
if (!topmostNode) {
|
|
return 0
|
|
}
|
|
|
|
// Find actual asset nodes for the topmost node: {topmostNodeId}-asset-in, type 'asset'
|
|
const assetNodes = nodes.filter((n) => n.id.startsWith(`${topmostNodeId}-asset-in-`))
|
|
|
|
if (assetNodes.length === 0) {
|
|
return 0
|
|
}
|
|
|
|
// Calculate the spacing based on actual asset node positions
|
|
const assetSpacing = Math.max(
|
|
...assetNodes.map((assetNode) => {
|
|
// Calculate how much space the asset node takes above the main node
|
|
return Math.max(0, -assetNode.position.y)
|
|
})
|
|
)
|
|
|
|
return assetSpacing
|
|
}
|
|
|
|
/**
|
|
* Calculate extra spacing needed for AI tool nodes of the topmost node
|
|
*/
|
|
function calculateExtraAIToolSpacing(topmostNodeId: string, nodes: NodeDep[]): number {
|
|
// Find the topmost node position
|
|
const topmostNode = nodes.find((n) => n.id === topmostNodeId)
|
|
if (!topmostNode) {
|
|
return 0
|
|
}
|
|
|
|
// Find actual AI tool nodes for the topmost node: {topmostNodeId}-tool-, type 'aiTool'
|
|
const toolNodes = nodes.filter((n) => n.id.startsWith(`${topmostNodeId}-tool-`))
|
|
|
|
if (toolNodes.length === 0) {
|
|
return 0
|
|
}
|
|
|
|
// Calculate the spacing based on actual AI tool node positions
|
|
const toolSpacing = Math.max(
|
|
...toolNodes.map((toolNode) => {
|
|
// Calculate how much space the tool node takes above/below the main node
|
|
return Math.max(0, -toolNode.position.y)
|
|
})
|
|
)
|
|
|
|
return toolSpacing
|
|
}
|
|
|
|
/**
|
|
* Calculate position and size for group notes based on contained nodes
|
|
*/
|
|
function calculateGroupNoteLayout(
|
|
note: FlowNote,
|
|
nodes: NodeDep[],
|
|
textHeight: number = 60,
|
|
topMostNodeId: string
|
|
): { position: { x: number; y: number }; size: { width: number; height: number } } {
|
|
if (note.type !== 'group' || !note.contained_node_ids?.length) {
|
|
return {
|
|
position: note.position ?? { x: 0, y: 0 },
|
|
size: note.size ?? { width: MIN_NOTE_WIDTH, height: MIN_NOTE_HEIGHT }
|
|
}
|
|
}
|
|
|
|
const containedNodes = nodes.filter((node) => note.contained_node_ids?.includes(node.id))
|
|
|
|
if (containedNodes.length === 0) {
|
|
return {
|
|
position: note.position ?? { x: 0, y: 0 },
|
|
size: note.size ?? { width: MIN_NOTE_WIDTH, height: MIN_NOTE_HEIGHT }
|
|
}
|
|
}
|
|
|
|
const bounds = calculateNodesBoundsWithOffset(
|
|
note.contained_node_ids || [],
|
|
nodes.map((n) => ({
|
|
id: n.id,
|
|
position: n.position,
|
|
data: { offset: n.offset ?? 0 },
|
|
type: n.type ?? ''
|
|
}))
|
|
)
|
|
|
|
const padding = 16
|
|
|
|
// Calculate extra spacing for asset nodes and AI tool nodes of the topmost node
|
|
const extraAssetSpacing = topMostNodeId ? calculateExtraAssetSpacing(topMostNodeId, nodes) : 0
|
|
|
|
const extraAIToolSpacing = topMostNodeId ? calculateExtraAIToolSpacing(topMostNodeId, nodes) : 0
|
|
|
|
const totalTextHeight = textHeight + extraAssetSpacing + extraAIToolSpacing
|
|
|
|
return {
|
|
position: {
|
|
x: bounds.minX - padding,
|
|
y: bounds.minY - totalTextHeight - padding
|
|
},
|
|
size: {
|
|
width: bounds.maxX - bounds.minX + 2 * padding,
|
|
height: bounds.maxY - bounds.minY + totalTextHeight + 2 * padding
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Create common data object for note nodes
|
|
*/
|
|
function createNoteData(
|
|
note: FlowNote,
|
|
onTextHeightChange: (noteId: string, height: number) => void,
|
|
isGroupNote: boolean,
|
|
editMode: boolean
|
|
) {
|
|
return {
|
|
noteId: note.id,
|
|
text: note.text,
|
|
color: note.color,
|
|
locked: note.locked || false,
|
|
isGroupNote,
|
|
editMode,
|
|
...(isGroupNote && { containedNodeIds: note.contained_node_ids || [] }),
|
|
onTextHeightChange: (textHeight: number) => {
|
|
onTextHeightChange(note.id, textHeight)
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Main function to compute note nodes and adjust nodes position based on group notes
|
|
*/
|
|
export function computeNoteNodes(
|
|
nodes: NodeDep[],
|
|
notes: FlowNote[],
|
|
noteTextHeights: Record<string, number>,
|
|
onTextHeightChange: (noteId: string, height: number) => void,
|
|
editMode: boolean = false,
|
|
noteEditorContext: NoteEditorContext | undefined
|
|
): NoteComputeResult {
|
|
// Check cache first
|
|
if (
|
|
computeNoteNodesCache &&
|
|
deepEqual(nodes, computeNoteNodesCache[0]) &&
|
|
deepEqual(notes, computeNoteNodesCache[1]) &&
|
|
deepEqual(noteTextHeights, computeNoteNodesCache[2])
|
|
) {
|
|
return computeNoteNodesCache[3]
|
|
}
|
|
|
|
if (editMode) {
|
|
if (noteEditorContext?.noteEditor?.isAvailable()) {
|
|
noteEditorContext.noteEditor.cleanupGroupNotes(nodes)
|
|
}
|
|
}
|
|
|
|
const allNoteNodes: (Node & NodeLayout)[] = []
|
|
|
|
// Build a map of Y positions that need extra spacing for group notes
|
|
const yPosMap: Record<number, number> = {} // Y position -> spacing needed
|
|
|
|
// Group notes that need spacing
|
|
const groupNotes = notes.filter((n) => n.type === 'group')
|
|
|
|
const topMostNodesMap: Record<string, string> = {}
|
|
|
|
const sortedNodes = topologicalSort(nodes).reverse()
|
|
|
|
for (const groupNote of groupNotes) {
|
|
if (groupNote.contained_node_ids?.length) {
|
|
const topmostNodeId = sortedNodes.find((node) =>
|
|
groupNote.contained_node_ids?.includes(node.id)
|
|
)?.id
|
|
const topmostNode = nodes.find((node) => node.id === topmostNodeId)
|
|
if (topmostNode) {
|
|
const textHeight = noteTextHeights[groupNote.id] || 60
|
|
const spacing = textHeight + 16 // padding
|
|
// Mark this Y position as needing spacing
|
|
yPosMap[topmostNode.position.y] = Math.max(yPosMap[topmostNode.position.y] || 0, spacing)
|
|
topMostNodesMap[groupNote.id] = topmostNode.id
|
|
}
|
|
}
|
|
}
|
|
|
|
// Calculate new positions for nodes (offset by group notes)
|
|
const sortedNewNodes = nodes
|
|
.map((n) => ({ position: { ...n.position }, id: n.id }))
|
|
.sort((a, b) => a.position.y - b.position.y)
|
|
|
|
let currentYOffset = 0
|
|
let prevYPos = NaN
|
|
|
|
for (const node of sortedNewNodes) {
|
|
if (node.position.y !== prevYPos) {
|
|
// Add spacing for group notes at this Y level
|
|
if (yPosMap[node.position.y]) {
|
|
currentYOffset += yPosMap[node.position.y]
|
|
}
|
|
prevYPos = node.position.y
|
|
}
|
|
node.position.y += currentYOffset
|
|
}
|
|
|
|
// Create note nodes AFTER calculating adjusted node positions
|
|
// For group notes, we need to use the adjusted node positions
|
|
const adjustedNodes = sortedNewNodes.map((n) => {
|
|
const origNode = nodes.find((orig) => orig.id === n.id)
|
|
return {
|
|
...n,
|
|
data: origNode?.data,
|
|
offset: origNode?.offset,
|
|
type: origNode?.type
|
|
}
|
|
})
|
|
|
|
// Calculate all z-indexes at once using hierarchy information
|
|
const noteZIndexes = calculateAllNoteZIndexes(notes, nodes)
|
|
|
|
for (const note of notes) {
|
|
const isGroupNote = note.type === 'group'
|
|
const zIndex = noteZIndexes[note.id]
|
|
|
|
// Calculate position and size using adjusted node positions for group notes
|
|
const { position, size } = isGroupNote
|
|
? calculateGroupNoteLayout(
|
|
note,
|
|
adjustedNodes,
|
|
noteTextHeights[note.id] || 60,
|
|
topMostNodesMap[note.id]
|
|
)
|
|
: {
|
|
position: note.position ?? { x: 0, y: 0 },
|
|
size: note.size ?? { width: MIN_NOTE_WIDTH, height: MIN_NOTE_HEIGHT }
|
|
}
|
|
|
|
// Create the note node
|
|
const noteNode: Node & NodeLayout = {
|
|
id: note.id,
|
|
type: 'note' as any, // Note nodes are handled specially
|
|
position,
|
|
width: size.width,
|
|
height: size.height,
|
|
zIndex,
|
|
draggable: isGroupNote ? false : editMode && !note.locked,
|
|
selectable: false,
|
|
data: createNoteData(note, onTextHeightChange, isGroupNote, editMode) as any
|
|
}
|
|
|
|
allNoteNodes.push(noteNode)
|
|
}
|
|
|
|
const newNodePositions: Record<string, { x: number; y: number }> = Object.fromEntries(
|
|
sortedNewNodes.map((n) => [n.id, n.position])
|
|
)
|
|
|
|
const result: NoteComputeResult = {
|
|
noteNodes: allNoteNodes,
|
|
newNodePositions
|
|
}
|
|
|
|
// Cache the result
|
|
computeNoteNodesCache = [
|
|
structuredClone($state.snapshot(nodes)),
|
|
structuredClone($state.snapshot(notes)),
|
|
structuredClone($state.snapshot(noteTextHeights)),
|
|
result
|
|
]
|
|
|
|
return result
|
|
}
|
|
|
|
export function addGroupNoteContextMenuItem(
|
|
nodeId: string,
|
|
noteEditorContext: NoteEditorContext | undefined
|
|
) {
|
|
const isDisabled =
|
|
!noteEditorContext?.noteEditor ||
|
|
(noteEditorContext?.noteEditor?.isNodeOnlyMemberOfGroupNote(nodeId) ?? false)
|
|
|
|
return {
|
|
id: 'add-group-note',
|
|
label: 'Add note',
|
|
icon: StickyNote,
|
|
disabled: isDisabled,
|
|
onClick: () => {
|
|
if (noteEditorContext?.noteEditor && !isDisabled) {
|
|
noteEditorContext.noteEditor.createGroupNote([nodeId])
|
|
}
|
|
}
|
|
}
|
|
}
|