Files
windmill/frontend/src/lib/components/graph/NoteTool.svelte
T
Guilhem 24f9115dc0 feat(frontend): add notes to flow (#6628)
* Add note component

* save note size and position

* move add note button up

* nit

* Add markdown support

* wip

* fix add sticky note button

* fix text update

* Add sticky note to saved flow data

* add note color picker

* Introduce node multiselect

* Add group notes

* Adapt layout to group node

* create a note manager class

* clean reactivity

* clean

* improve adaptive layout to group note

* modify layout based on cached text height

* fined grained graph rendering for notes

* separate noteManager into editor and render

* separate noteManager into editor and render

* create a note change observer

* render note node from context

* simplify note state managment

* show note in flow viewer

* clean dirty changes

* clean selection manager

* fix layout check

* improve bg surface select

* Handle z-index for stacked group notes

* clean selection manager

* exclude notes from rect select

* Allow switch between selection modes with keyboard keys

* improve selection box styling

* prevent dragging note when editing

* nit

* Simplify selection using svelte flow built in feature

* handle note selection separately

* Add min size for notes

* improve selection toggle

* improve mode switch

* make size and position optional for group notes

* Improve initial viewport position

* Add context menu for the canevas

* nit

* Add node context menu

* improve note select

* use clickoutside for note deselect

* use pointerdown outside to close context menu

* nit

* fix selection issues

* make edges non selectable

* improve color palette

* fix backend

* fix backend check

* cargo lock restore

* Add toggle to display notes

* fix note selection

* nit

* account for css offset in for loop

* fix multiple selection pannel styling

* clear flow selection when creating note

* Improve placeholder and note default text

* Escape note edit mode when pressing Esc

* Allow note edition in local dev

* clean

* Handle subflow selection

* prevent group note resizing

* nit

* allow notes in flow expand

* Improve multi select panel

* Allow context menu in note mode

* Add event listenner to fix pane click deselect

* prevent zoom in text area in notes

* improve bounding box styling

* Use control for box selection for non mac users

* nit

* clean notes groups

* nit

* use portal for note actions

* handle assets node when computing note layout

* Simplify layout compute for notes

* use smart color choice for notes

* Switch display note when adding a new note

* clean code

* improve group note bound size calculation

* simplify AI tool nodes and asset handling

* nit

* nit

* improve flow centering

* create group note button

* Improve selection of nodes

* Revert "Improve selection of nodes"

This reverts commit d2c40d82b1.

* refert backend changes

* nit

* improve graph selection

* clean

* make backend work except job runs

* fix notSelectable

---------

Co-authored-by: Ruben Fiszel <ruben@windmill.dev>
2025-11-19 21:25:57 +00:00

217 lines
5.4 KiB
Svelte

<script lang="ts">
import { useSvelteFlow, type XYPosition } from '@xyflow/svelte'
import { getNoteEditorContext } from './noteEditor.svelte'
import { DEFAULT_NOTE_COLOR, MIN_NOTE_WIDTH, MIN_NOTE_HEIGHT } from './noteColors'
import { StickyNote } from 'lucide-svelte'
import ContextMenu, { type ContextMenuItem } from '../common/contextmenu/ContextMenu.svelte'
interface Props {
exitNoteMode?: () => void
yOffset: number
}
let { exitNoteMode, yOffset }: Props = $props()
// Get NoteEditor context for direct note creation
const noteEditorContext = getNoteEditorContext()
const { screenToFlowPosition, getViewport } = useSvelteFlow()
let isDrawing = $state(false)
let startPosition: XYPosition | null = $state(null)
let endPosition: XYPosition | null = $state(null)
let rect: DOMRect | null = $state(null)
let contextMenuPosition: XYPosition | null = $state(null)
function onPointerDown(event: PointerEvent) {
// 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
}
isDrawing = true
}
function onPointerMove(event: PointerEvent) {
if (event.buttons !== 1) return
// Use page coordinates as reference
const target = event.currentTarget as Element
rect = target.getBoundingClientRect()
endPosition = {
x: event.pageX - rect.left,
y: event.pageY - rect.top
}
}
function onPointerUp() {
if (!isDrawing || !startPosition || !endPosition || !rect) return
// We need to convert the start and end positions to absolute positions to then convert to flow 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
}
const flowPosition = screenToFlowPosition({
x: Math.min(absoluteStartPosition.x, absoluteEndPosition.x),
y: Math.min(absoluteStartPosition.y, absoluteEndPosition.y)
})
const position = {
x: flowPosition.x,
y: flowPosition.y - (yOffset || 0)
}
const zoom = getViewport().zoom
const size = {
width: Math.max(
MIN_NOTE_WIDTH,
Math.abs(absoluteEndPosition.x - absoluteStartPosition.x) / zoom
),
height: Math.max(
MIN_NOTE_HEIGHT,
Math.abs(absoluteEndPosition.y - absoluteStartPosition.y) / zoom
)
}
// Create the actual note using NoteEditor context
if (noteEditorContext?.noteEditor) {
noteEditorContext.noteEditor.addNote({
text: '### Free note\nDouble click to edit me',
position,
size,
color: DEFAULT_NOTE_COLOR,
type: 'free',
locked: false
})
}
// Exit note mode after creating note
exitNoteMode?.()
// Reset state
isDrawing = false
startPosition = null
}
function handleAddStickyNote() {
if (!noteEditorContext?.noteEditor || !contextMenuPosition) return
noteEditorContext.noteEditor.addNote({
text: '### Free note\nDouble click to edit me',
position: contextMenuPosition,
size: { width: 300, height: 200 },
color: DEFAULT_NOTE_COLOR,
type: 'free',
locked: false
})
// Clear the position after use
contextMenuPosition = null
exitNoteMode?.()
}
function handleItemClick(item: ContextMenuItem) {
if (item.id === 'add-sticky-note') {
handleAddStickyNote()
}
}
const contextMenuItems: ContextMenuItem[] = [
{
id: 'add-sticky-note',
label: 'Add sticky note',
icon: StickyNote,
onClick: handleAddStickyNote
}
]
const previewNote = $derived(
startPosition && endPosition
? {
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'])
}
}
: null
)
</script>
<ContextMenu items={contextMenuItems} onItemClick={handleItemClick}>
<div
class="tool-overlay"
onpointerdown={onPointerDown}
onpointermove={onPointerMove}
onpointerup={onPointerUp}
oncontextmenu={(e) => {
// Capture the position when context menu is triggered
const flowPosition = screenToFlowPosition({
x: e.clientX,
y: e.clientY
})
contextMenuPosition = {
x: flowPosition.x,
y: flowPosition.y - yOffset
}
}}
role="button"
tabindex="0"
aria-label="Click and drag to create a note, or right-click to add a sticky note"
onkeydown={(e) => {
if (e.key === 'Escape') {
if (isDrawing) {
// Cancel current drawing
isDrawing = false
startPosition = null
} else {
// Exit note mode
exitNoteMode?.()
}
}
}}
>
<!-- Preview note while drawing -->
{#if previewNote}
<div
class="absolute border-2 border-dashed border-lime-400 bg-lime-100 bg-opacity-50 rounded-md pointer-events-none"
style="
width: {previewNote.size.width}px;
height: {previewNote.size.height}px;
transform: translate({previewNote.position.x}px, {previewNote.position.y}px);
"
>
</div>
{/if}
</div>
</ContextMenu>
<style>
.tool-overlay {
pointer-events: auto;
position: absolute;
top: 0;
left: 0;
z-index: 4;
height: 100%;
width: 100%;
transform-origin: top left;
cursor: crosshair;
touch-action: none;
}
</style>