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 ? (
+
+ >
+ );
+}
+
+export default TextNoteNode;
diff --git a/frontend/src/styles.css b/frontend/src/styles.css
index 3c9ec0e..6f77cfe 100644
--- a/frontend/src/styles.css
+++ b/frontend/src/styles.css
@@ -1427,6 +1427,101 @@ html, body, #root {
height: 8px !important;
}
+/* ── Text Note node ────────────────────────────────────────────────── */
+.text-note-node {
+ width: 100%;
+ height: 100%;
+ border: 1px solid;
+ border-radius: 6px;
+ display: flex;
+ flex-direction: column;
+ overflow: hidden;
+ cursor: grab;
+ box-sizing: border-box;
+}
+.text-note-node.selected {
+ outline: 2px solid var(--selection);
+ outline-offset: 1px;
+}
+.text-note-toolbar {
+ display: flex;
+ align-items: center;
+ gap: 5px;
+ padding: 5px 8px;
+ border-bottom: 1px solid rgba(255,255,255,0.07);
+ flex-shrink: 0;
+}
+.text-note-color-btn {
+ width: 12px;
+ height: 12px;
+ border-radius: 50%;
+ border: 2px solid transparent;
+ padding: 0;
+ cursor: pointer;
+ flex-shrink: 0;
+ transition: transform 0.1s;
+}
+.text-note-color-btn:hover { transform: scale(1.25); }
+.text-note-color-btn.active {
+ border-color: rgba(255,255,255,0.7);
+}
+.text-note-hint {
+ margin-left: auto;
+ font-size: 10px;
+ color: var(--text-faint);
+ user-select: none;
+ white-space: nowrap;
+}
+.text-note-content {
+ flex: 1;
+ overflow-y: auto;
+ padding: 10px 12px;
+ cursor: text;
+ font-size: 13px;
+ line-height: 1.55;
+ color: inherit;
+ min-height: 0;
+}
+.text-note-content h1 { font-size: 17px; font-weight: 700; margin: 0 0 6px; color: inherit; }
+.text-note-content h2 { font-size: 15px; font-weight: 600; margin: 8px 0 4px; color: inherit; }
+.text-note-content h3 { font-size: 13px; font-weight: 600; margin: 6px 0 3px; color: inherit; }
+.text-note-content p { margin: 0 0 5px; }
+.text-note-content ul,
+.text-note-content ol { margin: 0 0 5px; padding-left: 18px; }
+.text-note-content li { margin-bottom: 2px; }
+.text-note-content code {
+ font-family: monospace;
+ font-size: 11px;
+ background: rgba(0,0,0,0.3);
+ border-radius: 3px;
+ padding: 1px 4px;
+}
+.text-note-content strong { font-weight: 600; }
+.text-note-content em { font-style: italic; opacity: 0.85; }
+.text-note-content hr { border: none; border-top: 1px solid rgba(255,255,255,0.1); margin: 8px 0; }
+.text-note-content blockquote {
+ border-left: 3px solid rgba(255,255,255,0.2);
+ margin: 4px 0;
+ padding-left: 10px;
+ opacity: 0.8;
+}
+.text-note-placeholder { color: var(--text-faint); font-style: italic; }
+.text-note-textarea {
+ flex: 1;
+ background: rgba(0,0,0,0.25);
+ border: none;
+ outline: none;
+ color: inherit;
+ font-family: monospace;
+ font-size: 12px;
+ line-height: 1.5;
+ padding: 10px 12px;
+ resize: none;
+ min-height: 0;
+ width: 100%;
+ box-sizing: border-box;
+}
+
/* ── Context menu ──────────────────────────────────────────────────── */
.context-menu {
position: fixed;