animations on menu, toasts
This commit is contained in:
@@ -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 */}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
Reference in New Issue
Block a user