more qol upgrades
This commit is contained in:
@@ -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.
|
||||||
|
|
||||||
|
|||||||
@@ -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,59 +3203,63 @@ 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 && (
|
||||||
|
<div className="floating-menu-dropdown">
|
||||||
|
<button className="btn btn-primary" onClick={() => { runWorkflow(); setMenuOpen(false); }} title="Run workflow (Ctrl+Enter)">
|
||||||
▶ Run
|
▶ Run
|
||||||
</button>
|
</button>
|
||||||
<button className="btn" onClick={clearGraph} title="Clear graph">
|
<button className="btn" onClick={() => { clearGraph(); setMenuOpen(false); }} title="Clear graph">
|
||||||
✕ Clear
|
✕ Clear
|
||||||
</button>
|
</button>
|
||||||
</div>
|
<hr className="floating-menu-divider" />
|
||||||
|
<button className="btn" onClick={() => { saveWorkflow(); setMenuOpen(false); }} title="Save workflow as PNG">
|
||||||
<div className="toolbar-group">
|
|
||||||
<button className="btn" onClick={saveWorkflow} title="Save workflow as PNG">
|
|
||||||
⤓ Save
|
⤓ Save
|
||||||
</button>
|
</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
|
⊞ Pack
|
||||||
</button>
|
</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
|
⤒ Load
|
||||||
</button>
|
</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
|
⎘ Snapshot
|
||||||
</button>
|
</button>
|
||||||
{window.pywebview && (
|
{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
|
⊕ Plugin
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
</div>
|
<hr className="floating-menu-divider" />
|
||||||
|
<button className="btn" onClick={() => { loadExampleWorkflow(); setMenuOpen(false); }} title="Load example workflow">
|
||||||
<div className="toolbar-group">
|
◈ Example
|
||||||
<button className="btn" onClick={openJournalTab} title="Open journal">
|
</button>
|
||||||
|
<button className="btn" onClick={() => { openJournalTab(); setMenuOpen(false); }} title="Open journal">
|
||||||
✎ Journal
|
✎ Journal
|
||||||
</button>
|
</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
|
? Help
|
||||||
</button>
|
</button>
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className={`status-bar ${status.level}`}>{status.text}</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{updateInfo && (
|
{updateInfo && (
|
||||||
<div className="update-banner">
|
<>
|
||||||
tono {updateInfo.latest} is available.
|
<hr className="floating-menu-divider" />
|
||||||
{' '}
|
<a className="btn floating-menu-update" href={updateInfo.url} target="_blank" rel="noopener noreferrer">
|
||||||
<a href={updateInfo.url} target="_blank" rel="noopener noreferrer">Download</a>
|
↑ Update to {updateInfo.latest}
|
||||||
<button className="update-banner-dismiss" onClick={() => setUpdateInfo(null)} title="Dismiss">✕</button>
|
</a>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Status toast */}
|
||||||
|
{status.text && (
|
||||||
|
<div className={`status-toast ${status.level}`}>{status.text}</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* React Flow canvas */}
|
{/* React Flow canvas */}
|
||||||
<div
|
<div
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
Reference in New Issue
Block a user