Compare commits

..

2 Commits

Author SHA1 Message Date
ruinivist 58ac662d8e feat: implement manual save via Ctrl+S with toast and increase autosave interval
- Increased autosave interval to 30s.
- Captured Ctrl+S / Meta+S to trigger manual save.
- Added a minimalistic, translucent toast in the top right to display saving status.
2026-05-30 18:07:44 +00:00
ruinivist 467b54c402 feat: implement manual save via Ctrl+S with toast and increase autosave interval
- Increased autosave interval to 30s.
- Captured Ctrl+S / Meta+S to trigger manual save.
- Added a minimalistic, translucent toast in the top right to display saving status.
2026-05-30 17:56:30 +00:00
2 changed files with 89 additions and 18 deletions
+61 -7
View File
@@ -80,10 +80,10 @@ async function requestJson<T>(url: string, init?: RequestInit): Promise<T> {
function DrawerIcon() { function DrawerIcon() {
return ( return (
<svg viewBox="0 0 24 24" aria-hidden="true" width="16" height="16"> <svg viewBox="0 0 24 24" aria-hidden="true">
<rect x="3.5" y="5" width="17" height="14" rx="2.5" fill="none" stroke="currentColor" strokeWidth="1.5" /> <rect x="3.5" y="5" width="17" height="14" rx="2.5" fill="none" stroke="currentColor" strokeWidth="1.8" />
<path d="M9 5v14" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" /> <path d="M9 5v14" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" />
<path d="M5.75 9h1.5M5.75 12h1.5M5.75 15h1.5" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" /> <path d="M5.75 9h1.5M5.75 12h1.5M5.75 15h1.5" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" />
</svg> </svg>
); );
} }
@@ -96,6 +96,7 @@ export function App() {
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const [isSidebarOpen, setIsSidebarOpen] = useState(false); const [isSidebarOpen, setIsSidebarOpen] = useState(false);
const [toastMessage, setToastMessage] = useState<string | null>(null);
const currentIdRef = useRef<string | null>(activeId); const currentIdRef = useRef<string | null>(activeId);
const currentTitleRef = useRef(activeTitle); const currentTitleRef = useRef(activeTitle);
@@ -106,6 +107,7 @@ export function App() {
const inFlightSaveRef = useRef<Promise<void> | null>(null); const inFlightSaveRef = useRef<Promise<void> | null>(null);
const loadVersionRef = useRef(0); const loadVersionRef = useRef(0);
const themeSyncFrameRef = useRef<number | null>(null); const themeSyncFrameRef = useRef<number | null>(null);
const toastTimeoutRef = useRef<number | null>(null);
const activeDrawing = useMemo( const activeDrawing = useMemo(
() => drawings.find((drawing) => drawing.id === activeId) ?? null, () => drawings.find((drawing) => drawing.id === activeId) ?? null,
@@ -118,7 +120,7 @@ export function App() {
return list; return list;
}, []); }, []);
const saveScene = useCallback(async () => { const saveScene = useCallback(async (isManual = false) => {
const drawingId = currentIdRef.current; const drawingId = currentIdRef.current;
const nextScene = latestSceneRef.current; const nextScene = latestSceneRef.current;
if (!drawingId || !nextScene) { if (!drawingId || !nextScene) {
@@ -126,15 +128,37 @@ export function App() {
} }
setError(null); setError(null);
if (isManual) {
setToastMessage("Saving...");
if (toastTimeoutRef.current !== null) {
clearTimeout(toastTimeoutRef.current);
toastTimeoutRef.current = null;
}
}
const promise = requestJson<{ ok: true; drawing: DrawingMeta }>(`/api/drawings/${drawingId}`, { const promise = requestJson<{ ok: true; drawing: DrawingMeta }>(`/api/drawings/${drawingId}`, {
method: "PUT", method: "PUT",
body: JSON.stringify(nextScene), body: JSON.stringify(nextScene),
}) })
.then((body) => { .then((body) => {
setDrawings((current) => replaceMeta(current, body.drawing)); setDrawings((current) => replaceMeta(current, body.drawing));
if (isManual) {
setToastMessage("Saved");
if (toastTimeoutRef.current !== null) {
clearTimeout(toastTimeoutRef.current);
}
toastTimeoutRef.current = window.setTimeout(() => setToastMessage(null), 2000);
}
}) })
.catch((saveError: unknown) => { .catch((saveError: unknown) => {
setError(saveError instanceof Error ? saveError.message : "Save failed"); setError(saveError instanceof Error ? saveError.message : "Save failed");
if (isManual) {
setToastMessage("Save failed");
if (toastTimeoutRef.current !== null) {
clearTimeout(toastTimeoutRef.current);
}
toastTimeoutRef.current = window.setTimeout(() => setToastMessage(null), 2000);
}
throw saveError; throw saveError;
}) })
.finally(() => { .finally(() => {
@@ -158,6 +182,17 @@ export function App() {
} }
}, [saveScene]); }, [saveScene]);
const triggerManualSave = useCallback(async () => {
if (timeoutRef.current !== null) {
clearTimeout(timeoutRef.current);
timeoutRef.current = null;
}
if (inFlightSaveRef.current) {
await inFlightSaveRef.current;
}
await saveScene(true);
}, [saveScene]);
const scheduleSave = useCallback(() => { const scheduleSave = useCallback(() => {
if (timeoutRef.current !== null) { if (timeoutRef.current !== null) {
clearTimeout(timeoutRef.current); clearTimeout(timeoutRef.current);
@@ -166,7 +201,7 @@ export function App() {
timeoutRef.current = window.setTimeout(() => { timeoutRef.current = window.setTimeout(() => {
timeoutRef.current = null; timeoutRef.current = null;
void saveScene(); void saveScene();
}, 1500); }, 30000);
}, [saveScene]); }, [saveScene]);
const loadDrawing = useCallback( const loadDrawing = useCallback(
@@ -329,6 +364,20 @@ export function App() {
}); });
}, [syncThemeTokens]); }, [syncThemeTokens]);
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === "s") {
e.preventDefault();
e.stopPropagation();
void triggerManualSave();
}
};
window.addEventListener("keydown", handleKeyDown, { capture: true });
return () => {
window.removeEventListener("keydown", handleKeyDown, { capture: true });
};
}, [triggerManualSave]);
useEffect(() => { useEffect(() => {
const currentPathId = pathToId(); const currentPathId = pathToId();
if (currentPathId) { if (currentPathId) {
@@ -410,11 +459,16 @@ export function App() {
return ( return (
<div className="app-shell" ref={appShellRef}> <div className="app-shell" ref={appShellRef}>
{toastMessage && (
<div className="toast-overlay" aria-live="polite">
{toastMessage}
</div>
)}
<main className="editor-shell"> <main className="editor-shell">
<div className="app-actions"> <div className="app-actions">
<button <button
type="button" type="button"
className="app-actions-toggle" className="secondary-button app-actions-toggle"
onClick={openSidebar} onClick={openSidebar}
aria-label="Open drawings" aria-label="Open drawings"
> >
+28 -11
View File
@@ -169,21 +169,22 @@ input {
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
width: 2.25rem; width: var(--lg-button-size, 2.25rem);
height: 2.25rem; height: var(--lg-button-size, 2.25rem);
padding: 0; padding: 0;
border: none; border: none;
border-radius: 8px; border-radius: var(--border-radius-lg, 12px);
box-shadow: none; box-shadow: 0 0 0 1px var(--app-surface-lowest);
background: var(--app-surface-low); background: var(--app-surface-low);
color: var(--app-text-color); color: var(--app-text-color);
cursor: pointer;
transition: background-color 120ms ease, box-shadow 120ms ease;
} }
.app-actions-toggle svg { .app-actions-toggle svg {
width: 16px; width: 1rem;
height: 16px; height: 1rem;
overflow: visible;
transform: translateY(0.5px) scale(1.08);
transform-origin: center;
} }
.primary-button, .primary-button,
@@ -252,6 +253,22 @@ input {
pointer-events: auto; pointer-events: auto;
} }
.toast-overlay {
position: fixed;
top: 16px;
right: 16px;
z-index: 100;
background: var(--app-overlay-bg);
backdrop-filter: blur(4px);
color: var(--app-text-color);
padding: 8px 16px;
border-radius: 8px;
font-size: var(--app-sidebar-font-size);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
border: 1px solid var(--app-sidebar-border);
pointer-events: none;
}
.editor-frame, .editor-frame,
.editor-loading { .editor-loading {
height: 100%; height: 100%;
@@ -264,12 +281,12 @@ input {
} }
.app-actions-toggle:hover { .app-actions-toggle:hover {
background-color: var(--app-button-hover-bg); background: var(--app-button-hover-bg);
} }
.app-actions-toggle:active { .app-actions-toggle:active {
background-color: var(--app-button-active-bg); box-shadow: 0 0 0 1px var(--app-button-active-border);
box-shadow: none; background: var(--app-button-active-bg);
} }
.sr-only { .sr-only {