animations on menu, toasts

This commit is contained in:
2026-04-03 21:36:52 -07:00
parent ab3b4d5ec3
commit c24eed104e
2 changed files with 69 additions and 19 deletions

View File

@@ -170,6 +170,12 @@ function Flow() {
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 [menuOpen, setMenuOpen] = useState(false);
const [menuClosing, setMenuClosing] = useState(false);
const closeMenu = useCallback(() => {
if (!menuOpen || menuClosing) return;
setMenuClosing(true);
setTimeout(() => { setMenuOpen(false); setMenuClosing(false); }, 150);
}, [menuOpen, menuClosing]);
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);
@@ -2226,18 +2232,21 @@ function Flow() {
if (!menuOpen) return; if (!menuOpen) return;
const handler = (e: MouseEvent) => { const handler = (e: MouseEvent) => {
if (floatingMenuRef.current && !floatingMenuRef.current.contains(e.target as HTMLElement)) { if (floatingMenuRef.current && !floatingMenuRef.current.contains(e.target as HTMLElement)) {
setMenuOpen(false); closeMenu();
} }
}; };
document.addEventListener('pointerdown', handler); document.addEventListener('pointerdown', handler);
return () => document.removeEventListener('pointerdown', handler); return () => document.removeEventListener('pointerdown', handler);
}, [menuOpen]); }, [menuOpen]);
// Auto-dismiss status toast after 5 seconds // Auto-dismiss status toast after 5 seconds with close animation
const [toastClosing, setToastClosing] = useState(false);
useEffect(() => { useEffect(() => {
if (!status.text) return; if (!status.text) return;
const timer = setTimeout(() => setStatus({ text: '', level: 'info' }), 5000); setToastClosing(false);
return () => clearTimeout(timer); const fadeTimer = setTimeout(() => setToastClosing(true), 4700);
const removeTimer = setTimeout(() => { setToastClosing(false); setStatus({ text: '', level: 'info' }); }, 5000);
return () => { clearTimeout(fadeTimer); clearTimeout(removeTimer); };
}, [status]); }, [status]);
useEffect(() => { useEffect(() => {
@@ -2444,43 +2453,43 @@ function Flow() {
<div className="app-container"> <div className="app-container">
{/* Floating menu */} {/* Floating menu */}
<div className="floating-menu" ref={floatingMenuRef}> <div className="floating-menu" ref={floatingMenuRef}>
<button className="floating-menu-toggle" onClick={() => setMenuOpen((o) => !o)} title="Menu"> <button className="floating-menu-toggle" onClick={() => menuOpen ? closeMenu() : setMenuOpen(true)} title="Menu">
<img src="/favicon.svg" alt="tono" className="floating-menu-logo" /> <img src="/favicon.svg" alt="tono" className="floating-menu-logo" />
</button> </button>
{menuOpen && ( {(menuOpen || menuClosing) && (
<div className="floating-menu-dropdown"> <div className={`floating-menu-dropdown${menuClosing ? ' closing' : ''}`}>
<button className="btn btn-primary" onClick={() => { runWorkflow(); setMenuOpen(false); }} title="Run workflow (Ctrl+Enter)"> <button className="btn btn-primary" onClick={() => { runWorkflow(); closeMenu(); }} title="Run workflow (Ctrl+Enter)">
Run Run
</button> </button>
<button className="btn" onClick={() => { clearGraph(); setMenuOpen(false); }} title="Clear graph"> <button className="btn" onClick={() => { clearGraph(); closeMenu(); }} title="Clear graph">
Clear Clear
</button> </button>
<hr className="floating-menu-divider" /> <hr className="floating-menu-divider" />
<button className="btn" onClick={() => { saveWorkflow(); setMenuOpen(false); }} title="Save workflow as PNG"> <button className="btn" onClick={() => { saveWorkflow(); closeMenu(); }} title="Save workflow as PNG">
Save Save
</button> </button>
<button className="btn" onClick={() => { savePackedWorkflow(); setMenuOpen(false); }} title="Save packed workflow (with files)"> <button className="btn" onClick={() => { savePackedWorkflow(); closeMenu(); }} title="Save packed workflow (with files)">
Pack Pack
</button> </button>
<button className="btn" onClick={() => { loadWorkflow(); setMenuOpen(false); }} title="Load workflow (JSON or PNG)"> <button className="btn" onClick={() => { loadWorkflow(); closeMenu(); }} title="Load workflow (JSON or PNG)">
Load Load
</button> </button>
<button className="btn" onClick={() => { copySnapshot(); setMenuOpen(false); }} title="Copy workflow screenshot to clipboard"> <button className="btn" onClick={() => { copySnapshot(); closeMenu(); }} title="Copy workflow screenshot to clipboard">
Snapshot Snapshot
</button> </button>
{window.pywebview && ( {window.pywebview && (
<button className="btn" onClick={() => { uploadPlugin(); setMenuOpen(false); }} title="Upload a plugin (.py)"> <button className="btn" onClick={() => { uploadPlugin(); closeMenu(); }} title="Upload a plugin (.py)">
Plugin Plugin
</button> </button>
)} )}
<hr className="floating-menu-divider" /> <hr className="floating-menu-divider" />
<button className="btn" onClick={() => { loadExampleWorkflow(); setMenuOpen(false); }} title="Load example workflow"> <button className="btn" onClick={() => { loadExampleWorkflow(); closeMenu(); }} title="Load example workflow">
Example Example
</button> </button>
<button className="btn" onClick={() => { openJournalTab(); setMenuOpen(false); }} title="Open journal"> <button className="btn" onClick={() => { openJournalTab(); closeMenu(); }} title="Open journal">
Journal Journal
</button> </button>
<button className="btn" onClick={() => { openDocByFilename('getting-started.md'); setMenuOpen(false); }} title="Getting started guide"> <button className="btn" onClick={() => { openDocByFilename('getting-started.md'); closeMenu(); }} title="Getting started guide">
? Help ? Help
</button> </button>
{updateInfo && ( {updateInfo && (
@@ -2496,8 +2505,8 @@ function Flow() {
</div> </div>
{/* Status toast */} {/* Status toast */}
{status.text && ( {(status.text || toastClosing) && (
<div className={`status-toast ${status.level}`}>{status.text}</div> <div className={`status-toast ${status.level}${toastClosing ? ' closing' : ''}`}>{status.text}</div>
)} )}
{/* React Flow canvas */} {/* React Flow canvas */}

View File

@@ -188,6 +188,34 @@ html, body, #root {
gap: 3px; gap: 3px;
min-width: 150px; min-width: 150px;
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.4); box-shadow: 0 8px 24px rgba(0, 0, 0, 0.4);
animation: menu-open 0.15s ease-out;
transform-origin: top left;
}
@keyframes menu-open {
from {
opacity: 0;
transform: scale(0.9) translateY(-4px);
}
to {
opacity: 1;
transform: scale(1) translateY(0);
}
}
.floating-menu-dropdown.closing {
animation: menu-close 0.15s ease-in forwards;
}
@keyframes menu-close {
from {
opacity: 1;
transform: scale(1) translateY(0);
}
to {
opacity: 0;
transform: scale(0.9) translateY(-4px);
}
} }
.floating-menu-dropdown .btn { .floating-menu-dropdown .btn {
@@ -252,10 +280,23 @@ html, body, #root {
border: 1px solid var(--border-toolbar); border: 1px solid var(--border-toolbar);
max-width: 60%; max-width: 60%;
text-align: center; text-align: center;
animation: toast-in 0.2s ease-out;
}
.status-toast.closing {
animation: toast-out 0.3s ease-in forwards;
} }
.status-toast.info { color: var(--accent-light); } .status-toast.info { color: var(--accent-light); }
.status-toast.error { color: var(--error-text); background: var(--error-bg); } .status-toast.error { color: var(--error-text); background: var(--error-bg); }
@keyframes toast-in {
from { opacity: 0; transform: translateX(-50%) translateY(8px); }
to { opacity: 1; transform: translateX(-50%) translateY(0); }
}
@keyframes toast-out {
from { opacity: 1; transform: translateX(-50%) translateY(0); }
to { opacity: 0; transform: translateX(-50%) translateY(8px); }
}
/* ── React Flow container ──────────────────────────────────────────── */ /* ── React Flow container ──────────────────────────────────────────── */
.flow-container { .flow-container {
flex: 1; flex: 1;