more qol upgrades

This commit is contained in:
2026-04-01 22:58:46 -07:00
parent bde6abe4ed
commit 878c7b415c
3 changed files with 163 additions and 103 deletions

View File

@@ -6,6 +6,8 @@ Welcome to tono, a node-based SPM image analysis tool. This guide covers the bas
The main area is an infinite canvas where you build workflows by placing and connecting nodes. You can pan by holding middle click and dragging (two-finger drag on a trackpad), and zoom by holding right click and dragging (pinching on a trackpad). You can make a box selection by left clicking and dragging. The main area is an infinite canvas where you build workflows by placing and connecting nodes. You can pan by holding middle click and dragging (two-finger drag on a trackpad), and zoom by holding right click and dragging (pinching on a trackpad). You can make a box selection by left clicking and dragging.
You can open a menu by clicking the blue cantilever bubble in the top left.
### Adding Nodes ### Adding Nodes
Right-click anywhere on the canvas to open the **Add Node** menu. Nodes are organized into categories — hover a category to see the available nodes, then click one to place it. The same nodes can be found in different categories, if they share similar features. Right-click anywhere on the canvas to open the **Add Node** menu. Nodes are organized into categories — hover a category to see the available nodes, then click one to place it. The same nodes can be found in different categories, if they share similar features.
@@ -101,3 +103,10 @@ Use **Ctrl+Z** / **Cmd+Z** to undo and **Ctrl+Shift+Z** / **Cmd+Shift+Z** to red
| Ctrl+V | Paste nodes | | Ctrl+V | Paste nodes |
| Right-click canvas | Add node menu | | Right-click canvas | Add node menu |
| Escape | Close help panel | | Escape | Close help panel |
## Plugins
It's possible to write your own nodes and load them as plugins in tono. Please refer to the documentation in the [GitHub](https://github.com/vipqualitypost/tono) for how to do this.
Plugins are disabled by default on webapp for security reasons. You can install and run the native app if you want to develop and use your own node plugins.

View File

