mirror of
https://github.com/windmill-labs/windmill.git
synced 2026-09-09 16:05:42 +00:00
Simplify selection using svelte flow built in feature
This commit is contained in:
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user