add text notes

This commit is contained in:
2026-03-30 20:47:08 -07:00
parent 7b309a8b23
commit c5c861717a
8 changed files with 291 additions and 0 deletions

View File

@@ -21,6 +21,7 @@ from backend.nodes import (
mask_threshold,
note,
number,
text_note,
range_slider,
rotate,
save,

View File

@@ -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 ()

View File

@@ -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",

View File

@@ -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"

View File

@@ -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,

View File

@@ -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 <GroupNode id={id} data={data} />;
}
if (data.className === 'TextNote') {
return <TextNoteNode id={id} data={data} />;
}
const def = data.definition;
const scalarDisplay = formatScalarDisplay(data.scalarValue);
const processingTimeText = formatProcessingTime(data.processingTimeMs);

View File

@@ -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 && (
<NodeResizeControl
position="bottom-right"
className="node-resize-handle"
minWidth={160}
minHeight={80}
/>
)}
<div
className={`text-note-node drag-handle${selected ? ' selected' : ''}`}
style={{
background: palette.bg,
borderColor: palette.border,
}}
>
{/* Colour picker row */}
<div className="text-note-toolbar nodrag nopan">
{Object.entries(NOTE_COLORS).map(([key, p]) => (
<button
key={key}
title={key}
className={`text-note-color-btn${color === key ? ' active' : ''}`}
style={{ background: p.dot }}
onClick={() => setField('color', key)}
/>
))}
<span className="text-note-hint">
{isEditing ? 'Ctrl+Enter or Esc to finish' : 'Double-click to edit'}
</span>
</div>
{/* Content area */}
{isEditing ? (
<textarea
ref={textareaRef}
className="text-note-textarea nodrag nopan nowheel"
value={text}
onChange={(e) => setField('text', e.target.value)}
onBlur={onBlur}
onKeyDown={onKeyDown}
placeholder="Write your guide here (Markdown supported)…"
/>
) : (
<div
className="text-note-content nodrag nopan"
onDoubleClick={onDoubleClick}
// eslint-disable-next-line react/no-danger
dangerouslySetInnerHTML={
renderedHtml
? { __html: renderedHtml }
: undefined
}
>
{!renderedHtml && (
<span className="text-note-placeholder">Double-click to write</span>
)}
</div>
)}
</div>
</>
);
}
export default TextNoteNode;

View File

@@ -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;