mirror of
https://github.com/windmill-labs/windmill.git
synced 2026-09-10 00:05:27 +00:00
Adapt layout to group node
This commit is contained in:
@@ -65,7 +65,12 @@
|
||||
import NodeContextMenu from './NodeContextMenu.svelte'
|
||||
import { SelectionManager } from './selectionUtils.svelte'
|
||||
import { ChangeTracker } from '$lib/svelte5Utils.svelte'
|
||||
import { createGroupNote } from './groupNoteUtils'
|
||||
import {
|
||||
createGroupNote,
|
||||
isGroupNote,
|
||||
calculateGroupNoteBounds,
|
||||
convertToExtendedNote
|
||||
} from './groupNoteUtils'
|
||||
import type { ModulesTestStates } from '../modulesTest.svelte'
|
||||
import { deepEqual } from 'fast-equals'
|
||||
import type { AssetWithAltAccessType } from '../assets/lib'
|
||||
@@ -314,7 +319,7 @@
|
||||
}
|
||||
|
||||
const yOffset = insertable ? 100 : 0
|
||||
const newNodes = dag.descendants().map((des) => ({
|
||||
const initialNodes = dag.descendants().map((des) => ({
|
||||
id: des.data.id,
|
||||
position: {
|
||||
x: des.x
|
||||
@@ -331,8 +336,39 @@
|
||||
}
|
||||
}))
|
||||
|
||||
lastNodes = [nodes, newNodes]
|
||||
return newNodes
|
||||
// Apply group note spacing adjustments
|
||||
// First, collect all nodes that need spacing above them
|
||||
const spacingMap = new Map<string, number>()
|
||||
for (const node of initialNodes) {
|
||||
const groupNoteHeight = getGroupNoteHeightForNode(node.id, initialNodes)
|
||||
if (groupNoteHeight > 0) {
|
||||
spacingMap.set(node.id, groupNoteHeight)
|
||||
}
|
||||
}
|
||||
|
||||
// Apply spacing - move nodes down by the cumulative spacing above them
|
||||
const adjustedNodes = initialNodes.map(node => {
|
||||
let totalSpacingAbove = 0
|
||||
|
||||
// Calculate total spacing needed above this node from all group notes above it
|
||||
for (const [spacingNodeId, spacing] of spacingMap) {
|
||||
const spacingNode = initialNodes.find(n => n.id === spacingNodeId)
|
||||
if (spacingNode && spacingNode.position.y <= node.position.y) {
|
||||
totalSpacingAbove += spacing
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
...node,
|
||||
position: {
|
||||
...node.position,
|
||||
y: node.position.y + totalSpacingAbove
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
lastNodes = [nodes, adjustedNodes]
|
||||
return adjustedNodes
|
||||
}
|
||||
|
||||
let eventHandler = {
|
||||
@@ -493,9 +529,23 @@
|
||||
if (selectedNodeIds.length === 0 || !onNotesChange) return
|
||||
|
||||
try {
|
||||
const groupNote = createGroupNote(selectedNodeIds, nodes)
|
||||
// Group notes are locked by default
|
||||
const lockedGroupNote = { ...groupNote, locked: true, isGroupNote: true }
|
||||
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 lockedGroupNote = {
|
||||
...groupNote,
|
||||
position: { x: 0, y: 0 }, // Dummy values, will be calculated dynamically
|
||||
size: { width: 300, height: 100 }, // Dummy values, will be calculated dynamically
|
||||
locked: true,
|
||||
isGroupNote: true,
|
||||
containedNodeIds: groupNote.containedNodeIds,
|
||||
type: 'group'
|
||||
} as FlowNote & {
|
||||
locked: boolean;
|
||||
isGroupNote: boolean;
|
||||
containedNodeIds: string[];
|
||||
type: string;
|
||||
}
|
||||
onNotesChange([...notes, lockedGroupNote])
|
||||
nextNoteId += 1
|
||||
} catch (error) {
|
||||
@@ -504,29 +554,92 @@
|
||||
updateStores()
|
||||
}
|
||||
|
||||
function convertNotesToNodes(): Node[] {
|
||||
return notes.map((note) => ({
|
||||
id: note.id,
|
||||
type: 'note',
|
||||
position: note.position,
|
||||
data: {
|
||||
text: note.text,
|
||||
color: note.color,
|
||||
locked: (note as any).locked || false,
|
||||
isGroupNote: note.id.startsWith('group-note-'),
|
||||
onUpdate: (text: string) => updateNoteText(note.id, text),
|
||||
onDelete: () => deleteNote(note.id),
|
||||
onColorChange: (color: NoteColor) => updateNoteColor(note.id, color),
|
||||
onSizeChange: (size: { width: number; height: number }) => updateNoteSize(note.id, size),
|
||||
onLockToggle: (locked: boolean) => updateNoteLock(note.id, locked)
|
||||
},
|
||||
style: `width: ${note.size.width}px; height: ${note.size.height}px;`,
|
||||
width: note.size.width,
|
||||
height: note.size.height,
|
||||
zIndex: -2000,
|
||||
draggable: !(note as any).locked, // Don't allow dragging locked notes
|
||||
selectable: true
|
||||
}))
|
||||
|
||||
/**
|
||||
* Helper function to determine if a node needs additional spacing above it for group notes.
|
||||
* Returns the height needed above the node.
|
||||
*/
|
||||
function getGroupNoteHeightForNode(nodeId: string, layoutedNodes: (NodeDep & NodePos)[]): number {
|
||||
for (const note of notes) {
|
||||
const extendedNote = convertToExtendedNote(note as any)
|
||||
if (isGroupNote(extendedNote) && extendedNote.containedNodeIds.includes(nodeId)) {
|
||||
// Find the topmost node in this group by Y position
|
||||
const containedNodes = layoutedNodes.filter(node =>
|
||||
extendedNote.containedNodeIds.includes(node.id)
|
||||
)
|
||||
|
||||
if (containedNodes.length > 0) {
|
||||
const topmostNode = containedNodes.reduce((topMost, node) =>
|
||||
node.position.y < topMost.position.y ? node : topMost
|
||||
)
|
||||
|
||||
// If this is the topmost node in the group, return the needed height
|
||||
if (topmostNode.id === nodeId) {
|
||||
return 60 // Height for group note text
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
function convertNotesToNodes(currentNodes: Node[]): Node[] {
|
||||
return notes.map((note) => {
|
||||
const extendedNote = convertToExtendedNote(note as any)
|
||||
|
||||
if (isGroupNote(extendedNote)) {
|
||||
// Calculate dynamic bounds for group notes
|
||||
const bounds = calculateGroupNoteBounds(extendedNote, currentNodes, 60)
|
||||
|
||||
return {
|
||||
id: extendedNote.id,
|
||||
type: 'note',
|
||||
position: bounds.position,
|
||||
data: {
|
||||
text: extendedNote.text,
|
||||
color: extendedNote.color,
|
||||
locked: (note as any).locked || true, // Group notes are locked by default
|
||||
isGroupNote: true,
|
||||
containedNodeIds: extendedNote.containedNodeIds,
|
||||
onUpdate: (text: string) => updateNoteText(extendedNote.id, text),
|
||||
onDelete: () => deleteNote(extendedNote.id),
|
||||
onColorChange: (color: NoteColor) => updateNoteColor(extendedNote.id, color),
|
||||
onSizeChange: (size: { width: number; height: number }) => updateNoteSize(extendedNote.id, size),
|
||||
onLockToggle: (locked: boolean) => updateNoteLock(extendedNote.id, locked)
|
||||
},
|
||||
style: `width: ${bounds.size.width}px; height: ${bounds.size.height}px;`,
|
||||
width: bounds.size.width,
|
||||
height: bounds.size.height,
|
||||
zIndex: -2000,
|
||||
draggable: !(note as any).locked, // Don't allow dragging locked notes
|
||||
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) => updateNoteText(extendedNote.id, text),
|
||||
onDelete: () => deleteNote(extendedNote.id),
|
||||
onColorChange: (color: NoteColor) => updateNoteColor(extendedNote.id, color),
|
||||
onSizeChange: (size: { width: number; height: number }) => updateNoteSize(extendedNote.id, size),
|
||||
onLockToggle: (locked: boolean) => updateNoteLock(extendedNote.id, locked)
|
||||
},
|
||||
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
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
async function updateStores() {
|
||||
@@ -560,11 +673,15 @@
|
||||
}))
|
||||
}
|
||||
let aiToolNodesResult = computeAIToolNodes(newNodes, eventHandler, insertable, flowModuleStates)
|
||||
nodes = [
|
||||
const finalNodes = [
|
||||
...newNodes.map((n) => ({ ...n, position: aiToolNodesResult.newNodePositions[n.id] })),
|
||||
...(assetNodesResult?.newAssetNodes ?? []),
|
||||
...aiToolNodesResult.toolNodes,
|
||||
...convertNotesToNodes()
|
||||
...aiToolNodesResult.toolNodes
|
||||
]
|
||||
|
||||
nodes = [
|
||||
...finalNodes,
|
||||
...convertNotesToNodes(finalNodes)
|
||||
]
|
||||
edges = [
|
||||
...(assetNodesResult?.newAssetEdges ?? []),
|
||||
|
||||
@@ -9,18 +9,46 @@ 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
|
||||
|
||||
/**
|
||||
* Computes the bounding box that wraps all selected nodes with padding
|
||||
* 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
|
||||
*/
|
||||
export function computeGroupNoteBounds(
|
||||
selectedNodeIds: string[],
|
||||
nodes: Node[]
|
||||
containedNodeIds: string[],
|
||||
nodes: Node[],
|
||||
textHeight: number = 60
|
||||
): GroupNoteBounds {
|
||||
const selectedNodes = nodes.filter(node => selectedNodeIds.includes(node.id))
|
||||
const containedNodes = nodes.filter(node => containedNodeIds.includes(node.id))
|
||||
|
||||
if (selectedNodes.length === 0) {
|
||||
throw new Error('No nodes selected for group note')
|
||||
if (containedNodes.length === 0) {
|
||||
throw new Error('No nodes contained in group note')
|
||||
}
|
||||
|
||||
// Calculate flow coordinates bounds using same logic as SelectionBoundingBox
|
||||
@@ -29,7 +57,7 @@ export function computeGroupNoteBounds(
|
||||
let minY = Infinity
|
||||
let maxY = -Infinity
|
||||
|
||||
selectedNodes.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)
|
||||
@@ -41,25 +69,38 @@ export function computeGroupNoteBounds(
|
||||
|
||||
return {
|
||||
x: minX - padding,
|
||||
y: minY - padding,
|
||||
y: minY - padding - textHeight, // Position text above the nodes
|
||||
width: maxX - minX + (padding * 2),
|
||||
height: maxY - minY + (padding * 2)
|
||||
height: maxY - minY + (padding * 2) + textHeight
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a new group note with computed position and size
|
||||
* Gets the topmost node from a list of contained nodes
|
||||
*/
|
||||
export function createGroupNote(
|
||||
selectedNodeIds: string[],
|
||||
export function getTopMostNode(containedNodeIds: string[], nodes: Node[]): Node | null {
|
||||
const containedNodes = nodes.filter(node => containedNodeIds.includes(node.id))
|
||||
|
||||
if (containedNodes.length === 0) {
|
||||
return null
|
||||
}
|
||||
|
||||
return containedNodes.reduce((topMost, node) =>
|
||||
node.position.y < topMost.position.y ? node : topMost
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculates the dynamic bounds for a group note
|
||||
*/
|
||||
export function calculateGroupNoteBounds(
|
||||
groupNote: GroupNote,
|
||||
nodes: Node[],
|
||||
color: string = 'yellow'
|
||||
): FlowNote {
|
||||
const bounds = computeGroupNoteBounds(selectedNodeIds, nodes)
|
||||
textHeight: number = 60
|
||||
): { position: { x: number; y: number }; size: { width: number; height: number } } {
|
||||
const bounds = computeGroupNoteBounds(groupNote.containedNodeIds, nodes, textHeight)
|
||||
|
||||
return {
|
||||
id: `group-note-${Date.now()}`,
|
||||
text: `Group note for ${selectedNodeIds.length} nodes`,
|
||||
position: {
|
||||
x: bounds.x,
|
||||
y: bounds.y
|
||||
@@ -67,7 +108,98 @@ export function createGroupNote(
|
||||
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 {
|
||||
const validNodeIds = nodes.map(node => node.id)
|
||||
const validContainedNodeIds = groupNote.containedNodeIds.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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -27,13 +27,57 @@
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
function handleTextSave() {
|
||||
// Only update parent when done editing
|
||||
data.onUpdate?.(textContent)
|
||||
// Recalculate height after saving
|
||||
setTimeout(calculateContentHeight, 0)
|
||||
}
|
||||
|
||||
function handleDelete(event?: Event) {
|
||||
@@ -71,6 +115,7 @@
|
||||
// Focus the textarea after a short delay to ensure it's rendered
|
||||
setTimeout(() => {
|
||||
textareaElement?.focus()
|
||||
calculateContentHeight()
|
||||
}, 0)
|
||||
}
|
||||
|
||||
@@ -90,6 +135,21 @@
|
||||
}
|
||||
})
|
||||
|
||||
// Auto-resize when content changes
|
||||
$effect(() => {
|
||||
textContent // Track textContent changes
|
||||
if (editMode || contentElement) {
|
||||
setTimeout(calculateContentHeight, 0)
|
||||
}
|
||||
})
|
||||
|
||||
// Calculate initial content height
|
||||
$effect(() => {
|
||||
if (data.text && !editMode && contentElement) {
|
||||
setTimeout(calculateContentHeight, 0)
|
||||
}
|
||||
})
|
||||
|
||||
let colorPickerIsOpen = $state(false)
|
||||
</script>
|
||||
|
||||
@@ -179,31 +239,34 @@
|
||||
{/if}
|
||||
|
||||
<!-- Note content -->
|
||||
<div class="h-full rounded-md">
|
||||
<div class="w-full min-h-[60px] max-h-[400px] rounded-md">
|
||||
{#if editMode}
|
||||
<!-- Edit mode: show textarea -->
|
||||
<textarea
|
||||
bind:this={textareaElement}
|
||||
bind:value={textContent}
|
||||
class={twMerge(
|
||||
'windmillapp w-full h-full min-h-0 shadow-none resize-none text-xs overflow-y-auto border-none rounded-md bg-transparent transition-colors p-4',
|
||||
'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',
|
||||
colorConfig.text
|
||||
)}
|
||||
placeholder="Add your note here... (Markdown supported)"
|
||||
onblur={handleTextSave}
|
||||
oninput={calculateContentHeight}
|
||||
spellcheck="false"
|
||||
style="height: auto;"
|
||||
></textarea>
|
||||
{:else}
|
||||
<!-- Render mode: show markdown or empty state -->
|
||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||
<div
|
||||
bind:this={contentElement}
|
||||
class={twMerge(
|
||||
'w-full h-full overflow-auto cursor-pointer flex items-start justify-center rounded-md p-4'
|
||||
'w-full 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 h-full text-xs rounded-md', colorConfig.text)}>
|
||||
<div class={twMerge('w-full text-xs rounded-md', colorConfig.text)}>
|
||||
<GfmMarkdown md={data.text} noPadding />
|
||||
</div>
|
||||
{:else}
|
||||
@@ -220,7 +283,7 @@
|
||||
<NodeResizer
|
||||
isVisible={selected && !dragging}
|
||||
minWidth={200}
|
||||
minHeight={100}
|
||||
minHeight={60}
|
||||
lineClass="!border-4 !border-transparent !rounded-md"
|
||||
handleClass="!bg-transparent !w-4 !h-4 !border-none !rounded-md"
|
||||
onResizeEnd={(_, params) => {
|
||||
|
||||
Reference in New Issue
Block a user