add text notes
This commit is contained in:
@@ -21,6 +21,7 @@ from backend.nodes import (
|
|||||||
mask_threshold,
|
mask_threshold,
|
||||||
note,
|
note,
|
||||||
number,
|
number,
|
||||||
|
text_note,
|
||||||
range_slider,
|
range_slider,
|
||||||
rotate,
|
rotate,
|
||||||
save,
|
save,
|
||||||
|
|||||||
30
backend/nodes/text_note.py
Normal file
30
backend/nodes/text_note.py
Normal 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 ()
|
||||||
13
frontend/package-lock.json
generated
13
frontend/package-lock.json
generated
@@ -8,6 +8,7 @@
|
|||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@xyflow/react": "^12.0.0",
|
"@xyflow/react": "^12.0.0",
|
||||||
"html-to-image": "^1.11.13",
|
"html-to-image": "^1.11.13",
|
||||||
|
"marked": "^17.0.5",
|
||||||
"react": "^18.3.0",
|
"react": "^18.3.0",
|
||||||
"react-dom": "^18.3.0",
|
"react-dom": "^18.3.0",
|
||||||
"three": "^0.183.2"
|
"three": "^0.183.2"
|
||||||
@@ -2116,6 +2117,18 @@
|
|||||||
"node": ">=10"
|
"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": {
|
"node_modules/minimatch": {
|
||||||
"version": "10.2.4",
|
"version": "10.2.4",
|
||||||
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.4.tgz",
|
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.4.tgz",
|
||||||
|
|||||||
@@ -16,6 +16,7 @@
|
|||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@xyflow/react": "^12.0.0",
|
"@xyflow/react": "^12.0.0",
|
||||||
"html-to-image": "^1.11.13",
|
"html-to-image": "^1.11.13",
|
||||||
|
"marked": "^17.0.5",
|
||||||
"react": "^18.3.0",
|
"react": "^18.3.0",
|
||||||
"react-dom": "^18.3.0",
|
"react-dom": "^18.3.0",
|
||||||
"three": "^0.183.2"
|
"three": "^0.183.2"
|
||||||
|
|||||||
@@ -1649,11 +1649,13 @@ function Flow() {
|
|||||||
const widgetValues = buildDefaultWidgetValues(def);
|
const widgetValues = buildDefaultWidgetValues(def);
|
||||||
|
|
||||||
const newNodeId = String(nextIdRef.current++);
|
const newNodeId = String(nextIdRef.current++);
|
||||||
|
const isTextNote = className === 'TextNote';
|
||||||
const newNode = {
|
const newNode = {
|
||||||
id: newNodeId,
|
id: newNodeId,
|
||||||
type: 'custom',
|
type: 'custom',
|
||||||
position,
|
position,
|
||||||
dragHandle: '.drag-handle',
|
dragHandle: '.drag-handle',
|
||||||
|
...(isTextNote ? { width: 300, height: 220, style: { width: 300, height: 220 } } : {}),
|
||||||
data: {
|
data: {
|
||||||
label: def.display_name || className,
|
label: def.display_name || className,
|
||||||
className,
|
className,
|
||||||
|
|||||||
@@ -10,6 +10,8 @@ const MarkupOverlay = lazy(() => import('./MarkupOverlay'));
|
|||||||
const AngleMeasureOverlay = lazy(() => import('./AngleMeasureOverlay'));
|
const AngleMeasureOverlay = lazy(() => import('./AngleMeasureOverlay'));
|
||||||
const ThresholdHistogram = lazy(() => import('./ThresholdHistogram'));
|
const ThresholdHistogram = lazy(() => import('./ThresholdHistogram'));
|
||||||
|
|
||||||
|
import TextNoteNode from './TextNoteNode';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
getSpecTypeAndOptions, isDataSocketSpec, SOCKET_WIDGET_TYPES, TYPE_COLORS, CAT_COLORS,
|
getSpecTypeAndOptions, isDataSocketSpec, SOCKET_WIDGET_TYPES, TYPE_COLORS, CAT_COLORS,
|
||||||
} from './constants';
|
} from './constants';
|
||||||
@@ -971,6 +973,9 @@ function CustomNode({ id, data }) {
|
|||||||
if (data.className === 'Group') {
|
if (data.className === 'Group') {
|
||||||
return <GroupNode id={id} data={data} />;
|
return <GroupNode id={id} data={data} />;
|
||||||
}
|
}
|
||||||
|
if (data.className === 'TextNote') {
|
||||||
|
return <TextNoteNode id={id} data={data} />;
|
||||||
|
}
|
||||||
const def = data.definition;
|
const def = data.definition;
|
||||||
const scalarDisplay = formatScalarDisplay(data.scalarValue);
|
const scalarDisplay = formatScalarDisplay(data.scalarValue);
|
||||||
const processingTimeText = formatProcessingTime(data.processingTimeMs);
|
const processingTimeText = formatProcessingTime(data.processingTimeMs);
|
||||||
|
|||||||
144
frontend/src/TextNoteNode.jsx
Normal file
144
frontend/src/TextNoteNode.jsx
Normal 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;
|
||||||
@@ -1427,6 +1427,101 @@ html, body, #root {
|
|||||||
height: 8px !important;
|
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 ──────────────────────────────────────────────────── */
|
||||||
.context-menu {
|
.context-menu {
|
||||||
position: fixed;
|
position: fixed;
|
||||||
|
|||||||
Reference in New Issue
Block a user