From b862a3dc620036b2e24cd59368353259f00e634b Mon Sep 17 00:00:00 2001 From: Guilhem Date: Mon, 23 Feb 2026 15:35:07 +0000 Subject: [PATCH] feat: improve group overlay with always-visible border, note-style spacing and action bar - Always-visible thin solid 1px border (no background, no dashed) - Push nodes down for group label space (same pattern as group notes) - Label positioned above the border, aligned to left edge - Flat action bar matching group note style (collapse, color picker, delete) - Remove Popover/Toggle, use inline NoteColorPicker and X delete button - Re-layout on group changes via derived dependency Co-Authored-By: Claude Opus 4.6 --- .../lib/components/graph/FlowGraphV2.svelte | 20 +- .../lib/components/graph/GroupOverlay.svelte | 294 +++++++----------- .../components/graph/groupEditor.svelte.ts | 55 ++++ 3 files changed, 182 insertions(+), 187 deletions(-) diff --git a/frontend/src/lib/components/graph/FlowGraphV2.svelte b/frontend/src/lib/components/graph/FlowGraphV2.svelte index b044b8e11f..3674f2a6a8 100644 --- a/frontend/src/lib/components/graph/FlowGraphV2.svelte +++ b/frontend/src/lib/components/graph/FlowGraphV2.svelte @@ -64,6 +64,7 @@ import NoteTool from './NoteTool.svelte' import SelectionBoundingBox from './SelectionBoundingBox.svelte' import GroupOverlay from './GroupOverlay.svelte' + import { getGroupEditorContext, computeGroupSpacing } from './groupEditor.svelte' import SelectionTool from './SelectionTool.svelte' import PaneContextMenu from './PaneContextMenu.svelte' import { SelectionManager } from './selectionUtils.svelte' @@ -274,6 +275,7 @@ const selectedId = $derived(selectionManager.getSelectedId()) const noteEditorContext = getNoteEditorContext() + const groupEditorContext = getGroupEditorContext() // Function to calculate extra gap needed for notes below the lowest flow nodes function calculateNoteGap(notes: FlowNote[] | undefined): number { @@ -698,6 +700,19 @@ })) } + // Apply group label spacing (pushes nodes down like group notes do) + const groups = groupEditorContext?.groupEditor.getGroups() ?? [] + if (groups.length > 0) { + const groupPositions = computeGroupSpacing( + groups, + finalNodes.map((n) => ({ id: n.id, position: n.position })) + ) + finalNodes = finalNodes.map((n) => ({ + ...n, + position: groupPositions[n.id] || n.position + })) + } + // update nodes nodes = [...finalNodes, ...(noteNodesResult?.noteNodes ?? [])] @@ -821,8 +836,11 @@ ) let hideNotesToggle = $derived(!notes || notes.length === 0) + // Track groups for re-layout when groups change + let currentGroups = $derived(groupEditorContext?.groupEditor.getGroups() ?? []) + $effect(() => { - ;[graph, allowSimplifiedPoll, $showAssets, showNotes, noteManager.renderCount] + ;[graph, allowSimplifiedPoll, $showAssets, showNotes, noteManager.renderCount, currentGroups] untrack(async () => { await updateStores() }) diff --git a/frontend/src/lib/components/graph/GroupOverlay.svelte b/frontend/src/lib/components/graph/GroupOverlay.svelte index 045929c5fc..26ace9eb81 100644 --- a/frontend/src/lib/components/graph/GroupOverlay.svelte +++ b/frontend/src/lib/components/graph/GroupOverlay.svelte @@ -1,12 +1,10 @@ - {#each allGroups as group (group.id)} - {@const labelBounds = computeGroupBounds(group)} - {#if labelBounds} + {@const bounds = computeGroupBounds(group)} + {#if bounds} +
{ - if (hideTimeout) { - clearTimeout(hideTimeout) - hideTimeout = undefined - } - hoveredLabelGroupId = group.id - visibleGroup = group - if (group.id && !(group.id in collapsedState)) { - collapsedState[group.id] = group.collapsed ?? false - } - }} - onpointerleave={() => { - hoveredLabelGroupId = null - if (!popoverOpen && !actionBarHovered && !editingSummary) { - hideTimeout = setTimeout(() => { - visibleGroup = undefined - }, 150) - } - }} > - {#if editingGroupId === group.id} - commitSummary(group.id)} - onkeydown={(e) => { - if (e.key === 'Enter') commitSummary(group.id) - if (e.key === 'Escape') { - editingGroupId = null + +
{ + if (hideTimeout) { + clearTimeout(hideTimeout) + hideTimeout = undefined + } + hoveredLabelGroupId = group.id + visibleGroup = group + if (group.id && !(group.id in collapsedState)) { + collapsedState[group.id] = group.collapsed ?? false + } + }} + onpointerleave={() => { + hoveredLabelGroupId = null + if (!colorPickerOpen && !actionBarHovered && !editingSummary) { + hideTimeout = setTimeout(() => { + visibleGroup = undefined + }, 150) + } + }} + > + {#if editingGroupId === group.id} + commitSummary(group.id)} + onkeydown={(e) => { + if (e.key === 'Enter') commitSummary(group.id) + if (e.key === 'Escape') { + editingGroupId = null + } + }} + autofocus + /> + {:else} + + {group.summary || 'Group'} + + {#if editMode && hoveredLabelGroupId === group.id} + + {/if} + {/if} +
+ + + {#if editMode && visibleGroup?.id === group.id} +
{ + actionBarHovered = true + if (hideTimeout) { + clearTimeout(hideTimeout) + hideTimeout = undefined } }} - autofocus - /> - {:else} - - {group.summary || 'Group'} - - {#if editMode && hoveredLabelGroupId === group.id} - - {/if} + onpointerleave={() => { + actionBarHovered = false + }} + > +
+
+
{/if}
{/if} {/each} - - -{#if visibleGroup && hoverBounds} - {@const group = visibleGroup} - {@const currentBounds = hoverBounds} - -
- - {#if editMode} -
{ - actionBarHovered = true - if (hideTimeout) { - clearTimeout(hideTimeout) - hideTimeout = undefined - } - }} - onpointerleave={() => { - actionBarHovered = false - }} - > - - -
- {/snippet} - -
- {/if} - -
-{/if} diff --git a/frontend/src/lib/components/graph/groupEditor.svelte.ts b/frontend/src/lib/components/graph/groupEditor.svelte.ts index 0967997bac..3420e889bf 100644 --- a/frontend/src/lib/components/graph/groupEditor.svelte.ts +++ b/frontend/src/lib/components/graph/groupEditor.svelte.ts @@ -131,3 +131,58 @@ export function setGroupEditorContext(groupEditor: GroupEditor): void { export function getGroupEditorContext(): GroupEditorContext | undefined { return getContext(CONTEXT_KEY) } + +/** Extra vertical space pushed above the topmost node of each group for the label */ +export const GROUP_LABEL_HEIGHT = 24 + +/** + * Compute adjusted node positions that account for group label spacing. + * Follows the same push-down pattern as computeNoteNodes in noteUtils. + */ +export function computeGroupSpacing( + groups: FlowGroup[], + nodes: Array<{ id: string; position: { x: number; y: number } }> +): Record { + if (groups.length === 0) { + return Object.fromEntries(nodes.map((n) => [n.id, { ...n.position }])) + } + + // Build yPosMap: Y position → spacing needed + const yPosMap: Record = {} + + for (const group of groups) { + if (group.module_ids.length === 0) continue + + // Find topmost node Y position in this group + let topY = Infinity + for (const node of nodes) { + if (group.module_ids.includes(node.id) && node.position.y < topY) { + topY = node.position.y + } + } + + if (topY < Infinity) { + yPosMap[topY] = Math.max(yPosMap[topY] || 0, GROUP_LABEL_HEIGHT) + } + } + + // Sort nodes by Y and apply cumulative offset + const sortedNodes = nodes + .map((n) => ({ id: n.id, position: { ...n.position } })) + .sort((a, b) => a.position.y - b.position.y) + + let currentYOffset = 0 + let prevYPos = NaN + + for (const node of sortedNodes) { + if (node.position.y !== prevYPos) { + if (yPosMap[node.position.y]) { + currentYOffset += yPosMap[node.position.y] + } + prevYPos = node.position.y + } + node.position.y += currentYOffset + } + + return Object.fromEntries(sortedNodes.map((n) => [n.id, n.position])) +}