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.
You can open a menu by clicking the blue cantilever bubble in the top left.
### 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.
@@ -101,3 +103,10 @@ Use **Ctrl+Z** / **Cmd+Z** to undo and **Ctrl+Shift+Z** / **Cmd+Shift+Z** to red
| Ctrl+V | Paste nodes |
| Right-click canvas | Add node menu |
| 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 [activeHelpTab, setActiveHelpTab] = useState<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 panTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
@@ -2145,6 +2146,9 @@ function Flow() {
if (defaultWorkflowLoadAttemptedRef.current) return;
defaultWorkflowLoadAttemptedRef.current = true;
// Only auto-load the example workflow on first visit
if (localStorage.getItem('tono_visited')) return;
const graphHasContent = () => {
const currentNodes = (reactFlow.getNodes() as TonoNode[]);
const currentEdges = (reactFlow.getEdges() as TonoEdge[]);
@@ -2167,6 +2171,20 @@ function Flow() {
}
}, [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 ───────────────────────────────────────────
useEffect(() => {
@@ -2970,6 +2988,19 @@ function Flow() {
// ── 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(() => {
const handler = (e: KeyboardEvent) => {
if ((e.ctrlKey || e.metaKey) && e.key === 'Enter') {
@@ -3172,59 +3203,63 @@ function Flow() {
return (
<NodeContext.Provider value={contextValue as any}>
<div className="app-container">
{/* Toolbar */}
<div id="toolbar">
<span id="app-title">tono</span>
<div className="toolbar-group">
<button className="btn btn-primary" onClick={runWorkflow} title="Run workflow (Ctrl+Enter)">
{/* Floating menu */}
<div className="floating-menu" ref={floatingMenuRef}>
<button className="floating-menu-toggle" onClick={() => setMenuOpen((o) => !o)} title="Menu">
<img src="/favicon.svg" alt="tono" className="floating-menu-logo" />
</button>
{menuOpen && (
<div className="floating-menu-dropdown">
<button className="btn btn-primary" onClick={() => { runWorkflow(); setMenuOpen(false); }} title="Run workflow (Ctrl+Enter)">
Run
</button>
<button className="btn" onClick={clearGraph} title="Clear graph">
<button className="btn" onClick={() => { clearGraph(); setMenuOpen(false); }} title="Clear graph">
Clear
</button>
</div>
<div className="toolbar-group">
<button className="btn" onClick={saveWorkflow} title="Save workflow as PNG">
<hr className="floating-menu-divider" />
<button className="btn" onClick={() => { saveWorkflow(); setMenuOpen(false); }} title="Save workflow as PNG">
Save
</button>
<button className="btn" onClick={savePackedWorkflow} title="Save packed workflow (with files)">
<button className="btn" onClick={() => { savePackedWorkflow(); setMenuOpen(false); }} title="Save packed workflow (with files)">
Pack
</button>
<button className="btn" onClick={loadWorkflow} title="Load workflow (JSON or PNG)">
<button className="btn" onClick={() => { loadWorkflow(); setMenuOpen(false); }} title="Load workflow (JSON or PNG)">
Load
</button>
<button className="btn" onClick={copySnapshot} title="Copy workflow screenshot to clipboard">
<button className="btn" onClick={() => { copySnapshot(); setMenuOpen(false); }} title="Copy workflow screenshot to clipboard">
Snapshot
</button>
{window.pywebview && (
<button className="btn" onClick={uploadPlugin} title="Upload a plugin (.py)">
<button className="btn" onClick={() => { uploadPlugin(); setMenuOpen(false); }} title="Upload a plugin (.py)">
Plugin
</button>
)}
</div>
<div className="toolbar-group">
<button className="btn" onClick={openJournalTab} title="Open journal">
<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')} title="Getting started guide">
<button className="btn" onClick={() => { openDocByFilename('getting-started.md'); setMenuOpen(false); }} title="Getting started guide">
? Help
</button>
</div>
<div className={`status-bar ${status.level}`}>{status.text}</div>
</div>
{updateInfo && (
<div className="update-banner">
tono {updateInfo.latest} is available.
{' '}
<a href={updateInfo.url} target="_blank" rel="noopener noreferrer">Download</a>
<button className="update-banner-dismiss" onClick={() => setUpdateInfo(null)} title="Dismiss"></button>
<>
<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>
{/* Status toast */}
{status.text && (
<div className={`status-toast ${status.level}`}>{status.text}</div>
)}
{/* React Flow canvas */}
<div

View File

@@ -142,33 +142,70 @@ html, body, #root {
flex-direction: column;
}
/* ── Toolbar ───────────────────────────────────────────────────────── */
#toolbar {
height: 44px;
/* ── Floating menu ─────────────────────────────────────────────────── */
.floating-menu {
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);
border-bottom: 1px solid var(--border-toolbar);
cursor: pointer;
display: flex;
align-items: center;
padding: 0 12px;
gap: 10px;
z-index: 100;
user-select: none;
flex-shrink: 0;
justify-content: center;
padding: 0;
transition: border-color 0.15s, box-shadow 0.15s;
}
.floating-menu-toggle:hover {
border-color: var(--accent);
box-shadow: 0 0 8px rgba(85, 126, 255, 0.3);
}
#app-title {
font-size: 15px;
font-weight: 700;
letter-spacing: 0.5px;
color: var(--text-heading);
margin-right: 8px;
flex-shrink: 0;
.floating-menu-logo {
width: 28px;
height: 28px;
border-radius: 6px;
}
.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;
gap: 6px;
flex-shrink: 0;
flex-direction: column;
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 ───────────────────────────────────────────────────────── */
@@ -200,45 +237,24 @@ html, body, #root {
border-color: var(--danger-hover);
}
/* ── Status bar ────────────────────────────────────────────────────── */
.status-bar {
margin-left: auto;
padding: 4px 10px;
border-radius: 4px;
/* ── Status toast ─────────────────────────────────────────────────── */
.status-toast {
position: fixed;
bottom: 12px;
left: 50%;
transform: translateX(-50%);
padding: 6px 16px;
border-radius: 6px;
font-size: 11px;
z-index: 200;
pointer-events: none;
background: var(--bg-toolbar);
border: 1px solid var(--border-toolbar);
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;
padding: 4px 12px;
font-size: 12px;
position: relative;
}
.update-banner a {
color: #fff;
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; }
.status-toast.info { color: var(--accent-light); }
.status-toast.error { color: var(--error-text); background: var(--error-bg); }
/* ── React Flow container ──────────────────────────────────────────── */
.flow-container {