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 <noreply@anthropic.com>
This commit is contained in:
Guilhem
2026-03-04 14:45:51 +00:00
co-authored by Claude Opus 4.6
parent e4280861d9
commit b862a3dc62
3 changed files with 182 additions and 187 deletions
@@ -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()
})
@@ -1,12 +1,10 @@
<script lang="ts">
import { ViewportPortal, type Node } from '@xyflow/svelte'
import { calculateNodesBoundsWithOffset } from './util'
import { ChevronDown, ChevronRight, Ellipsis, Pen, Trash2 } from 'lucide-svelte'
import { ChevronDown, ChevronRight, Pen, X } from 'lucide-svelte'
import { getGroupEditorContext, type FlowGroup } from './groupEditor.svelte'
import { NoteColor, NOTE_COLORS } from './noteColors'
import NoteColorPicker from './NoteColorPicker.svelte'
import Popover from '../meltComponents/Popover.svelte'
import Toggle from '../Toggle.svelte'
import Button from '../common/button/Button.svelte'
interface Props {
@@ -22,8 +20,8 @@
// Runtime collapse state, keyed by group ID
let collapsedState: Record<string, boolean> = $state({})
// Popover open state
let popoverOpen = $state(false)
// Color picker open state
let colorPickerOpen = $state(false)
// Action bar hover state to prevent flicker
let actionBarHovered = $state(false)
@@ -51,7 +49,7 @@
if (activeGroup.id && !(activeGroup.id in collapsedState)) {
collapsedState[activeGroup.id] = activeGroup.collapsed ?? false
}
} else if (!popoverOpen && !actionBarHovered && !editingSummary) {
} else if (!colorPickerOpen && !actionBarHovered && !editingSummary) {
hideTimeout = setTimeout(() => {
visibleGroup = undefined
}, 150)
@@ -77,12 +75,6 @@
}
}
// Compute bounds for the visible (hovered) group
let hoverBounds = $derived.by(() => {
if (!visibleGroup || visibleGroup.module_ids.length === 0) return null
return computeGroupBounds(visibleGroup)
})
// Border color mapping from NoteColor
const GROUP_BORDER_COLORS: Record<NoteColor, string> = {
[NoteColor.YELLOW]: 'border-yellow-400 dark:border-yellow-600',
@@ -97,19 +89,6 @@
[NoteColor.GRAY]: 'border-gray-400 dark:border-gray-600'
}
const GROUP_BG_COLORS: Record<NoteColor, string> = {
[NoteColor.YELLOW]: 'bg-yellow-200/10 dark:bg-yellow-800/10',
[NoteColor.BLUE]: 'bg-blue-200/10 dark:bg-blue-800/10',
[NoteColor.GREEN]: 'bg-green-200/10 dark:bg-green-800/10',
[NoteColor.PURPLE]: 'bg-purple-200/10 dark:bg-purple-800/10',
[NoteColor.PINK]: 'bg-pink-200/10 dark:bg-pink-800/10',
[NoteColor.ORANGE]: 'bg-orange-200/10 dark:bg-orange-800/10',
[NoteColor.RED]: 'bg-red-200/10 dark:bg-red-800/10',
[NoteColor.CYAN]: 'bg-cyan-200/10 dark:bg-cyan-800/10',
[NoteColor.LIME]: 'bg-lime-200/10 dark:bg-lime-800/10',
[NoteColor.GRAY]: 'bg-gray-200/10 dark:bg-gray-800/10'
}
function getBorderColorClass(color?: string): string {
return (
GROUP_BORDER_COLORS[(color as NoteColor) ?? NoteColor.BLUE] ??
@@ -117,13 +96,6 @@
)
}
function getBgColorClass(color?: string): string {
return (
GROUP_BG_COLORS[(color as NoteColor) ?? NoteColor.BLUE] ??
GROUP_BG_COLORS[NoteColor.BLUE]
)
}
function getTextColorClass(color?: string): string {
return (
NOTE_COLORS[(color as NoteColor) ?? NoteColor.BLUE]?.text ??
@@ -159,169 +131,119 @@
}
</script>
<!-- Layer 1: Always-visible group labels -->
{#each allGroups as group (group.id)}
{@const labelBounds = computeGroupBounds(group)}
{#if labelBounds}
{@const bounds = computeGroupBounds(group)}
{#if bounds}
<ViewportPortal target="front">
<!-- Always-visible border (no bg, solid 1px) -->
<div
class="absolute flex items-center gap-1"
style:transform="translate({labelBounds.x}px, {labelBounds.y - 20}px)"
class="absolute rounded-lg border pointer-events-none {getBorderColorClass(group.color)}"
style:transform="translate({bounds.x}px, {bounds.y}px)"
style:width="{bounds.width}px"
style:height="{bounds.height}px"
style:z-index="4"
style="pointer-events: auto; cursor: default;"
onpointerenter={() => {
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}
<input
class="text-xs font-medium bg-transparent border-none outline-none {getTextColorClass(group.color)} w-24"
bind:value={summaryInput}
onblur={() => commitSummary(group.id)}
onkeydown={(e) => {
if (e.key === 'Enter') commitSummary(group.id)
if (e.key === 'Escape') {
editingGroupId = null
<!-- Label (top-left, above the border) -->
<div
class="absolute -top-6 left-0 flex items-center gap-1 h-5"
style="pointer-events: auto; cursor: default;"
onpointerenter={() => {
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}
<input
class="text-xs font-medium bg-transparent border-none outline-none {getTextColorClass(group.color)} w-24"
bind:value={summaryInput}
onblur={() => commitSummary(group.id)}
onkeydown={(e) => {
if (e.key === 'Enter') commitSummary(group.id)
if (e.key === 'Escape') {
editingGroupId = null
}
}}
autofocus
/>
{:else}
<span class="text-xs font-medium {getTextColorClass(group.color)}">
{group.summary || 'Group'}
</span>
{#if editMode && hoveredLabelGroupId === group.id}
<button
class="flex items-center justify-center w-4 h-4 rounded hover:bg-surface-hover {getTextColorClass(group.color)} opacity-60 hover:opacity-100"
onclick={() => startEditSummary(group)}
title="Edit group name"
>
<Pen size={10} />
</button>
{/if}
{/if}
</div>
<!-- Action bar (top-right, hover only) — matches group note style -->
{#if editMode && visibleGroup?.id === group.id}
<div
class="absolute -top-7 right-0 p-1 h-7 group flex justify-end"
style="pointer-events: auto;"
onpointerenter={() => {
actionBarHovered = true
if (hideTimeout) {
clearTimeout(hideTimeout)
hideTimeout = undefined
}
}}
autofocus
/>
{:else}
<span class="text-xs font-medium {getTextColorClass(group.color)}">
{group.summary || 'Group'}
</span>
{#if editMode && hoveredLabelGroupId === group.id}
<button
class="flex items-center justify-center w-4 h-4 rounded hover:bg-surface-hover {getTextColorClass(group.color)} opacity-60 hover:opacity-100"
onclick={() => startEditSummary(group)}
title="Edit group name"
>
<Pen size={10} />
</button>
{/if}
onpointerleave={() => {
actionBarHovered = false
}}
>
<div class="flex flex-row gap-2 h-fit">
<Button
variant="subtle"
unifiedSize="xs"
iconOnly
title={isCollapsed(group.id) ? 'Expand group' : 'Collapse group'}
startIcon={{ icon: isCollapsed(group.id) ? ChevronRight : ChevronDown }}
onclick={() => toggleCollapse(group.id)}
/>
<NoteColorPicker
selectedColor={(group.color as NoteColor) ?? NoteColor.BLUE}
onColorChange={(color) => {
groupEditorContext?.groupEditor.updateColor(group.id, color)
}}
bind:isOpen={colorPickerOpen}
/>
<Button
variant="subtle"
unifiedSize="xs"
title="Delete group"
startIcon={{ icon: X }}
onclick={() => {
groupEditorContext?.groupEditor.deleteGroup(group.id)
visibleGroup = undefined
}}
iconOnly
destructive
/>
</div>
</div>
{/if}
</div>
</ViewportPortal>
{/if}
{/each}
<!-- Layer 2: Hover bounding box + action bar -->
{#if visibleGroup && hoverBounds}
{@const group = visibleGroup}
{@const currentBounds = hoverBounds}
<ViewportPortal target="front">
<div
class="absolute rounded-lg border-2 border-dashed pointer-events-none {getBorderColorClass(
group.color
)} {getBgColorClass(group.color)}"
style:transform="translate({currentBounds.x}px, {currentBounds.y}px)"
style:width="{currentBounds.width}px"
style:height="{currentBounds.height}px"
style:z-index="5"
>
<!-- Action bar at top-right -->
{#if editMode}
<div
class="absolute -top-8 right-0 flex items-center gap-1 bg-surface rounded-t-md px-1 py-0.5 shadow-sm border border-b-0 {getBorderColorClass(
group.color
)}"
style="pointer-events: auto;"
onpointerenter={() => {
actionBarHovered = true
if (hideTimeout) {
clearTimeout(hideTimeout)
hideTimeout = undefined
}
}}
onpointerleave={() => {
actionBarHovered = false
}}
>
<!-- Collapse toggle button -->
<Button
variant="subtle"
unifiedSize="xs"
iconOnly
title={isCollapsed(group.id) ? 'Expand group' : 'Collapse group'}
startIcon={{ icon: isCollapsed(group.id) ? ChevronRight : ChevronDown }}
onclick={() => toggleCollapse(group.id)}
/>
<!-- Settings popover -->
<Popover
placement="bottom-end"
contentClasses="p-3"
floatingConfig={{ strategy: 'absolute' }}
usePointerDownOutside
bind:isOpen={popoverOpen}
>
{#snippet trigger()}
<Button
variant="subtle"
unifiedSize="xs"
selected={popoverOpen}
nonCaptureEvent
title="Group settings"
startIcon={{ icon: Ellipsis }}
iconOnly
/>
{/snippet}
{#snippet content()}
<div class="flex flex-col gap-3 min-w-48">
<Toggle
checked={group.collapsed ?? false}
options={{ right: 'Collapse by default' }}
size="xs"
on:change={(e) => {
groupEditorContext?.groupEditor.updateCollapsedDefault(
group.id,
e.detail
)
}}
/>
<div class="flex items-center justify-between">
<span class="text-xs text-secondary">Color</span>
<NoteColorPicker
selectedColor={(group.color as NoteColor) ?? NoteColor.BLUE}
onColorChange={(color) => {
groupEditorContext?.groupEditor.updateColor(group.id, color)
}}
/>
</div>
<Button
variant="border"
color="red"
unifiedSize="xs"
startIcon={{ icon: Trash2 }}
onclick={() => {
groupEditorContext?.groupEditor.deleteGroup(group.id)
popoverOpen = false
visibleGroup = undefined
}}
>
Delete group
</Button>
</div>
{/snippet}
</Popover>
</div>
{/if}
</div>
</ViewportPortal>
{/if}
@@ -131,3 +131,58 @@ export function setGroupEditorContext(groupEditor: GroupEditor): void {
export function getGroupEditorContext(): GroupEditorContext | undefined {
return getContext<GroupEditorContext | undefined>(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<string, { x: number; y: number }> {
if (groups.length === 0) {
return Object.fromEntries(nodes.map((n) => [n.id, { ...n.position }]))
}
// Build yPosMap: Y position → spacing needed
const yPosMap: Record<number, number> = {}
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]))
}