separate noteManager into editor and render

This commit is contained in:
Guilhem
2025-11-11 12:53:51 +01:00
parent 0de527dfd8
commit 84e39e7236
10 changed files with 322 additions and 225 deletions
@@ -44,6 +44,8 @@
import FlowPreviewButtons from './flows/header/FlowPreviewButtons.svelte'
import type { FlowEditorContext, FlowInput, FlowInputEditorState } from './flows/types'
import { SelectionManager } from './graph/selectionUtils.svelte'
import { NoteEditor } from './graph/noteEditor.svelte'
import { setNoteEditorContext } from './graph/noteEditor.svelte'
import { cleanInputs } from './flows/utils'
import {
Calendar,
@@ -636,6 +638,10 @@
outputPickerOpenFns
})
// Set up NoteEditor context for note editing capabilities
const noteEditor = new NoteEditor(flowStore)
setNoteEditorContext(noteEditor)
setContext(
'FlowGraphAssetContext',
initFlowGraphAssetsCtx({ getModules: () => flowStore.val.value.modules })
@@ -42,6 +42,7 @@
import { ModulesTestStates } from '$lib/components/modulesTest.svelte'
import type { StateStore } from '$lib/utils'
import { type AgentTool, flowModuleToAgentTool, createMcpTool } from '../agentToolUtils'
import { getNoteEditorContext } from '$lib/components/graph/noteEditor.svelte'
interface Props {
sidebarSize?: number | undefined
@@ -111,6 +112,9 @@
const { flowPropPickerConfig } = getContext<PropPickerContext>('PropPickerContext')
// Get NoteEditor context for note position updates
const noteEditorContext = getNoteEditorContext()
export async function insertNewModuleAtIndex(
modules: FlowModule[] | AgentTool[],
index: number,
@@ -426,7 +430,7 @@
maxHeight={minHeight}
modules={flowStore.val.value.modules}
{noteMode}
bind:notes={flowStore.val.value.notes}
notes={flowStore.val.value.notes}
preprocessorModule={flowStore.val.value?.preprocessor_module}
{selectionManager}
{workspace}
@@ -655,6 +659,12 @@
{onOpenPreview}
{onHideJobStatus}
exitNoteMode={() => (noteMode = false)}
onNotePositionUpdate={(noteId, position) => {
// Update note position via NoteEditor context in edit mode
if (noteEditorContext?.noteEditor) {
noteEditorContext.noteEditor.updatePosition(noteId, position)
}
}}
multiSelectEnabled
/>
</div>
@@ -37,9 +37,6 @@ export type ExtendedOpenFlow = OpenFlow & {
dedicated_worker?: boolean
visible_to_runner_only?: boolean
on_behalf_of_email?: string
ui?: {
notes?: FlowNote[]
}
}
export type FlowInputEditorState = {
@@ -1,7 +1,6 @@
<script lang="ts">
import { FlowService, type FlowModule, type FlowNote, type Job } from '../../gen'
import { NODE, type GraphModuleState } from '.'
import { DEFAULT_NOTE_COLOR } from './noteColors'
import { getContext, onDestroy, tick, untrack, type Snippet } from 'svelte'
import { get, writable, type Writable } from 'svelte/store'
@@ -152,6 +151,7 @@
onShowModuleDiff?: (moduleId: string) => void
flowHasChanged?: boolean
exitNoteMode?: () => void
onNotePositionUpdate?: (noteId: string, position: { x: number; y: number }) => void
// Viewport synchronization props (for diff viewer)
sharedViewport?: Viewport
onViewportChange?: (viewport: Viewport, isUserInitiated: boolean) => void
@@ -210,8 +210,9 @@
suspendStatus = {},
flowHasChanged = false,
noteMode = false,
notes = $bindable(),
notes = undefined,
exitNoteMode = undefined,
onNotePositionUpdate = undefined,
chatInputEnabled = false,
sharedViewport = undefined,
onViewportChange = undefined,
@@ -426,7 +427,6 @@
let height = $state(0)
// Note feature state
let nextNoteId = $state(1)
function isSimplifiable(modules: FlowModule[] | undefined): boolean {
if (!modules || modules?.length !== 2) {
@@ -449,26 +449,6 @@
// Keep for potential future use
}
function onNoteAdded(newNoteFromTool: any) {
// Add the note to our separate notes array if a note was created
if (newNoteFromTool) {
const newNote = {
id: `note-${nextNoteId}`,
text: '',
position: newNoteFromTool.position,
size: newNoteFromTool.size || { width: 200, height: 100 },
color: DEFAULT_NOTE_COLOR
}
notes = noteManager.addNote(notes ?? [], newNote)
nextNoteId += 1
}
exitNoteMode?.()
}
function handleCreateGroupNote(selectedNodeIds: string[]) {
notes = noteManager.createGroupNote(notes ?? [], selectedNodeIds)
}
async function updateStores() {
if (graph.error) {
return
@@ -514,10 +494,7 @@
notes ?? [],
finalNodes,
noteTextHeights,
(newNotes) => {
notes = newNotes
},
(noteId, height) => {
(noteId: string, height: number) => {
noteTextHeights[noteId] = height
}
)
@@ -740,7 +717,7 @@
onnodedragstop={(event) => {
const node = event.targetNode
if (node && node.type === 'note') {
notes = noteManager.updatePosition(notes ?? [], node.id, node.position)
onNotePositionUpdate?.(node.id, node.position)
}
}}
onmove={(event, viewport) => {
@@ -766,15 +743,13 @@
<div class="absolute inset-0 !bg-surface-secondary h-full" id="flow-graph-v2"></div>
{#if noteMode}
<NoteTool {onNoteAdded} />
<NoteTool {exitNoteMode} />
{/if}
<NodeContextMenu
selectedNodeIds={actualSelectionManager.selectedIds.filter(
(id) =>
!id.startsWith('Settings') && !id.startsWith('Trigger') && !id.startsWith('Result')
)}
onCreateGroupNote={handleCreateGroupNote}
>
<SelectionBoundingBox
selectedNodes={nodes.filter((node) =>
@@ -2,30 +2,35 @@
import ContextMenu, { type ContextMenuItem } from '../common/contextmenu/ContextMenu.svelte'
import { StickyNote } from 'lucide-svelte'
import type { Snippet } from 'svelte'
import { getNoteEditorContext } from './noteEditor.svelte'
interface Props {
children: Snippet
selectedNodeIds: string[]
onCreateGroupNote?: (selectedNodeIds: string[]) => void
}
let { children, selectedNodeIds, onCreateGroupNote }: Props = $props()
let { children, selectedNodeIds }: Props = $props()
// Get NoteEditor context for group note creation
const noteEditorContext = getNoteEditorContext()
const menuItems: ContextMenuItem[] = $derived([
{
id: 'create-group-note',
label: `Create group note (${selectedNodeIds.length} nodes)`,
icon: StickyNote,
disabled: selectedNodeIds.length === 0,
disabled: selectedNodeIds.length === 0 || !noteEditorContext?.noteEditor,
onClick: () => {
if (selectedNodeIds.length > 0) {
onCreateGroupNote?.(selectedNodeIds)
if (selectedNodeIds.length > 0 && noteEditorContext?.noteEditor) {
noteEditorContext.noteEditor.createGroupNote(selectedNodeIds, 'Group Note')
}
}
}
])
</script>
<ContextMenu items={menuItems}>
{@render children()}
</ContextMenu>
{#if noteEditorContext?.noteEditor}
<ContextMenu items={menuItems}>
{@render children()}
</ContextMenu>
{/if}
@@ -1,11 +1,16 @@
<script lang="ts">
import { useSvelteFlow, type XYPosition } from '@xyflow/svelte'
import { getNoteEditorContext } from './noteEditor.svelte'
import { DEFAULT_NOTE_COLOR } from './noteColors'
interface Props {
onNoteAdded?: (note: any) => void
exitNoteMode?: () => void
}
let { onNoteAdded }: Props = $props()
let { exitNoteMode }: Props = $props()
// Get NoteEditor context for direct note creation
const noteEditorContext = getNoteEditorContext()
const { screenToFlowPosition, getViewport } = useSvelteFlow()
@@ -64,11 +69,20 @@
height: Math.abs(absoluteEndPosition.y - absoluteStartPosition.y) / zoom
}
// Create the actual note with the calculated size and position
onNoteAdded?.({
position,
size
})
// Create the actual note using NoteEditor context
if (noteEditorContext?.noteEditor) {
noteEditorContext.noteEditor.addNote({
text: '',
position,
size,
color: DEFAULT_NOTE_COLOR,
type: 'free',
locked: false
})
}
// Exit note mode after creating note
exitNoteMode?.()
// Reset state
isDrawing = false
@@ -107,7 +121,7 @@
startPosition = null
} else {
// Exit note mode
onNoteAdded?.(null)
exitNoteMode?.()
}
}
}}
@@ -0,0 +1,152 @@
import type { FlowNote } from '$lib/gen'
import type { StateStore } from '$lib/utils'
import type { ExtendedOpenFlow } from '../flows/types'
import type { NoteColor } from './noteColors'
import { generateId } from './util'
import { getContext, setContext } from 'svelte'
/**
* Utility class for editing flow notes via direct flowStore mutations
* This class is designed to be used in editor contexts via Svelte context
*/
export class NoteEditor {
private flowStore: StateStore<ExtendedOpenFlow>
constructor(flowStore: StateStore<ExtendedOpenFlow>) {
this.flowStore = flowStore
}
/**
* Get the current notes array from the flow store
*/
private getNotes(): FlowNote[] {
return this.flowStore.val.value?.notes || []
}
/**
* Set the notes array in the flow store
*/
private setNotes(notes: FlowNote[]): void {
if (this.flowStore.val.value) {
this.flowStore.val.value.notes = notes
}
}
/**
* Add a new note to the flow
*/
addNote(note: Omit<FlowNote, 'id'>): string {
const notes = this.getNotes()
const newNote: FlowNote = {
id: generateId(),
...note
}
this.setNotes([...notes, newNote])
return newNote.id
}
/**
* Update the text content of a note
*/
updateText(noteId: string, text: string): void {
const notes = this.getNotes()
const updatedNotes = notes.map((note) => (note.id === noteId ? { ...note, text } : note))
this.setNotes(updatedNotes)
}
/**
* Update the color of a note
*/
updateColor(noteId: string, color: NoteColor): void {
const notes = this.getNotes()
const updatedNotes = notes.map((note) => (note.id === noteId ? { ...note, color } : note))
this.setNotes(updatedNotes)
}
/**
* Update the position of a note
*/
updatePosition(noteId: string, position: { x: number; y: number }): void {
const notes = this.getNotes()
const updatedNotes = notes.map((note) => (note.id === noteId ? { ...note, position } : note))
this.setNotes(updatedNotes)
}
/**
* Update the size of a note
*/
updateSize(noteId: string, size: { width: number; height: number }): void {
const notes = this.getNotes()
const updatedNotes = notes.map((note) => (note.id === noteId ? { ...note, size } : note))
this.setNotes(updatedNotes)
}
/**
* Toggle the locked state of a note
*/
updateLock(noteId: string, locked: boolean): void {
const notes = this.getNotes()
const updatedNotes = notes.map((note) => (note.id === noteId ? { ...note, locked } : note))
this.setNotes(updatedNotes)
}
/**
* Delete a note from the flow
*/
deleteNote(noteId: string): void {
const notes = this.getNotes()
const updatedNotes = notes.filter((note) => note.id !== noteId)
this.setNotes(updatedNotes)
}
/**
* Create a group note containing the specified node IDs
*/
createGroupNote(nodeIds: string[], text: string = 'Group'): string {
// Calculate rough position based on contained nodes (can be refined by layout)
const defaultPosition = { x: 0, y: 0 }
const defaultSize = { width: 200, height: 100 }
const groupNote: Omit<FlowNote, 'id'> = {
text,
color: 'blue', // Default color, can be made configurable
position: defaultPosition,
size: defaultSize,
type: 'group',
contained_node_ids: nodeIds,
locked: false
}
return this.addNote(groupNote)
}
/**
* Check if editing is available (flowStore is properly initialized)
*/
isAvailable(): boolean {
return !!this.flowStore.val.value
}
}
/**
* Context type for NoteEditor
*/
export type NoteEditorContext = {
noteEditor: NoteEditor
}
const CONTEXT_KEY = 'NoteEditorContext'
/**
* Set the NoteEditor context (used in FlowBuilder)
*/
export function setNoteEditorContext(noteEditor: NoteEditor): void {
setContext<NoteEditorContext>(CONTEXT_KEY, { noteEditor })
}
/**
* Get the NoteEditor context (used in components that need editing capabilities)
*/
export function getNoteEditorContext(): NoteEditorContext | undefined {
return getContext<NoteEditorContext | undefined>(CONTEXT_KEY)
}
@@ -1,6 +1,5 @@
import type { FlowNote } from '$lib/gen'
import type { Node } from '@xyflow/svelte'
import type { NoteColor } from './noteColors'
import { calculateNodesBounds } from './util'
export type NodePosition = {
@@ -34,95 +33,6 @@ export class NoteManager {
return this.#cache
}
/**
* Add a new note from the note tool
*/
addNote(notes: FlowNote[], newNoteFromTool: any): FlowNote[] {
// Add the note to our separate notes array if a note was created
if (newNoteFromTool) {
const newNote: FlowNote = {
id: newNoteFromTool.id,
text: newNoteFromTool.data?.text || '',
position: newNoteFromTool.position,
size: { width: newNoteFromTool.width || 300, height: newNoteFromTool.height || 100 },
color: newNoteFromTool.data?.color || 'yellow',
type: 'free',
locked: false
}
return [...notes, newNote]
}
return notes
}
/**
* Update note text
*/
updateText(notes: FlowNote[], noteId: string, text: string): FlowNote[] {
return notes.map((note) => (note.id === noteId ? { ...note, text } : note))
}
/**
* Delete a note
*/
delete(notes: FlowNote[], noteId: string): FlowNote[] {
return notes.filter((note) => note.id !== noteId)
}
/**
* Update note position
*/
updatePosition(
notes: FlowNote[],
noteId: string,
position: { x: number; y: number }
): FlowNote[] {
return notes.map((note) => (note.id === noteId ? { ...note, position } : note))
}
/**
* Update note size
*/
updateSize(
notes: FlowNote[],
noteId: string,
size: { width: number; height: number }
): FlowNote[] {
return notes.map((note) => (note.id === noteId ? { ...note, size } : note))
}
/**
* Update note color
*/
updateColor(notes: FlowNote[], noteId: string, color: NoteColor): FlowNote[] {
return notes.map((note) => (note.id === noteId ? { ...note, color } : note))
}
/**
* Update note lock state
*/
updateLock(notes: FlowNote[], noteId: string, locked: boolean): FlowNote[] {
return notes.map((note) => (note.id === noteId ? { ...note, locked } : note))
}
/**
* Create a group note from selected node IDs
*/
createGroupNote(notes: FlowNote[], selectedNodeIds: string[]): FlowNote[] {
if (selectedNodeIds.length === 0) return notes
const newGroupNote: FlowNote = {
id: `group-${Date.now()}`,
text: '',
position: { x: 0, y: 0 }, // Will be calculated dynamically
size: { width: 300, height: 100 }, // Will be calculated dynamically
color: 'gray',
type: 'group',
locked: false,
contained_node_ids: selectedNodeIds
}
return [...notes, newGroupNote]
}
/**
* Calculate position and size for group notes based on contained nodes
*/
@@ -159,46 +69,21 @@ export class NoteManager {
}
/**
* Create common data object for note nodes
* Create common data object for note nodes (rendering-only version)
*/
private createNoteData(
note: FlowNote,
notes: FlowNote[],
onNotesChange: (notes: FlowNote[]) => void,
onTextHeightChange: (noteId: string, height: number) => void,
isGroupNote: boolean
) {
return {
noteId: note.id,
text: note.text,
color: note.color,
locked: note.locked || false,
isGroupNote,
...(isGroupNote && { containedNodeIds: note.contained_node_ids || [] }),
onUpdate: (text: string) => {
const newNotes = this.updateText(notes, note.id, text)
onNotesChange(newNotes)
this.render()
},
onDelete: () => {
const newNotes = this.delete(notes, note.id)
onNotesChange(newNotes)
this.render()
},
onColorChange: (color: NoteColor) => {
const newNotes = this.updateColor(notes, note.id, color)
onNotesChange(newNotes)
this.render()
},
onSizeChange: (size: { width: number; height: number }) => {
const newNotes = this.updateSize(notes, note.id, size)
onNotesChange(newNotes)
this.render()
},
onLockToggle: (locked: boolean) => {
const newNotes = this.updateLock(notes, note.id, locked)
onNotesChange(newNotes)
this.render()
},
// Note: Edit callbacks will be added by NoteNode when NoteEditor context is available
onTextHeightChange: (textHeight: number) => {
onTextHeightChange(note.id, textHeight)
// Cache the text height for improved performance
@@ -208,13 +93,12 @@ export class NoteManager {
}
/**
* Convert notes to SvelteFlow nodes
* Convert notes to SvelteFlow nodes (rendering-only)
*/
convertToNodes(
notes: FlowNote[],
currentNodes: Node[],
textHeights: Record<string, number>,
onNotesChange: (notes: FlowNote[]) => void,
onTextHeightChange: (noteId: string, height: number) => void
): Node[] {
return notes.map((note) => {
@@ -229,7 +113,7 @@ export class NoteManager {
id: note.id,
type: 'note',
position,
data: this.createNoteData(note, notes, onNotesChange, onTextHeightChange, isGroupNote),
data: this.createNoteData(note, onTextHeightChange, isGroupNote),
style: `width: ${size.width}px; height: ${size.height}px;`,
width: size.width,
height: size.height,
@@ -7,13 +7,16 @@
import NoteColorPicker from '../../NoteColorPicker.svelte'
import { NoteColor, NOTE_COLORS, DEFAULT_NOTE_COLOR } from '../../noteColors'
import { Button } from '$lib/components/common'
import { getNoteEditorContext } from '../../noteEditor.svelte'
interface Props {
data: {
noteId: string
text: string
color: NoteColor
locked?: boolean
isGroupNote?: boolean
// Callback props for view mode (when no NoteEditor context)
onUpdate?: (text: string) => void
onDelete?: () => void
onColorChange?: (color: NoteColor) => void
@@ -27,6 +30,10 @@
let { data, selected = false, dragging = false }: Props = $props()
// Get NoteEditor context for edit mode
const noteEditorContext = getNoteEditorContext()
const isEditModeAvailable = $derived(!!noteEditorContext?.noteEditor)
let textareaElement: HTMLTextAreaElement | undefined = $state(undefined)
let editMode = $state(false)
let hovering = $state(false)
@@ -35,24 +42,47 @@
function handleTextSave() {
// Only update parent when done editing
data.onUpdate?.(textContent)
if (isEditModeAvailable && noteEditorContext?.noteEditor) {
// Use NoteEditor context in edit mode
noteEditorContext.noteEditor.updateText(data.noteId, textContent)
} else {
// Fallback to callback in view mode
data.onUpdate?.(textContent)
}
}
function handleDelete(event?: Event) {
event?.preventDefault?.()
event?.stopPropagation?.()
// Call the delete callback
data.onDelete?.()
if (isEditModeAvailable && noteEditorContext?.noteEditor) {
// Use NoteEditor context in edit mode
noteEditorContext.noteEditor.deleteNote(data.noteId)
} else {
// Fallback to callback in view mode
data.onDelete?.()
}
}
function handleColorChange(color: NoteColor) {
data.onColorChange?.(color)
if (isEditModeAvailable && noteEditorContext?.noteEditor) {
// Use NoteEditor context in edit mode
noteEditorContext.noteEditor.updateColor(data.noteId, color)
} else {
// Fallback to callback in view mode
data.onColorChange?.(color)
}
}
function handleLockToggle(event?: Event) {
event?.preventDefault?.()
event?.stopPropagation?.()
data.onLockToggle?.(!data.locked)
if (isEditModeAvailable && noteEditorContext?.noteEditor) {
// Use NoteEditor context in edit mode
noteEditorContext.noteEditor.updateLock(data.noteId, !data.locked)
} else {
// Fallback to callback in view mode
data.onLockToggle?.(!data.locked)
}
}
// Get color configuration for current color
@@ -62,8 +92,8 @@
event.preventDefault()
event.stopPropagation()
// Don't allow editing if note is locked
if (data.locked) {
// Don't allow editing if note is locked or edit mode is not available
if (data.locked || !isEditModeAvailable) {
return
}
@@ -127,49 +157,51 @@
onmouseleave={handleMouseLeave}
role="note"
>
<!-- Action buttons -->
<div class="absolute -top-10 -right-2.5 p-2 w-32 h-12 group flex justify-end">
<div
class={twMerge(
'hidden group-hover:flex flex-row gap-2 h-fit',
hovering || editMode || colorPickerIsOpen || selected ? 'flex' : ''
)}
>
<!-- Lock/Unlock button -->
<Button
variant="subtle"
unifiedSize="sm"
title={data.locked ? 'Unlock note' : 'Lock note'}
aria-label={data.locked ? 'Unlock note' : 'Lock note'}
startIcon={{ icon: data.locked ? Lock : Unlock }}
onClick={handleLockToggle}
iconOnly
/>
<!-- Color picker -->
{#if !data.locked}
<NoteColorPicker
selectedColor={data.color}
onColorChange={handleColorChange}
bind:isOpen={colorPickerIsOpen}
/>
{/if}
<!-- Delete button -->
{#if !data.locked}
<!-- Action buttons - only show in edit mode -->
{#if isEditModeAvailable}
<div class="absolute -top-10 -right-2.5 p-2 w-32 h-12 group flex justify-end">
<div
class={twMerge(
'hidden group-hover:flex flex-row gap-2 h-fit',
hovering || editMode || colorPickerIsOpen || selected ? 'flex' : ''
)}
>
<!-- Lock/Unlock button -->
<Button
variant="subtle"
unifiedSize="sm"
title="Delete note"
aria-label="Delete note"
startIcon={{ icon: X }}
onClick={handleDelete}
title={data.locked ? 'Unlock note' : 'Lock note'}
aria-label={data.locked ? 'Unlock note' : 'Lock note'}
startIcon={{ icon: data.locked ? Lock : Unlock }}
onClick={handleLockToggle}
iconOnly
destructive
/>
{/if}
<!-- Color picker -->
{#if !data.locked}
<NoteColorPicker
selectedColor={data.color}
onColorChange={handleColorChange}
bind:isOpen={colorPickerIsOpen}
/>
{/if}
<!-- Delete button -->
{#if !data.locked}
<Button
variant="subtle"
unifiedSize="sm"
title="Delete note"
aria-label="Delete note"
startIcon={{ icon: X }}
onClick={handleDelete}
iconOnly
destructive
/>
{/if}
</div>
</div>
</div>
{/if}
<!-- Hover help text -->
{#if hovering || selected}
@@ -178,9 +210,13 @@
in:fade={{ duration: 200 }}
class="absolute -top-5 h-5 left-0 text-2xs text-secondary rounded-md z-10 transition-opacity duration-300"
>
{data.locked ? 'Note is locked' : 'Double click to edit'}
{data.locked
? 'Note is locked'
: isEditModeAvailable
? 'Double click to edit'
: 'View only mode'}
</div>
{:else if !data.locked}
{:else if !data.locked && isEditModeAvailable}
<div
in:fade={{ duration: 200 }}
class="absolute -top-5 h-5 left-0 text-2xs text-secondary rounded-md z-10 transition-opacity duration-300"
@@ -238,8 +274,8 @@
{/if}
</div>
<!-- Node resizer - only visible when selected and not locked -->
{#if !data.locked}
<!-- Node resizer - only visible when selected and not locked and edit mode is available -->
{#if !data.locked && isEditModeAvailable}
<NodeResizer
isVisible={selected && !dragging}
minWidth={200}
@@ -248,8 +284,15 @@
handleClass="!bg-transparent !w-4 !h-4 !border-none !rounded-md"
onResizeEnd={(_, params) => {
// Update note size when resizing ends
if (data.onSizeChange && params.width !== undefined && params.height !== undefined) {
data.onSizeChange({ width: params.width, height: params.height })
if (params.width !== undefined && params.height !== undefined) {
const size = { width: params.width, height: params.height }
if (isEditModeAvailable && noteEditorContext?.noteEditor) {
// Use NoteEditor context in edit mode
noteEditorContext.noteEditor.updateSize(data.noteId, size)
} else {
// Fallback to callback in view mode
data.onSizeChange?.(size)
}
}
}}
/>
+14 -3
View File
@@ -132,9 +132,12 @@ export function getNodeColorClasses(state: FlowNodeState, selected: boolean): Fl
* @param nodes - Array of nodes with position.x and position.y properties
* @returns The bounds { minX, minY, maxX, maxY }
*/
export function calculateNodesBounds(
nodes: Array<{ position: { x: number; y: number } }>
): { minX: number; minY: number; maxX: number; maxY: number } {
export function calculateNodesBounds(nodes: Array<{ position: { x: number; y: number } }>): {
minX: number
minY: number
maxX: number
maxY: number
} {
return nodes.reduce(
(acc, node) => ({
minX: Math.min(acc.minX, node.position.x),
@@ -150,3 +153,11 @@ export function calculateNodesBounds(
}
)
}
/**
* Generate a random unique ID for notes
* @returns A random string ID
*/
export function generateId(): string {
return 'note-' + Math.random().toString(36).substring(2) + Math.random().toString(36).substring(2)
}