Compare commits
3 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 4a04de9a4f | |||
| 66168e95cc | |||
| 17d01aff21 |
@@ -0,0 +1,5 @@
|
|||||||
|
node_modules
|
||||||
|
dist
|
||||||
|
data
|
||||||
|
.git
|
||||||
|
coverage
|
||||||
@@ -0,0 +1,80 @@
|
|||||||
|
name: Publish GHCR Image
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
tags:
|
||||||
|
- 'release-*'
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
packages: write
|
||||||
|
|
||||||
|
concurrency:
|
||||||
|
group: publish-ghcr-${{ github.ref }}
|
||||||
|
cancel-in-progress: false
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
publish:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Check out repository
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Set up Bun
|
||||||
|
uses: oven-sh/setup-bun@v2
|
||||||
|
with:
|
||||||
|
bun-version: 1.3.11
|
||||||
|
|
||||||
|
- name: Install dependencies
|
||||||
|
run: bun install --frozen-lockfile
|
||||||
|
|
||||||
|
- name: Run typecheck
|
||||||
|
run: bun run typecheck
|
||||||
|
|
||||||
|
- name: Run tests
|
||||||
|
run: bun run test
|
||||||
|
|
||||||
|
- name: Set up Docker Buildx
|
||||||
|
uses: docker/setup-buildx-action@v3
|
||||||
|
|
||||||
|
- name: Log in to GHCR
|
||||||
|
uses: docker/login-action@v3
|
||||||
|
with:
|
||||||
|
registry: ghcr.io
|
||||||
|
username: ${{ github.actor }}
|
||||||
|
password: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
|
||||||
|
- name: Derive image metadata
|
||||||
|
shell: bash
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
version="${GITHUB_REF_NAME#release-}"
|
||||||
|
version="${version#v}"
|
||||||
|
|
||||||
|
if [[ -z "$version" ]]; then
|
||||||
|
echo "Release version is empty after stripping prefix from tag: $GITHUB_REF_NAME" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
image_name="ghcr.io/${GITHUB_REPOSITORY,,}"
|
||||||
|
|
||||||
|
{
|
||||||
|
echo "VERSION=$version"
|
||||||
|
echo "IMAGE_NAME=$image_name"
|
||||||
|
} >> "$GITHUB_ENV"
|
||||||
|
|
||||||
|
- name: Build and push image
|
||||||
|
uses: docker/build-push-action@v6
|
||||||
|
with:
|
||||||
|
context: .
|
||||||
|
file: ./Dockerfile
|
||||||
|
push: true
|
||||||
|
tags: |
|
||||||
|
${{ env.IMAGE_NAME }}:${{ env.VERSION }}
|
||||||
|
${{ env.IMAGE_NAME }}:latest
|
||||||
|
labels: |
|
||||||
|
org.opencontainers.image.source=https://github.com/${{ github.repository }}
|
||||||
|
org.opencontainers.image.revision=${{ github.sha }}
|
||||||
|
org.opencontainers.image.version=${{ env.VERSION }}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
# Commit Style
|
||||||
|
|
||||||
|
- Use Conventional Commits.
|
||||||
|
- Keep the type and subject lowercase.
|
||||||
|
- Keep commit messages terse.
|
||||||
|
|
||||||
|
Example: `feat: dark mode by default`
|
||||||
+25
@@ -0,0 +1,25 @@
|
|||||||
|
FROM oven/bun:1.3.11-alpine AS build
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
COPY package.json bun.lock tsconfig.json ./
|
||||||
|
RUN bun install --frozen-lockfile
|
||||||
|
|
||||||
|
COPY src ./src
|
||||||
|
COPY README.md ./
|
||||||
|
RUN bun run build
|
||||||
|
|
||||||
|
FROM oven/bun:1.3.11-alpine AS runner
|
||||||
|
WORKDIR /app/dist
|
||||||
|
|
||||||
|
ENV NODE_ENV=production
|
||||||
|
ENV HOST=0.0.0.0
|
||||||
|
ENV PORT=3000
|
||||||
|
ENV DATABASE_PATH=/data/excalidraw.sqlite
|
||||||
|
|
||||||
|
COPY --from=build /app/dist ./
|
||||||
|
|
||||||
|
RUN mkdir -p /data
|
||||||
|
|
||||||
|
EXPOSE 3000
|
||||||
|
|
||||||
|
CMD ["bun", "./server.js"]
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
# excali-box
|
||||||
|
|
||||||
|
Private self-hosted Excalidraw with Bun and SQLite.
|
||||||
|
|
||||||
|
This is bare wrapper around the excalidraw packages that adds autosave and disk-persistence.
|
||||||
|
No auth to keep things simple - you either run in a private network or put it behind a Caddy auth or similar solutions.
|
||||||
|
|
||||||
|
> GPT 5.5 generated
|
||||||
|
|
||||||
|
## Pull from GHCR and run
|
||||||
|
|
||||||
|
Images are published to:
|
||||||
|
|
||||||
|
`ghcr.io/ruinivist/excalidraw-box:<version>`
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker pull ghcr.io/ruinivist/excalidraw-box:latest
|
||||||
|
docker run --rm -p 127.0.0.1:3000:3000 -v "$PWD/excali-box-data:/data" ghcr.io/ruinivist/excalidraw-box:latest
|
||||||
|
```
|
||||||
|
|
||||||
|
## Run locally
|
||||||
|
|
||||||
|
```bash
|
||||||
|
bun install
|
||||||
|
bun run dev
|
||||||
|
```
|
||||||
|
|
||||||
|
Open `http://localhost:3000`.
|
||||||
|
|
||||||
|
## Test
|
||||||
|
|
||||||
|
```bash
|
||||||
|
bun run test
|
||||||
|
bun run typecheck
|
||||||
|
```
|
||||||
|
|
||||||
|
## Build the image
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker build -t excali .
|
||||||
|
docker run --rm -p 127.0.0.1:3000:3000 -v "$PWD/excali-box-data:/data" excali
|
||||||
|
```
|
||||||
|
|
||||||
|
This will make the data persistent in the `excali-box-data` folder in the current directory, the site will be available at `localhost:3000`.
|
||||||
+6
-2
@@ -1,9 +1,13 @@
|
|||||||
{
|
{
|
||||||
"name": "excali-repro",
|
"name": "excali",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"build": "rm -rf dist && bun build --target=browser --splitting --outdir ./dist/public ./src/index.html"
|
"dev": "bun --hot src/server.tsx",
|
||||||
|
"build": "rm -rf dist && bun build --minify --target=browser ./src/index.html --outdir ./dist/public && bun build --target=bun ./src/server.tsx --outdir ./dist",
|
||||||
|
"start": "cd dist && DATABASE_PATH=../data/excalidraw.sqlite bun ./server.js",
|
||||||
|
"test": "bun test",
|
||||||
|
"typecheck": "tsc --noEmit"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@excalidraw/excalidraw": "^0.18.1",
|
"@excalidraw/excalidraw": "^0.18.1",
|
||||||
|
|||||||
+505
-2
@@ -1,9 +1,512 @@
|
|||||||
|
import "../node_modules/@excalidraw/excalidraw/dist/prod/index.css";
|
||||||
import { Excalidraw } from "@excalidraw/excalidraw";
|
import { Excalidraw } from "@excalidraw/excalidraw";
|
||||||
|
import type {
|
||||||
|
AppState,
|
||||||
|
BinaryFiles,
|
||||||
|
ExcalidrawInitialDataState,
|
||||||
|
} from "@excalidraw/excalidraw/types";
|
||||||
|
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||||
|
import { DEFAULT_TITLE, type DrawingMeta, type ScenePayload } from "./shared";
|
||||||
|
|
||||||
|
type DrawingResponse = DrawingMeta & ScenePayload;
|
||||||
|
|
||||||
|
const EXCALIDRAW_THEME_TOKEN_MAP = [
|
||||||
|
["--island-bg-color", "--app-island-bg"],
|
||||||
|
["--sidebar-bg-color", "--app-sidebar-bg"],
|
||||||
|
["--sidebar-border-color", "--app-sidebar-border"],
|
||||||
|
["--sidebar-shadow", "--app-sidebar-shadow"],
|
||||||
|
["--color-surface-lowest", "--app-surface-lowest"],
|
||||||
|
["--color-surface-low", "--app-surface-low"],
|
||||||
|
["--color-surface-primary-container", "--app-selected-bg"],
|
||||||
|
["--color-on-surface", "--app-text-color"],
|
||||||
|
["--color-on-primary-container", "--app-selected-text-color"],
|
||||||
|
["--color-disabled", "--app-disabled-color"],
|
||||||
|
["--input-bg-color", "--app-input-bg"],
|
||||||
|
["--input-border-color", "--app-input-border"],
|
||||||
|
["--input-label-color", "--app-input-color"],
|
||||||
|
["--default-border-color", "--app-button-border"],
|
||||||
|
["--button-hover-bg", "--app-button-hover-bg"],
|
||||||
|
["--button-active-bg", "--app-button-active-bg"],
|
||||||
|
["--button-active-border", "--app-button-active-border"],
|
||||||
|
["--overlay-bg-color", "--app-overlay-bg"],
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
function pathToId(pathname = window.location.pathname): string | null {
|
||||||
|
const match = pathname.match(/^\/d\/([^/]+)$/);
|
||||||
|
return match?.[1] ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function sortDrawings(drawings: DrawingMeta[]): DrawingMeta[] {
|
||||||
|
return [...drawings].sort((a, b) => b.updatedAt.localeCompare(a.updatedAt));
|
||||||
|
}
|
||||||
|
|
||||||
|
function replaceMeta(drawings: DrawingMeta[], drawing: DrawingMeta): DrawingMeta[] {
|
||||||
|
const filtered = drawings.filter((item) => item.id !== drawing.id);
|
||||||
|
return sortDrawings([drawing, ...filtered]);
|
||||||
|
}
|
||||||
|
|
||||||
|
function sceneToInitialData(scene: ScenePayload): ExcalidrawInitialDataState {
|
||||||
|
return scene as ExcalidrawInitialDataState;
|
||||||
|
}
|
||||||
|
|
||||||
|
function sceneFromEditor(
|
||||||
|
elements: readonly unknown[],
|
||||||
|
appState: AppState,
|
||||||
|
files: BinaryFiles,
|
||||||
|
): ScenePayload {
|
||||||
|
return {
|
||||||
|
elements: [...elements],
|
||||||
|
appState: appState as unknown as Record<string, unknown>,
|
||||||
|
files: files as unknown as Record<string, unknown>,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function requestJson<T>(url: string, init?: RequestInit): Promise<T> {
|
||||||
|
const response = await fetch(url, {
|
||||||
|
...init,
|
||||||
|
headers: {
|
||||||
|
...(init?.body ? { "content-type": "application/json" } : {}),
|
||||||
|
...init?.headers,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const body = (await response.json()) as T & { error?: string };
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(body.error ?? `Request failed: ${response.status}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
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() {
|
export function App() {
|
||||||
|
const [drawings, setDrawings] = useState<DrawingMeta[]>([]);
|
||||||
|
const [activeId, setActiveId] = useState<string | null>(pathToId());
|
||||||
|
const [activeTitle, setActiveTitle] = useState(DEFAULT_TITLE);
|
||||||
|
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);
|
||||||
|
const latestSceneRef = useRef<ScenePayload | null>(null);
|
||||||
|
const ignoreChangeRef = useRef(false);
|
||||||
|
const appShellRef = useRef<HTMLDivElement | null>(null);
|
||||||
|
const timeoutRef = useRef<number | null>(null);
|
||||||
|
const inFlightSaveRef = useRef<Promise<void> | null>(null);
|
||||||
|
const loadVersionRef = useRef(0);
|
||||||
|
const themeSyncFrameRef = useRef<number | null>(null);
|
||||||
|
|
||||||
|
const activeDrawing = useMemo(
|
||||||
|
() => drawings.find((drawing) => drawing.id === activeId) ?? null,
|
||||||
|
[activeId, drawings],
|
||||||
|
);
|
||||||
|
|
||||||
|
const refreshList = useCallback(async () => {
|
||||||
|
const list = await requestJson<DrawingMeta[]>("/api/drawings", { method: "GET" });
|
||||||
|
setDrawings(sortDrawings(list));
|
||||||
|
return list;
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const saveScene = useCallback(async () => {
|
||||||
|
const drawingId = currentIdRef.current;
|
||||||
|
const nextScene = latestSceneRef.current;
|
||||||
|
if (!drawingId || !nextScene) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setError(null);
|
||||||
|
|
||||||
|
const promise = requestJson<{ ok: true; drawing: DrawingMeta }>(`/api/drawings/${drawingId}`, {
|
||||||
|
method: "PUT",
|
||||||
|
body: JSON.stringify(nextScene),
|
||||||
|
})
|
||||||
|
.then((body) => {
|
||||||
|
setDrawings((current) => replaceMeta(current, body.drawing));
|
||||||
|
})
|
||||||
|
.catch((saveError: unknown) => {
|
||||||
|
setError(saveError instanceof Error ? saveError.message : "Save failed");
|
||||||
|
throw saveError;
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
inFlightSaveRef.current = null;
|
||||||
|
});
|
||||||
|
|
||||||
|
inFlightSaveRef.current = promise;
|
||||||
|
await promise;
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const flushPendingSave = useCallback(async () => {
|
||||||
|
if (timeoutRef.current !== null) {
|
||||||
|
clearTimeout(timeoutRef.current);
|
||||||
|
timeoutRef.current = null;
|
||||||
|
await saveScene();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (inFlightSaveRef.current) {
|
||||||
|
await inFlightSaveRef.current;
|
||||||
|
}
|
||||||
|
}, [saveScene]);
|
||||||
|
|
||||||
|
const scheduleSave = useCallback(() => {
|
||||||
|
if (timeoutRef.current !== null) {
|
||||||
|
clearTimeout(timeoutRef.current);
|
||||||
|
}
|
||||||
|
|
||||||
|
timeoutRef.current = window.setTimeout(() => {
|
||||||
|
timeoutRef.current = null;
|
||||||
|
void saveScene();
|
||||||
|
}, 1500);
|
||||||
|
}, [saveScene]);
|
||||||
|
|
||||||
|
const loadDrawing = useCallback(
|
||||||
|
async (drawingId: string) => {
|
||||||
|
const version = ++loadVersionRef.current;
|
||||||
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const [list, drawing] = await Promise.all([
|
||||||
|
requestJson<DrawingMeta[]>("/api/drawings", { method: "GET" }),
|
||||||
|
requestJson<DrawingResponse>(`/api/drawings/${drawingId}`, { method: "GET" }),
|
||||||
|
]);
|
||||||
|
|
||||||
|
if (version !== loadVersionRef.current) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const nextScene = {
|
||||||
|
elements: drawing.elements,
|
||||||
|
appState: drawing.appState,
|
||||||
|
files: drawing.files,
|
||||||
|
};
|
||||||
|
|
||||||
|
ignoreChangeRef.current = true;
|
||||||
|
currentIdRef.current = drawing.id;
|
||||||
|
currentTitleRef.current = drawing.title;
|
||||||
|
latestSceneRef.current = nextScene;
|
||||||
|
|
||||||
|
setDrawings(sortDrawings(list));
|
||||||
|
setActiveId(drawing.id);
|
||||||
|
setActiveTitle(drawing.title);
|
||||||
|
setScene(nextScene);
|
||||||
|
} catch (loadError) {
|
||||||
|
const list = await refreshList();
|
||||||
|
if (list.length === 0) {
|
||||||
|
const created = await requestJson<DrawingResponse>("/api/drawings", { method: "POST" });
|
||||||
|
window.history.replaceState(null, "", `/d/${created.id}`);
|
||||||
|
await loadDrawing(created.id);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const fallback = list[0];
|
||||||
|
if (fallback && fallback.id !== drawingId) {
|
||||||
|
window.history.replaceState(null, "", `/d/${fallback.id}`);
|
||||||
|
await loadDrawing(fallback.id);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setError(loadError instanceof Error ? loadError.message : "Failed to load drawing");
|
||||||
|
} finally {
|
||||||
|
if (version === loadVersionRef.current) {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[refreshList],
|
||||||
|
);
|
||||||
|
|
||||||
|
const navigateToDrawing = useCallback(
|
||||||
|
async (drawingId: string, options?: { replace?: boolean; flush?: boolean }) => {
|
||||||
|
const replace = options?.replace ?? false;
|
||||||
|
const flush = options?.flush ?? true;
|
||||||
|
|
||||||
|
if (drawingId === currentIdRef.current && latestSceneRef.current) {
|
||||||
|
if (replace) {
|
||||||
|
window.history.replaceState(null, "", `/d/${drawingId}`);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (flush) {
|
||||||
|
await flushPendingSave();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (replace) {
|
||||||
|
window.history.replaceState(null, "", `/d/${drawingId}`);
|
||||||
|
} else {
|
||||||
|
window.history.pushState(null, "", `/d/${drawingId}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
await loadDrawing(drawingId);
|
||||||
|
},
|
||||||
|
[flushPendingSave, loadDrawing],
|
||||||
|
);
|
||||||
|
|
||||||
|
const createDrawing = useCallback(async () => {
|
||||||
|
await flushPendingSave();
|
||||||
|
const created = await requestJson<DrawingResponse>("/api/drawings", { method: "POST" });
|
||||||
|
await navigateToDrawing(created.id, { flush: false });
|
||||||
|
}, [flushPendingSave, navigateToDrawing]);
|
||||||
|
|
||||||
|
const deleteDrawing = useCallback(
|
||||||
|
async (drawingId: string) => {
|
||||||
|
if (!window.confirm("Delete this drawing?")) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (drawingId === currentIdRef.current) {
|
||||||
|
await flushPendingSave();
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await requestJson<{ ok: true; nextId: string | null }>(`/api/drawings/${drawingId}`, {
|
||||||
|
method: "DELETE",
|
||||||
|
});
|
||||||
|
|
||||||
|
if (drawingId === currentIdRef.current && response.nextId) {
|
||||||
|
await navigateToDrawing(response.nextId, { replace: true, flush: false });
|
||||||
|
} else {
|
||||||
|
await refreshList();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[flushPendingSave, navigateToDrawing, refreshList],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleTitleSubmit = useCallback(async () => {
|
||||||
|
const drawingId = currentIdRef.current;
|
||||||
|
if (!drawingId) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const body = await requestJson<{ ok: true; drawing: DrawingMeta }>(`/api/drawings/${drawingId}`, {
|
||||||
|
method: "PUT",
|
||||||
|
body: JSON.stringify({ title: currentTitleRef.current }),
|
||||||
|
});
|
||||||
|
|
||||||
|
currentTitleRef.current = body.drawing.title;
|
||||||
|
setActiveTitle(body.drawing.title);
|
||||||
|
setDrawings((current) => replaceMeta(current, body.drawing));
|
||||||
|
} catch (renameError) {
|
||||||
|
setError(renameError instanceof Error ? renameError.message : "Rename failed");
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const syncThemeTokens = useCallback(() => {
|
||||||
|
const appShell = appShellRef.current;
|
||||||
|
const excalidrawRoot = appShell?.querySelector<HTMLElement>(".excalidraw");
|
||||||
|
if (!appShell || !excalidrawRoot) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const computedStyles = window.getComputedStyle(excalidrawRoot);
|
||||||
|
for (const [sourceToken, targetToken] of EXCALIDRAW_THEME_TOKEN_MAP) {
|
||||||
|
const value = computedStyles.getPropertyValue(sourceToken).trim();
|
||||||
|
if (value) {
|
||||||
|
appShell.style.setProperty(targetToken, value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const scheduleThemeTokenSync = useCallback(() => {
|
||||||
|
if (themeSyncFrameRef.current !== null) {
|
||||||
|
window.cancelAnimationFrame(themeSyncFrameRef.current);
|
||||||
|
}
|
||||||
|
|
||||||
|
themeSyncFrameRef.current = window.requestAnimationFrame(() => {
|
||||||
|
themeSyncFrameRef.current = null;
|
||||||
|
syncThemeTokens();
|
||||||
|
});
|
||||||
|
}, [syncThemeTokens]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const currentPathId = pathToId();
|
||||||
|
if (currentPathId) {
|
||||||
|
void loadDrawing(currentPathId);
|
||||||
|
} else {
|
||||||
|
window.location.replace("/");
|
||||||
|
}
|
||||||
|
|
||||||
|
const onPopState = () => {
|
||||||
|
const nextId = pathToId();
|
||||||
|
if (nextId) {
|
||||||
|
void navigateToDrawing(nextId, { replace: true, flush: true });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
window.addEventListener("popstate", onPopState);
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener("popstate", onPopState);
|
||||||
|
};
|
||||||
|
}, [loadDrawing, navigateToDrawing]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
return () => {
|
||||||
|
if (timeoutRef.current !== null) {
|
||||||
|
clearTimeout(timeoutRef.current);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (themeSyncFrameRef.current !== null) {
|
||||||
|
window.cancelAnimationFrame(themeSyncFrameRef.current);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!scene || loading) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
scheduleThemeTokenSync();
|
||||||
|
}, [activeId, loading, scene, scheduleThemeTokenSync]);
|
||||||
|
|
||||||
|
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 (
|
return (
|
||||||
<div style={{ height: "100vh" }}>
|
<div className="app-shell" ref={appShellRef}>
|
||||||
<Excalidraw />
|
<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;
|
||||||
|
scheduleThemeTokenSync();
|
||||||
|
|
||||||
|
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-box</h1>
|
||||||
|
<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="drawing-list">
|
||||||
|
{drawings.map((drawing) => (
|
||||||
|
<div
|
||||||
|
key={drawing.id}
|
||||||
|
className={drawing.id === activeId ? "drawing-item drawing-item-active" : "drawing-item"}
|
||||||
|
>
|
||||||
|
{drawing.id === activeId ? (
|
||||||
|
<div className="drawing-link">
|
||||||
|
<input
|
||||||
|
className="title-input"
|
||||||
|
value={activeTitle}
|
||||||
|
onChange={(event) => {
|
||||||
|
currentTitleRef.current = event.target.value;
|
||||||
|
setActiveTitle(event.target.value);
|
||||||
|
}}
|
||||||
|
onBlur={() => void handleTitleSubmit()}
|
||||||
|
onKeyDown={(event) => {
|
||||||
|
if (event.key === "Enter") {
|
||||||
|
event.currentTarget.blur();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="drawing-link"
|
||||||
|
onClick={() => void handleSelectDrawing(drawing.id)}
|
||||||
|
>
|
||||||
|
<span className="drawing-title">{drawing.title}</span>
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="icon-button"
|
||||||
|
onClick={() => void deleteDrawing(drawing.id)}
|
||||||
|
aria-label={`Delete ${drawing.title}`}
|
||||||
|
>
|
||||||
|
×
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,82 @@
|
|||||||
|
import { describe, expect, test } from "bun:test";
|
||||||
|
import { mkdtempSync, rmSync } from "node:fs";
|
||||||
|
import { tmpdir } from "node:os";
|
||||||
|
import { join } from "node:path";
|
||||||
|
import { createApi } from "./api";
|
||||||
|
import { createDrawingStore } from "./db";
|
||||||
|
|
||||||
|
function withApi() {
|
||||||
|
const dir = mkdtempSync(join(tmpdir(), "excali-api-"));
|
||||||
|
const store = createDrawingStore(join(dir, "test.sqlite"));
|
||||||
|
const api = createApi(store);
|
||||||
|
|
||||||
|
return {
|
||||||
|
api,
|
||||||
|
cleanup() {
|
||||||
|
store.close();
|
||||||
|
rmSync(dir, { recursive: true, force: true });
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("api", () => {
|
||||||
|
test("creates a drawing with dark theme by default", async () => {
|
||||||
|
const { api, cleanup } = withApi();
|
||||||
|
|
||||||
|
const created = await api.createDrawing().json();
|
||||||
|
|
||||||
|
expect(created.appState.theme).toBe("dark");
|
||||||
|
cleanup();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("returns 400 for invalid JSON", async () => {
|
||||||
|
const { api, cleanup } = withApi();
|
||||||
|
const drawing = await api.createDrawing().json();
|
||||||
|
|
||||||
|
const response = await api.updateDrawing(
|
||||||
|
Object.assign(
|
||||||
|
new Request(`http://local/api/drawings/${drawing.id}`, {
|
||||||
|
method: "PUT",
|
||||||
|
body: "{",
|
||||||
|
}),
|
||||||
|
{ params: { id: drawing.id } },
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(response.status).toBe(400);
|
||||||
|
cleanup();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("returns 404 for missing drawing", () => {
|
||||||
|
const { api, cleanup } = withApi();
|
||||||
|
|
||||||
|
const response = api.getDrawing(
|
||||||
|
Object.assign(new Request("http://local/api/drawings/missing"), { params: { id: "missing" } }),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(response.status).toBe(404);
|
||||||
|
cleanup();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("updates a drawing scene", async () => {
|
||||||
|
const { api, cleanup } = withApi();
|
||||||
|
const created = await api.createDrawing().json();
|
||||||
|
|
||||||
|
const response = await api.updateDrawing(
|
||||||
|
Object.assign(
|
||||||
|
new Request(`http://local/api/drawings/${created.id}`, {
|
||||||
|
method: "PUT",
|
||||||
|
body: JSON.stringify({
|
||||||
|
elements: [{ id: "shape" }],
|
||||||
|
appState: {},
|
||||||
|
files: {},
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
{ params: { id: created.id } },
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(response.status).toBe(200);
|
||||||
|
cleanup();
|
||||||
|
});
|
||||||
|
});
|
||||||
+119
@@ -0,0 +1,119 @@
|
|||||||
|
import { type DrawingStore } from "./db";
|
||||||
|
import { HttpError, normalizeTitle, parseJsonBody, parseSceneText } from "./scene";
|
||||||
|
|
||||||
|
type RouteRequest = Request & {
|
||||||
|
params?: Record<string, string>;
|
||||||
|
};
|
||||||
|
|
||||||
|
function json(data: unknown, init?: ResponseInit): Response {
|
||||||
|
return Response.json(data, init);
|
||||||
|
}
|
||||||
|
|
||||||
|
function errorResponse(error: unknown): Response {
|
||||||
|
if (error instanceof HttpError) {
|
||||||
|
return json({ ok: false, error: error.message }, { status: error.status });
|
||||||
|
}
|
||||||
|
|
||||||
|
console.error(error);
|
||||||
|
return json({ ok: false, error: "Internal server error" }, { status: 500 });
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createApi(store: DrawingStore) {
|
||||||
|
return {
|
||||||
|
health() {
|
||||||
|
return json({ ok: true });
|
||||||
|
},
|
||||||
|
|
||||||
|
listDrawings() {
|
||||||
|
return json(store.listDrawings());
|
||||||
|
},
|
||||||
|
|
||||||
|
createDrawing() {
|
||||||
|
const drawing = store.createDrawing();
|
||||||
|
return json(
|
||||||
|
{
|
||||||
|
id: drawing.id,
|
||||||
|
title: drawing.title,
|
||||||
|
createdAt: drawing.createdAt,
|
||||||
|
updatedAt: drawing.updatedAt,
|
||||||
|
...drawing.scene,
|
||||||
|
},
|
||||||
|
{ status: 201 },
|
||||||
|
);
|
||||||
|
},
|
||||||
|
|
||||||
|
getDrawing(request: RouteRequest) {
|
||||||
|
const id = request.params?.id;
|
||||||
|
const drawing = id ? store.getDrawing(id) : null;
|
||||||
|
|
||||||
|
if (!drawing) {
|
||||||
|
return json({ ok: false, error: "Drawing not found" }, { status: 404 });
|
||||||
|
}
|
||||||
|
|
||||||
|
return json({
|
||||||
|
id: drawing.id,
|
||||||
|
title: drawing.title,
|
||||||
|
createdAt: drawing.createdAt,
|
||||||
|
updatedAt: drawing.updatedAt,
|
||||||
|
...drawing.scene,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
async updateDrawing(request: RouteRequest) {
|
||||||
|
try {
|
||||||
|
const id = request.params?.id;
|
||||||
|
if (!id) {
|
||||||
|
throw new HttpError(400, "Missing drawing id");
|
||||||
|
}
|
||||||
|
|
||||||
|
const text = await request.text();
|
||||||
|
const raw = parseJsonBody<Record<string, unknown>>(text);
|
||||||
|
const hasTitle = Object.hasOwn(raw, "title");
|
||||||
|
const hasScene =
|
||||||
|
Object.hasOwn(raw, "elements") ||
|
||||||
|
Object.hasOwn(raw, "appState") ||
|
||||||
|
Object.hasOwn(raw, "files");
|
||||||
|
|
||||||
|
if (!hasTitle && !hasScene) {
|
||||||
|
throw new HttpError(400, "Nothing to update");
|
||||||
|
}
|
||||||
|
|
||||||
|
const scene = hasScene ? parseSceneText(text) : undefined;
|
||||||
|
const updated = store.updateDrawing(id, {
|
||||||
|
scene,
|
||||||
|
title: hasTitle ? normalizeTitle(raw.title) : undefined,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!updated) {
|
||||||
|
return json({ ok: false, error: "Drawing not found" }, { status: 404 });
|
||||||
|
}
|
||||||
|
|
||||||
|
return json({
|
||||||
|
ok: true,
|
||||||
|
drawing: {
|
||||||
|
id: updated.id,
|
||||||
|
title: updated.title,
|
||||||
|
createdAt: updated.createdAt,
|
||||||
|
updatedAt: updated.updatedAt,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
return errorResponse(error);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
deleteDrawing(request: RouteRequest) {
|
||||||
|
const id = request.params?.id;
|
||||||
|
if (!id) {
|
||||||
|
return json({ ok: false, error: "Missing drawing id" }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const { deleted, next } = store.deleteDrawing(id);
|
||||||
|
if (!deleted) {
|
||||||
|
return json({ ok: false, error: "Drawing not found" }, { status: 404 });
|
||||||
|
}
|
||||||
|
|
||||||
|
return json({ ok: true, nextId: next?.id ?? null });
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
+4
-3
@@ -1,6 +1,7 @@
|
|||||||
import { StrictMode } from "react";
|
import React from "react";
|
||||||
import { createRoot } from "react-dom/client";
|
import { createRoot } from "react-dom/client";
|
||||||
import { App } from "./App";
|
import { App } from "./App";
|
||||||
|
import "./styles.css";
|
||||||
|
|
||||||
const root = document.getElementById("root");
|
const root = document.getElementById("root");
|
||||||
|
|
||||||
@@ -9,7 +10,7 @@ if (!root) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
createRoot(root).render(
|
createRoot(root).render(
|
||||||
<StrictMode>
|
<React.StrictMode>
|
||||||
<App />
|
<App />
|
||||||
</StrictMode>,
|
</React.StrictMode>,
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -0,0 +1,50 @@
|
|||||||
|
import { afterEach, describe, expect, test } from "bun:test";
|
||||||
|
import { mkdtempSync, rmSync } from "node:fs";
|
||||||
|
import { tmpdir } from "node:os";
|
||||||
|
import { join } from "node:path";
|
||||||
|
import { createDrawingStore } from "./db";
|
||||||
|
|
||||||
|
const cleanup: string[] = [];
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
while (cleanup.length > 0) {
|
||||||
|
const dir = cleanup.pop();
|
||||||
|
if (dir) {
|
||||||
|
rmSync(dir, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
function createTempStore() {
|
||||||
|
const dir = mkdtempSync(join(tmpdir(), "excali-"));
|
||||||
|
cleanup.push(dir);
|
||||||
|
return createDrawingStore(join(dir, "test.sqlite"));
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("drawing store", () => {
|
||||||
|
test("creates, updates, lists, and deletes drawings", () => {
|
||||||
|
const store = createTempStore();
|
||||||
|
const created = store.createDrawing();
|
||||||
|
|
||||||
|
expect(created.title).toBe("Untitled");
|
||||||
|
expect(created.scene.appState.theme).toBe("dark");
|
||||||
|
expect(store.listDrawings()).toHaveLength(1);
|
||||||
|
|
||||||
|
const updated = store.updateDrawing(created.id, {
|
||||||
|
title: "Flow",
|
||||||
|
scene: {
|
||||||
|
elements: [{ id: "one" }],
|
||||||
|
appState: { gridSize: 20 },
|
||||||
|
files: {},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(updated?.title).toBe("Flow");
|
||||||
|
expect(updated?.scene.elements).toHaveLength(1);
|
||||||
|
|
||||||
|
const removed = store.deleteDrawing(created.id);
|
||||||
|
expect(removed.deleted).toBe(true);
|
||||||
|
expect(store.listDrawings()).toHaveLength(1);
|
||||||
|
expect(removed.next?.id).not.toBe(created.id);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,209 @@
|
|||||||
|
import { Database } from "bun:sqlite";
|
||||||
|
import { randomUUID } from "node:crypto";
|
||||||
|
import { mkdirSync } from "node:fs";
|
||||||
|
import { dirname } from "node:path";
|
||||||
|
import { DEFAULT_TITLE, type DrawingMeta, type DrawingRecord, type ScenePayload } from "./shared";
|
||||||
|
import { coerceStoredScene, emptyScene, normalizeTitle } from "./scene";
|
||||||
|
|
||||||
|
type DrawingRow = {
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
data: string;
|
||||||
|
createdAt: string;
|
||||||
|
updatedAt: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
type DrawingMetaRow = Omit<DrawingRow, "data">;
|
||||||
|
|
||||||
|
export type DrawingStore = ReturnType<typeof createDrawingStore>;
|
||||||
|
|
||||||
|
function createId(): string {
|
||||||
|
return randomUUID().replaceAll("-", "").slice(0, 12);
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveDatabasePath(): string {
|
||||||
|
if (process.env.DATABASE_PATH) {
|
||||||
|
return process.env.DATABASE_PATH;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
mkdirSync("/data", { recursive: true });
|
||||||
|
return "/data/excalidraw.sqlite";
|
||||||
|
} catch {
|
||||||
|
return `${process.cwd()}/data/excalidraw.sqlite`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function readMeta(row: DrawingMetaRow | null | undefined): DrawingMeta | null {
|
||||||
|
if (!row) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: row.id,
|
||||||
|
title: row.title,
|
||||||
|
createdAt: row.createdAt,
|
||||||
|
updatedAt: row.updatedAt,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function readRow(row: DrawingRow | null | undefined): DrawingRecord | null {
|
||||||
|
if (!row) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
...readMeta(row)!,
|
||||||
|
scene: coerceStoredScene(JSON.parse(row.data)),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createDrawingStore(databasePath = resolveDatabasePath()) {
|
||||||
|
mkdirSync(dirname(databasePath), { recursive: true });
|
||||||
|
|
||||||
|
const db = new Database(databasePath, { create: true, strict: true });
|
||||||
|
db.exec(`
|
||||||
|
PRAGMA journal_mode = WAL;
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS drawings (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
title TEXT NOT NULL,
|
||||||
|
data TEXT NOT NULL,
|
||||||
|
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS drawings_updated_at_idx
|
||||||
|
ON drawings(updated_at DESC, created_at DESC);
|
||||||
|
`);
|
||||||
|
|
||||||
|
const listQuery = db.query(`
|
||||||
|
SELECT
|
||||||
|
id,
|
||||||
|
title,
|
||||||
|
created_at AS createdAt,
|
||||||
|
updated_at AS updatedAt
|
||||||
|
FROM drawings
|
||||||
|
ORDER BY updated_at DESC, created_at DESC
|
||||||
|
`);
|
||||||
|
|
||||||
|
const getQuery = db.query(`
|
||||||
|
SELECT
|
||||||
|
id,
|
||||||
|
title,
|
||||||
|
data,
|
||||||
|
created_at AS createdAt,
|
||||||
|
updated_at AS updatedAt
|
||||||
|
FROM drawings
|
||||||
|
WHERE id = ?1
|
||||||
|
`);
|
||||||
|
|
||||||
|
const createQuery = db.query(`
|
||||||
|
INSERT INTO drawings (id, title, data)
|
||||||
|
VALUES (?1, ?2, ?3)
|
||||||
|
`);
|
||||||
|
|
||||||
|
const updateSceneQuery = db.query(`
|
||||||
|
UPDATE drawings
|
||||||
|
SET data = ?2, updated_at = CURRENT_TIMESTAMP
|
||||||
|
WHERE id = ?1
|
||||||
|
`);
|
||||||
|
|
||||||
|
const updateTitleQuery = db.query(`
|
||||||
|
UPDATE drawings
|
||||||
|
SET title = ?2, updated_at = CURRENT_TIMESTAMP
|
||||||
|
WHERE id = ?1
|
||||||
|
`);
|
||||||
|
|
||||||
|
const updateBothQuery = db.query(`
|
||||||
|
UPDATE drawings
|
||||||
|
SET title = ?2, data = ?3, updated_at = CURRENT_TIMESTAMP
|
||||||
|
WHERE id = ?1
|
||||||
|
`);
|
||||||
|
|
||||||
|
const deleteQuery = db.query(`
|
||||||
|
DELETE FROM drawings
|
||||||
|
WHERE id = ?1
|
||||||
|
`);
|
||||||
|
|
||||||
|
function listDrawings(): DrawingMeta[] {
|
||||||
|
return (listQuery.all() as DrawingMetaRow[]).map((row) => readMeta(row)!);
|
||||||
|
}
|
||||||
|
|
||||||
|
function getDrawing(id: string): DrawingRecord | null {
|
||||||
|
return readRow(getQuery.get(id) as DrawingRow | null | undefined);
|
||||||
|
}
|
||||||
|
|
||||||
|
function createDrawing(title = DEFAULT_TITLE): DrawingRecord {
|
||||||
|
const id = createId();
|
||||||
|
createQuery.run(id, normalizeTitle(title), JSON.stringify(emptyScene()));
|
||||||
|
return getDrawing(id)!;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getLatestDrawing(): DrawingMeta | null {
|
||||||
|
return listDrawings()[0] ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function ensureInitialDrawing(): DrawingMeta {
|
||||||
|
return getLatestDrawing() ?? createDrawing();
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateDrawing(id: string, update: { scene?: ScenePayload; title?: string }): DrawingRecord | null {
|
||||||
|
const existing = getDrawing(id);
|
||||||
|
if (!existing) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const hasScene = update.scene !== undefined;
|
||||||
|
const hasTitle = update.title !== undefined;
|
||||||
|
|
||||||
|
if (!hasScene && !hasTitle) {
|
||||||
|
return existing;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (hasScene && hasTitle) {
|
||||||
|
updateBothQuery.run(id, normalizeTitle(update.title), JSON.stringify(update.scene));
|
||||||
|
} else if (hasScene) {
|
||||||
|
updateSceneQuery.run(id, JSON.stringify(update.scene));
|
||||||
|
} else {
|
||||||
|
updateTitleQuery.run(id, normalizeTitle(update.title));
|
||||||
|
}
|
||||||
|
|
||||||
|
return getDrawing(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
function deleteDrawing(id: string): { deleted: boolean; next: DrawingMeta | null } {
|
||||||
|
const changes = Number(deleteQuery.run(id).changes);
|
||||||
|
if (changes === 0) {
|
||||||
|
return { deleted: false, next: null };
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
deleted: true,
|
||||||
|
next: ensureInitialDrawing(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function close() {
|
||||||
|
db.close(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
databasePath,
|
||||||
|
close,
|
||||||
|
listDrawings,
|
||||||
|
getDrawing,
|
||||||
|
createDrawing,
|
||||||
|
getLatestDrawing,
|
||||||
|
ensureInitialDrawing,
|
||||||
|
updateDrawing,
|
||||||
|
deleteDrawing,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
let defaultStore: DrawingStore | null = null;
|
||||||
|
|
||||||
|
export function getDefaultDrawingStore(): DrawingStore {
|
||||||
|
defaultStore ??= createDrawingStore();
|
||||||
|
return defaultStore;
|
||||||
|
}
|
||||||
+1
-1
@@ -5,7 +5,7 @@
|
|||||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
<!-- to resolve the hs and css from root -->
|
<!-- to resolve the hs and css from root -->
|
||||||
<base href="/" />
|
<base href="/" />
|
||||||
<title>Repro</title>
|
<title>excali-box</title>
|
||||||
<script type="module" src="./client.tsx"></script>
|
<script type="module" src="./client.tsx"></script>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
|
|||||||
@@ -0,0 +1,31 @@
|
|||||||
|
import { describe, expect, test } from "bun:test";
|
||||||
|
import { HttpError, MAX_SCENE_BYTES, emptyScene, normalizeScene, parseJsonBody } from "./scene";
|
||||||
|
|
||||||
|
describe("scene helpers", () => {
|
||||||
|
test("creates empty scenes with dark theme by default", () => {
|
||||||
|
const scene = emptyScene();
|
||||||
|
|
||||||
|
expect(scene.appState.theme).toBe("dark");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("normalizes volatile app state fields", () => {
|
||||||
|
const scene = normalizeScene({
|
||||||
|
elements: [],
|
||||||
|
appState: {
|
||||||
|
selectedElementIds: { one: true },
|
||||||
|
viewModeEnabled: false,
|
||||||
|
},
|
||||||
|
files: {},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(scene.appState.selectedElementIds).toBeUndefined();
|
||||||
|
expect(scene.appState.viewModeEnabled).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("rejects oversized payloads", () => {
|
||||||
|
const text = "x".repeat(MAX_SCENE_BYTES + 1);
|
||||||
|
|
||||||
|
expect(() => parseJsonBody(text)).toThrow(HttpError);
|
||||||
|
expect(() => parseJsonBody(text)).toThrow("Payload too large");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,97 @@
|
|||||||
|
import { DEFAULT_TITLE, type ScenePayload } from "./shared";
|
||||||
|
|
||||||
|
export const MAX_SCENE_BYTES = 25 * 1024 * 1024;
|
||||||
|
|
||||||
|
export class HttpError extends Error {
|
||||||
|
constructor(
|
||||||
|
readonly status: number,
|
||||||
|
message: string,
|
||||||
|
) {
|
||||||
|
super(message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const VOLATILE_APP_STATE_KEYS = new Set([
|
||||||
|
"collaborators",
|
||||||
|
"selectedElementIds",
|
||||||
|
"selectedGroupIds",
|
||||||
|
"editingElement",
|
||||||
|
"editingLinearElement",
|
||||||
|
"openMenu",
|
||||||
|
"openSidebar",
|
||||||
|
"contextMenu",
|
||||||
|
"toast",
|
||||||
|
]);
|
||||||
|
|
||||||
|
export function emptyScene(): ScenePayload {
|
||||||
|
return {
|
||||||
|
elements: [],
|
||||||
|
appState: { theme: "dark" },
|
||||||
|
files: {},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||||
|
return typeof value === "object" && value !== null && !Array.isArray(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function normalizeTitle(input: unknown): string {
|
||||||
|
if (typeof input !== "string") {
|
||||||
|
return DEFAULT_TITLE;
|
||||||
|
}
|
||||||
|
|
||||||
|
const trimmed = input.trim();
|
||||||
|
return trimmed.length > 0 ? trimmed : DEFAULT_TITLE;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function normalizeScene(input: unknown): ScenePayload {
|
||||||
|
if (!isRecord(input)) {
|
||||||
|
throw new HttpError(400, "Body must be an object");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!Array.isArray(input.elements)) {
|
||||||
|
throw new HttpError(400, "elements must be an array");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isRecord(input.appState)) {
|
||||||
|
throw new HttpError(400, "appState must be an object");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isRecord(input.files)) {
|
||||||
|
throw new HttpError(400, "files must be an object");
|
||||||
|
}
|
||||||
|
|
||||||
|
const appState = Object.fromEntries(
|
||||||
|
Object.entries(input.appState).filter(([key]) => !VOLATILE_APP_STATE_KEYS.has(key)),
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
elements: input.elements,
|
||||||
|
appState,
|
||||||
|
files: input.files,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parseJsonBody<T = unknown>(text: string): T {
|
||||||
|
if (new TextEncoder().encode(text).byteLength > MAX_SCENE_BYTES) {
|
||||||
|
throw new HttpError(413, "Payload too large");
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
return JSON.parse(text) as T;
|
||||||
|
} catch {
|
||||||
|
throw new HttpError(400, "Invalid JSON");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parseSceneText(text: string): ScenePayload {
|
||||||
|
return normalizeScene(parseJsonBody(text));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function coerceStoredScene(input: unknown): ScenePayload {
|
||||||
|
try {
|
||||||
|
return normalizeScene(input);
|
||||||
|
} catch {
|
||||||
|
return emptyScene();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,85 @@
|
|||||||
|
import index from "./index.html";
|
||||||
|
import { createApi } from "./api";
|
||||||
|
import { getDefaultDrawingStore } from "./db";
|
||||||
|
|
||||||
|
export function createServer() {
|
||||||
|
const drawingStore = getDefaultDrawingStore();
|
||||||
|
const api = createApi(drawingStore);
|
||||||
|
|
||||||
|
return {
|
||||||
|
port: Number(process.env.PORT ?? "3000"),
|
||||||
|
hostname: process.env.HOST ?? "localhost",
|
||||||
|
routes: {
|
||||||
|
"/": (request: Request) => {
|
||||||
|
const drawing = drawingStore.ensureInitialDrawing();
|
||||||
|
return Response.redirect(new URL(`/d/${drawing.id}`, request.url), 302);
|
||||||
|
},
|
||||||
|
...(process.cwd().endsWith("/dist") ? {
|
||||||
|
"/d/:id": async (req: Request) => {
|
||||||
|
let file = Bun.file("./public/index.html");
|
||||||
|
return new Response(file, {
|
||||||
|
headers: { "Content-Type": "text/html; charset=utf-8" }
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} : {
|
||||||
|
"/d/:id": index
|
||||||
|
}),
|
||||||
|
"/api/health": {
|
||||||
|
GET: () => api.health(),
|
||||||
|
},
|
||||||
|
"/api/drawings": {
|
||||||
|
GET: () => api.listDrawings(),
|
||||||
|
POST: () => api.createDrawing(),
|
||||||
|
},
|
||||||
|
"/api/drawings/:id": {
|
||||||
|
GET: (request: Request & { params: Record<string, string> }) => api.getDrawing(request),
|
||||||
|
PUT: (request: Request & { params: Record<string, string> }) => api.updateDrawing(request),
|
||||||
|
DELETE: (request: Request & { params: Record<string, string> }) => api.deleteDrawing(request),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
async fetch(request: Request) {
|
||||||
|
const url = new URL(request.url);
|
||||||
|
|
||||||
|
const isProd = process.cwd().endsWith("/dist");
|
||||||
|
|
||||||
|
if (!isProd) {
|
||||||
|
return Response.json({ ok: false, error: "Not found" }, { status: 404 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remove any "/public" prefix from the request URL if it accidentally got included
|
||||||
|
let reqPath = url.pathname;
|
||||||
|
if (reqPath.startsWith("/public/")) {
|
||||||
|
reqPath = reqPath.substring(7);
|
||||||
|
}
|
||||||
|
|
||||||
|
// In dist, static files are inside public
|
||||||
|
const path = "./public" + reqPath;
|
||||||
|
const file = Bun.file(path);
|
||||||
|
if (await file.exists()) {
|
||||||
|
// Cache indefinitely except index.html
|
||||||
|
const headers: Record<string, string> = {};
|
||||||
|
if (!reqPath.endsWith("index.html")) {
|
||||||
|
// Add etag logic or use bun's hashes by only caching files that actually have hashes in them.
|
||||||
|
// Bun generates hashes like chunk-02ahh9r5.js, we can check for that pattern
|
||||||
|
// If we wanted to do ETag we could use Bun.file(path).size + lastModified but hashing is safer
|
||||||
|
const isHashed = reqPath.match(/-[a-zA-Z0-9]{8}\.(js|css)$/);
|
||||||
|
if (isHashed) {
|
||||||
|
headers["Cache-Control"] = "public, max-age=31536000, immutable";
|
||||||
|
} else {
|
||||||
|
headers["Cache-Control"] = "no-cache";
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
headers["Content-Type"] = "text/html; charset=utf-8";
|
||||||
|
headers["Cache-Control"] = "no-cache";
|
||||||
|
}
|
||||||
|
return new Response(file, { headers });
|
||||||
|
}
|
||||||
|
return Response.json({ ok: false, error: "Not found" }, { status: 404 });
|
||||||
|
},
|
||||||
|
} satisfies Parameters<typeof Bun.serve>[0];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (import.meta.main) {
|
||||||
|
const server = Bun.serve(createServer());
|
||||||
|
console.log(`Listening on http://${server.hostname}:${server.port}`);
|
||||||
|
}
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
export type ScenePayload = {
|
||||||
|
elements: unknown[];
|
||||||
|
appState: Record<string, unknown>;
|
||||||
|
files: Record<string, unknown>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type DrawingMeta = {
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
createdAt: string;
|
||||||
|
updatedAt: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type DrawingRecord = DrawingMeta & {
|
||||||
|
scene: ScenePayload;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const DEFAULT_TITLE = "Untitled";
|
||||||
+297
@@ -0,0 +1,297 @@
|
|||||||
|
:root {
|
||||||
|
color-scheme: light;
|
||||||
|
font-family:
|
||||||
|
Inter,
|
||||||
|
ui-sans-serif,
|
||||||
|
system-ui,
|
||||||
|
-apple-system,
|
||||||
|
BlinkMacSystemFont,
|
||||||
|
"Segoe UI",
|
||||||
|
sans-serif;
|
||||||
|
}
|
||||||
|
|
||||||
|
* {
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
html,
|
||||||
|
body,
|
||||||
|
#root {
|
||||||
|
margin: 0;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
background: #f5f5f4;
|
||||||
|
color: #18181b;
|
||||||
|
}
|
||||||
|
|
||||||
|
button,
|
||||||
|
input {
|
||||||
|
font: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-shell {
|
||||||
|
height: 100%;
|
||||||
|
color: var(--app-text-color, #18181b);
|
||||||
|
--app-sidebar-font-size: 0.875rem;
|
||||||
|
--app-island-bg: #ffffff;
|
||||||
|
--app-sidebar-bg: #ffffff;
|
||||||
|
--app-sidebar-border: #e4e4e7;
|
||||||
|
--app-sidebar-shadow: 0 24px 80px rgba(24, 24, 27, 0.22);
|
||||||
|
--app-surface-lowest: #ffffff;
|
||||||
|
--app-surface-low: #f4f4f5;
|
||||||
|
--app-selected-bg: #f4f4f5;
|
||||||
|
--app-text-color: #18181b;
|
||||||
|
--app-selected-text-color: #18181b;
|
||||||
|
--app-disabled-color: #a1a1aa;
|
||||||
|
--app-input-bg: #ffffff;
|
||||||
|
--app-input-border: #d4d4d8;
|
||||||
|
--app-input-color: #18181b;
|
||||||
|
--app-button-border: #d4d4d8;
|
||||||
|
--app-button-hover-bg: #f4f4f5;
|
||||||
|
--app-button-active-bg: #e4e4e7;
|
||||||
|
--app-button-active-border: #5b8def;
|
||||||
|
--app-overlay-bg: rgba(255, 255, 255, 0.88);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar {
|
||||||
|
position: fixed;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
z-index: 40;
|
||||||
|
display: flex;
|
||||||
|
height: 100%;
|
||||||
|
width: min(340px, calc(100vw - 32px));
|
||||||
|
flex-direction: column;
|
||||||
|
border-right: 1px solid var(--app-sidebar-border);
|
||||||
|
background: var(--app-sidebar-bg);
|
||||||
|
box-shadow: var(--app-sidebar-shadow);
|
||||||
|
transform: translateX(calc(-100% - 24px));
|
||||||
|
transition:
|
||||||
|
transform 180ms ease,
|
||||||
|
box-shadow 180ms ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-open {
|
||||||
|
transform: translateX(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 12px;
|
||||||
|
border-bottom: 1px solid var(--app-sidebar-border);
|
||||||
|
padding: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-header h1 {
|
||||||
|
margin: 0;
|
||||||
|
font-size: var(--app-sidebar-font-size);
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--app-text-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-actions {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-actions .primary-button,
|
||||||
|
.sidebar-actions .icon-button {
|
||||||
|
height: 32px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.drawing-list {
|
||||||
|
flex: 1;
|
||||||
|
overflow: auto;
|
||||||
|
padding: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.drawing-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 4px 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.drawing-item-active {
|
||||||
|
background: var(--app-selected-bg);
|
||||||
|
color: var(--app-selected-text-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.drawing-link {
|
||||||
|
flex: 1 1 auto;
|
||||||
|
min-width: 0;
|
||||||
|
border: 0;
|
||||||
|
background: transparent;
|
||||||
|
color: inherit;
|
||||||
|
padding: 8px 0;
|
||||||
|
text-align: left;
|
||||||
|
font-size: var(--app-sidebar-font-size);
|
||||||
|
}
|
||||||
|
|
||||||
|
.drawing-title,
|
||||||
|
.title-input {
|
||||||
|
display: block;
|
||||||
|
width: 100%;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.title-input {
|
||||||
|
border: 1px solid var(--app-input-border);
|
||||||
|
border-radius: 6px;
|
||||||
|
background: var(--app-input-bg);
|
||||||
|
color: var(--app-input-color);
|
||||||
|
font-size: var(--app-sidebar-font-size);
|
||||||
|
padding: 6px 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.editor-shell {
|
||||||
|
position: relative;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-actions {
|
||||||
|
position: fixed;
|
||||||
|
right: max(16px, calc(env(safe-area-inset-right) + 16px));
|
||||||
|
bottom: calc(max(16px, env(safe-area-inset-bottom)) + 48px);
|
||||||
|
z-index: 20;
|
||||||
|
display: flex;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-actions-toggle {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: var(--lg-button-size, 2.25rem);
|
||||||
|
height: var(--lg-button-size, 2.25rem);
|
||||||
|
padding: 0;
|
||||||
|
border: none;
|
||||||
|
border-radius: var(--border-radius-lg, 12px);
|
||||||
|
box-shadow: 0 0 0 1px var(--app-surface-lowest);
|
||||||
|
background: var(--app-surface-low);
|
||||||
|
color: var(--app-text-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-actions-toggle svg {
|
||||||
|
width: 1rem;
|
||||||
|
height: 1rem;
|
||||||
|
overflow: visible;
|
||||||
|
transform: translateY(0.5px) scale(1.08);
|
||||||
|
transform-origin: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.primary-button,
|
||||||
|
.secondary-button,
|
||||||
|
.icon-button {
|
||||||
|
border: 1px solid var(--app-button-border);
|
||||||
|
border-radius: 8px;
|
||||||
|
background: var(--app-island-bg);
|
||||||
|
color: var(--app-text-color);
|
||||||
|
transition:
|
||||||
|
background-color 120ms ease,
|
||||||
|
border-color 120ms ease,
|
||||||
|
color 120ms ease,
|
||||||
|
box-shadow 120ms ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.primary-button,
|
||||||
|
.secondary-button {
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: var(--app-sidebar-font-size);
|
||||||
|
font-weight: 500;
|
||||||
|
padding: 8px 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.primary-button:hover,
|
||||||
|
.secondary-button:hover,
|
||||||
|
.icon-button:hover {
|
||||||
|
background: var(--app-button-hover-bg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.primary-button:active,
|
||||||
|
.secondary-button:active,
|
||||||
|
.icon-button:active {
|
||||||
|
background: var(--app-button-active-bg);
|
||||||
|
border-color: var(--app-button-active-border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.secondary-button:disabled {
|
||||||
|
cursor: not-allowed;
|
||||||
|
opacity: 0.6;
|
||||||
|
color: var(--app-disabled-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.icon-button {
|
||||||
|
display: inline-flex;
|
||||||
|
flex: 0 0 32px;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 32px;
|
||||||
|
height: 32px;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-backdrop {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
z-index: 30;
|
||||||
|
background: color-mix(in srgb, var(--app-overlay-bg) 72%, #000000);
|
||||||
|
opacity: 0;
|
||||||
|
pointer-events: none;
|
||||||
|
transition: opacity 180ms ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-backdrop-open {
|
||||||
|
opacity: 1;
|
||||||
|
pointer-events: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.editor-frame,
|
||||||
|
.editor-loading {
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.editor-loading {
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
color: var(--app-text-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-actions-toggle:hover {
|
||||||
|
background: var(--app-button-hover-bg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-actions-toggle:active {
|
||||||
|
box-shadow: 0 0 0 1px var(--app-button-active-border);
|
||||||
|
background: var(--app-button-active-bg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.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: min(300px, calc(100vw - 20px));
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-actions {
|
||||||
|
right: max(16px, calc(env(safe-area-inset-right) + 16px));
|
||||||
|
bottom: calc(max(16px, env(safe-area-inset-bottom)) + 56px);
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user