@@ -898,6 +898,7 @@ function Flow() {
const [helpTabs, setHelpTabs] = useState<{ label: string; type?: string; content: string | null }[]>([]); const [helpTabs, setHelpTabs] = useState<{ label: string; type?: string; content: string | null }[]>([]);
const [activeHelpTab, setActiveHelpTab] = useState<string | null>(null); const [activeHelpTab, setActiveHelpTab] = useState<string | null>(null);
const [updateInfo, setUpdateInfo] = useState<{ latest: string; url: string } | null>(null); const [updateInfo, setUpdateInfo] = useState<{ latest: string; url: string } | null>(null);
const [menuOpen, setMenuOpen] = useState(false);
const flowContainerRef = useRef<HTMLDivElement | null>(null); const flowContainerRef = useRef<HTMLDivElement | null>(null);
const panTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null); const panTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
@@ -2145,6 +2146,9 @@ function Flow() {
if (defaultWorkflowLoadAttemptedRef.current) return; if (defaultWorkflowLoadAttemptedRef.current) return;
defaultWorkflowLoadAttemptedRef.current = true; defaultWorkflowLoadAttemptedRef.current = true;
// Only auto-load the example workflow on first visit
if (localStorage.getItem('tono_visited')) return;
const graphHasContent = () => { const graphHasContent = () => {
const currentNodes = (reactFlow.getNodes() as TonoNode[]); const currentNodes = (reactFlow.getNodes() as TonoNode[]);
const currentEdges = (reactFlow.getEdges() as TonoEdge[]); const currentEdges = (reactFlow.getEdges() as TonoEdge[]);
@@ -2167,6 +2171,20 @@ function Flow() {
} }
}, [applyMaybePackedWorkflow, reactFlow, scheduleAutoRun]); }, [applyMaybePackedWorkflow, reactFlow, scheduleAutoRun]);
const loadExampleWorkflow = useCallback(async () => {
try {
const loaded = await loadDefaultWorkflowAsset();
if (!loaded) {
setStatus({ text: 'No example workflow found.', level: 'error' });
return;
}
await applyMaybePackedWorkflow(loaded.workflow);
setStatus({ text: 'Loaded example workflow.', level: 'info' });
} catch (err: any) {
setStatus({ text: 'Failed to load example workflow: ' + err.message, level: 'error' });
}
}, [applyMaybePackedWorkflow]);
// ── Load node definitions ─────────────────────────────────────────── // ── Load node definitions ───────────────────────────────────────────
useEffect(() => { useEffect(() => {
@@ -2970,6 +2988,19 @@ function Flow() {
// ── Keyboard shortcut ─────────────────────────────────────────────── // ── Keyboard shortcut ───────────────────────────────────────────────
// Close floating menu on outside click
const floatingMenuRef = useRef<HTMLDivElement>(null);
useEffect(() => {
if (!menuOpen) return;
const handler = (e: MouseEvent) => {
if (floatingMenuRef.current && !floatingMenuRef.current.contains(e.target as HTMLElement)) {
setMenuOpen(false);
}
};
document.addEventListener('pointerdown', handler);
return () => document.removeEventListener('pointerdown', handler);
}, [menuOpen]);
useEffect(() => { useEffect(() => {
const handler = (e: KeyboardEvent) => { const handler = (e: KeyboardEvent) => {
if ((e.ctrlKey || e.metaKey) && e.key === 'Enter') { if ((e.ctrlKey || e.metaKey) && e.key === 'Enter') {
@@ -3172,58 +3203,62 @@ function Flow() {
return ( return (
<NodeContext.Provider value={contextValue as any}> <NodeContext.Provider value={contextValue as any}>
<div className="app-container"> <div className="app-container">
{/* Toolbar */} {/* Floating menu */}
<div id="toolbar"> <div className="floating-menu" ref={floatingMenuRef}>
<span id="app-title">tono</span> <button className="floating-menu-toggle" onClick={() => setMenuOpen((o) => !o)} title="Menu">
<img src="/favicon.svg" alt="tono" className="floating-menu-logo" />
<div className="toolbar-group"> </button>
<button className="btn btn-primary" onClick={runWorkflow} title="Run workflow (Ctrl+Enter)"> {menuOpen && (
Run <div className="floating-menu-dropdown">
</button> <button className="btn btn-primary" onClick={() => { runWorkflow(); setMenuOpen(false); }} title="Run workflow (Ctrl+Enter)">
<button className="btn" onClick={clearGraph} title="Clear graph"> Run
Clear
</button>
</div>
<div className="toolbar-group">
<button className="btn" onClick={saveWorkflow} title="Save workflow as PNG">
Save
</button>
<button className="btn" onClick={savePackedWorkflow} title="Save packed workflow (with files)">
Pack
</button>
<button className="btn" onClick={loadWorkflow} title="Load workflow (JSON or PNG)">
Load
</button>
<button className="btn" onClick={copySnapshot} title="Copy workflow screenshot to clipboard">
Snapshot
</button>
{window.pywebview && (
<button className="btn" onClick={uploadPlugin} title="Upload a plugin (.py)">
Plugin
</button> </button>
)} <button className="btn" onClick={() => { clearGraph(); setMenuOpen(false); }} title="Clear graph">
</div> Clear
</button>
<div className="toolbar-group"> <hr className="floating-menu-divider" />
<button className="btn" onClick={openJournalTab} title="Open journal"> <button className="btn" onClick={() => { saveWorkflow(); setMenuOpen(false); }} title="Save workflow as PNG">
Journal Save
</button> </button>
<button className="btn" onClick={() => openDocByFilename('getting-started.md')} title="Getting started guide"> <button className="btn" onClick={() => { savePackedWorkflow(); setMenuOpen(false); }} title="Save packed workflow (with files)">
? Help Pack
</button> </button>
</div> <button className="btn" onClick={() => { loadWorkflow(); setMenuOpen(false); }} title="Load workflow (JSON or PNG)">
Load
<div className={`status-bar ${status.level}`}>{status.text}</div> </button>
<button className="btn" onClick={() => { copySnapshot(); setMenuOpen(false); }} title="Copy workflow screenshot to clipboard">
Snapshot
</button>
{window.pywebview && (
<button className="btn" onClick={() => { uploadPlugin(); setMenuOpen(false); }} title="Upload a plugin (.py)">
Plugin
</button>
)}
<hr className="floating-menu-divider" />
<button className="btn" onClick={() => { loadExampleWorkflow(); setMenuOpen(false); }} title="Load example workflow">
Example
</button>
<button className="btn" onClick={() => { openJournalTab(); setMenuOpen(false); }} title="Open journal">
Journal
</button>
<button className="btn" onClick={() => { openDocByFilename('getting-started.md'); setMenuOpen(false); }} title="Getting started guide">
? Help
</button>
{updateInfo && (
<>
<hr className="floating-menu-divider" />
<a className="btn floating-menu-update" href={updateInfo.url} target="_blank" rel="noopener noreferrer">
Update to {updateInfo.latest}
</a>
</>
)}
</div>
)}
</div> </div>
{updateInfo && ( {/* Status toast */}
<div className="update-banner"> {status.text && (
tono {updateInfo.latest} is available. <div className={`status-toast ${status.level}`}>{status.text}</div>
{' '}
<a href={updateInfo.url} target="_blank" rel="noopener noreferrer">Download</a>
<button className="update-banner-dismiss" onClick={() => setUpdateInfo(null)} title="Dismiss"></button>
</div>
)} )}
{/* React Flow canvas */} {/* React Flow canvas */}

View File

@@ -142,33 +142,70 @@ html, body, #root {
flex-direction: column; flex-direction: column;
} }
/* ── Toolbar ───────────────────────────────────────────────────────── */ /* ── Floating menu ─────────────────────────────────────────────────── */
#toolbar { .floating-menu {
height: 44px; position: fixed;
top: 12px;
left: 12px;
z-index: 200;
user-select: none;
}
.floating-menu-toggle {
width: 40px;
height: 40px;
border-radius: 10px;
border: 1px solid var(--border-toolbar);
background: var(--bg-toolbar); background: var(--bg-toolbar);
border-bottom: 1px solid var(--border-toolbar); cursor: pointer;
display: flex; display: flex;
align-items: center; align-items: center;
padding: 0 12px; justify-content: center;
gap: 10px; padding: 0;
z-index: 100; transition: border-color 0.15s, box-shadow 0.15s;
user-select: none; }
flex-shrink: 0; .floating-menu-toggle:hover {
border-color: var(--accent);
box-shadow: 0 0 8px rgba(85, 126, 255, 0.3);
} }
#app-title { .floating-menu-logo {
font-size: 15px; width: 28px;
font-weight: 700; height: 28px;
letter-spacing: 0.5px; border-radius: 6px;
color: var(--text-heading);
margin-right: 8px;
flex-shrink: 0;
} }
.toolbar-group { .floating-menu-dropdown {
position: absolute;
top: 48px;
left: 0;
background: var(--bg-toolbar);
border: 1px solid var(--border-toolbar);
border-radius: 8px;
padding: 6px;
display: flex; display: flex;
gap: 6px; flex-direction: column;
flex-shrink: 0; gap: 3px;
min-width: 150px;
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.4);
}
.floating-menu-dropdown .btn {
width: 100%;
text-align: left;
box-sizing: border-box;
}
.floating-menu-divider {
border: none;
border-top: 1px solid var(--border-toolbar);
margin: 3px 0;
}
.floating-menu-update {
text-decoration: none;
display: block;
text-align: left;
} }
/* ── Buttons ───────────────────────────────────────────────────────── */ /* ── Buttons ───────────────────────────────────────────────────────── */
@@ -200,45 +237,24 @@ html, body, #root {
border-color: var(--danger-hover); border-color: var(--danger-hover);
} }
/* ── Status bar ────────────────────────────────────────────────────── */ /* ── Status toast ─────────────────────────────────────────────────── */
.status-bar { .status-toast {
margin-left: auto; position: fixed;
padding: 4px 10px; bottom: 12px;
border-radius: 4px; left: 50%;
transform: translateX(-50%);
padding: 6px 16px;
border-radius: 6px;
font-size: 11px; font-size: 11px;
z-index: 200;
pointer-events: none;
background: var(--bg-toolbar);
border: 1px solid var(--border-toolbar);
max-width: 60%; max-width: 60%;
flex-shrink: 1;
}
.status-bar.info { color: var(--accent-light); }
.status-bar.error { color: var(--error-text); background: var(--error-bg); }
.update-banner {
background: var(--accent);
color: #fff;
text-align: center; text-align: center;
padding: 4px 12px;
font-size: 12px;
position: relative;
} }
.update-banner a { .status-toast.info { color: var(--accent-light); }
color: #fff; .status-toast.error { color: var(--error-text); background: var(--error-bg); }
font-weight: 600;
text-decoration: underline;
}
.update-banner-dismiss {
position: absolute;
right: 8px;
top: 50%;
transform: translateY(-50%);
background: none;
border: none;
color: #fff;
cursor: pointer;
font-size: 13px;
padding: 2px 4px;
opacity: 0.7;
}
.update-banner-dismiss:hover { opacity: 1; }
/* ── React Flow container ──────────────────────────────────────────── */ /* ── React Flow container ──────────────────────────────────────────── */
.flow-container { .flow-container {