mirror of
https://github.com/windmill-labs/windmill.git
synced 2026-09-06 08:01:35 +00:00
Add note component
This commit is contained in:
@@ -34,7 +34,7 @@
|
||||
import BaseEdge from './renderers/edges/BaseEdge.svelte'
|
||||
import EmptyEdge from './renderers/edges/EmptyEdge.svelte'
|
||||
import { sugiyama, dagStratify, coordCenter, decrossTwoLayer, decrossOpt } from 'd3-dag'
|
||||
import { Expand } from 'lucide-svelte'
|
||||
import { Expand, StickyNote } from 'lucide-svelte'
|
||||
import Toggle from '../Toggle.svelte'
|
||||
import DataflowEdge from './renderers/edges/DataflowEdge.svelte'
|
||||
import { encodeState, readFieldsRecursively } from '$lib/utils'
|
||||
@@ -55,6 +55,8 @@
|
||||
import type { FlowGraphAssetContext } from '../flows/types'
|
||||
import AiToolNode, { computeAIToolNodes } from './renderers/nodes/AIToolNode.svelte'
|
||||
import NewAiToolNode from './renderers/nodes/NewAIToolNode.svelte'
|
||||
import NoteNode from './renderers/nodes/NoteNode.svelte'
|
||||
import NoteTool from './NoteTool.svelte'
|
||||
import { ChangeTracker } from '$lib/svelte5Utils.svelte'
|
||||
import type { ModulesTestStates } from '../modulesTest.svelte'
|
||||
import { deepEqual } from 'fast-equals'
|
||||
@@ -367,6 +369,19 @@
|
||||
|
||||
let height = $state(0)
|
||||
|
||||
// Note feature state
|
||||
type NoteData = {
|
||||
id: string
|
||||
text: string
|
||||
position: { x: number; y: number }
|
||||
size: { width: number; height: number }
|
||||
color: string
|
||||
}
|
||||
|
||||
let noteMode = $state(false)
|
||||
let notes = $state<NoteData[]>([])
|
||||
let nextNoteId = $state(1)
|
||||
|
||||
function isSimplifiable(modules: FlowModule[] | undefined): boolean {
|
||||
if (!modules || modules?.length !== 2) {
|
||||
return false
|
||||
@@ -379,6 +394,56 @@
|
||||
return false
|
||||
}
|
||||
|
||||
function toggleNoteMode() {
|
||||
noteMode = !noteMode
|
||||
}
|
||||
|
||||
function onNoteAdded(newNoteFromTool: any) {
|
||||
// Add the note to our separate notes array if a note was created
|
||||
if (newNoteFromTool) {
|
||||
const newNote: NoteData = {
|
||||
id: `note-${nextNoteId}`,
|
||||
text: '',
|
||||
position: newNoteFromTool.position,
|
||||
size: newNoteFromTool.size || { width: 200, height: 100 },
|
||||
color: 'oklch(96.2% 0.059 95.617)'
|
||||
}
|
||||
notes = [...notes, newNote]
|
||||
nextNoteId += 1
|
||||
}
|
||||
noteMode = false
|
||||
updateStores()
|
||||
}
|
||||
|
||||
function updateNoteText(noteId: string, text: string) {
|
||||
notes = notes.map((note) => (note.id === noteId ? { ...note, text } : note))
|
||||
}
|
||||
|
||||
function deleteNote(noteId: string) {
|
||||
notes = notes.filter((note) => note.id !== noteId)
|
||||
updateStores()
|
||||
}
|
||||
|
||||
function convertNotesToNodes(): Node[] {
|
||||
return notes.map((note) => ({
|
||||
id: note.id,
|
||||
type: 'note',
|
||||
position: note.position,
|
||||
data: {
|
||||
text: note.text,
|
||||
color: note.color,
|
||||
onUpdate: (text: string) => updateNoteText(note.id, text),
|
||||
onDelete: () => deleteNote(note.id)
|
||||
},
|
||||
style: `width: ${note.size.width}px; height: ${note.size.height}px;`,
|
||||
width: note.size.width,
|
||||
height: note.size.height,
|
||||
zIndex: 1,
|
||||
draggable: true,
|
||||
selectable: true
|
||||
}))
|
||||
}
|
||||
|
||||
async function updateStores() {
|
||||
if (graph.error) {
|
||||
return
|
||||
@@ -409,7 +474,8 @@
|
||||
nodes = [
|
||||
...newNodes.map((n) => ({ ...n, position: aiToolNodesResult.newNodePositions[n.id] })),
|
||||
...assetNodesResult.newAssetNodes,
|
||||
...aiToolNodesResult.toolNodes
|
||||
...aiToolNodesResult.toolNodes,
|
||||
...convertNotesToNodes()
|
||||
]
|
||||
edges = [...assetNodesResult.newAssetEdges, ...aiToolNodesResult.toolEdges, ...graph.edges]
|
||||
|
||||
@@ -435,7 +501,8 @@
|
||||
asset: AssetNode,
|
||||
assetsOverflowed: AssetsOverflowedNode,
|
||||
aiTool: AiToolNode,
|
||||
newAiTool: NewAiToolNode
|
||||
newAiTool: NewAiToolNode,
|
||||
note: NoteNode
|
||||
} as any
|
||||
|
||||
const edgeTypes = {
|
||||
@@ -565,7 +632,7 @@
|
||||
<SvelteFlowProvider>
|
||||
<ViewportResizer {height} {width} {nodes} bind:this={viewportResizer} />
|
||||
<SvelteFlow
|
||||
onpaneclick={(e) => {
|
||||
onpaneclick={() => {
|
||||
document.dispatchEvent(new Event('focus'))
|
||||
}}
|
||||
{nodes}
|
||||
@@ -586,6 +653,10 @@
|
||||
--background-color={false}
|
||||
>
|
||||
<div class="absolute inset-0 !bg-surface-secondary h-full"></div>
|
||||
|
||||
{#if noteMode}
|
||||
<NoteTool {onNoteAdded} />
|
||||
{/if}
|
||||
<Controls position="top-right" orientation="horizontal" showLock={false}>
|
||||
{#if download}
|
||||
<ControlButton
|
||||
@@ -605,6 +676,15 @@
|
||||
<Expand size="14" />
|
||||
</ControlButton>
|
||||
{/if}
|
||||
{#if editMode}
|
||||
<ControlButton
|
||||
onclick={toggleNoteMode}
|
||||
class={`!bg-surface ${noteMode ? '!bg-blue-100 dark:!bg-blue-900/30' : ''}`}
|
||||
title={noteMode ? 'Exit note mode' : 'Add notes'}
|
||||
>
|
||||
<StickyNote size="14" class={noteMode ? 'text-blue-600' : ''} />
|
||||
</ControlButton>
|
||||
{/if}
|
||||
</Controls>
|
||||
|
||||
<Controls
|
||||
|
||||
@@ -0,0 +1,143 @@
|
||||
<script lang="ts">
|
||||
import { useSvelteFlow, type XYPosition } from '@xyflow/svelte'
|
||||
|
||||
interface Props {
|
||||
onNoteAdded?: (note: any) => void
|
||||
}
|
||||
|
||||
let { onNoteAdded }: Props = $props()
|
||||
|
||||
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)
|
||||
|
||||
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) {
|
||||
console.log('NoteTool: pointer move', event)
|
||||
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 position = screenToFlowPosition({
|
||||
x: Math.min(absoluteStartPosition.x, absoluteEndPosition.x),
|
||||
y: Math.min(absoluteStartPosition.y, absoluteEndPosition.y)
|
||||
})
|
||||
|
||||
const zoom = getViewport().zoom
|
||||
const size = {
|
||||
width: Math.abs(absoluteEndPosition.x - absoluteStartPosition.x) / zoom,
|
||||
height: Math.abs(absoluteEndPosition.y - absoluteStartPosition.y) / zoom
|
||||
}
|
||||
|
||||
// Create the actual note with the calculated size and position
|
||||
onNoteAdded?.({
|
||||
position,
|
||||
size
|
||||
})
|
||||
|
||||
// Reset state
|
||||
isDrawing = false
|
||||
startPosition = null
|
||||
}
|
||||
|
||||
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>
|
||||
|
||||
<div
|
||||
class="tool-overlay"
|
||||
onpointerdown={onPointerDown}
|
||||
onpointermove={onPointerMove}
|
||||
onpointerup={onPointerUp}
|
||||
role="button"
|
||||
tabindex="0"
|
||||
aria-label="Click and drag to create a note"
|
||||
onkeydown={(e) => {
|
||||
if (e.key === 'Escape') {
|
||||
if (isDrawing) {
|
||||
// Cancel current drawing
|
||||
isDrawing = false
|
||||
startPosition = null
|
||||
} else {
|
||||
// Exit note mode
|
||||
onNoteAdded?.(null)
|
||||
}
|
||||
}
|
||||
}}
|
||||
>
|
||||
<!-- Preview note while drawing -->
|
||||
{#if previewNote}
|
||||
<div
|
||||
class="absolute border-2 border-dashed border-amber-400 bg-amber-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>
|
||||
|
||||
<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>
|
||||
@@ -0,0 +1,107 @@
|
||||
<script lang="ts">
|
||||
import { NodeResizer } from '@xyflow/svelte'
|
||||
import { X } from 'lucide-svelte'
|
||||
import { twMerge } from 'tailwind-merge'
|
||||
|
||||
interface Props {
|
||||
data: {
|
||||
text: string
|
||||
color: string
|
||||
onUpdate?: (text: string) => void
|
||||
onDelete?: () => void
|
||||
}
|
||||
selected?: boolean
|
||||
dragging?: boolean
|
||||
}
|
||||
|
||||
let { data, selected = false, dragging = false }: Props = $props()
|
||||
|
||||
let textareaElement: HTMLTextAreaElement | undefined = $state(undefined)
|
||||
|
||||
function handleTextChange(event: Event) {
|
||||
const target = event.target as HTMLTextAreaElement
|
||||
// Call the update callback
|
||||
data.onUpdate?.(target.value)
|
||||
|
||||
// Auto-resize textarea
|
||||
target.style.height = 'auto'
|
||||
target.style.height = target.scrollHeight + 'px'
|
||||
}
|
||||
|
||||
function handleDelete(event: Event) {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
// Call the delete callback
|
||||
data.onDelete?.()
|
||||
}
|
||||
|
||||
// Auto-resize textarea when text changes
|
||||
$effect(() => {
|
||||
if (textareaElement) {
|
||||
textareaElement.style.height = 'auto'
|
||||
textareaElement.style.height = textareaElement.scrollHeight + 'px'
|
||||
}
|
||||
})
|
||||
$inspect('dbg selected', selected)
|
||||
</script>
|
||||
|
||||
<div
|
||||
class={twMerge(
|
||||
'relative w-full h-full rounded-md shadow-md border',
|
||||
selected ? 'outline outline-1 outline-amber-300' : ''
|
||||
)}
|
||||
style:background-color={data.color}
|
||||
onpointerup={() => {
|
||||
dragging = false
|
||||
}}
|
||||
ondragstart={() => {
|
||||
dragging = true
|
||||
}}
|
||||
ondragend={() => {
|
||||
dragging = false
|
||||
}}
|
||||
role="note"
|
||||
>
|
||||
<!-- Delete button -->
|
||||
<button
|
||||
class="absolute top-3 right-3 w-5 h-5 bg-transparent text-secondary hover:bg-red-500 hover:text-white rounded-md flex items-center justify-center shadow-sm border transition-colors z-10"
|
||||
onclick={handleDelete}
|
||||
title="Delete note"
|
||||
aria-label="Delete note"
|
||||
>
|
||||
<X size="12" />
|
||||
</button>
|
||||
|
||||
<!-- Note content -->
|
||||
<div class="p-2 h-full">
|
||||
<textarea
|
||||
bind:this={textareaElement}
|
||||
class="windmillapp w-full h-full !bg-transparent !border-none outline-none shadow-none focus:outline-none focus:ring-transparent resize-none text-sm font-mono overflow-hidden"
|
||||
placeholder="Add your note here..."
|
||||
value={data.text}
|
||||
oninput={handleTextChange}
|
||||
></textarea>
|
||||
</div>
|
||||
|
||||
<!-- Node resizer - only visible when selected -->
|
||||
<NodeResizer
|
||||
isVisible={selected && !dragging}
|
||||
minWidth={200}
|
||||
minHeight={100}
|
||||
lineClass="!border-4 !border-transparent !rounded-md"
|
||||
handleClass="!bg-transparent !w-4 !h-4 !border-none !rounded-md"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
textarea::placeholder {
|
||||
color: #6b7280;
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
/* Remove default textarea styling */
|
||||
textarea {
|
||||
font-family: inherit;
|
||||
line-height: 1.4;
|
||||
}
|
||||
</style>
|
||||
Reference in New Issue
Block a user