feat: refine mobile drawer trigger

This commit is contained in:
2026-05-24 22:17:26 +02:00
parent ebd283627e
commit b475189598
2 changed files with 197 additions and 58 deletions
+105 -41
View File
@@ -87,6 +87,16 @@ async function requestJson<T>(url: string, init?: RequestInit): Promise<T> {
return body;
}
function DrawerIcon() {
return (
<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.8" />
<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.8" strokeLinecap="round" />
</svg>
);
}
export function App() {
const [drawings, setDrawings] = useState<DrawingMeta[]>([]);
const [activeId, setActiveId] = useState<string | null>(pathToId());
@@ -94,6 +104,7 @@ export function App() {
const [scene, setScene] = useState<ScenePayload | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [isSidebarOpen, setIsSidebarOpen] = useState(false);
const currentIdRef = useRef<string | null>(activeId);
const currentTitleRef = useRef(activeTitle);
@@ -370,14 +381,105 @@ export function App() {
};
}, []);
useEffect(() => {
if (!isSidebarOpen) {
return;
}
const onKeyDown = (event: KeyboardEvent) => {
if (event.key === "Escape") {
setIsSidebarOpen(false);
}
};
window.addEventListener("keydown", onKeyDown);
return () => {
window.removeEventListener("keydown", onKeyDown);
};
}, [isSidebarOpen]);
const openSidebar = useCallback(() => {
setIsSidebarOpen(true);
}, []);
const closeSidebar = useCallback(() => {
setIsSidebarOpen(false);
}, []);
const handleSelectDrawing = useCallback(
async (drawingId: string) => {
setIsSidebarOpen(false);
await navigateToDrawing(drawingId);
},
[navigateToDrawing],
);
const handleCreateDrawing = useCallback(async () => {
setIsSidebarOpen(false);
await createDrawing();
}, [createDrawing]);
return (
<div className="app-shell">
<aside className="sidebar">
<main className="editor-shell">
<div className="app-actions">
<button
type="button"
className="secondary-button app-actions-toggle"
onClick={openSidebar}
aria-label="Open drawings"
>
<DrawerIcon />
<span className="sr-only">Open drawings</span>
</button>
</div>
{loading || !scene ? (
<div className="editor-loading">{error ?? "Loading..."}</div>
) : (
<div className="editor-frame">
<Excalidraw
key={activeDrawing?.id ?? activeId}
initialData={sceneToInitialData(scene)}
onChange={(elements, appState, files) => {
const nextScene = sceneFromEditor(elements, appState, files);
latestSceneRef.current = nextScene;
if (ignoreChangeRef.current) {
ignoreChangeRef.current = false;
return;
}
scheduleSave();
}}
/>
</div>
)}
</main>
<div
className={isSidebarOpen ? "sidebar-backdrop sidebar-backdrop-open" : "sidebar-backdrop"}
onClick={closeSidebar}
aria-hidden={!isSidebarOpen}
/>
<aside className={isSidebarOpen ? "sidebar sidebar-open" : "sidebar"} aria-hidden={!isSidebarOpen}>
<div className="sidebar-header">
<h1>excali</h1>
<button type="button" className="primary-button" onClick={() => void createDrawing()}>
<div className="sidebar-actions">
<button type="button" className="primary-button" onClick={() => void handleCreateDrawing()}>
New
</button>
<button type="button" className="icon-button" onClick={closeSidebar} aria-label="Close drawings">
×
</button>
</div>
</div>
<div className="sidebar-toolbar">
<button type="button" className="secondary-button" onClick={() => void exportPng()} disabled={!scene}>
Export PNG
</button>
<button type="button" className="secondary-button" onClick={exportExcalidraw} disabled={!scene}>
Export .excalidraw
</button>
</div>
<div className="drawing-list">
{drawings.map((drawing) => (
@@ -406,7 +508,7 @@ export function App() {
<button
type="button"
className="drawing-link"
onClick={() => void navigateToDrawing(drawing.id)}
onClick={() => void handleSelectDrawing(drawing.id)}
>
<span className="drawing-title">{drawing.title}</span>
</button>
@@ -423,44 +525,6 @@ export function App() {
))}
</div>
</aside>
<main className="editor-shell">
<div className="toolbar">
<button type="button" className="secondary-button" onClick={() => void exportPng()} disabled={!scene}>
Export PNG
</button>
<button
type="button"
className="secondary-button"
onClick={exportExcalidraw}
disabled={!scene}
>
Export .excalidraw
</button>
</div>
{loading || !scene ? (
<div className="editor-loading">{error ?? "Loading..."}</div>
) : (
<div className="editor-frame">
<Excalidraw
key={activeDrawing?.id ?? activeId}
initialData={sceneToInitialData(scene)}
onChange={(elements, appState, files) => {
const nextScene = sceneFromEditor(elements, appState, files);
latestSceneRef.current = nextScene;
if (ignoreChangeRef.current) {
ignoreChangeRef.current = false;
return;
}
scheduleSave();
}}
/>
</div>
)}
</main>
</div>
);
}
+91 -16
View File
@@ -32,17 +32,29 @@ input {
}
.app-shell {
display: flex;
height: 100%;
}
.sidebar {
position: fixed;
top: 0;
left: 0;
z-index: 40;
display: flex;
width: 260px;
min-width: 260px;
height: 100%;
width: min(340px, calc(100vw - 32px));
flex-direction: column;
border-right: 1px solid #e4e4e7;
background: #ffffff;
box-shadow: 0 24px 80px rgba(24, 24, 27, 0.22);
transform: translateX(calc(-100% - 24px));
transition:
transform 180ms ease,
box-shadow 180ms ease;
}
.sidebar-open {
transform: translateX(0);
}
.sidebar-header {
@@ -60,7 +72,14 @@ input {
font-weight: 600;
}
.sidebar-actions {
display: flex;
align-items: center;
gap: 8px;
}
.drawing-list {
flex: 1;
overflow: auto;
padding: 8px;
}
@@ -104,18 +123,41 @@ input {
.editor-shell {
position: relative;
min-width: 0;
flex: 1;
height: 100%;
}
.toolbar {
position: absolute;
top: 16px;
right: 16px;
.app-actions {
position: fixed;
right: max(16px, calc(env(safe-area-inset-right) + 16px));
bottom: calc(max(16px, env(safe-area-inset-bottom)) + 56px);
z-index: 20;
display: flex;
}
.app-actions-toggle {
display: inline-flex;
align-items: center;
justify-content: center;
width: 2.25rem;
height: 2.25rem;
padding: 0;
border: none;
box-shadow: 0 0 0 1px #1e1e24;
background: #2f2f37;
color: #f4f4f5;
}
.app-actions-toggle svg {
width: 0.95rem;
height: 0.95rem;
}
.sidebar-toolbar {
display: flex;
flex-wrap: wrap;
gap: 8px;
border-bottom: 1px solid #e4e4e7;
padding: 12px 16px;
}
.primary-button,
@@ -143,6 +185,21 @@ input {
cursor: pointer;
}
.sidebar-backdrop {
position: fixed;
inset: 0;
z-index: 30;
background: rgba(24, 24, 27, 0.38);
opacity: 0;
pointer-events: none;
transition: opacity 180ms ease;
}
.sidebar-backdrop-open {
opacity: 1;
pointer-events: auto;
}
.editor-frame,
.editor-loading {
height: 100%;
@@ -154,16 +211,34 @@ input {
color: #52525b;
}
.app-actions-toggle:hover {
background: #3b3b45;
}
.app-actions-toggle:active {
box-shadow: 0 0 0 1px #5b8def;
background: #444450;
}
.sr-only {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
white-space: nowrap;
border: 0;
}
@media (max-width: 900px) {
.sidebar {
width: 220px;
min-width: 220px;
width: min(300px, calc(100vw - 20px));
}
.toolbar {
left: 12px;
right: 12px;
flex-wrap: wrap;
justify-content: flex-end;
.app-actions {
right: max(16px, calc(env(safe-area-inset-right) + 16px));
bottom: calc(max(16px, env(safe-area-inset-bottom)) + 64px);
}
}