Simplify selection using svelte flow built in feature

This commit is contained in:
Guilhem
2025-11-12 12:14:33 +01:00
parent 638f74d370
commit 2c6c40f5df
5 changed files with 61 additions and 250 deletions
@@ -13,7 +13,8 @@
Controls,
ControlButton,
SvelteFlowProvider,
type Viewport
type Viewport,
SelectionMode
} from '@xyflow/svelte'
import {
graphBuilder,
@@ -388,10 +389,11 @@
insert: (detail) => {
onInsert?.(detail)
},
select: (modId) => {
select: (mod: string | FlowModule) => {
if (!notSelectable) {
// TODO: Handle Ctrl/Cmd and Shift modifiers when node-level click events are available
// For now, normal click behavior
const modId = typeof mod === 'string' ? mod : mod.id
selectionManager.selectId(modId)
onSelect?.(modId)
}
@@ -423,7 +425,7 @@
delete expandedSubflows[id]
expandedSubflows = expandedSubflows
},
updateMock: (detail) => {
updateMock: (detail: any) => {
onUpdateMock?.(detail)
},
testUpTo: (id: string) => {
@@ -472,8 +474,8 @@
selectionManager.handleKeyDown(event, nodes)
}
function handleKeyUp(event: KeyboardEvent) {
selectionManager.handleKeyUp(event)
function handleKeyUp(_event: KeyboardEvent) {
// Keep for potential future use
}
async function updateStores() {
@@ -767,8 +769,12 @@
connectionLineType={ConnectionLineType.SmoothStep}
defaultEdgeOptions={{ type: 'smoothstep' }}
preventScrolling={scroll}
selectionOnDrag={selectionManager.mode === 'rect-select'}
elementsSelectable={true}
selectionMode={SelectionMode.Partial}
selectionKey={selectionManager.mode === 'rect-select' ? null : 'Shift'}
panActivationKey={selectionManager.mode === 'rect-select' ? 'Shift' : null}
zoomOnDoubleClick={false}
elementsSelectable={false}
elevateNodesOnSelect={false}
{proOptions}
nodesDraggable={false}
@@ -791,15 +797,11 @@
selectedNodes={nodes.filter((node) => selectionManager.selectedIds.includes(node.id))}
/>
</NodeContextMenu>
<SelectionTool
selectionMode={selectionManager.mode}
onNodesSelected={(nodeIds, addToExisting) =>
selectionManager.selectNodes(nodeIds, addToExisting, modules, nodes)}
{nodes}
/>
{/if}
<!-- SelectionTool for handling selection changes and filtering -->
<SelectionTool {nodes} {modules} {selectionManager} />
{#if leftHeader}
<div class="absolute top-2 left-2 z-10">
{@render leftHeader()}
@@ -882,4 +884,8 @@
:global(.svelte-flow__edgelabel-renderer) {
@apply z-50;
}
:global(.svelte-flow__selection) {
display: none;
}
</style>
@@ -1,208 +1,61 @@
<script lang="ts">
import { useSvelteFlow, type XYPosition } from '@xyflow/svelte'
import { NODE } from './util'
import { useOnSelectionChange, useStore, type Node } from '@xyflow/svelte'
interface Props {
selectionMode: 'normal' | 'rect-select'
onNodesSelected: (nodeIds: string[], addToExisting: boolean) => void
nodes: any[]
modules?: any[]
selectionManager: any
}
let { selectionMode, onNodesSelected, nodes }: Props = $props()
let { nodes, modules, selectionManager }: Props = $props()
const { screenToFlowPosition } = useSvelteFlow()
// Get store to access selectionRect
const store = useStore()
let isDrawing = $state(false)
let startPosition: XYPosition | null = $state(null)
let endPosition: XYPosition | null = $state(null)
let rect: DOMRect | null = $state(null)
// Handle selection changes from SvelteFlow
useOnSelectionChange(({ nodes: selectedNodes, edges: _selectedEdges }) => {
console.log('dbg useOnSelectionChange', selectedNodes, _selectedEdges)
// Notes are already non-selectable, so no filtering needed
const selectedNodeIds = selectedNodes.map((node: Node) => node.id)
// Global handler to handle middle-click panning in rect mode
function handleGlobalPointerDown(event: PointerEvent) {
if (selectionMode === 'rect-select' && event.button === 1) {
// Find the SvelteFlow element and dispatch the event to it
const flowElement = document.querySelector('.svelte-flow')
if (flowElement) {
const syntheticEvent = new PointerEvent('pointerdown', {
bubbles: true,
pointerId: event.pointerId,
button: event.button,
buttons: event.buttons,
clientX: event.clientX,
clientY: event.clientY,
screenX: event.screenX,
screenY: event.screenY
})
flowElement.dispatchEvent(syntheticEvent)
}
if (selectedNodeIds.length > 0) {
selectionManager.selectNodes(selectedNodeIds, false, modules, nodes)
} else if (selectedNodes.length === 0) {
// Clear selection when SvelteFlow selection is cleared
selectionManager.clearSelection()
}
}
})
function onPointerDown(event: PointerEvent) {
if (selectionMode !== 'rect-select') return
// Allow middle-click (button 1) to pass through for graph panning
if (event.button === 1) {
// Stop the event from being handled by this overlay
event.stopPropagation()
// Re-dispatch to SvelteFlow
handleGlobalPointerDown(event)
return
// Compute selection box bounds
let selectionBoxBounds = $derived(() => {
const rect = store.selectionRect
if (!rect) {
return null
}
// Only handle left-click (button 0) for rectangle selection
if (event.button !== 0) return
// Capture pointer to continue tracking outside the element
const target = event.currentTarget as Element
target?.setPointerCapture?.(event.pointerId)
// Use page coordinates as reference
rect = target.getBoundingClientRect()
startPosition = {
x: event.pageX - rect.left,
y: event.pageY - rect.top
}
endPosition = startPosition
isDrawing = true
event.preventDefault()
}
function onPointerMove(event: PointerEvent) {
if (!isDrawing || !rect) return
// Use page coordinates as reference
endPosition = {
x: event.pageX - rect.left,
y: event.pageY - rect.top
}
}
function onPointerUp(event: PointerEvent) {
if (!isDrawing || !startPosition || !endPosition || !rect) return
// Only proceed if we have a meaningful selection area
const deltaX = Math.abs(endPosition.x - startPosition.x)
const deltaY = Math.abs(endPosition.y - startPosition.y)
if (deltaX > 5 || deltaY > 5) {
// Convert the start and end positions to absolute positions
const absoluteStartPosition = {
x: startPosition.x + rect.left,
y: startPosition.y + rect.top
}
const absoluteEndPosition = {
x: endPosition.x + rect.left,
y: endPosition.y + rect.top
}
// Convert to flow coordinates
const flowStart = screenToFlowPosition({
x: Math.min(absoluteStartPosition.x, absoluteEndPosition.x),
y: Math.min(absoluteStartPosition.y, absoluteEndPosition.y)
})
const flowEnd = screenToFlowPosition({
x: Math.max(absoluteStartPosition.x, absoluteEndPosition.x),
y: Math.max(absoluteStartPosition.y, absoluteEndPosition.y)
})
// Find nodes within the selection rectangle
const selectedNodeIds = getNodesInFlowRectangle({
x1: flowStart.x,
y1: flowStart.y,
x2: flowEnd.x,
y2: flowEnd.y
})
if (selectedNodeIds.length > 0) {
onNodesSelected(selectedNodeIds, event.shiftKey)
}
}
// Reset state
isDrawing = false
startPosition = null
endPosition = null
rect = null
}
function getNodesInFlowRectangle(rect: {
x1: number
y1: number
x2: number
y2: number
}): string[] {
const minX = Math.min(rect.x1, rect.x2)
const maxX = Math.max(rect.x1, rect.x2)
const minY = Math.min(rect.y1, rect.y2)
const maxY = Math.max(rect.y1, rect.y2)
return nodes
.filter((node) => {
// Exclude note nodes from selection (similar to "Select All" behavior)
if (node.type === 'note') return false
const nodeMinX = node.position.x
const nodeMaxX = node.position.x + NODE.width
const nodeMinY = node.position.y
const nodeMaxY = node.position.y + NODE.height
// Check if node intersects with selection rectangle
return !(nodeMaxX < minX || nodeMinX > maxX || nodeMaxY < minY || nodeMinY > maxY)
})
.map((node) => node.id)
}
const previewNote = $derived.by(() => {
if (!startPosition || !endPosition) return null
// selectionRect is already in the correct coordinate system relative to the flow container
// Just return it directly
return {
position: {
x: Math.min(startPosition.x, endPosition.x),
y: Math.min(startPosition.y, endPosition.y)
},
size: {
width: Math.abs(endPosition.x - startPosition.x),
height: Math.abs(endPosition.y - startPosition.y)
}
x: rect.x,
y: rect.y,
width: rect.width,
height: rect.height
}
})
</script>
{#if selectionMode === 'rect-select'}
<!-- Render custom selection box during drag selection -->
{#if selectionBoxBounds()}
{@const bounds = selectionBoxBounds()!}
<div
class="selection-overlay"
onpointerdown={onPointerDown}
onpointermove={onPointerMove}
onpointerup={onPointerUp}
role="button"
tabindex="0"
aria-label="Click and drag to select nodes"
class="absolute rounded cursor-pointer bg-surface-selected/30 border border-accent/30 pointer-events-none"
style="
left: {bounds.x}px;
top: {bounds.y}px;
width: {bounds.width}px;
height: {bounds.height}px;
z-index: 10;
"
>
<!-- Preview selection rectangle while drawing -->
{#if previewNote && isDrawing}
<div
class="absolute border border-accent/30 pointer-events-none bg-surface-selected/30"
style="
width: {previewNote.size.width}px;
height: {previewNote.size.height}px;
transform: translate({previewNote.position.x}px, {previewNote.position.y}px);
"
></div>
{/if}
</div>
{/if}
<style>
.selection-overlay {
pointer-events: auto;
position: absolute;
top: 0;
left: 0;
z-index: 5;
height: 100%;
width: 100%;
cursor: crosshair;
touch-action: none;
}
</style>
@@ -215,7 +215,7 @@ export class NoteManager {
height: size.height,
zIndex: zIndex ?? -2000, // Use provided zIndex or fallback
draggable: isGroupNote ? false : editMode && !note.locked,
selectable: true
selectable: false
}
}
@@ -20,11 +20,11 @@
// Callback for layout calculations (needed in both edit and view modes)
onTextHeightChange?: (height: number) => void
}
selected?: boolean
dragging?: boolean
}
let { data, selected = false, dragging = false }: Props = $props()
let { data, dragging = false }: Props = $props()
let selected = $state(false)
// Get NoteEditor context for edit mode
const noteEditorContext = getNoteEditorContext()
@@ -10,9 +10,6 @@ export interface SelectionState {
export class SelectionManager {
public selectedIds = $state<string[]>([])
#selectionMode = $state<'normal' | 'rect-select'>('normal')
#modeSource = $state<'button' | 'keyboard' | 'temporary'>('button')
#previousMode = $state<'normal' | 'rect-select'>('normal')
#cmdKeyPressed = $state<boolean>(false)
constructor() {}
@@ -29,39 +26,10 @@ export class SelectionManager {
}
set mode(mode: 'normal' | 'rect-select') {
this.#previousMode = this.#selectionMode
this.#selectionMode = mode
// Default to button source when mode is set directly (for backward compatibility)
if (this.#modeSource !== 'temporary') {
this.#modeSource = 'button'
}
// Note: No automatic selection clearing when changing modes - preserve current selection
}
// Toggle mode temporarily (for cmd key hold behavior)
toggleModeTemporary() {
this.#previousMode = this.#selectionMode
this.#modeSource = 'temporary'
this.#selectionMode = this.#selectionMode === 'normal' ? 'rect-select' : 'normal'
}
// Toggle mode persistently (for cmd key tap behavior)
toggleModePersistent() {
this.#previousMode = this.#selectionMode
this.#modeSource = 'keyboard'
this.#selectionMode = this.#selectionMode === 'normal' ? 'rect-select' : 'normal'
// Note: Preserve current selection when toggling modes
}
// Revert temporary mode change
revertTemporaryMode() {
if (this.#modeSource === 'temporary') {
this.#selectionMode = this.#previousMode
this.#modeSource = 'button' // Reset to default button source
// Note: Preserve current selection when reverting temporary mode changes
}
}
// Get hierarchical children of a node
getNodeChildrenIds(nodeId: string, modules: FlowModule[] | undefined, nodes: Node[]): string[] {
const module = modules?.find((m) => m.id === nodeId)
@@ -203,13 +171,6 @@ export class SelectionManager {
if (event.key === 'Escape') {
// Escape key clears selection regardless of mode
this.clearSelection()
} else if (event.key === 'Meta' || event.key === 'Cmd') {
// Cmd/Meta key pressed - temporarily toggle mode
if (!this.#cmdKeyPressed) {
this.#cmdKeyPressed = true
event.preventDefault()
this.toggleModeTemporary()
}
} else if ((event.ctrlKey || event.metaKey) && event.key === 'a') {
event.preventDefault()
// Select all visible nodes (exclude note nodes)
@@ -219,13 +180,4 @@ export class SelectionManager {
}
}
}
// Handle keyboard releases
handleKeyUp(event: KeyboardEvent) {
if (event.key === 'Meta' || event.key === 'Cmd') {
// Cmd/Meta key released - revert temporary mode change
this.#cmdKeyPressed = false
this.revertTemporaryMode()
}
}
}