Add note component

This commit is contained in:
Guilhem
2025-09-17 17:27:03 +01:00
parent c24c629317
commit f78dc93fd3
3 changed files with 334 additions and 4 deletions
@@ -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>