diff --git a/backend/nodes/__init__.py b/backend/nodes/__init__.py index f58dc0a..21a8cb1 100644 --- a/backend/nodes/__init__.py +++ b/backend/nodes/__init__.py @@ -21,6 +21,7 @@ from backend.nodes import ( mask_threshold, note, number, + text_note, range_slider, rotate, save, diff --git a/backend/nodes/text_note.py b/backend/nodes/text_note.py new file mode 100644 index 0000000..306e297 --- /dev/null +++ b/backend/nodes/text_note.py @@ -0,0 +1,30 @@ +from __future__ import annotations + +from backend.node_registry import register_node + + +@register_node(display_name="Text Note") +class TextNote: + """A floating text card for annotating workflows. Supports Markdown.""" + + CATEGORY = "Canvas" + + @classmethod + def INPUT_TYPES(cls): + return { + "required": { + "text": ("STRING", { + "default": "# Guide\n\nDouble-click to edit this note.\n\n- Step 1\n- Step 2", + "multiline": True, + }), + "color": (["default", "blue", "green", "yellow", "red", "purple"], { + "default": "default", + }), + }, + } + + OUTPUTS = () + FUNCTION = "noop" + + def noop(self, text: str, color: str = "default") -> tuple: + return () diff --git a/frontend/package-lock.json b/frontend/package-lock.json index bee3a18..48edf3d 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -8,6 +8,7 @@ "dependencies": { "@xyflow/react": "^12.0.0", "html-to-image": "^1.11.13", + "marked": "^17.0.5", "react": "^18.3.0", "react-dom": "^18.3.0", "three": "^0.183.2" @@ -2116,6 +2117,18 @@ "node": ">=10" } }, + "node_modules/marked": { + "version": "17.0.5", + "resolved": "https://registry.npmjs.org/marked/-/marked-17.0.5.tgz", + "integrity": "sha512-6hLvc0/JEbRjRgzI6wnT2P1XuM1/RrrDEX0kPt0N7jGm1133g6X7DlxFasUIx+72aKAr904GTxhSLDrd5DIlZg==", + "license": "MIT", + "bin": { + "marked": "bin/marked.js" + }, + "engines": { + "node": ">= 20" + } + }, "node_modules/minimatch": { "version": "10.2.4", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.4.tgz", diff --git a/frontend/package.json b/frontend/package.json index ea75912..9dfbf6f 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -16,6 +16,7 @@ "dependencies": { "@xyflow/react": "^12.0.0", "html-to-image": "^1.11.13", + "marked": "^17.0.5", "react": "^18.3.0", "react-dom": "^18.3.0", "three": "^0.183.2" diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index 2cabf0c..3837a63 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -1649,11 +1649,13 @@ function Flow() { const widgetValues = buildDefaultWidgetValues(def); const newNodeId = String(nextIdRef.current++); + const isTextNote = className === 'TextNote'; const newNode = { id: newNodeId, type: 'custom', position, dragHandle: '.drag-handle', + ...(isTextNote ? { width: 300, height: 220, style: { width: 300, height: 220 } } : {}), data: { label: def.display_name || className, className, diff --git a/frontend/src/CustomNode.jsx b/frontend/src/CustomNode.jsx index 5636e5b..a8ba017 100644 --- a/frontend/src/CustomNode.jsx +++ b/frontend/src/CustomNode.jsx @@ -10,6 +10,8 @@ const MarkupOverlay = lazy(() => import('./MarkupOverlay')); const AngleMeasureOverlay = lazy(() => import('./AngleMeasureOverlay')); const ThresholdHistogram = lazy(() => import('./ThresholdHistogram')); +import TextNoteNode from './TextNoteNode'; + import { getSpecTypeAndOptions, isDataSocketSpec, SOCKET_WIDGET_TYPES, TYPE_COLORS, CAT_COLORS, } from './constants'; @@ -971,6 +973,9 @@ function CustomNode({ id, data }) { if (data.className === 'Group') { return ; } + if (data.className === 'TextNote') { + return ; + } const def = data.definition; const scalarDisplay = formatScalarDisplay(data.scalarValue); const processingTimeText = formatProcessingTime(data.processingTimeMs); diff --git a/frontend/src/TextNoteNode.jsx b/frontend/src/TextNoteNode.jsx new file mode 100644 index 0000000..76f41e4 --- /dev/null +++ b/frontend/src/TextNoteNode.jsx @@ -0,0 +1,144 @@ +import React, { useContext, useRef, useState, useEffect, useCallback, useMemo } from 'react'; +import { NodeResizeControl, useStore } from '@xyflow/react'; +import { marked } from 'marked'; +import { NodeContext } from './CustomNode'; + +marked.use({ breaks: true, gfm: true }); + +const NOTE_COLORS = { + default: { bg: '#1e293b', border: '#334155', dot: '#475569' }, + blue: { bg: '#0c1f3d', border: '#1d4ed8', dot: '#3b82f6' }, + green: { bg: '#062016', border: '#15803d', dot: '#22c55e' }, + yellow: { bg: '#1f1500', border: '#a16207', dot: '#eab308' }, + red: { bg: '#1f0808', border: '#b91c1c', dot: '#ef4444' }, + purple: { bg: '#160c2a', border: '#7c3aed', dot: '#a855f7' }, +}; + +function TextNoteNode({ id, data }) { + const ctx = useContext(NodeContext); + const [isEditing, setIsEditing] = useState(false); + const textareaRef = useRef(null); + + const selected = useStore( + useCallback( + (s) => { + const node = s.nodeLookup?.get(id) || s.nodes?.find((n) => n.id === id); + return !!node?.selected; + }, + [id], + ), + ); + + const text = data.widgetValues?.text ?? ''; + const color = data.widgetValues?.color ?? 'default'; + const palette = NOTE_COLORS[color] ?? NOTE_COLORS.default; + + const setField = useCallback( + (name, value) => ctx?.onWidgetChange?.(id, name, value), + [ctx, id], + ); + + useEffect(() => { + if (isEditing) { + textareaRef.current?.focus(); + } + }, [isEditing]); + + const onDoubleClick = useCallback((e) => { + e.stopPropagation(); + setIsEditing(true); + }, []); + + const onBlur = useCallback(() => setIsEditing(false), []); + + const onKeyDown = useCallback((e) => { + // Ctrl/Cmd+Enter or Escape finishes editing + if (e.key === 'Escape' || (e.key === 'Enter' && (e.ctrlKey || e.metaKey))) { + e.preventDefault(); + setIsEditing(false); + } + // Tab inserts spaces + if (e.key === 'Tab') { + e.preventDefault(); + const ta = textareaRef.current; + const start = ta.selectionStart; + const end = ta.selectionEnd; + const next = text.substring(0, start) + ' ' + text.substring(end); + setField('text', next); + requestAnimationFrame(() => { + ta.selectionStart = ta.selectionEnd = start + 2; + }); + } + }, [text, setField]); + + const renderedHtml = useMemo(() => { + if (!text.trim()) return ''; + return marked.parse(text); + }, [text]); + + return ( + <> + {selected && ( + + )} +
+ {/* Colour picker row */} +
+ {Object.entries(NOTE_COLORS).map(([key, p]) => ( +
+ + {/* Content area */} + {isEditing ? ( +