Compare commits

...

27 Commits

Author SHA1 Message Date
ruinivist 10ee988ce2 feat: add http security headers to caddy config
Adds X-Frame-Options, X-Content-Type-Options, and Referrer-Policy
headers to all responses served by Caddy to mitigate clickjacking and
MIME-type sniffing.
2026-06-05 08:20:05 +00:00
ruinivist 01300c7061 fix: fallback to custom uuid generation for http contexts 2026-06-02 15:33:00 +00:00
ruinivist 7d36b6c9ea feat: code blocks with shiki highlighting 2026-06-02 15:20:42 +00:00
ruinivist e5eaa4f8d7 perf: eliminate layout thrashing and array allocations during drawing (#14) 2026-06-02 13:49:21 +05:30
ruinivist d8d917d315 fix: copy patches to dockerfile 2026-06-01 22:42:02 +00:00
ruinivist 5887baf324 fix: add decisions to AGENTS.md 2026-06-01 22:15:13 +00:00
ruinivist 8db56d9ab7 fix: bun patch for excalidraw's weird pen sizes ( #3693 there is still open ) 2026-06-01 22:02:47 +00:00
ruinivist 6cc028305a chore: add formatting 2026-06-01 21:34:05 +00:00
ruinivist f64d5a73d2 feat: read only publish of drawings 2026-06-01 21:31:11 +00:00
ruinivist 22f13820ab docs: some decisions 2026-06-01 19:18:39 +00:00
ruinivist 459993a78d memoize EditorCanvas to prevent unnecessary re-renders 2026-06-02 00:26:01 +05:30
ruinivist 95d27c5142 feat: bundle default styles guide 2026-05-31 19:14:02 +00:00
ruinivist b60b921ff8 chore: gitignore 2026-05-31 18:17:00 +00:00
ruinivist b3e79d839e fix: style guide 2026-05-31 18:14:41 +00:00
ruinivist 0d9dd742b2 feat: add a styles guide 2026-05-31 14:54:46 +00:00
ruinivist acce5ed05c docs: add tl;dr section 2026-05-31 14:54:38 +00:00
ruinivist 6e3d18ce94 refactor: remove drawing load fallback handling 2026-05-31 14:41:41 +00:00
ruinivist b4a540214b feat: add optional styles guide resource 2026-05-31 14:36:51 +00:00
ruinivist 25bf171f6b refactor: reorganize src structure 2026-05-31 12:46:21 +00:00
ruinivist f349617427 fix: skip no-op scene revision bumps 2026-05-31 12:35:57 +00:00
ruinivist d7a0a5d076 test: remove negative legacy cases 2026-05-31 12:31:34 +00:00
ruinivist 83191055bd refactor: drop legacy db and scene fallback 2026-05-31 12:29:58 +00:00
ruinivist ab786a7861 refactor: split app shell 2026-05-31 12:17:18 +00:00
ruinivist 6911033c1a fix: prevent stale drawing saves 2026-05-31 11:47:20 +00:00
ruinivist c13c137e24 feat: handle 499 cases as no throw on server
* fix(api): gracefully handle aborted requests

When a client closes a connection early (e.g. while `await request.text()` is parsing), it throws a `DOMException` with `name: "AbortError"`. Previously, this hit our catch block, was logged as an unhandled exception to the console via `console.error`, and returned a `500` status. This resulted in an alarming error message which looked like a server crash, but the server actually stayed up.

This commit updates `errorResponse` in `src/api.ts` to intercept `AbortError` and handle it properly. If we detect an `AbortError`, we now log a simple `400 Bad Request` instead, without writing out the `DOMException` stack trace.

* fix(api): gracefully handle aborted requests

When a client closes a connection early (e.g. while `await request.text()` is parsing), it throws a `DOMException` with `name: "AbortError"`. Previously, this hit our catch block, was logged as an unhandled exception to the console via `console.error`, and returned a `500` status. This resulted in an alarming error message which looked like a server crash, but the server actually stayed up.

This commit updates `errorResponse` in `src/api.ts` to intercept `AbortError` and handle it properly. If we detect an `AbortError`, we now log a simple `499 Client Closed Request` instead, without writing out the `DOMException` stack trace.

---------

Co-authored-by: ruinivist <179396038+ruinivist@users.noreply.github.com>
2026-05-31 16:48:02 +05:30
ruinivist 1b0024d78f feat: add mcp scene api 2026-05-31 00:08:51 +00:00
ruinivist 570baf83ab feat: implement manual save with toast notification (#4)
* 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.

* 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.

---------

Co-authored-by: ruinivist <179396038+ruinivist@users.noreply.github.com>
2026-05-30 23:40:01 +05:30
49 changed files with 5749 additions and 1318 deletions
+1 -1
View File
@@ -3,7 +3,7 @@ name: Publish GHCR Image
on:
push:
tags:
- 'release-*'
- "release-*"
permissions:
contents: read
+1 -1
View File
@@ -34,4 +34,4 @@ report.[0-9]_.[0-9]_.[0-9]_.[0-9]_.json
# Finder (MacOS) folder config
.DS_Store
PLAN.md
excali-box-data/
+4
View File
@@ -0,0 +1,4 @@
## 2025-02-28 - Caddy HTTP Security Headers Configuration
**Vulnerability:** The application was missing basic security headers (X-Frame-Options, X-Content-Type-Options, Referrer-Policy), increasing exposure to clickjacking and MIME-sniffing attacks.
**Learning:** Security posture isn't just about application-level code. The reverse proxy (Caddy) configuration serves as the first line of defense and is the correct layer to enforce global HTTP response headers.
**Prevention:** Always verify that the reverse proxy layer enforces foundational HTTP security headers instead of relying solely on the application code to handle response security.
+3
View File
@@ -0,0 +1,3 @@
dist
data
excali-box-data
+4
View File
@@ -5,3 +5,7 @@
- Keep commit messages terse.
Example: `feat: dark mode by default`
# Decisions on architecture and design
Read [DECISIONS.md](DECISIONS.md)
+11 -6
View File
@@ -6,20 +6,25 @@
:80 {
encode zstd gzip
@api path /api/*
handle @api {
reverse_proxy 127.0.0.1:3000
header {
X-Frame-Options "DENY"
X-Content-Type-Options "nosniff"
Referrer-Policy "strict-origin-when-cross-origin"
}
@root path /
handle @root {
handle /mcp {
reverse_proxy 127.0.0.1:3001
}
@app path / /api/*
handle @app {
reverse_proxy 127.0.0.1:3000
}
handle {
root * /srv/public
@html path /index.html /d/*
@html path /index.html /d/* /p/*
header @html Cache-Control "no-cache"
@assets path_regexp assets \.(?:css|js|mjs|svg|png|jpg|jpeg|gif|webp|ico|woff2?|ttf|otf)$
+8
View File
@@ -0,0 +1,8 @@
# a list of architectural decisions
( to remind me when I over-engineer and be fickle minded )
## Image is NOT meant to be exposed directly to the internet
- the defaults MUST take care of this
- and hence AUTH won't be a part of the image, whatever the user is using for reverse proxy should handle auth.
+8 -1
View File
@@ -2,6 +2,7 @@ FROM oven/bun:1.3.11-alpine AS build
WORKDIR /app
COPY package.json bun.lock tsconfig.json ./
COPY patches ./patches
RUN bun install --frozen-lockfile
COPY src ./src
@@ -15,15 +16,21 @@ WORKDIR /app
ENV NODE_ENV=production
ENV HOST=127.0.0.1
ENV PORT=3000
ENV MCP_HOST=127.0.0.1
ENV MCP_PORT=3001
ENV DATABASE_PATH=/data/excalidraw.sqlite
RUN mkdir -p /config /data
COPY --from=caddy /usr/bin/caddy /usr/bin/caddy
COPY --from=build /app/dist/public /srv/public
COPY --from=build /app/dist/server /app/dist/server
COPY --from=build /app/dist/mcp /app/dist/mcp
COPY Caddyfile /etc/caddy/Caddyfile
COPY STYLES_GUIDE.md /config/styles-guide.md
COPY docker-entrypoint.sh /usr/local/bin/docker-entrypoint.sh
RUN mkdir -p /data && chmod +x /usr/local/bin/docker-entrypoint.sh
RUN chmod +x /usr/local/bin/docker-entrypoint.sh
VOLUME ["/data"]
+43 -4
View File
@@ -5,7 +5,27 @@ Private self-hosted Excalidraw with Bun, Caddy, 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 Caddy auth or similar solutions.
> GPT 5.5 generated
## Tl;dr
When building the image yourself
```
docker run --rm \
-p 127.0.0.1:3000:80 \
-e PUBLIC_BASE_URL=http://localhost:3000 \
-v "$PWD/excali-box-data:/data" \
excali
```
When pulling from GHCR ( you would want to change the `PUBLIC_BASE_URL` )
```
docker run --rm \
-p 127.0.0.1:3000:80 \
-e PUBLIC_BASE_URL=http://localhost:3000 \
-v "$PWD/excali-box-data:/data" \
ghcr.io/ruinivist/excalidraw-box:latest
```
## Pull from GHCR and run
@@ -15,7 +35,7 @@ Images are published to:
```bash
docker pull ghcr.io/ruinivist/excalidraw-box:latest
docker run --rm -p 127.0.0.1:3000:80 -v "$PWD/excali-box-data:/data" ghcr.io/ruinivist/excalidraw-box:latest
docker run --rm -p 127.0.0.1:3000:80 -e PUBLIC_BASE_URL=http://localhost:3000 -v "$PWD/excali-box-data:/data" ghcr.io/ruinivist/excalidraw-box:latest
```
## Run locally
@@ -30,6 +50,7 @@ Open `http://localhost:3000`.
## Test
```bash
bun run format
bun run test
bun run typecheck
```
@@ -38,7 +59,25 @@ bun run typecheck
```bash
docker build -t excali .
docker run --rm -p 127.0.0.1:3000:80 -v "$PWD/excali-box-data:/data" excali
docker run --rm -p 127.0.0.1:3000:80 -e PUBLIC_BASE_URL=http://localhost:3000 -v "$PWD/excali-box-data:/data" excali
```
This will make the data persistent in the `excali-box-data` folder in the current directory, with Caddy serving the browser build on `localhost:3000`
This will make the data persistent in the `excali-box-data` folder in the current directory, with Caddy serving the browser build on `localhost:3000`. `PUBLIC_BASE_URL` is required so MCP tools can return drawing URLs with the public browser origin. You would want it to be the same url you use to access the app in the browser - in case you reverse proxy or use a tailscale alias etc.
## Optional MCP styles guide resource
The MCP server can optionally expose one extra read-only resource at `excali://styles-guide`. This file is then
discoverable as an MCP resource. Container images already include the repo `STYLES_GUIDE.md` at
`/config/styles-guide.md`, so the default guide is exposed automatically in Docker/GHCR.
```bash
docker run --rm \
-p 127.0.0.1:3000:80 \
-e PUBLIC_BASE_URL=http://localhost:3000 \
-v "$PWD/excali-box-data:/data" \
-v "$PWD/STYLES_GUIDE.md:/config/styles-guide.md:ro" \
ghcr.io/ruinivist/excalidraw-box:latest
```
Bind-mounting a different file to `/config/styles-guide.md` overrides the bundled default for that container.
Local non-container runs are unchanged; if you want a styles guide there, you still need a file at `/config/styles-guide.md`.
+212
View File
@@ -0,0 +1,212 @@
# Styles Guide
Use this guide when generating or modifying Excalidraw scenes.
## Author intent (must follow)
- Excalidraw is for free-flow visual thinking: nodes, arrows, curves, boundaries, and hanging labels.
- Treat this as a diagramming task, not a document-writing task.
- Prefer visual relationships over long prose.
- If content starts becoming paragraph-heavy, split it into smaller nodes and explicit connectors.
- Prioritize flow clarity over text density.
## Core intent
- Always set `scene.appState.theme` to `"dark"`.
- Always use light-style element colors.
- Optimize for clarity, technical precision, and fast visual parsing.
- Tailor structure to the problem. Do not default to generic flowcharts.
- Prefer diagrams that look like working engineering notes, not slides.
## Theme
- Use a light canvas and light containers by default.
- Keep contrast high enough for comfortable reading.
- Use vibrant accent colors with strong readability on light surfaces.
- Keep color mapping stable: the same logical component/flow must keep the same color across node, connector, and connector label.
- For subtypes within a flow, use close shades of the same family instead of unrelated hues.
### Default palette
- Canvas / background: `#f8fafc`
- Surface: `#ffffff`
- Muted surface: `#f1f5f9`
- Text: `#212529`
- Muted text: `#343a40`
- Green: `#099268`
- Pink: `#c2255c`
- Red: `#ff5d73`
- Purple: `#6741d9`
- Blue: `#1971c2`
- Teal: `#0c8599`
- Border / connector: `#868e96`
### Extended accent set
- Mint: `#12b886`
- Cyan: `#1098ad`
- Indigo: `#364fc7`
- Violet: `#5f3dc4`
- Magenta: `#a61e4d`
- Rose: `#e64980`
- Slate dark: `#212529`
- Slate mid: `#495057`
- Slate light: `#868e96`
## Layout
- Keep the layout spacious.
- Use consistent alignment and clear grouping.
- Maintain obvious reading order (usually left-to-right or top-to-bottom).
- Separate major groups with generous whitespace.
- Avoid dense clusters and unclear crossings.
### Spacing defaults
- Between major groups: `160-240px`
- Between related nodes: `72-120px`
- Container padding: `48-72px`
- Keep connector crossings rare. Re-route instead of stacking lines through central content.
### Connector label placement
- Do not place connector labels directly on top of arrow/line strokes.
- Offset connector labels from the path by at least `16-24px` on the normal axis.
- Prefer labels slightly to the side of the connector midpoint, not centered on the stroke.
- If lines still reduce readability, move the label further away.
- Connector labels must remain clearly associated with their connector.
- Connector endpoints should stop with a visible gap before node/container borders: `8-16px`.
- Prefer slight natural curves for connectors (gentle 3-point bends) instead of rigid perfectly straight arrows.
- Keep curvature subtle; avoid dramatic arcs unless the route needs explicit detouring.
- Use straight connectors only when they are materially clearer than curved ones.
### Container nesting
- Default to one container level.
- Maximum nesting depth is two.
- Use second-level nesting only when needed.
- Avoid box-within-box-within-box structures unless explicitly required.
## Text fit and sizing
Use explicit sizing so text fits without clipping or cramped boxes.
### Typography defaults
- Body/node text: `20px` Excalidraw monospace (`fontFamily: 3`)
- Section labels: `24px`
- Auxiliary notes: `20px` minimum
- Line height multiplier: `1.25`
### Text length and wrapping
- Target max line length: `22-28` characters.
- If label text exceeds `28` characters, insert line breaks at phrase boundaries.
- Keep most labels to `1-2` lines.
- Hard cap: `3` lines for standard nodes, `4` lines for large containers.
### Box size contract
- Horizontal text padding: `32px` per side (`64px` total)
- Vertical text padding: `24px` per side (`48px` total)
- Minimum node size: `260x120`
- Recommended width bands:
- Short labels (`<=18` chars): `260-320px`
- Medium labels (`19-40` chars): `340-460px`
- Long labels (wrapped): `480-680px`
### Overflow guardrails
- Text must remain fully inside its box at `zoom: 1`.
- Keep at least `28px` interior clearance from text to borders after render.
- If text would overflow: increase width first, then height, then split content into multiple nodes.
- No label overlap is allowed.
- Bias toward extra whitespace over dense packing.
## Structural guidance
Pick the structure that matches the content.
- Flows/lifecycles: sequence or pipeline layouts
- Layered systems: stacked layers with strict boundaries
- Ownership/containment: nested containers
- Stateful behavior: state-machine style
- Dependencies: directional graphs grouped by subsystem
- Comparisons/migrations: side-by-side layouts
Do not force every task into boxes with arrows when a better structure exists.
## Logical coherence
- Every element must have a reason to exist.
- Group by real system boundaries, not visual symmetry alone.
- Make relationships explicit: data flow, control flow, ownership, lifecycle, dependency.
- Minimize ambiguous arrows.
- If a connection has specific meaning, label it briefly.
- Prefer fewer, clearer elements over exhaustive clutter.
## Language
- Use terse, technical labels.
- Use short phrases, not full sentences.
- Assume the reader is a senior engineer.
- Prefer concrete nouns/verbs.
- Use concrete system terms: API, worker, queue, WAL, cache, AST, token, retry loop, reconciliation pass.
### Avoid
- Business speak
- Marketing language
- Vague labels like `Platform`, `Service Layer`, `System`, `Magic`
- Ambiguous shorthand like `edge`, `core`, `backend`, `worker` without qualifiers
- Filler phrases like `leverages`, `enables`, `streamlines`, `orchestrates`
### Naming specificity
- Prefer concrete component names over abstract layer names.
- Label the actual technology/runtime boundary when known.
- Example: use `caddy router` or `reverse proxy (caddy :80)` instead of `edge`.
- Example: use `bun http api (:3000)` instead of `backend`.
## Visual style
- Use subtle emphasis, not decoration.
- Reserve accent colors for meaning, not aesthetics alone.
- Keep color mapping stable for each logical subsystem/flow.
- Use container fills and border weight to show hierarchy.
- Keep shapes simple and consistent unless variation materially helps.
## Creativity rule
Be creative in structure, not flashy in styling.
- Adapt composition to the specific problem.
- Use framing, grouping, and flow intentionally.
- Make the diagram feel specific to the task.
## Dark mode persistence
- Treat dark mode as a persisted app-state requirement only.
- At create/update time, `scene.appState.theme` must be `"dark"`.
- Do not rely on UI defaults or post-hoc toggles.
- Theme and element colors are separate controls:
- `theme: "dark"` is required.
- Keep element colors in a light-style palette.
- Do not manually invert colors.
- Do not use dark canvas/surface colors for theme compliance.
## Hard constraints
- Always set `scene.appState.theme` to `"dark"`.
- Always use light-mode element colors.
- No manual color inversion.
- Default to one container level; max two unless explicitly needed.
- No tight text boxes.
- No cluttered layouts.
- No overlapping labels.
- No label text with connector strokes running through glyphs.
- No connector endpoint flush against a box border; keep a visible gap.
- No inconsistent color mapping for the same logical subsystem/flow.
- No decorative noise.
- No business/management tone.
- No generic one-size-fits-all flowchart when a better structure is appropriate.
+319 -4
View File
@@ -6,17 +6,28 @@
"name": "excali",
"dependencies": {
"@excalidraw/excalidraw": "^0.18.1",
"@modelcontextprotocol/sdk": "^1.29.0",
"fast-json-patch": "^3.1.1",
"mcp-handler": "^1.1.0",
"react": "^19.2.6",
"react-dom": "^19.2.6",
"shiki": "^4.1.0",
"zod": "^4.4.3",
},
"devDependencies": {
"@types/bun": "^1.3.14",
"@types/react": "^19.2.15",
"@types/react-dom": "^19.2.3",
"prettier": "^3.8.3",
"pretty-quick": "^4.2.2",
"simple-git-hooks": "^2.13.1",
"typescript": "^5.9.3",
},
},
},
"patchedDependencies": {
"@excalidraw/excalidraw@0.18.1": "patches/@excalidraw%2Fexcalidraw@0.18.1.patch",
},
"packages": {
"@antfu/install-pkg": ["@antfu/install-pkg@1.1.0", "", { "dependencies": { "package-manager-detector": "^1.3.0", "tinyexec": "^1.0.1" } }, "sha512-MGQsmw10ZyI+EJo45CdSER4zEb+p31LpDAFp2Z3gkSd1yqVZGi0Ebx++YTEMonJy4oChEMLsxZ64j8FH6sSqtQ=="],
@@ -52,12 +63,18 @@
"@floating-ui/utils": ["@floating-ui/utils@0.2.11", "", {}, "sha512-RiB/yIh78pcIxl6lLMG0CgBXAZ2Y0eVHqMPYugu+9U0AeT6YBeiJpf7lbdJNIugFP5SIjwNRgo4DhR1Qxi26Gg=="],
"@hono/node-server": ["@hono/node-server@1.19.14", "", { "peerDependencies": { "hono": "^4" } }, "sha512-GwtvgtXxnWsucXvbQXkRgqksiH2Qed37H9xHZocE5sA3N8O8O8/8FA3uclQXxXVzc9XBZuEOMK7+r02FmSpHtw=="],
"@iconify/types": ["@iconify/types@2.0.0", "", {}, "sha512-+wluvCrRhXrhyOmRDJ3q8mux9JkKy5SJ/v8ol2tu4FVjyYvtEzkc/3pK15ET6RKg4b4w4BmTk1+gsCUhf21Ykg=="],
"@iconify/utils": ["@iconify/utils@3.1.3", "", { "dependencies": { "@antfu/install-pkg": "^1.1.0", "@iconify/types": "^2.0.0", "import-meta-resolve": "^4.2.0" } }, "sha512-LPKOXPn/zV+zis1oOfGWogaXVpqUybF3ZS6SCZIsz8vg0ivVp9+fVqyYB7xq0aiST/VhUQYGO1qo6uoYSiEJqw=="],
"@mermaid-js/parser": ["@mermaid-js/parser@0.6.3", "", { "dependencies": { "langium": "3.3.1" } }, "sha512-lnjOhe7zyHjc+If7yT4zoedx2vo4sHaTmtkl1+or8BRTnCtDmcTpAjpzDSfCZrshM5bCoz0GyidzadJAH1xobA=="],
"@modelcontextprotocol/sdk": ["@modelcontextprotocol/sdk@1.29.0", "", { "dependencies": { "@hono/node-server": "^1.19.9", "ajv": "^8.17.1", "ajv-formats": "^3.0.1", "content-type": "^1.0.5", "cors": "^2.8.5", "cross-spawn": "^7.0.5", "eventsource": "^3.0.2", "eventsource-parser": "^3.0.0", "express": "^5.2.1", "express-rate-limit": "^8.2.1", "hono": "^4.11.4", "jose": "^6.1.3", "json-schema-typed": "^8.0.2", "pkce-challenge": "^5.0.0", "raw-body": "^3.0.0", "zod": "^3.25 || ^4.0", "zod-to-json-schema": "^3.25.1" }, "peerDependencies": { "@cfworker/json-schema": "^4.1.1" }, "optionalPeers": ["@cfworker/json-schema"] }, "sha512-zo37mZA9hJWpULgkRpowewez1y6ML5GsXJPY8FI0tBBCd77HEvza4jDqRKOXgHNn867PVGCyTdzqpz0izu5ZjQ=="],
"@pkgr/core": ["@pkgr/core@0.2.10", "", {}, "sha512-x6fFWCeak8aCGfqZfe6CXYt5xVjxe9Os1cIPmVRcToInKLjhJkRVXvJ/L3/1KxFkjDQdbZV/YsuLKqa8t/xKpA=="],
"@radix-ui/primitive": ["@radix-ui/primitive@1.1.1", "", {}, "sha512-SJ31y+Q/zAyShtXJc8x83i9TYdbAfHZ++tUZnvjJJqFjzsdUnKsxPL6IEtBlxKkU7yzer//GQtZSV4GbldL3YA=="],
"@radix-ui/react-arrow": ["@radix-ui/react-arrow@1.1.2", "", { "dependencies": { "@radix-ui/react-primitive": "2.0.2" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-G+KcpzXHq24iH0uGG/pF8LyzpFJYGD4RfLjCIBfGdSLXvjLHST31RUiRVrupIBMvIppMgSzQ6l66iAxl03tdlg=="],
@@ -108,6 +125,34 @@
"@radix-ui/rect": ["@radix-ui/rect@1.1.0", "", {}, "sha512-A9+lCBZoaMJlVKcRBz2YByCG+Cp2t6nAnMnNba+XiWxnj6r4JUFqfsgwocMBZU9LPtdxC6wB56ySYpc7LQIoJg=="],
"@redis/bloom": ["@redis/bloom@1.2.0", "", { "peerDependencies": { "@redis/client": "^1.0.0" } }, "sha512-HG2DFjYKbpNmVXsa0keLHp/3leGJz1mjh09f2RLGGLQZzSHpkmZWuwJbAvo3QcRY8p80m5+ZdXZdYOSBLlp7Cg=="],
"@redis/client": ["@redis/client@1.6.1", "", { "dependencies": { "cluster-key-slot": "1.1.2", "generic-pool": "3.9.0", "yallist": "4.0.0" } }, "sha512-/KCsg3xSlR+nCK8/8ZYSknYxvXHwubJrU82F3Lm1Fp6789VQ0/3RJKfsmRXjqfaTA++23CvC3hqmqe/2GEt6Kw=="],
"@redis/graph": ["@redis/graph@1.1.1", "", { "peerDependencies": { "@redis/client": "^1.0.0" } }, "sha512-FEMTcTHZozZciLRl6GiiIB4zGm5z5F3F6a6FZCyrfxdKOhFlGkiAqlexWMBzCi4DcRoyiOsuLfW+cjlGWyExOw=="],
"@redis/json": ["@redis/json@1.0.7", "", { "peerDependencies": { "@redis/client": "^1.0.0" } }, "sha512-6UyXfjVaTBTJtKNG4/9Z8PSpKE6XgSyEb8iwaqDcy+uKrd/DGYHTWkUdnQDyzm727V7p21WUMhsqz5oy65kPcQ=="],
"@redis/search": ["@redis/search@1.2.0", "", { "peerDependencies": { "@redis/client": "^1.0.0" } }, "sha512-tYoDBbtqOVigEDMAcTGsRlMycIIjwMCgD8eR2t0NANeQmgK/lvxNAvYyb6bZDD4frHRhIHkJu2TBRvB0ERkOmw=="],
"@redis/time-series": ["@redis/time-series@1.1.0", "", { "peerDependencies": { "@redis/client": "^1.0.0" } }, "sha512-c1Q99M5ljsIuc4YdaCwfUEXsofakb9c8+Zse2qxTadu8TalLXuAESzLvFAvNVbkmSlvlzIQOLpBCmWI9wTOt+g=="],
"@shikijs/core": ["@shikijs/core@4.1.0", "", { "dependencies": { "@shikijs/primitive": "4.1.0", "@shikijs/types": "4.1.0", "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4", "hast-util-to-html": "^9.0.5" } }, "sha512-jLJtSJeuFffqX6/inRE1zqU5aFv2hrszvYgq3OjbAgFRZiWv7abKMDdQzYxuSDfmUPQozZvI/kuy6VMTvnvqTQ=="],
"@shikijs/engine-javascript": ["@shikijs/engine-javascript@4.1.0", "", { "dependencies": { "@shikijs/types": "4.1.0", "@shikijs/vscode-textmate": "^10.0.2", "oniguruma-to-es": "^4.3.6" } }, "sha512-YquhawCUgaBfhsS72e2Y/dI59gCBNPHu3fEO/tvLaXrTssxZrY5ddjtNLTwndrMgPo8b3IscE+xoICDzpTmlFQ=="],
"@shikijs/engine-oniguruma": ["@shikijs/engine-oniguruma@4.1.0", "", { "dependencies": { "@shikijs/types": "4.1.0", "@shikijs/vscode-textmate": "^10.0.2" } }, "sha512-axLpjVs45YBvvINa+dJF+NPW+KtFkNXsFr4SDw2BMj9GdeMnGxVB9PQb2xXlJYovslt/nz6giedAyOANkfc7hg=="],
"@shikijs/langs": ["@shikijs/langs@4.1.0", "", { "dependencies": { "@shikijs/types": "4.1.0" } }, "sha512-nwOMruEkbgdZfQ/b8CgpNBVOpvG1k0N5tbmgiFeqsan401+x3ILqlzZJowSla4Agmq4hG2Uf2wh5jLTEhR8VSg=="],
"@shikijs/primitive": ["@shikijs/primitive@4.1.0", "", { "dependencies": { "@shikijs/types": "4.1.0", "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" } }, "sha512-zx2/2Uwj2q9X3KSyYREEhXO23xBw5WUhP4orK2lE4r+t9JGITmEe0JH+wPmJhqHpOT2bRRs6lAL945+LDvOAGw=="],
"@shikijs/themes": ["@shikijs/themes@4.1.0", "", { "dependencies": { "@shikijs/types": "4.1.0" } }, "sha512-emCcTnUM7yO2wltYbaxm+yLvcCI4+h8XBKc4KmJ7EZUXoSGjcCHifkI//R4OFit9ewpg7H2/9tjOuXrT2v/Knw=="],
"@shikijs/types": ["@shikijs/types@4.1.0", "", { "dependencies": { "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" } }, "sha512-3EQWX54fMpniOrDblzAhiwiJwpiTMW6+B9DWyUd9ska483tbayFYuw47UxwuPknI31bKnySfVQ/QW+jFL4rFdA=="],
"@shikijs/vscode-textmate": ["@shikijs/vscode-textmate@10.0.2", "", {}, "sha512-83yeghZ2xxin3Nj8z1NMd/NCuca+gsYXswywDy5bHvwlWL8tpTQmzGeUuHd9FC3E/SBEMvzJRwWEOz5gGes9Qg=="],
"@types/bun": ["@types/bun@1.3.14", "", { "dependencies": { "bun-types": "1.3.14" } }, "sha512-h1hFqFVcvAvD9j9K7ZW7vd82aSA+rTdznZa+5bwvCwqSB1jmmfLcbIWhOLx1/+boy/xmjgCs/OMUL8hRJSmnPw=="],
"@types/d3": ["@types/d3@7.4.3", "", { "dependencies": { "@types/d3-array": "*", "@types/d3-axis": "*", "@types/d3-brush": "*", "@types/d3-chord": "*", "@types/d3-color": "*", "@types/d3-contour": "*", "@types/d3-delaunay": "*", "@types/d3-dispatch": "*", "@types/d3-drag": "*", "@types/d3-dsv": "*", "@types/d3-ease": "*", "@types/d3-fetch": "*", "@types/d3-force": "*", "@types/d3-format": "*", "@types/d3-geo": "*", "@types/d3-hierarchy": "*", "@types/d3-interpolate": "*", "@types/d3-path": "*", "@types/d3-polygon": "*", "@types/d3-quadtree": "*", "@types/d3-random": "*", "@types/d3-scale": "*", "@types/d3-scale-chromatic": "*", "@types/d3-selection": "*", "@types/d3-shape": "*", "@types/d3-time": "*", "@types/d3-time-format": "*", "@types/d3-timer": "*", "@types/d3-transition": "*", "@types/d3-zoom": "*" } }, "sha512-lZXZ9ckh5R8uiFVt8ogUNf+pIrK4EsWrx2Np75WvF/eTpJ0FMHNhjXk8CKEx/+gpHbNQyJWehbFaTvqmHWB3ww=="],
@@ -174,6 +219,10 @@
"@types/geojson": ["@types/geojson@7946.0.16", "", {}, "sha512-6C8nqWur3j98U6+lXDfTUWIfgvZU+EumvpHKcYjujKH7woYyLj2sUmff0tRhrqM7BohUw7Pz3ZB1jj2gW9Fvmg=="],
"@types/hast": ["@types/hast@3.0.4", "", { "dependencies": { "@types/unist": "*" } }, "sha512-WPs+bbQw5aCj+x6laNGWLH3wviHtoCv/P3+otBhbOhJgG8qtpdAMlTCxLtsTWA7LH1Oh/bFCHsBn0TPS5m30EQ=="],
"@types/mdast": ["@types/mdast@4.0.4", "", { "dependencies": { "@types/unist": "*" } }, "sha512-kGaNbPh1k7AFzgpud/gMdvIm5xuECykRR+JnWKQno9TAXVa6WIVCGTPvYGekIDL4uwCZQSYbUxNBSb1aUo79oA=="],
"@types/node": ["@types/node@25.9.1", "", { "dependencies": { "undici-types": ">=7.24.0 <7.24.7" } }, "sha512-xfrlY7UD5rMJk3ZVJP8BNzS28J36YJg+xp+LPXV1TdWxr8uMH5A860QNxYDGQe/ylDSgjxE52Q9VnO7p75tJxg=="],
"@types/react": ["@types/react@19.2.15", "", { "dependencies": { "csstype": "^3.2.2" } }, "sha512-eRwcGNHve+E8qtEQSSRl6urh+rFop4v8gm6O8rGv25CodbvFdLjA1vVQ1KkiFE0w0UPOnb8tDiFKL5lp0rtY5Q=="],
@@ -182,22 +231,48 @@
"@types/trusted-types": ["@types/trusted-types@2.0.7", "", {}, "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw=="],
"@types/unist": ["@types/unist@3.0.3", "", {}, "sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q=="],
"@ungap/structured-clone": ["@ungap/structured-clone@1.3.1", "", {}, "sha512-mUFwbeTqrVgDQxFveS+df2yfap6iuP20NAKAsBt5jDEoOTDew+zwLAOilHCeQJOVSvmgCX4ogqIrA0mnyr08yQ=="],
"@upsetjs/venn.js": ["@upsetjs/venn.js@2.0.0", "", { "optionalDependencies": { "d3-selection": "^3.0.0", "d3-transition": "^3.0.1" } }, "sha512-WbBhLrooyePuQ1VZxrJjtLvTc4NVfpOyKx0sKqioq9bX1C1m7Jgykkn8gLrtwumBioXIqam8DLxp88Adbue6Hw=="],
"accepts": ["accepts@2.0.0", "", { "dependencies": { "mime-types": "^3.0.0", "negotiator": "^1.0.0" } }, "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng=="],
"ajv": ["ajv@8.20.0", "", { "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", "json-schema-traverse": "^1.0.0", "require-from-string": "^2.0.2" } }, "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA=="],
"ajv-formats": ["ajv-formats@3.0.1", "", { "dependencies": { "ajv": "^8.0.0" } }, "sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ=="],
"anymatch": ["anymatch@3.1.3", "", { "dependencies": { "normalize-path": "^3.0.0", "picomatch": "^2.0.4" } }, "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw=="],
"aria-hidden": ["aria-hidden@1.2.6", "", { "dependencies": { "tslib": "^2.0.0" } }, "sha512-ik3ZgC9dY/lYVVM++OISsaYDeg1tb0VtP5uL3ouh1koGOaUMDPpbFIei4JkFimWUFPn90sbMNMXQAIVOlnYKJA=="],
"binary-extensions": ["binary-extensions@2.3.0", "", {}, "sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw=="],
"body-parser": ["body-parser@2.2.2", "", { "dependencies": { "bytes": "^3.1.2", "content-type": "^1.0.5", "debug": "^4.4.3", "http-errors": "^2.0.0", "iconv-lite": "^0.7.0", "on-finished": "^2.4.1", "qs": "^6.14.1", "raw-body": "^3.0.1", "type-is": "^2.0.1" } }, "sha512-oP5VkATKlNwcgvxi0vM0p/D3n2C3EReYVX+DNYs5TjZFn/oQt2j+4sVJtSMr18pdRr8wjTcBl6LoV+FUwzPmNA=="],
"braces": ["braces@3.0.3", "", { "dependencies": { "fill-range": "^7.1.1" } }, "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA=="],
"browser-fs-access": ["browser-fs-access@0.29.1", "", {}, "sha512-LSvVX5e21LRrXqVMhqtAwj5xPgDb+fXAIH80NsnCQ9xuZPs2xWsOREi24RKgZa1XOiQRbcmVrv87+ulOKsgjxw=="],
"bun-types": ["bun-types@1.3.14", "", { "dependencies": { "@types/node": "*" } }, "sha512-4N0ig0fEomHt5R0KCFWjovxow98rIoRwKolrYdCcknNwMekCXRnWEUvgu5soYV8QXtVsrUD8B95MBOZGPvr6KQ=="],
"bytes": ["bytes@3.1.2", "", {}, "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg=="],
"call-bind-apply-helpers": ["call-bind-apply-helpers@1.0.2", "", { "dependencies": { "es-errors": "^1.3.0", "function-bind": "^1.1.2" } }, "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ=="],
"call-bound": ["call-bound@1.0.4", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.2", "get-intrinsic": "^1.3.0" } }, "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg=="],
"canvas-roundrect-polyfill": ["canvas-roundrect-polyfill@0.0.1", "", {}, "sha512-yWq+R3U3jE+coOeEb3a3GgE2j/0MMiDKM/QpLb6h9ihf5fGY9UXtvK9o4vNqjWXoZz7/3EaSVU3IX53TvFFUOw=="],
"ccount": ["ccount@2.0.1", "", {}, "sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg=="],
"chalk": ["chalk@5.6.2", "", {}, "sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA=="],
"character-entities-html4": ["character-entities-html4@2.1.0", "", {}, "sha512-1v7fgQRj6hnSwFpq1Eu0ynr/CDEw0rXo2B61qXrLNdHZmPKgb7fqS1a2JwF0rISo9q77jDI8VMEHoApn8qDoZA=="],
"character-entities-legacy": ["character-entities-legacy@3.0.0", "", {}, "sha512-RpPp0asT/6ufRm//AJVwpViZbGM/MkjQFxJccQRHmISF/22NBtsHqAWmL+/pmkPWoIUJdWyeVleTl1wydHATVQ=="],
"chevrotain": ["chevrotain@11.0.3", "", { "dependencies": { "@chevrotain/cst-dts-gen": "11.0.3", "@chevrotain/gast": "11.0.3", "@chevrotain/regexp-to-ast": "11.0.3", "@chevrotain/types": "11.0.3", "@chevrotain/utils": "11.0.3", "lodash-es": "4.17.21" } }, "sha512-ci2iJH6LeIkvP9eJW6gpueU8cnZhv85ELY8w8WiFtNjMHA5ad6pQLaJo9mEly/9qUyCpvqX8/POVUTf18/HFdw=="],
"chevrotain-allstar": ["chevrotain-allstar@0.3.1", "", { "dependencies": { "lodash-es": "^4.17.21" }, "peerDependencies": { "chevrotain": "^11.0.0" } }, "sha512-b7g+y9A0v4mxCW1qUhf3BSVPg+/NvGErk/dOkrDaHA0nQIQGAtrOjlX//9OQtRlSCy+x9rfB5N8yC71lH1nvMw=="],
@@ -206,7 +281,21 @@
"clsx": ["clsx@1.1.1", "", {}, "sha512-6/bPho624p3S2pMyvP5kKBPXnI3ufHLObBFCfgx+LkeR5lg2XYy2hqZqUf45ypD8COn2bhgGJSUE+l5dhNBieA=="],
"commander": ["commander@8.3.0", "", {}, "sha512-OkTL9umf+He2DZkUq8f8J9of7yL6RJKI24dVITBmNfZBmri9zYZQrKkuXiKhyfPSu8tUhnVBB1iKXevvnlR4Ww=="],
"cluster-key-slot": ["cluster-key-slot@1.1.2", "", {}, "sha512-RMr0FhtfXemyinomL4hrWcYJxmX6deFdCxpJzhDttxgO1+bcCnkk+9drydLVDmAMG7NE6aN/fl4F7ucU/90gAA=="],
"comma-separated-tokens": ["comma-separated-tokens@2.0.3", "", {}, "sha512-Fu4hJdvzeylCfQPp9SGWidpzrMs7tTrlu6Vb8XGaRGck8QSNZJJp538Wrb60Lax4fPwR64ViY468OIUTbRlGZg=="],
"commander": ["commander@11.1.0", "", {}, "sha512-yPVavfyCcRhmorC7rWlkHn15b4wDVgVmBA7kV4QVBsF7kv/9TKJAbAXVTxvTnwP8HHKjRCJDClKbciiYS7p0DQ=="],
"content-disposition": ["content-disposition@1.1.0", "", {}, "sha512-5jRCH9Z/+DRP7rkvY83B+yGIGX96OYdJmzngqnw2SBSxqCFPd0w2km3s5iawpGX8krnwSGmF0FW5Nhr0Hfai3g=="],
"content-type": ["content-type@1.0.5", "", {}, "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA=="],
"cookie": ["cookie@0.7.2", "", {}, "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w=="],
"cookie-signature": ["cookie-signature@1.2.2", "", {}, "sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg=="],
"cors": ["cors@2.8.6", "", { "dependencies": { "object-assign": "^4", "vary": "^1" } }, "sha512-tJtZBBHA6vjIAaF6EnIaq6laBBP9aq/Y3ouVJjEfoHbRBcHBAHYcMh/w8LDrk2PvIMMq8gmopa5D4V8RmbrxGw=="],
"cose-base": ["cose-base@1.0.3", "", { "dependencies": { "layout-base": "^1.0.0" } }, "sha512-s9whTXInMSgAp/NVXVNuVxVKzGH2qck3aQlVHxDCdAEPgtMKwc4Wq6/QKhgdEdgbLSi9rBTAcPoRa6JpiG4ksg=="],
@@ -292,33 +381,103 @@
"dayjs": ["dayjs@1.11.20", "", {}, "sha512-YbwwqR/uYpeoP4pu043q+LTDLFBLApUP6VxRihdfNTqu4ubqMlGDLd6ErXhEgsyvY0K6nCs7nggYumAN+9uEuQ=="],
"debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="],
"delaunator": ["delaunator@5.1.0", "", { "dependencies": { "robust-predicates": "^3.0.2" } }, "sha512-AGrQ4QSgssa1NGmWmLPqN5NY2KajF5MqxetNEO+o0n3ZwZZeTmt7bBnvzHWrmkZFxGgr4HdyFgelzgi06otLuQ=="],
"depd": ["depd@2.0.0", "", {}, "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw=="],
"dequal": ["dequal@2.0.3", "", {}, "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA=="],
"detect-node-es": ["detect-node-es@1.1.0", "", {}, "sha512-ypdmJU/TbBby2Dxibuv7ZLW3Bs1QEmM7nHjEANfohJLvE0XVujisn1qPJcZxg+qDucsr+bP6fLD1rPS3AhJ7EQ=="],
"devlop": ["devlop@1.1.0", "", { "dependencies": { "dequal": "^2.0.0" } }, "sha512-RWmIqhcFf1lRYBvNmr7qTNuyCt/7/ns2jbpp1+PalgE/rDQcBT0fioSMUpJ93irlUhC5hrg4cYqe6U+0ImW0rA=="],
"dompurify": ["dompurify@3.4.5", "", { "optionalDependencies": { "@types/trusted-types": "^2.0.7" } }, "sha512-OrwIBKsdNSVEeubdJ1HBv/wNENRM9ytAVCv7YXt//A3vPdVMNuACRqK9mXCGCBW2ln7BT/A4X0jXHo2Gu89miA=="],
"dunder-proto": ["dunder-proto@1.0.1", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.1", "es-errors": "^1.3.0", "gopd": "^1.2.0" } }, "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A=="],
"ee-first": ["ee-first@1.1.1", "", {}, "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow=="],
"encodeurl": ["encodeurl@2.0.0", "", {}, "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg=="],
"es-define-property": ["es-define-property@1.0.1", "", {}, "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g=="],
"es-errors": ["es-errors@1.3.0", "", {}, "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw=="],
"es-object-atoms": ["es-object-atoms@1.1.2", "", { "dependencies": { "es-errors": "^1.3.0" } }, "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw=="],
"es-toolkit": ["es-toolkit@1.46.1", "", {}, "sha512-5eNtXOs3tbfxXOj04tjjseeWkRWaoCjdEI+96DgwzZoe6c9juL49pXlzAFTI72aWC9Y8p7168g6XIKjh7k6pyQ=="],
"es6-promise-pool": ["es6-promise-pool@2.5.0", "", {}, "sha512-VHErXfzR/6r/+yyzPKeBvO0lgjfC5cbDCQWjWwMZWSb6YU39TGIl51OUmCfWCq4ylMdJSB8zkz2vIuIeIxXApA=="],
"escape-html": ["escape-html@1.0.3", "", {}, "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow=="],
"etag": ["etag@1.8.1", "", {}, "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg=="],
"eventsource": ["eventsource@3.0.7", "", { "dependencies": { "eventsource-parser": "^3.0.1" } }, "sha512-CRT1WTyuQoD771GW56XEZFQ/ZoSfWid1alKGDYMmkt2yl8UXrVR4pspqWNEcqKvVIzg6PAltWjxcSSPrboA4iA=="],
"eventsource-parser": ["eventsource-parser@3.1.0", "", {}, "sha512-kJezFj9YFAMLeORyi7aCLxLbD5/qWMQnoMVlVPyHIll7lgRJCc3JVln9Vgl9nwQi0YkMnhdGTMNn7CkRRAptMg=="],
"express": ["express@5.2.1", "", { "dependencies": { "accepts": "^2.0.0", "body-parser": "^2.2.1", "content-disposition": "^1.0.0", "content-type": "^1.0.5", "cookie": "^0.7.1", "cookie-signature": "^1.2.1", "debug": "^4.4.0", "depd": "^2.0.0", "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "etag": "^1.8.1", "finalhandler": "^2.1.0", "fresh": "^2.0.0", "http-errors": "^2.0.0", "merge-descriptors": "^2.0.0", "mime-types": "^3.0.0", "on-finished": "^2.4.1", "once": "^1.4.0", "parseurl": "^1.3.3", "proxy-addr": "^2.0.7", "qs": "^6.14.0", "range-parser": "^1.2.1", "router": "^2.2.0", "send": "^1.1.0", "serve-static": "^2.2.0", "statuses": "^2.0.1", "type-is": "^2.0.1", "vary": "^1.1.2" } }, "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw=="],
"express-rate-limit": ["express-rate-limit@8.5.2", "", { "dependencies": { "ip-address": "^10.2.0" }, "peerDependencies": { "express": ">= 4.11" } }, "sha512-5Kb34ipNX694DH48vN9irak1Qx30nb0PLYHXfJgw4YEjiC3ZEmZJhwOp+VfiCYwFzvFTdB9QkArYS5kXa2cx2A=="],
"fast-deep-equal": ["fast-deep-equal@3.1.3", "", {}, "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q=="],
"fast-json-patch": ["fast-json-patch@3.1.1", "", {}, "sha512-vf6IHUX2SBcA+5/+4883dsIjpBTqmfBjmYiWK1savxQmFk4JfBMLa7ynTYOs1Rolp/T1betJxHiGD3g1Mn8lUQ=="],
"fast-uri": ["fast-uri@3.1.2", "", {}, "sha512-rVjf7ArG3LTk+FS6Yw81V1DLuZl1bRbNrev6Tmd/9RaroeeRRJhAt7jg/6YFxbvAQXUCavSoZhPPj6oOx+5KjQ=="],
"fill-range": ["fill-range@7.1.1", "", { "dependencies": { "to-regex-range": "^5.0.1" } }, "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg=="],
"finalhandler": ["finalhandler@2.1.1", "", { "dependencies": { "debug": "^4.4.0", "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "on-finished": "^2.4.1", "parseurl": "^1.3.3", "statuses": "^2.0.1" } }, "sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA=="],
"forwarded": ["forwarded@0.2.0", "", {}, "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow=="],
"fractional-indexing": ["fractional-indexing@3.2.0", "", {}, "sha512-PcOxmqwYCW7O2ovKRU8OoQQj2yqTfEB/yeTYk4gPid6dN5ODRfU1hXd9tTVZzax/0NkO7AxpHykvZnT1aYp/BQ=="],
"fresh": ["fresh@2.0.0", "", {}, "sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A=="],
"fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="],
"function-bind": ["function-bind@1.1.2", "", {}, "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA=="],
"fuzzy": ["fuzzy@0.1.3", "", {}, "sha512-/gZffu4ykarLrCiP3Ygsa86UAo1E5vEVlvTrpkKywXSbP9Xhln3oSp9QSV57gEq3JFFpGJ4GZ+5zdEp3FcUh4w=="],
"generic-pool": ["generic-pool@3.9.0", "", {}, "sha512-hymDOu5B53XvN4QT9dBmZxPX4CWhBPPLguTZ9MMFeFa/Kg0xWVfylOVNlJji/E7yTZWFd/q9GO5TxDLq156D7g=="],
"get-intrinsic": ["get-intrinsic@1.3.0", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.2", "es-define-property": "^1.0.1", "es-errors": "^1.3.0", "es-object-atoms": "^1.1.1", "function-bind": "^1.1.2", "get-proto": "^1.0.1", "gopd": "^1.2.0", "has-symbols": "^1.1.0", "hasown": "^2.0.2", "math-intrinsics": "^1.1.0" } }, "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ=="],
"get-nonce": ["get-nonce@1.0.1", "", {}, "sha512-FJhYRoDaiatfEkUK8HKlicmu/3SGFD51q3itKDGoSTysQJBnfOcxU5GxnhE1E6soB76MbT0MBtnKJuXyAx+96Q=="],
"get-proto": ["get-proto@1.0.1", "", { "dependencies": { "dunder-proto": "^1.0.1", "es-object-atoms": "^1.0.0" } }, "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g=="],
"glob-parent": ["glob-parent@5.1.2", "", { "dependencies": { "is-glob": "^4.0.1" } }, "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow=="],
"glur": ["glur@1.1.2", "", {}, "sha512-l+8esYHTKOx2G/Aao4lEQ0bnHWg4fWtJbVoZZT9Knxi01pB8C80BR85nONLFwkkQoFRCmXY+BUcGZN3yZ2QsRA=="],
"gopd": ["gopd@1.2.0", "", {}, "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg=="],
"hachure-fill": ["hachure-fill@0.5.2", "", {}, "sha512-3GKBOn+m2LX9iq+JC1064cSFprJY4jL1jCXTcpnfER5HYE2l/4EfWSGzkPa/ZDBmYI0ZOEj5VHV/eKnPGkHuOg=="],
"iconv-lite": ["iconv-lite@0.6.3", "", { "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" } }, "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw=="],
"has-symbols": ["has-symbols@1.1.0", "", {}, "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ=="],
"hasown": ["hasown@2.0.4", "", { "dependencies": { "function-bind": "^1.1.2" } }, "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A=="],
"hast-util-to-html": ["hast-util-to-html@9.0.5", "", { "dependencies": { "@types/hast": "^3.0.0", "@types/unist": "^3.0.0", "ccount": "^2.0.0", "comma-separated-tokens": "^2.0.0", "hast-util-whitespace": "^3.0.0", "html-void-elements": "^3.0.0", "mdast-util-to-hast": "^13.0.0", "property-information": "^7.0.0", "space-separated-tokens": "^2.0.0", "stringify-entities": "^4.0.0", "zwitch": "^2.0.4" } }, "sha512-OguPdidb+fbHQSU4Q4ZiLKnzWo8Wwsf5bZfbvu7//a9oTYoqD/fWpe96NuHkoS9h0ccGOTe0C4NGXdtS0iObOw=="],
"hast-util-whitespace": ["hast-util-whitespace@3.0.0", "", { "dependencies": { "@types/hast": "^3.0.0" } }, "sha512-88JUN06ipLwsnv+dVn+OIYOvAuvBMy/Qoi6O7mQHxdPXpjy+Cd6xRkWwux7DKO+4sYILtLBRIKgsdpS2gQc7qw=="],
"hono": ["hono@4.12.23", "", {}, "sha512-eIaZ9qDgu7XV0pxOCrg7/WhnQ6Ivm22UcxhXx/A3dcbqbbYgBEkc6e/J/s7j2tS96zoB0S9VBdLwQNCWwUo4LA=="],
"html-void-elements": ["html-void-elements@3.0.0", "", {}, "sha512-bEqo66MRXsUGxWHV5IP0PUiAWwoEjba4VCzg0LjFJBpchPaTfyfCKTG6bc5F8ucKec3q5y6qOdGyYTSBEvhCrg=="],
"http-errors": ["http-errors@2.0.1", "", { "dependencies": { "depd": "~2.0.0", "inherits": "~2.0.4", "setprototypeof": "~1.2.0", "statuses": "~2.0.2", "toidentifier": "~1.0.1" } }, "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ=="],
"iconv-lite": ["iconv-lite@0.7.2", "", { "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" } }, "sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw=="],
"ignore": ["ignore@7.0.5", "", {}, "sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg=="],
"image-blob-reduce": ["image-blob-reduce@3.0.1", "", { "dependencies": { "pica": "^7.1.0" } }, "sha512-/VmmWgIryG/wcn4TVrV7cC4mlfUC/oyiKIfSg5eVM3Ten/c1c34RJhMYKCWTnoSMHSqXLt3tsrBR4Q2HInvN+Q=="],
@@ -330,6 +489,10 @@
"internmap": ["internmap@2.0.3", "", {}, "sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg=="],
"ip-address": ["ip-address@10.2.0", "", {}, "sha512-/+S6j4E9AHvW9SWMSEY9Xfy66O5PWvVEJ08O0y5JGyEKQpojb0K0GKpz/v5HJ/G0vi3D2sjGK78119oXZeE0qA=="],
"ipaddr.js": ["ipaddr.js@1.9.1", "", {}, "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g=="],
"is-binary-path": ["is-binary-path@2.1.0", "", { "dependencies": { "binary-extensions": "^2.0.0" } }, "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw=="],
"is-extglob": ["is-extglob@2.1.1", "", {}, "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ=="],
@@ -338,12 +501,20 @@
"is-number": ["is-number@7.0.0", "", {}, "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng=="],
"is-promise": ["is-promise@4.0.0", "", {}, "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ=="],
"isexe": ["isexe@2.0.0", "", {}, "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw=="],
"jose": ["jose@6.2.3", "", {}, "sha512-YYVDInQKFJfR/xa3ojUTl8c2KoTwiL1R5Wg9YCydwH0x0B9grbzlg5HC7mMjCtUJjbQ/YnGEZIhI5tCgfTb4Hw=="],
"jotai": ["jotai@2.11.0", "", { "peerDependencies": { "@types/react": ">=17.0.0", "react": ">=17.0.0" }, "optionalPeers": ["@types/react", "react"] }, "sha512-zKfoBBD1uDw3rljwHkt0fWuja1B76R7CjznuBO+mSX6jpsO1EBeWNRKpeaQho9yPI/pvCv4recGfgOXGxwPZvQ=="],
"jotai-scope": ["jotai-scope@0.7.2", "", { "peerDependencies": { "jotai": ">=2.9.2", "react": ">=17.0.0" } }, "sha512-Gwed97f3dDObrO43++2lRcgOqw4O2sdr4JCjP/7eHK1oPACDJ7xKHGScpJX9XaflU+KBHXF+VhwECnzcaQiShg=="],
"json-schema-traverse": ["json-schema-traverse@1.0.0", "", {}, "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug=="],
"json-schema-typed": ["json-schema-typed@8.0.2", "", {}, "sha512-fQhoXdcvc3V28x7C7BMs4P5+kNlgUURe2jmUT1T//oBRMDrqy1QPelJimwZGo7Hg9VPV3EQV5Bnq4hbFy2vetA=="],
"katex": ["katex@0.16.47", "", { "dependencies": { "commander": "^8.3.0" }, "bin": { "katex": "cli.js" } }, "sha512-Eeo8Ys1doU1z+x8AZsPpQu+p/QcZBI5PeOo7QGQdy2x2m0MU/hYagBbGOmXwr5KVbEfVuWv9LpnQWeehogurjg=="],
"khroma": ["khroma@2.1.0", "", {}, "sha512-Ls993zuzfayK269Svk9hzpeGUKob/sIgZzyHYdjQoAdQetRKpOLj+k/QQQ/6Qi0Yz65mlROrfd+Ev+1+7dz9Kw=="],
@@ -360,31 +531,79 @@
"marked": ["marked@16.4.2", "", { "bin": { "marked": "bin/marked.js" } }, "sha512-TI3V8YYWvkVf3KJe1dRkpnjs68JUPyEa5vjKrp1XEEJUAOaQc+Qj+L1qWbPd0SJuAdQkFU0h73sXXqwDYxsiDA=="],
"math-intrinsics": ["math-intrinsics@1.1.0", "", {}, "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g=="],
"mcp-handler": ["mcp-handler@1.1.0", "", { "dependencies": { "chalk": "^5.3.0", "commander": "^11.1.0", "redis": "^4.6.0" }, "peerDependencies": { "@modelcontextprotocol/sdk": "1.26.0", "next": ">=13.0.0" }, "optionalPeers": ["next"], "bin": { "mcp-adapter": "dist/cli/index.js", "mcp-handler": "dist/cli/index.js", "create-mcp-route": "dist/cli/index.js" } }, "sha512-MVCES7g18gcoZy+R/3v5nadkUMzMAWdos8jRl6DyljOKvd2/ZKDmwlCjL6zp4vo+7FeCXOYL1uWinHWlkKAAUg=="],
"mdast-util-to-hast": ["mdast-util-to-hast@13.2.1", "", { "dependencies": { "@types/hast": "^3.0.0", "@types/mdast": "^4.0.0", "@ungap/structured-clone": "^1.0.0", "devlop": "^1.0.0", "micromark-util-sanitize-uri": "^2.0.0", "trim-lines": "^3.0.0", "unist-util-position": "^5.0.0", "unist-util-visit": "^5.0.0", "vfile": "^6.0.0" } }, "sha512-cctsq2wp5vTsLIcaymblUriiTcZd0CwWtCbLvrOzYCDZoWyMNV8sZ7krj09FSnsiJi3WVsHLM4k6Dq/yaPyCXA=="],
"media-typer": ["media-typer@1.1.0", "", {}, "sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw=="],
"merge-descriptors": ["merge-descriptors@2.0.0", "", {}, "sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g=="],
"mermaid": ["mermaid@11.15.0", "", { "dependencies": { "@braintree/sanitize-url": "^7.1.1", "@iconify/utils": "^3.0.2", "@mermaid-js/parser": "^1.1.1", "@types/d3": "^7.4.3", "@upsetjs/venn.js": "^2.0.0", "cytoscape": "^3.33.1", "cytoscape-cose-bilkent": "^4.1.0", "cytoscape-fcose": "^2.2.0", "d3": "^7.9.0", "d3-sankey": "^0.12.3", "dagre-d3-es": "7.0.14", "dayjs": "^1.11.19", "dompurify": "^3.3.1", "es-toolkit": "^1.45.1", "katex": "^0.16.25", "khroma": "^2.1.0", "marked": "^16.3.0", "roughjs": "^4.6.6", "stylis": "^4.3.6", "ts-dedent": "^2.2.0", "uuid": "^11.1.0 || ^12 || ^13 || ^14.0.0" } }, "sha512-pTMbcf3rWdtLiYGpmoTjHEpeY8seiy6sR+9nD7LOs8KfUbHE4lOUAprTRqRAcWSQ6MQpdX+YEsxShtGsINtPtw=="],
"micromark-util-character": ["micromark-util-character@2.1.1", "", { "dependencies": { "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-wv8tdUTJ3thSFFFJKtpYKOYiGP2+v96Hvk4Tu8KpCAsTMs6yi+nVmGh1syvSCsaxz45J6Jbw+9DD6g97+NV67Q=="],
"micromark-util-encode": ["micromark-util-encode@2.0.1", "", {}, "sha512-c3cVx2y4KqUnwopcO9b/SCdo2O67LwJJ/UyqGfbigahfegL9myoEFoDYZgkT7f36T0bLrM9hZTAaAyH+PCAXjw=="],
"micromark-util-sanitize-uri": ["micromark-util-sanitize-uri@2.0.1", "", { "dependencies": { "micromark-util-character": "^2.0.0", "micromark-util-encode": "^2.0.0", "micromark-util-symbol": "^2.0.0" } }, "sha512-9N9IomZ/YuGGZZmQec1MbgxtlgougxTodVwDzzEouPKo3qFWvymFHWcnDi2vzV1ff6kas9ucW+o3yzJK9YB1AQ=="],
"micromark-util-symbol": ["micromark-util-symbol@2.0.1", "", {}, "sha512-vs5t8Apaud9N28kgCrRUdEed4UJ+wWNvicHLPxCa9ENlYuAY31M0ETy5y1vA33YoNPDFTghEbnh6efaE8h4x0Q=="],
"micromark-util-types": ["micromark-util-types@2.0.2", "", {}, "sha512-Yw0ECSpJoViF1qTU4DC6NwtC4aWGt1EkzaQB8KPPyCRR8z9TWeV0HbEFGTO+ZY1wB22zmxnJqhPyTpOVCpeHTA=="],
"mime-db": ["mime-db@1.54.0", "", {}, "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ=="],
"mime-types": ["mime-types@3.0.2", "", { "dependencies": { "mime-db": "^1.54.0" } }, "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A=="],
"mri": ["mri@1.2.0", "", {}, "sha512-tzzskb3bG8LvYGFF/mDTpq3jpI6Q9wc3LEmBaghu+DdCssd1FakN7Bc0hVNmEyGq1bq3RgfkCb3cmQLpNPOroA=="],
"ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="],
"multimath": ["multimath@2.0.0", "", { "dependencies": { "glur": "^1.1.2", "object-assign": "^4.1.1" } }, "sha512-toRx66cAMJ+Ccz7pMIg38xSIrtnbozk0dchXezwQDMgQmbGpfxjtv68H+L00iFL8hxDaVjrmwAFSb3I6bg8Q2g=="],
"nanoid": ["nanoid@3.3.3", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-p1sjXuopFs0xg+fPASzQ28agW1oHD7xDsd9Xkf3T15H3c/cifrFHVwrh74PdoklAPi+i7MdRsE47vm2r6JoB+w=="],
"negotiator": ["negotiator@1.0.0", "", {}, "sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg=="],
"normalize-path": ["normalize-path@3.0.0", "", {}, "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA=="],
"object-assign": ["object-assign@4.1.1", "", {}, "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg=="],
"object-inspect": ["object-inspect@1.13.4", "", {}, "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew=="],
"on-finished": ["on-finished@2.4.1", "", { "dependencies": { "ee-first": "1.1.1" } }, "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg=="],
"once": ["once@1.4.0", "", { "dependencies": { "wrappy": "1" } }, "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w=="],
"oniguruma-parser": ["oniguruma-parser@0.12.2", "", {}, "sha512-6HVa5oIrgMC6aA6WF6XyyqbhRPJrKR02L20+2+zpDtO5QAzGHAUGw5TKQvwi5vctNnRHkJYmjAhRVQF2EKdTQw=="],
"oniguruma-to-es": ["oniguruma-to-es@4.3.6", "", { "dependencies": { "oniguruma-parser": "^0.12.2", "regex": "^6.1.0", "regex-recursion": "^6.0.2" } }, "sha512-csuQ9x3Yr0cEIs/Zgx/OEt9iBw9vqIunAPQkx19R/fiMq2oGVTgcMqO/V3Ybqefr1TBvosI6jU539ksaBULJyA=="],
"open-color": ["open-color@1.9.1", "", {}, "sha512-vCseG/EQ6/RcvxhUcGJiHViOgrtz4x0XbZepXvKik66TMGkvbmjeJrKFyBEx6daG5rNyyd14zYXhz0hZVwQFOw=="],
"package-manager-detector": ["package-manager-detector@1.6.0", "", {}, "sha512-61A5ThoTiDG/C8s8UMZwSorAGwMJ0ERVGj2OjoW5pAalsNOg15+iQiPzrLJ4jhZ1HJzmC2PIHT2oEiH3R5fzNA=="],
"pako": ["pako@2.0.3", "", {}, "sha512-WjR1hOeg+kki3ZIOjaf4b5WVcay1jaliKSYiEaB1XzwhMQZJxRdQRv0V31EKBYlxb4T7SK3hjfc/jxyU64BoSw=="],
"parseurl": ["parseurl@1.3.3", "", {}, "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ=="],
"path-data-parser": ["path-data-parser@0.1.0", "", {}, "sha512-NOnmBpt5Y2RWbuv0LMzsayp3lVylAHLPUTut412ZA3l+C4uw4ZVkQbjShYCQ8TCpUMdPapr4YjUqLYD6v68j+w=="],
"path-key": ["path-key@3.1.1", "", {}, "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q=="],
"path-to-regexp": ["path-to-regexp@8.4.2", "", {}, "sha512-qRcuIdP69NPm4qbACK+aDogI5CBDMi1jKe0ry5rSQJz8JVLsC7jV8XpiJjGRLLol3N+R5ihGYcrPLTno6pAdBA=="],
"perfect-freehand": ["perfect-freehand@1.2.0", "", {}, "sha512-h/0ikF1M3phW7CwpZ5MMvKnfpHficWoOEyr//KVNTxV4F6deRK1eYMtHyBKEAKFK0aXIEUK9oBvlF6PNXMDsAw=="],
"pica": ["pica@7.1.1", "", { "dependencies": { "glur": "^1.1.2", "inherits": "^2.0.3", "multimath": "^2.0.0", "object-assign": "^4.1.1", "webworkify": "^1.5.0" } }, "sha512-WY73tMvNzXWEld2LicT9Y260L43isrZ85tPuqRyvtkljSDLmnNFQmZICt4xUJMVulmcc6L9O7jbBrtx3DOz/YQ=="],
"picomatch": ["picomatch@2.3.2", "", {}, "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA=="],
"picocolors": ["picocolors@1.1.1", "", {}, "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="],
"picomatch": ["picomatch@4.0.4", "", {}, "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A=="],
"pkce-challenge": ["pkce-challenge@5.0.1", "", {}, "sha512-wQ0b/W4Fr01qtpHlqSqspcj3EhBvimsdh0KlHhH8HRZnMsEa0ea2fTULOXOS9ccQr3om+GcGRk4e+isrZWV8qQ=="],
"png-chunk-text": ["png-chunk-text@1.0.0", "", {}, "sha512-DEROKU3SkkLGWNMzru3xPVgxyd48UGuMSZvioErCure6yhOc/pRH2ZV+SEn7nmaf7WNf3NdIpH+UTrRdKyq9Lw=="],
@@ -396,8 +615,22 @@
"points-on-path": ["points-on-path@0.2.1", "", { "dependencies": { "path-data-parser": "0.1.0", "points-on-curve": "0.2.0" } }, "sha512-25ClnWWuw7JbWZcgqY/gJ4FQWadKxGWk+3kR/7kD0tCaDtPPMj7oHu2ToLaVhfpnHrZzYby2w6tUA0eOIuUg8g=="],
"prettier": ["prettier@3.8.3", "", { "bin": { "prettier": "bin/prettier.cjs" } }, "sha512-7igPTM53cGHMW8xWuVTydi2KO233VFiTNyF5hLJqpilHfmn8C8gPf+PS7dUT64YcXFbiMGZxS9pCSxL/Dxm/Jw=="],
"pretty-quick": ["pretty-quick@4.2.2", "", { "dependencies": { "@pkgr/core": "^0.2.7", "ignore": "^7.0.5", "mri": "^1.2.0", "picocolors": "^1.1.1", "picomatch": "^4.0.2", "tinyexec": "^0.3.2", "tslib": "^2.8.1" }, "peerDependencies": { "prettier": "^3.0.0" }, "bin": { "pretty-quick": "lib/cli.mjs" } }, "sha512-uAh96tBW1SsD34VhhDmWuEmqbpfYc/B3j++5MC/6b3Cb8Ow7NJsvKFhg0eoGu2xXX+o9RkahkTK6sUdd8E7g5w=="],
"property-information": ["property-information@7.2.0", "", {}, "sha512-IAtzIB6sUiWaJYrX9smp3V46pBGbBeLFRGdh25kg1334VcBlD8HzhPeNIWQH9zhGmo2itIe25EHt9dQP7G5hmg=="],
"proxy-addr": ["proxy-addr@2.0.7", "", { "dependencies": { "forwarded": "0.2.0", "ipaddr.js": "1.9.1" } }, "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg=="],
"pwacompat": ["pwacompat@2.0.17", "", {}, "sha512-6Du7IZdIy7cHiv7AhtDy4X2QRM8IAD5DII69mt5qWibC2d15ZU8DmBG1WdZKekG11cChSu4zkSUGPF9sweOl6w=="],
"qs": ["qs@6.15.2", "", { "dependencies": { "side-channel": "^1.1.0" } }, "sha512-Rzq0KEyX/w/tEybncDgdkZrJgVUsUMk3xjh3t5bv3S1HTAtg+uOYt72+ZfwiQwKdysThkTBdL/rTi6HDmX9Ddw=="],
"range-parser": ["range-parser@1.2.1", "", {}, "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg=="],
"raw-body": ["raw-body@3.0.2", "", { "dependencies": { "bytes": "~3.1.2", "http-errors": "~2.0.1", "iconv-lite": "~0.7.0", "unpipe": "~1.0.0" } }, "sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA=="],
"react": ["react@19.2.6", "", {}, "sha512-sfWGGfavi0xr8Pg0sVsyHMAOziVYKgPLNrS7ig+ivMNb3wbCBw3KxtflsGBAwD3gYQlE/AEZsTLgToRrSCjb0Q=="],
"react-dom": ["react-dom@19.2.6", "", { "dependencies": { "scheduler": "^0.27.0" }, "peerDependencies": { "react": "^19.2.6" } }, "sha512-0prMI+hvBbPjsWnxDLxlCGyM8PN6UuWjEUCYmZhO67xIV9Xasa/r/vDnq+Xyq4Lo27g8QSbO5YzARu0D1Sps3g=="],
@@ -410,10 +643,22 @@
"readdirp": ["readdirp@3.6.0", "", { "dependencies": { "picomatch": "^2.2.1" } }, "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA=="],
"redis": ["redis@4.7.1", "", { "dependencies": { "@redis/bloom": "1.2.0", "@redis/client": "1.6.1", "@redis/graph": "1.1.1", "@redis/json": "1.0.7", "@redis/search": "1.2.0", "@redis/time-series": "1.1.0" } }, "sha512-S1bJDnqLftzHXHP8JsT5II/CtHWQrASX5K96REjWjlmWKrviSOLWmM7QnRLstAWsu1VBBV1ffV6DzCvxNP0UJQ=="],
"regex": ["regex@6.1.0", "", { "dependencies": { "regex-utilities": "^2.3.0" } }, "sha512-6VwtthbV4o/7+OaAF9I5L5V3llLEsoPyq9P1JVXkedTP33c7MfCG0/5NOPcSJn0TzXcG9YUrR0gQSWioew3LDg=="],
"regex-recursion": ["regex-recursion@6.0.2", "", { "dependencies": { "regex-utilities": "^2.3.0" } }, "sha512-0YCaSCq2VRIebiaUviZNs0cBz1kg5kVS2UKUfNIx8YVs1cN3AV7NTctO5FOKBA+UT2BPJIWZauYHPqJODG50cg=="],
"regex-utilities": ["regex-utilities@2.3.0", "", {}, "sha512-8VhliFJAWRaUiVvREIiW2NXXTmHs4vMNnSzuJVhscgmGav3g9VDxLrQndI3dZZVVdp0ZO/5v0xmX516/7M9cng=="],
"require-from-string": ["require-from-string@2.0.2", "", {}, "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw=="],
"robust-predicates": ["robust-predicates@3.0.3", "", {}, "sha512-NS3levdsRIUOmiJ8FZWCP7LG3QpJyrs/TE0Zpf1yvZu8cAJJ6QMW92H1c7kWpdIHo8RvmLxN/o2JXTKHp74lUA=="],
"roughjs": ["roughjs@4.6.4", "", { "dependencies": { "hachure-fill": "^0.5.2", "path-data-parser": "^0.1.0", "points-on-curve": "^0.2.0", "points-on-path": "^0.2.1" } }, "sha512-s6EZ0BntezkFYMf/9mGn7M8XGIoaav9QQBCnJROWB3brUWQ683Q2LbRD/hq0Z3bAJ/9NVpU/5LpiTWvQMyLDhw=="],
"router": ["router@2.2.0", "", { "dependencies": { "debug": "^4.4.0", "depd": "^2.0.0", "is-promise": "^4.0.0", "parseurl": "^1.3.3", "path-to-regexp": "^8.0.0" } }, "sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ=="],
"rw": ["rw@1.3.3", "", {}, "sha512-PdhdWy89SiZogBLaw42zdeqtRJ//zFd2PgQavcICDUgJT5oW10QCRKbJ6bg4r0/UY2M6BWd5tkxuGFRvCkgfHQ=="],
"safer-buffer": ["safer-buffer@2.1.2", "", {}, "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg=="],
@@ -422,30 +667,72 @@
"scheduler": ["scheduler@0.27.0", "", {}, "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q=="],
"send": ["send@1.2.1", "", { "dependencies": { "debug": "^4.4.3", "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "etag": "^1.8.1", "fresh": "^2.0.0", "http-errors": "^2.0.1", "mime-types": "^3.0.2", "ms": "^2.1.3", "on-finished": "^2.4.1", "range-parser": "^1.2.1", "statuses": "^2.0.2" } }, "sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ=="],
"serve-static": ["serve-static@2.2.1", "", { "dependencies": { "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "parseurl": "^1.3.3", "send": "^1.2.0" } }, "sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw=="],
"setprototypeof": ["setprototypeof@1.2.0", "", {}, "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw=="],
"shebang-command": ["shebang-command@2.0.0", "", { "dependencies": { "shebang-regex": "^3.0.0" } }, "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA=="],
"shebang-regex": ["shebang-regex@3.0.0", "", {}, "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A=="],
"shiki": ["shiki@4.1.0", "", { "dependencies": { "@shikijs/core": "4.1.0", "@shikijs/engine-javascript": "4.1.0", "@shikijs/engine-oniguruma": "4.1.0", "@shikijs/langs": "4.1.0", "@shikijs/themes": "4.1.0", "@shikijs/types": "4.1.0", "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" } }, "sha512-l/ABZPUR5v70jI10EzqfMS/I96vjSGv2y0ihUV+WYFzv0EfvW4s54m0Lg8wCrrL+2IkwBzFTuxkZjPf8b2NX9Q=="],
"side-channel": ["side-channel@1.1.0", "", { "dependencies": { "es-errors": "^1.3.0", "object-inspect": "^1.13.3", "side-channel-list": "^1.0.0", "side-channel-map": "^1.0.1", "side-channel-weakmap": "^1.0.2" } }, "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw=="],
"side-channel-list": ["side-channel-list@1.0.1", "", { "dependencies": { "es-errors": "^1.3.0", "object-inspect": "^1.13.4" } }, "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w=="],
"side-channel-map": ["side-channel-map@1.0.1", "", { "dependencies": { "call-bound": "^1.0.2", "es-errors": "^1.3.0", "get-intrinsic": "^1.2.5", "object-inspect": "^1.13.3" } }, "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA=="],
"side-channel-weakmap": ["side-channel-weakmap@1.0.2", "", { "dependencies": { "call-bound": "^1.0.2", "es-errors": "^1.3.0", "get-intrinsic": "^1.2.5", "object-inspect": "^1.13.3", "side-channel-map": "^1.0.1" } }, "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A=="],
"simple-git-hooks": ["simple-git-hooks@2.13.1", "", { "bin": { "simple-git-hooks": "cli.js" } }, "sha512-WszCLXwT4h2k1ufIXAgsbiTOazqqevFCIncOuUBZJ91DdvWcC5+OFkluWRQPrcuSYd8fjq+o2y1QfWqYMoAToQ=="],
"sliced": ["sliced@1.0.1", "", {}, "sha512-VZBmZP8WU3sMOZm1bdgTadsQbcscK0UM8oKxKVBs4XAhUo2Xxzm/OFMGBkPusxw9xL3Uy8LrzEqGqJhclsr0yA=="],
"source-map-js": ["source-map-js@1.2.1", "", {}, "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA=="],
"space-separated-tokens": ["space-separated-tokens@2.0.2", "", {}, "sha512-PEGlAwrG8yXGXRjW32fGbg66JAlOAwbObuqVoJpv/mRgoWDQfgH1wDPvtzWyUSNAXBGSk8h755YDbbcEy3SH2Q=="],
"statuses": ["statuses@2.0.2", "", {}, "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw=="],
"stringify-entities": ["stringify-entities@4.0.4", "", { "dependencies": { "character-entities-html4": "^2.0.0", "character-entities-legacy": "^3.0.0" } }, "sha512-IwfBptatlO+QCJUo19AqvrPNqlVMpW9YEL2LIVY+Rpv2qsjCGxaDLNRgeGsQWJhfItebuJhsGSLjaBbNSQ+ieg=="],
"stylis": ["stylis@4.4.0", "", {}, "sha512-5Z9ZpRzfuH6l/UAvCPAPUo3665Nk2wLaZU3x+TLHKVzIz33+sbJqbtrYoC3KD4/uVOr2Zp+L0LySezP9OHV9yA=="],
"tinyexec": ["tinyexec@1.2.2", "", {}, "sha512-M/Q0B2cp4K7kynaT/vnED1j8TlLY+Pp7C6Wl2bl/7u/F0mUVwdyOpwomQb8JpYLitHUssAJRmLZdMCGsrx7i+g=="],
"tinyexec": ["tinyexec@0.3.2", "", {}, "sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA=="],
"to-regex-range": ["to-regex-range@5.0.1", "", { "dependencies": { "is-number": "^7.0.0" } }, "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ=="],
"toidentifier": ["toidentifier@1.0.1", "", {}, "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA=="],
"trim-lines": ["trim-lines@3.0.1", "", {}, "sha512-kRj8B+YHZCc9kQYdWfJB2/oUl9rA99qbowYYBtr4ui4mZyAQ2JpvVBd/6U2YloATfqBhBTSMhTpgBHtU0Mf3Rg=="],
"ts-dedent": ["ts-dedent@2.2.0", "", {}, "sha512-q5W7tVM71e2xjHZTlgfTDoPF/SmqKG5hddq9SzR49CH2hayqRKJtQ4mtRlSxKaJlR/+9rEM+mnBHf7I2/BQcpQ=="],
"tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="],
"tunnel-rat": ["tunnel-rat@0.1.2", "", { "dependencies": { "zustand": "^4.3.2" } }, "sha512-lR5VHmkPhzdhrM092lI2nACsLO4QubF0/yoOhzX7c+wIpbN1GjHNzCc91QlpxBi+cnx8vVJ+Ur6vL5cEoQPFpQ=="],
"type-is": ["type-is@2.1.0", "", { "dependencies": { "content-type": "^2.0.0", "media-typer": "^1.1.0", "mime-types": "^3.0.0" } }, "sha512-faYHw0anBbc/kWF3zFTEnxSFOAGUX9GFbOBthvDdLsIlEoWOFOtS0zgCiQYwIskL9iGXZL3kAXD8OoZ4GmMATA=="],
"typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="],
"undici-types": ["undici-types@7.24.6", "", {}, "sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg=="],
"unist-util-is": ["unist-util-is@6.0.1", "", { "dependencies": { "@types/unist": "^3.0.0" } }, "sha512-LsiILbtBETkDz8I9p1dQ0uyRUWuaQzd/cuEeS1hoRSyW5E5XGmTzlwY1OrNzzakGowI9Dr/I8HVaw4hTtnxy8g=="],
"unist-util-position": ["unist-util-position@5.0.0", "", { "dependencies": { "@types/unist": "^3.0.0" } }, "sha512-fucsC7HjXvkB5R3kTCO7kUjRdrS0BJt3M/FPxmHMBOm8JQi2BsHAHFsy27E0EolP8rp0NzXsJ+jNPyDWvOJZPA=="],
"unist-util-stringify-position": ["unist-util-stringify-position@4.0.0", "", { "dependencies": { "@types/unist": "^3.0.0" } }, "sha512-0ASV06AAoKCDkS2+xw5RXJywruurpbC4JZSm7nr7MOt1ojAzvyyaO+UxZf18j8FCF6kmzCZKcAgN/yu2gm2XgQ=="],
"unist-util-visit": ["unist-util-visit@5.1.0", "", { "dependencies": { "@types/unist": "^3.0.0", "unist-util-is": "^6.0.0", "unist-util-visit-parents": "^6.0.0" } }, "sha512-m+vIdyeCOpdr/QeQCu2EzxX/ohgS8KbnPDgFni4dQsfSCtpz8UqDyY5GjRru8PDKuYn7Fq19j1CQ+nJSsGKOzg=="],
"unist-util-visit-parents": ["unist-util-visit-parents@6.0.2", "", { "dependencies": { "@types/unist": "^3.0.0", "unist-util-is": "^6.0.0" } }, "sha512-goh1s1TBrqSqukSc8wrjwWhL0hiJxgA8m4kFxGlQ+8FYQ3C/m11FcTs4YYem7V664AhHVvgoQLk890Ssdsr2IQ=="],
"unpipe": ["unpipe@1.0.0", "", {}, "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ=="],
"use-callback-ref": ["use-callback-ref@1.3.3", "", { "dependencies": { "tslib": "^2.0.0" }, "peerDependencies": { "@types/react": "*", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-jQL3lRnocaFtu3V00JToYz/4QkNWswxijDaCVNZRiRTO3HQDLsdu1ZtmIUvV4yPp+rvWm5j0y0TG/S61cuijTg=="],
"use-sidecar": ["use-sidecar@1.1.3", "", { "dependencies": { "detect-node-es": "^1.1.0", "tslib": "^2.0.0" }, "peerDependencies": { "@types/react": "*", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-Fedw0aZvkhynoPYlA5WXrMCAMm+nSWdZt6lzJQ7Ok8S6Q+VsHmHpRWndVRJ8Be0ZbkfPc5LRYH+5XrzXcEeLRQ=="],
@@ -454,6 +741,12 @@
"uuid": ["uuid@14.0.0", "", { "bin": { "uuid": "dist-node/bin/uuid" } }, "sha512-Qo+uWgilfSmAhXCMav1uYFynlQO7fMFiMVZsQqZRMIXp0O7rR7qjkj+cPvBHLgBqi960QCoo/PH2/6ZtVqKvrg=="],
"vary": ["vary@1.1.2", "", {}, "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg=="],
"vfile": ["vfile@6.0.3", "", { "dependencies": { "@types/unist": "^3.0.0", "vfile-message": "^4.0.0" } }, "sha512-KzIbH/9tXat2u30jf+smMwFCsno4wHVdNmzFyL+T/L3UGqqk6JKfVqOFOZEpZSHADH1k40ab6NUIXZq422ov3Q=="],
"vfile-message": ["vfile-message@4.0.3", "", { "dependencies": { "@types/unist": "^3.0.0", "unist-util-stringify-position": "^4.0.0" } }, "sha512-QTHzsGd1EhbZs4AsQ20JX1rC3cOlt/IWJruk893DfLRr57lcnOeMaWG4K0JrRta4mIJZKth2Au3mM3u03/JWKw=="],
"vscode-jsonrpc": ["vscode-jsonrpc@8.2.0", "", {}, "sha512-C+r0eKJUIfiDIfwJhria30+TYWPtuHJXHtI7J0YlOmKAo7ogxP20T0zxB7HZQIFhIyvoBPwWskjxrvAtfjyZfA=="],
"vscode-languageserver": ["vscode-languageserver@9.0.1", "", { "dependencies": { "vscode-languageserver-protocol": "3.17.5" }, "bin": { "installServerIntoExtension": "bin/installServerIntoExtension" } }, "sha512-woByF3PDpkHFUreUa7Hos7+pUWdeWMXRd26+ZX2A8cFx6v/JPTtd4/uN0/jB6XQHYaOlHbio03NTHCqrgG5n7g=="],
@@ -470,8 +763,20 @@
"which": ["which@2.0.2", "", { "dependencies": { "isexe": "^2.0.0" }, "bin": { "node-which": "./bin/node-which" } }, "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA=="],
"wrappy": ["wrappy@1.0.2", "", {}, "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ=="],
"yallist": ["yallist@4.0.0", "", {}, "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A=="],
"zod": ["zod@4.4.3", "", {}, "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ=="],
"zod-to-json-schema": ["zod-to-json-schema@3.25.2", "", { "peerDependencies": { "zod": "^3.25.28 || ^4" } }, "sha512-O/PgfnpT1xKSDeQYSCfRI5Gy3hPf91mKVDuYLUHZJMiDFptvP41MSnWofm8dnCm0256ZNfZIM7DSzuSMAFnjHA=="],
"zustand": ["zustand@4.5.7", "", { "dependencies": { "use-sync-external-store": "^1.2.2" }, "peerDependencies": { "@types/react": ">=16.8", "immer": ">=9.0.6", "react": ">=16.8" }, "optionalPeers": ["@types/react", "immer", "react"] }, "sha512-CHOUy7mu3lbD6o6LJLfllpjkzhHXSBlX8B9+qPddUsIfeF5S/UZ5q0kmCsnRqT1UHFQZchNFDDzMbQsuesHWlw=="],
"zwitch": ["zwitch@2.0.4", "", {}, "sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A=="],
"@antfu/install-pkg/tinyexec": ["tinyexec@1.2.2", "", {}, "sha512-M/Q0B2cp4K7kynaT/vnED1j8TlLY+Pp7C6Wl2bl/7u/F0mUVwdyOpwomQb8JpYLitHUssAJRmLZdMCGsrx7i+g=="],
"@chevrotain/cst-dts-gen/@chevrotain/types": ["@chevrotain/types@11.0.3", "", {}, "sha512-gsiM3G8b58kZC2HaWR50gu6Y1440cHiJ+i3JUvcp/35JchYejb2+5MVeJK0iKThYpAa/P2PYFV4hoi44HD+aHQ=="],
"@chevrotain/cst-dts-gen/lodash-es": ["lodash-es@4.17.21", "", {}, "sha512-mKnC+QJ9pWVzv+C4/U3rRsHapFfHvQFoFB92e52xeyGMcX6/OlIl78je1u8vePzYZSkkogMPJ2yjxxsb89cxyw=="],
@@ -516,6 +821,8 @@
"@radix-ui/react-tabs/@radix-ui/react-use-controllable-state": ["@radix-ui/react-use-controllable-state@1.0.0", "", { "dependencies": { "@babel/runtime": "^7.13.10", "@radix-ui/react-use-callback-ref": "1.0.0" }, "peerDependencies": { "react": "^16.8 || ^17.0 || ^18.0" } }, "sha512-FohDoZvk3mEXh9AWAVyRTYR4Sq7/gavuofglmiXB2g1aKyboUD4YtgWxKj8O5n+Uak52gXQ4wKz5IFST4vtJHg=="],
"anymatch/picomatch": ["picomatch@2.3.2", "", {}, "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA=="],
"chevrotain/@chevrotain/types": ["@chevrotain/types@11.0.3", "", {}, "sha512-gsiM3G8b58kZC2HaWR50gu6Y1440cHiJ+i3JUvcp/35JchYejb2+5MVeJK0iKThYpAa/P2PYFV4hoi44HD+aHQ=="],
"chevrotain/lodash-es": ["lodash-es@4.17.21", "", {}, "sha512-mKnC+QJ9pWVzv+C4/U3rRsHapFfHvQFoFB92e52xeyGMcX6/OlIl78je1u8vePzYZSkkogMPJ2yjxxsb89cxyw=="],
@@ -524,10 +831,14 @@
"d3-dsv/commander": ["commander@7.2.0", "", {}, "sha512-QrWXB+ZQSVPmIWIhtEO9H+gwHaMGYiF5ChvoJ+K9ZGHG/sVsa6yiesAD1GC/x46sET00Xlwo1u49RVVVzvcSkw=="],
"d3-dsv/iconv-lite": ["iconv-lite@0.6.3", "", { "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" } }, "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw=="],
"d3-sankey/d3-array": ["d3-array@2.12.1", "", { "dependencies": { "internmap": "^1.0.0" } }, "sha512-B0ErZK/66mHtEsR1TkPEEkwdy+WDesimkM5gpZr5Dsg54BiTA5RXtYW5qTLIAcekaS9xfZrzBLF/OAkB3Qn1YQ=="],
"d3-sankey/d3-shape": ["d3-shape@1.3.7", "", { "dependencies": { "d3-path": "1" } }, "sha512-EUkvKjqPFUAZyOlhY5gzCxCeI0Aep04LwIRpsZ/mLFelJiUfnK56jo5JMDSE7yyP2kLSb6LtF+S5chMk7uqPqw=="],
"katex/commander": ["commander@8.3.0", "", {}, "sha512-OkTL9umf+He2DZkUq8f8J9of7yL6RJKI24dVITBmNfZBmri9zYZQrKkuXiKhyfPSu8tUhnVBB1iKXevvnlR4Ww=="],
"mermaid/@braintree/sanitize-url": ["@braintree/sanitize-url@7.1.2", "", {}, "sha512-jigsZK+sMF/cuiB7sERuo9V7N9jx+dhmHHnQyDSVdpZwVutaBu7WvNYqMDLSgFgfB30n452TP3vjDAvFC973mA=="],
"mermaid/@mermaid-js/parser": ["@mermaid-js/parser@1.1.1", "", { "dependencies": { "@chevrotain/types": "~11.1.1" } }, "sha512-VuHdsYMK1bT6X2JbcAaWAhugTRvRBRyuZgd+c22swUeI9g/ntaxF7CY7dYarhZovofCbUNO0G7JesfmNtjYOCw=="],
@@ -536,8 +847,12 @@
"points-on-path/points-on-curve": ["points-on-curve@0.2.0", "", {}, "sha512-0mYKnYYe9ZcqMCWhUjItv/oHjvgEsfKvnUTg8sAtnHr3GVy7rGkXCb6d5cSyqrWqL4k81b9CPg3urd+T7aop3A=="],
"readdirp/picomatch": ["picomatch@2.3.2", "", {}, "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA=="],
"roughjs/points-on-curve": ["points-on-curve@0.2.0", "", {}, "sha512-0mYKnYYe9ZcqMCWhUjItv/oHjvgEsfKvnUTg8sAtnHr3GVy7rGkXCb6d5cSyqrWqL4k81b9CPg3urd+T7aop3A=="],
"type-is/content-type": ["content-type@2.0.0", "", {}, "sha512-j/O/d7GcZCyNl7/hwZAb606rzqkyvaDctLmckbxLzHvFBzTJHuGEdodATcP3yIRoDrLHkIATJuvzbFlp/ki2cQ=="],
"@radix-ui/react-roving-focus/@radix-ui/react-id/@radix-ui/react-use-layout-effect": ["@radix-ui/react-use-layout-effect@1.0.0", "", { "dependencies": { "@babel/runtime": "^7.13.10" }, "peerDependencies": { "react": "^16.8 || ^17.0 || ^18.0" } }, "sha512-6Tpkq+R6LOlmQb1R5NNETLG0B4YP0wc+klfXafpUCj6JGyaUc8il7/kUZ7m59rGbXGczE9Bs+iz2qloqsZBduQ=="],
"@radix-ui/react-roving-focus/@radix-ui/react-primitive/@radix-ui/react-slot": ["@radix-ui/react-slot@1.0.1", "", { "dependencies": { "@babel/runtime": "^7.13.10", "@radix-ui/react-compose-refs": "1.0.0" }, "peerDependencies": { "react": "^16.8 || ^17.0 || ^18.0" } }, "sha512-avutXAFL1ehGvAXtPquu0YK5oz6ctS474iM3vNGQIkswrVhdrS52e3uoMQBzZhNRAIE0jBnUyXWNmSjGHhCFcw=="],
+16 -6
View File
@@ -1,11 +1,12 @@
#!/bin/sh
set -eu
bun_pid=""
app_pid=""
mcp_pid=""
caddy_pid=""
stop_children() {
for pid in "$bun_pid" "$caddy_pid"; do
for pid in "$app_pid" "$mcp_pid" "$caddy_pid"; do
if [ -n "$pid" ] && kill -0 "$pid" 2>/dev/null; then
kill "$pid" 2>/dev/null || true
fi
@@ -15,7 +16,10 @@ stop_children() {
trap 'stop_children' INT TERM
bun /app/dist/server/server.js &
bun_pid=$!
app_pid=$!
bun /app/dist/mcp/mcp-server.js &
mcp_pid=$!
caddy run --config /etc/caddy/Caddyfile --adapter caddyfile &
caddy_pid=$!
@@ -23,8 +27,13 @@ caddy_pid=$!
exit_code=0
while :; do
if ! kill -0 "$bun_pid" 2>/dev/null; then
wait "$bun_pid" || exit_code=$?
if ! kill -0 "$app_pid" 2>/dev/null; then
wait "$app_pid" || exit_code=$?
break
fi
if ! kill -0 "$mcp_pid" 2>/dev/null; then
wait "$mcp_pid" || exit_code=$?
break
fi
@@ -37,7 +46,8 @@ while :; do
done
stop_children
wait "$bun_pid" 2>/dev/null || true
wait "$app_pid" 2>/dev/null || true
wait "$mcp_pid" 2>/dev/null || true
wait "$caddy_pid" 2>/dev/null || true
exit "$exit_code"
+22 -5
View File
@@ -3,23 +3,40 @@
"type": "module",
"private": true,
"scripts": {
"dev": "bun --hot src/dev-server.tsx",
"build": "rm -rf dist && bun run build:client && bun run build:server",
"build:client": "bun build --target=browser --production --outdir ./dist/public ./src/index.html",
"build:server": "mkdir -p ./dist/server && bun build --target=bun --production --outfile ./dist/server/server.js ./src/server.tsx",
"dev": "bun --hot src/server/dev-server.tsx",
"build": "rm -rf dist && bun run build:client && bun run build:server && bun run build:mcp",
"build:client": "bun build --target=browser --production --outdir ./dist/public ./src/client/index.html",
"build:server": "mkdir -p ./dist/server && bun build --target=bun --production --outfile ./dist/server/server.js ./src/server/http-server.tsx",
"build:mcp": "mkdir -p ./dist/mcp && bun build --target=bun --production --outfile ./dist/mcp/mcp-server.js ./src/mcp/server.ts",
"format": "prettier --write .",
"postinstall": "simple-git-hooks",
"start": "DATABASE_PATH=./data/excalidraw.sqlite bun ./dist/server/server.js",
"test": "bun test",
"typecheck": "tsc --noEmit"
},
"simple-git-hooks": {
"pre-commit": "bunx pretty-quick --staged"
},
"dependencies": {
"@excalidraw/excalidraw": "^0.18.1",
"@modelcontextprotocol/sdk": "^1.29.0",
"fast-json-patch": "^3.1.1",
"mcp-handler": "^1.1.0",
"react": "^19.2.6",
"react-dom": "^19.2.6"
"react-dom": "^19.2.6",
"shiki": "^4.1.0",
"zod": "^4.4.3"
},
"devDependencies": {
"@types/bun": "^1.3.14",
"@types/react": "^19.2.15",
"@types/react-dom": "^19.2.3",
"prettier": "^3.8.3",
"pretty-quick": "^4.2.2",
"simple-git-hooks": "^2.13.1",
"typescript": "^5.9.3"
},
"patchedDependencies": {
"@excalidraw/excalidraw@0.18.1": "patches/@excalidraw%2Fexcalidraw@0.18.1.patch"
}
}
File diff suppressed because one or more lines are too long
-512
View File
@@ -1,512 +0,0 @@
import "../node_modules/@excalidraw/excalidraw/dist/prod/index.css";
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() {
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 (
<div className="app-shell" ref={appShellRef}>
<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>
);
}
-82
View File
@@ -1,82 +0,0 @@
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
View File
@@ -1,119 +0,0 @@
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 });
},
};
}
+392
View File
@@ -0,0 +1,392 @@
import {
useCallback,
useEffect,
useEffectEvent,
useRef,
useState,
} from "react";
import { CaptureUpdateAction } from "@excalidraw/excalidraw";
import type { ExcalidrawImperativeAPI } from "@excalidraw/excalidraw/types";
import { DrawingSidebar, DrawingsToggle } from "./components/DrawingSidebar";
import {
CodeBlockSidebar,
InsertCodeBlockButton,
} from "./components/CodeBlockSidebar";
import { renderCodeBlockEmbeddable } from "./components/CodeBlockEmbeddable";
import { EditorCanvas } from "./components/EditorCanvas";
import { PublicViewer } from "./components/PublicViewer";
import {
createCodeBlockElement,
DEFAULT_CODE_BLOCK_HEIGHT,
DEFAULT_CODE_BLOCK_WIDTH,
EMPTY_CODE_BLOCK_SELECTION_STATE,
updateCodeBlockElements,
type CodeBlockDraftState,
type CodeBlockLanguage,
type CodeBlockSelectionState,
} from "./codeblock";
import { usePublicDrawing } from "./hooks/usePublicDrawing";
import { useDrawingSession } from "./hooks/useDrawingSession";
import { useThemeTokenSync } from "./hooks/useThemeTokenSync";
type AppRoute = { type: "private" } | { type: "public"; slug: string };
function routeFromPath(pathname = window.location.pathname): AppRoute {
if (!pathname.startsWith("/p/")) {
return { type: "private" };
}
const slug = pathname.slice(3);
if (slug.length === 0 || slug.includes("/")) {
return { type: "private" };
}
return { type: "public", slug: decodeURIComponent(slug) };
}
function PrivateApp() {
const [isSidebarOpen, setIsSidebarOpen] = useState(false);
const [codeBlockSelection, setCodeBlockSelection] =
useState<CodeBlockSelectionState>(EMPTY_CODE_BLOCK_SELECTION_STATE);
const [codeBlockDraft, setCodeBlockDraft] =
useState<CodeBlockDraftState | null>(null);
const appShellRef = useRef<HTMLDivElement | null>(null);
const excalidrawApiRef = useRef<ExcalidrawImperativeAPI | null>(null);
const codeBlockDraftRef = useRef<CodeBlockDraftState | null>(null);
const scheduleThemeTokenSync = useThemeTokenSync(appShellRef);
const {
drawings,
activeId,
activeTitle,
scene,
loading,
error,
toastMessage,
editorReloadNonce,
publication,
publicationSlug,
publicationBusy,
setActiveTitle,
submitTitle,
handleSceneChange,
selectDrawing,
createDrawing,
deleteDrawing,
setPublicationSlug,
publishPublication,
disablePublication,
} = useDrawingSession();
const flushCodeBlockDraft = useEffectEvent(() => {
const api = excalidrawApiRef.current;
const draft = codeBlockDraftRef.current;
if (!api || !draft) {
return;
}
const currentElements = api.getSceneElementsIncludingDeleted();
const nextElements = updateCodeBlockElements(
currentElements,
draft.elementId,
{
code: draft.code,
language: draft.language,
showBackground: draft.showBackground,
},
);
if (nextElements === currentElements) {
return;
}
api.updateScene({
elements: nextElements,
captureUpdate: CaptureUpdateAction.IMMEDIATELY,
});
});
useEffect(() => {
codeBlockDraftRef.current = codeBlockDraft;
}, [codeBlockDraft]);
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]);
useEffect(() => {
return () => {
flushCodeBlockDraft();
};
}, []);
useEffect(() => {
const selectedCodeBlock = codeBlockSelection.selectedCodeBlock;
setCodeBlockDraft((current) => {
const nextDraft = selectedCodeBlock
? {
elementId: selectedCodeBlock.id,
code: selectedCodeBlock.customData.code,
language: selectedCodeBlock.customData.language,
showBackground: selectedCodeBlock.customData.showBackground,
}
: null;
if (
current?.elementId === nextDraft?.elementId &&
current?.code === nextDraft?.code &&
current?.language === nextDraft?.language &&
current?.showBackground === nextDraft?.showBackground
) {
return current;
}
return nextDraft;
});
return () => {
flushCodeBlockDraft();
};
}, [codeBlockSelection.selectedCodeBlockId]);
useEffect(() => {
if (!codeBlockDraft) {
return;
}
const timeoutId = window.setTimeout(() => {
flushCodeBlockDraft();
}, 250);
return () => {
clearTimeout(timeoutId);
};
}, [codeBlockDraft]);
useEffect(() => {
if (loading) {
setCodeBlockSelection(EMPTY_CODE_BLOCK_SELECTION_STATE);
setCodeBlockDraft(null);
}
}, [loading]);
const openSidebar = useCallback(() => {
setIsSidebarOpen(true);
}, []);
const closeSidebar = useCallback(() => {
setIsSidebarOpen(false);
}, []);
const handleSelectDrawing = useCallback(
(drawingId: string) => {
setIsSidebarOpen(false);
void selectDrawing(drawingId);
},
[selectDrawing],
);
const handleCreateDrawing = useCallback(() => {
setIsSidebarOpen(false);
void createDrawing();
}, [createDrawing]);
const handleInsertCodeBlock = useCallback(() => {
const api = excalidrawApiRef.current;
if (!api) {
return;
}
const appState = api.getAppState();
const viewportWidth =
typeof appState.width === "number" ? appState.width : window.innerWidth;
const viewportHeight =
typeof appState.height === "number"
? appState.height
: window.innerHeight;
const zoom = appState.zoom.value;
const x =
-appState.scrollX +
viewportWidth / (2 * zoom) -
DEFAULT_CODE_BLOCK_WIDTH / 2;
const y =
-appState.scrollY +
viewportHeight / (2 * zoom) -
DEFAULT_CODE_BLOCK_HEIGHT / 2;
const codeBlock = createCodeBlockElement({ x, y });
api.updateScene({
elements: [...api.getSceneElementsIncludingDeleted(), codeBlock],
appState: {
selectedElementIds: {
[codeBlock.id]: true,
},
},
captureUpdate: CaptureUpdateAction.IMMEDIATELY,
});
}, []);
const handleCodeBlockSelectionChange = useCallback(
(selection: CodeBlockSelectionState) => {
setCodeBlockSelection((current) => {
if (
current.isPanelOpen === selection.isPanelOpen &&
current.selectedCodeBlockId === selection.selectedCodeBlockId &&
current.selectedCodeBlock?.customData.code ===
selection.selectedCodeBlock?.customData.code &&
current.selectedCodeBlock?.customData.language ===
selection.selectedCodeBlock?.customData.language &&
current.selectedCodeBlock?.customData.showBackground ===
selection.selectedCodeBlock?.customData.showBackground
) {
return current;
}
return selection;
});
},
[],
);
const handleCodeBlockCodeChange = useCallback((code: string) => {
setCodeBlockDraft((current) =>
current
? {
...current,
code,
}
: current,
);
}, []);
const handleCodeBlockLanguageChange = useCallback(
(language: CodeBlockLanguage) => {
setCodeBlockDraft((current) =>
current
? {
...current,
language,
}
: current,
);
},
[],
);
const handleCodeBlockShowBackgroundChange = useCallback(
(showBackground: boolean) => {
setCodeBlockDraft((current) =>
current
? {
...current,
showBackground,
}
: current,
);
},
[],
);
return (
<div className="app-shell" ref={appShellRef}>
{toastMessage && (
<div className="toast-overlay" aria-live="polite">
{toastMessage}
</div>
)}
<InsertCodeBlockButton
onClick={handleInsertCodeBlock}
disabled={loading || scene === null}
/>
<main className="editor-shell">
<div className="app-actions">
<DrawingsToggle onClick={openSidebar} />
</div>
<EditorCanvas
activeId={activeId}
scene={scene}
loading={loading}
error={error}
editorReloadNonce={editorReloadNonce}
onSceneChange={handleSceneChange}
onSelectionStateChange={handleCodeBlockSelectionChange}
onEditorActivity={scheduleThemeTokenSync}
onExcalidrawAPI={(api) => {
excalidrawApiRef.current = api;
}}
renderEmbeddable={renderCodeBlockEmbeddable}
/>
<CodeBlockSidebar
open={codeBlockSelection.isPanelOpen}
draft={codeBlockDraft}
onCodeChange={handleCodeBlockCodeChange}
onLanguageChange={handleCodeBlockLanguageChange}
onShowBackgroundChange={handleCodeBlockShowBackgroundChange}
onCommit={flushCodeBlockDraft}
/>
</main>
<DrawingSidebar
open={isSidebarOpen}
drawings={drawings}
activeId={activeId}
activeTitle={activeTitle}
publication={publication}
publicationSlug={publicationSlug}
publicationBusy={publicationBusy}
onClose={closeSidebar}
onCreate={handleCreateDrawing}
onSelect={handleSelectDrawing}
onDelete={(drawingId) => void deleteDrawing(drawingId)}
onTitleChange={setActiveTitle}
onTitleSubmit={() => void submitTitle()}
onPublicationSlugChange={setPublicationSlug}
onPublish={() => void publishPublication()}
onDisablePublication={() => void disablePublication()}
/>
</div>
);
}
function PublicApp({ slug }: { slug: string }) {
const { drawing, loading, error } = usePublicDrawing(slug);
return (
<PublicViewer
drawing={drawing}
loading={loading}
error={error}
renderEmbeddable={renderCodeBlockEmbeddable}
/>
);
}
export function App() {
const route = routeFromPath();
if (route.type === "public") {
return <PublicApp slug={route.slug} />;
}
return <PrivateApp />;
}
+202
View File
@@ -0,0 +1,202 @@
import { describe, expect, test } from "bun:test";
import {
DEFAULT_CODE_BLOCK_SHOW_BACKGROUND,
EMPTY_CODE_BLOCK_SELECTION_STATE,
createCodeBlockCustomData,
getCodeBlockSelectionState,
getCodeBlockCustomData,
isCodeBlockEmbeddable,
sanitizeCodeBlockLanguage,
updateCodeBlockElements,
} from "./codeblock";
const baseEmbeddable = {
id: "codeblock-1",
type: "embeddable" as const,
x: 10,
y: 20,
strokeColor: "transparent",
backgroundColor: "transparent",
fillStyle: "solid" as const,
strokeWidth: 1,
strokeStyle: "solid" as const,
roundness: null,
roughness: 0,
opacity: 100,
width: 420,
height: 260,
angle: 0,
seed: 1,
version: 1,
versionNonce: 2,
index: null,
isDeleted: false,
groupIds: [],
frameId: null,
boundElements: null,
updated: 1,
link: null,
locked: false,
};
describe("codeblock helpers", () => {
test("type guard accepts only repo-owned codeblock embeddables", () => {
expect(
isCodeBlockEmbeddable({
...baseEmbeddable,
customData: createCodeBlockCustomData(),
}),
).toBe(true);
expect(
isCodeBlockEmbeddable({
...baseEmbeddable,
customData: {
excaliType: "other",
version: 2,
code: "",
language: "tsx",
showBackground: true,
},
}),
).toBe(false);
expect(
isCodeBlockEmbeddable({
...baseEmbeddable,
link: "https://example.com",
customData: createCodeBlockCustomData(),
}),
).toBe(false);
expect(
isCodeBlockEmbeddable({
...baseEmbeddable,
type: "iframe",
customData: createCodeBlockCustomData(),
}),
).toBe(false);
expect(
isCodeBlockEmbeddable({
...baseEmbeddable,
customData: {
excaliType: "codeblock",
version: 1,
code: "",
language: "tsx",
showBackground: true,
},
}),
).toBe(false);
});
test("create helper defaults background to enabled", () => {
expect(createCodeBlockCustomData().showBackground).toBe(
DEFAULT_CODE_BLOCK_SHOW_BACKGROUND,
);
});
test("update helper preserves unrelated fields and non-codeblock embeddables", () => {
const codeBlock = {
...baseEmbeddable,
customData: createCodeBlockCustomData({ code: "const a = 1;" }),
};
const foreignEmbeddable = {
...baseEmbeddable,
id: "embed-2",
customData: { source: "foreign" },
};
const elements = [codeBlock, foreignEmbeddable];
const nextElements = updateCodeBlockElements(elements, "codeblock-1", {
code: "const a = 2;",
language: "ts",
showBackground: false,
});
expect(nextElements).not.toBe(elements);
expect(nextElements[0]).not.toBe(codeBlock);
expect(nextElements[0]).toMatchObject({
id: "codeblock-1",
x: 10,
y: 20,
width: 420,
customData: {
excaliType: "codeblock",
version: 2,
code: "const a = 2;",
language: "typescript",
showBackground: false,
},
});
expect(nextElements[1]).toBe(foreignEmbeddable);
expect(
updateCodeBlockElements(elements, "embed-2", { code: "ignored" }),
).toBe(elements);
});
test("selection helper opens only for a single selected codeblock", () => {
const codeBlock = {
...baseEmbeddable,
customData: createCodeBlockCustomData(),
};
const other = {
...baseEmbeddable,
id: "shape-2",
type: "rectangle" as const,
};
expect(
getCodeBlockSelectionState([codeBlock, other], {
selectedElementIds: { "codeblock-1": true },
}),
).toMatchObject({
isPanelOpen: true,
selectedCodeBlockId: "codeblock-1",
selectedCodeBlock: {
customData: {
showBackground: true,
},
},
});
expect(
getCodeBlockSelectionState([codeBlock, other], {
selectedElementIds: { "codeblock-1": true, "shape-2": true },
}),
).toEqual(EMPTY_CODE_BLOCK_SELECTION_STATE);
expect(
getCodeBlockSelectionState([codeBlock, other], {
selectedElementIds: { "shape-2": true },
}),
).toEqual(EMPTY_CODE_BLOCK_SELECTION_STATE);
});
test("language sanitization accepts shiki aliases and normalizes them", () => {
expect(sanitizeCodeBlockLanguage("ts")).toBe("typescript");
expect(sanitizeCodeBlockLanguage("js")).toBe("javascript");
expect(sanitizeCodeBlockLanguage("md")).toBe("markdown");
expect(sanitizeCodeBlockLanguage("bash")).toBe("shellscript");
expect(sanitizeCodeBlockLanguage("plaintext")).toBe("plaintext");
});
test("custom data reads normalize persisted shiki aliases", () => {
const codeBlock = {
...baseEmbeddable,
customData: {
excaliType: "codeblock" as const,
version: 2 as const,
code: "echo hi",
language: "bash" as const,
showBackground: true,
},
};
expect(getCodeBlockCustomData(codeBlock)).toMatchObject({
code: "echo hi",
language: "shellscript",
});
});
});
+327
View File
@@ -0,0 +1,327 @@
import type { AppState } from "@excalidraw/excalidraw/types";
import type {
ExcalidrawEmbeddableElement,
ExcalidrawElement,
NonDeleted,
} from "@excalidraw/excalidraw/element/types";
import {
bundledLanguages,
bundledLanguagesInfo,
type BundledLanguage,
} from "shiki";
export type CodeBlockLanguage = "plaintext" | BundledLanguage;
export type CodeBlockCustomData = {
excaliType: "codeblock";
version: 2;
code: string;
language: CodeBlockLanguage;
showBackground: boolean;
};
export type CodeBlockElement = NonDeleted<ExcalidrawEmbeddableElement> & {
customData: CodeBlockCustomData;
};
export type CodeBlockSelectionState = {
isPanelOpen: boolean;
selectedCodeBlock: CodeBlockElement | null;
selectedCodeBlockId: string | null;
};
export type CodeBlockDraftState = {
elementId: string;
code: string;
language: CodeBlockLanguage;
showBackground: boolean;
};
const SHIKI_LANGUAGE_SET = new Set<string>(Object.keys(bundledLanguages));
const SHIKI_LANGUAGE_ALIAS_TO_ID = new Map<string, BundledLanguage>(
bundledLanguagesInfo.flatMap(({ id, aliases }) =>
(aliases ?? []).map((alias) => [alias, id as BundledLanguage] as const),
),
);
export const CODE_BLOCK_LANGUAGES = Object.freeze([
"plaintext",
...bundledLanguagesInfo
.map(({ id }) => id as BundledLanguage)
.sort((left, right) => left.localeCompare(right)),
]) as readonly CodeBlockLanguage[];
export const DEFAULT_CODE_BLOCK_LANGUAGE: CodeBlockLanguage = "tsx";
export const DEFAULT_CODE_BLOCK_CODE = [
"export function Example() {",
' return <button type="button">Click me</button>;',
"}",
].join("\n");
export const DEFAULT_CODE_BLOCK_WIDTH = 420;
export const DEFAULT_CODE_BLOCK_HEIGHT = 260;
export const CODE_BLOCK_SHIKI_THEME = "poimandres";
export const DEFAULT_CODE_BLOCK_SHOW_BACKGROUND = true;
export const EMPTY_CODE_BLOCK_SELECTION_STATE: CodeBlockSelectionState = {
isPanelOpen: false,
selectedCodeBlock: null,
selectedCodeBlockId: null,
};
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
function randomInt(): number {
return (
globalThis.crypto?.getRandomValues(new Uint32Array(1))[0] ??
Math.floor(Math.random() * 2 ** 31)
);
}
function createElementId(): string {
if (typeof globalThis.crypto?.randomUUID === "function") {
// randomUUID is only available in https context
return globalThis.crypto.randomUUID();
}
const bytes = new Uint8Array(16);
globalThis.crypto?.getRandomValues(bytes);
// Mark the identifier as RFC 4122 version 4 when Web Crypto UUID support is
// unavailable, such as Firefox over plain HTTP.
bytes[6] = ((bytes[6] ?? 0) & 0x0f) | 0x40;
bytes[8] = ((bytes[8] ?? 0) & 0x3f) | 0x80;
const segments = [
bytes.subarray(0, 4),
bytes.subarray(4, 6),
bytes.subarray(6, 8),
bytes.subarray(8, 10),
bytes.subarray(10, 16),
];
return segments
.map((segment) =>
Array.from(segment, (byte) => byte.toString(16).padStart(2, "0")).join(
"",
),
)
.join("-");
}
function getCanonicalCodeBlockLanguage(
language: unknown,
): CodeBlockLanguage | null {
if (language === "plaintext") {
return language;
}
if (typeof language !== "string" || !SHIKI_LANGUAGE_SET.has(language)) {
return null;
}
return (
SHIKI_LANGUAGE_ALIAS_TO_ID.get(language) ?? (language as BundledLanguage)
);
}
export function sanitizeCodeBlockLanguage(
language: unknown,
): CodeBlockLanguage {
return getCanonicalCodeBlockLanguage(language) ?? DEFAULT_CODE_BLOCK_LANGUAGE;
}
export function createCodeBlockCustomData(
overrides?: Partial<
Pick<CodeBlockCustomData, "code" | "language" | "showBackground">
>,
): CodeBlockCustomData {
return {
excaliType: "codeblock",
version: 2,
code: overrides?.code ?? DEFAULT_CODE_BLOCK_CODE,
language: sanitizeCodeBlockLanguage(overrides?.language),
showBackground:
overrides?.showBackground ?? DEFAULT_CODE_BLOCK_SHOW_BACKGROUND,
};
}
export function isCodeBlockCustomData(
value: unknown,
): value is CodeBlockCustomData {
return (
isRecord(value) &&
value.excaliType === "codeblock" &&
value.version === 2 &&
typeof value.code === "string" &&
getCanonicalCodeBlockLanguage(value.language) !== null &&
typeof value.showBackground === "boolean"
);
}
export function isCodeBlockEmbeddable(
element: unknown,
): element is CodeBlockElement {
return (
isRecord(element) &&
element.type === "embeddable" &&
element.link === null &&
isCodeBlockCustomData(element.customData)
);
}
export function getCodeBlockCustomData(
element: unknown,
): CodeBlockCustomData | null {
if (!isCodeBlockEmbeddable(element)) {
return null;
}
const language = sanitizeCodeBlockLanguage(element.customData.language);
if (language === element.customData.language) {
return element.customData;
}
return {
...element.customData,
language,
};
}
export function createCodeBlockElement({
x,
y,
width = DEFAULT_CODE_BLOCK_WIDTH,
height = DEFAULT_CODE_BLOCK_HEIGHT,
}: {
x: number;
y: number;
width?: number;
height?: number;
}): CodeBlockElement {
const now = Date.now();
return {
id: createElementId(),
type: "embeddable",
x,
y,
strokeColor: "transparent",
backgroundColor: "transparent",
fillStyle: "solid",
strokeWidth: 1,
strokeStyle: "solid",
roundness: null,
roughness: 0,
opacity: 100,
width,
height,
angle: 0,
seed: randomInt(),
version: 1,
versionNonce: randomInt(),
index: null,
isDeleted: false,
groupIds: [],
frameId: null,
boundElements: null,
updated: now,
link: null,
locked: false,
customData: createCodeBlockCustomData(),
};
}
export function updateCodeBlockElement<TElement extends ExcalidrawElement>(
element: TElement,
patch: Partial<
Pick<CodeBlockCustomData, "code" | "language" | "showBackground">
>,
): TElement {
if (!isCodeBlockEmbeddable(element)) {
return element;
}
const nextCustomData = {
...element.customData,
...(patch.code !== undefined ? { code: patch.code } : {}),
...(patch.language !== undefined
? { language: sanitizeCodeBlockLanguage(patch.language) }
: {}),
...(patch.showBackground !== undefined
? { showBackground: patch.showBackground }
: {}),
} satisfies CodeBlockCustomData;
if (
nextCustomData.code === element.customData.code &&
nextCustomData.language === element.customData.language &&
nextCustomData.showBackground === element.customData.showBackground
) {
return element;
}
return {
...element,
customData: nextCustomData,
};
}
export function updateCodeBlockElements<TElement extends ExcalidrawElement>(
elements: readonly TElement[],
elementId: string,
patch: Partial<
Pick<CodeBlockCustomData, "code" | "language" | "showBackground">
>,
): readonly TElement[] {
let changed = false;
const nextElements = elements.map((element) => {
if (element.id !== elementId) {
return element;
}
const nextElement = updateCodeBlockElement(element, patch);
changed ||= nextElement !== element;
return nextElement;
});
return changed ? nextElements : elements;
}
export function getCodeBlockSelectionState(
elements: readonly ExcalidrawElement[],
appState: Pick<AppState, "selectedElementIds">,
): CodeBlockSelectionState {
const selectedIds = Object.entries(appState.selectedElementIds ?? {})
.filter(([, selected]) => selected)
.map(([elementId]) => elementId);
if (selectedIds.length !== 1) {
return EMPTY_CODE_BLOCK_SELECTION_STATE;
}
const selectedCodeBlock = elements.find(
(element) =>
element.id === selectedIds[0] && isCodeBlockEmbeddable(element),
);
if (!selectedCodeBlock || !isCodeBlockEmbeddable(selectedCodeBlock)) {
return EMPTY_CODE_BLOCK_SELECTION_STATE;
}
const customData = getCodeBlockCustomData(selectedCodeBlock);
if (!customData) {
return EMPTY_CODE_BLOCK_SELECTION_STATE;
}
return {
isPanelOpen: true,
selectedCodeBlock: {
...selectedCodeBlock,
customData,
},
selectedCodeBlockId: selectedCodeBlock.id,
};
}
+37
View File
@@ -0,0 +1,37 @@
import { getSingletonHighlighter, type Highlighter } from "shiki";
import { CODE_BLOCK_SHIKI_THEME, type CodeBlockLanguage } from "./codeblock";
let highlighterPromise: Promise<Highlighter> | null = null;
export function getCodeBlockHighlighter() {
if (!highlighterPromise) {
highlighterPromise = getSingletonHighlighter({
themes: [CODE_BLOCK_SHIKI_THEME],
});
}
return highlighterPromise;
}
export function getHighlightableCodeBlockLanguage(
language: CodeBlockLanguage,
): Exclude<CodeBlockLanguage, "plaintext"> | null {
return language === "plaintext" ? null : language;
}
export async function renderHighlightedCodeBlockHtml(
code: string,
language: CodeBlockLanguage,
): Promise<string | null> {
const shikiLanguage = getHighlightableCodeBlockLanguage(language);
if (!shikiLanguage) {
return null;
}
const highlighter = await getCodeBlockHighlighter();
await highlighter.loadLanguage(shikiLanguage);
return highlighter.codeToHtml(code, {
lang: shikiLanguage,
theme: CODE_BLOCK_SHIKI_THEME,
});
}
@@ -0,0 +1,79 @@
import { memo, useEffect, useState } from "react";
import type { ExcalidrawProps } from "@excalidraw/excalidraw/types";
import type {
ExcalidrawEmbeddableElement,
NonDeleted,
} from "@excalidraw/excalidraw/element/types";
import { getCodeBlockCustomData, isCodeBlockEmbeddable } from "../codeblock";
import { renderHighlightedCodeBlockHtml } from "../codeblockHighlight";
type CodeBlockEmbeddableProps = {
element: NonDeleted<ExcalidrawEmbeddableElement>;
};
const CodeBlockEmbeddable = memo(function CodeBlockEmbeddable({
element,
}: CodeBlockEmbeddableProps) {
const customData = getCodeBlockCustomData(element);
const [html, setHtml] = useState<string | null>(null);
useEffect(() => {
if (!customData) {
setHtml(null);
return;
}
let cancelled = false;
void renderHighlightedCodeBlockHtml(customData.code, customData.language)
.then((nextHtml) => {
if (!cancelled) {
setHtml(nextHtml);
}
})
.catch(() => {
if (!cancelled) {
setHtml(null);
}
});
return () => {
cancelled = true;
};
}, [customData]);
if (!customData) {
return null;
}
return (
<div
className={[
"codeblock-embeddable",
customData.showBackground
? "codeblock-surface--background"
: "codeblock-surface--transparent",
].join(" ")}
>
{html ? (
<div
className="codeblock-embeddable-html"
dangerouslySetInnerHTML={{ __html: html }}
/>
) : (
<pre className="codeblock-embeddable-fallback">
<code>{customData.code}</code>
</pre>
)}
</div>
);
});
export const renderCodeBlockEmbeddable: NonNullable<
ExcalidrawProps["renderEmbeddable"]
> = (element) => {
if (!isCodeBlockEmbeddable(element)) {
return null;
}
return <CodeBlockEmbeddable element={element} />;
};
+184
View File
@@ -0,0 +1,184 @@
import { useEffect, useRef, useState } from "react";
import {
CODE_BLOCK_LANGUAGES,
type CodeBlockDraftState,
type CodeBlockLanguage,
} from "../codeblock";
import { renderHighlightedCodeBlockHtml } from "../codeblockHighlight";
type CodeBlockSidebarProps = {
open: boolean;
draft: CodeBlockDraftState | null;
onCodeChange: (code: string) => void;
onLanguageChange: (language: CodeBlockLanguage) => void;
onShowBackgroundChange: (showBackground: boolean) => void;
onCommit: () => void;
};
function CodeBlockEditor({
draft,
onCodeChange,
onCommit,
}: {
draft: CodeBlockDraftState;
onCodeChange: (code: string) => void;
onCommit: () => void;
}) {
const [html, setHtml] = useState<string | null>(null);
const textareaRef = useRef<HTMLTextAreaElement | null>(null);
const highlightRef = useRef<HTMLDivElement | null>(null);
useEffect(() => {
let cancelled = false;
void renderHighlightedCodeBlockHtml(draft.code, draft.language)
.then((nextHtml) => {
if (!cancelled) {
setHtml(nextHtml);
}
})
.catch(() => {
if (!cancelled) {
setHtml(null);
}
});
return () => {
cancelled = true;
};
}, [draft.code, draft.language]);
const syncScroll = () => {
const textarea = textareaRef.current;
const highlight = highlightRef.current;
if (!textarea || !highlight) {
return;
}
highlight.scrollTop = textarea.scrollTop;
highlight.scrollLeft = textarea.scrollLeft;
};
return (
<div
className={[
"codeblock-editor-surface",
draft.showBackground
? "codeblock-surface--background"
: "codeblock-surface--transparent",
].join(" ")}
>
<div
ref={highlightRef}
className="codeblock-editor-highlight"
aria-hidden="true"
>
{html ? (
<div
className="codeblock-editor-highlight-html"
dangerouslySetInnerHTML={{ __html: html }}
/>
) : (
<pre className="codeblock-editor-highlight-fallback">
<code>{draft.code || " "}</code>
</pre>
)}
</div>
<textarea
ref={textareaRef}
className="codeblock-editor-textarea"
value={draft.code}
onChange={(event) => onCodeChange(event.target.value)}
onBlur={onCommit}
onScroll={syncScroll}
spellCheck={false}
autoCapitalize="none"
autoCorrect="off"
aria-label="Code block contents"
/>
</div>
);
}
function CodeBlockIcon() {
return (
<svg viewBox="0 0 24 24" aria-hidden="true">
<path
d="M8 8 4.5 12 8 16M16 8l3.5 4-3.5 4M13 6l-2 12"
fill="none"
stroke="currentColor"
strokeWidth="1.8"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
);
}
export function InsertCodeBlockButton({
onClick,
disabled,
}: {
onClick: () => void;
disabled?: boolean;
}) {
return (
<button
type="button"
className="secondary-button app-actions-toggle codeblock-toolbar-button"
onClick={onClick}
disabled={disabled}
aria-label="Insert code block"
>
<CodeBlockIcon />
<span className="sr-only">Insert code block</span>
</button>
);
}
export function CodeBlockSidebar({
open,
draft,
onCodeChange,
onLanguageChange,
onShowBackgroundChange,
onCommit,
}: CodeBlockSidebarProps) {
if (!open || !draft) {
return null;
}
return (
<aside className="codeblock-sidebar">
<select
className="codeblock-language-select"
value={draft.language}
onChange={(event) =>
onLanguageChange(event.target.value as CodeBlockLanguage)
}
onBlur={onCommit}
aria-label="Code block language"
>
{CODE_BLOCK_LANGUAGES.map((language) => (
<option key={language} value={language}>
{language}
</option>
))}
</select>
<CodeBlockEditor
key={draft.elementId}
draft={draft}
onCodeChange={onCodeChange}
onCommit={onCommit}
/>
<label className="codeblock-background-toggle">
<input
type="checkbox"
checked={draft.showBackground}
onChange={(event) => onShowBackgroundChange(event.target.checked)}
onBlur={onCommit}
/>
<span>Background</span>
</label>
</aside>
);
}
+229
View File
@@ -0,0 +1,229 @@
import { type DrawingMeta, type DrawingPublication } from "../../core/shared";
type DrawingSidebarProps = {
open: boolean;
drawings: DrawingMeta[];
activeId: string | null;
activeTitle: string;
publication: DrawingPublication;
publicationSlug: string;
publicationBusy: boolean;
onClose: () => void;
onCreate: () => void;
onSelect: (drawingId: string) => void;
onDelete: (drawingId: string) => void;
onTitleChange: (title: string) => void;
onTitleSubmit: () => void;
onPublicationSlugChange: (slug: string) => void;
onPublish: () => void;
onDisablePublication: () => void;
};
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 DrawingsToggle({ onClick }: { onClick: () => void }) {
return (
<button
type="button"
className="secondary-button app-actions-toggle"
onClick={onClick}
aria-label="Open drawings"
>
<DrawerIcon />
<span className="sr-only">Open drawings</span>
</button>
);
}
export function DrawingSidebar({
open,
drawings,
activeId,
activeTitle,
publication,
publicationSlug,
publicationBusy,
onClose,
onCreate,
onSelect,
onDelete,
onTitleChange,
onTitleSubmit,
onPublicationSlugChange,
onPublish,
onDisablePublication,
}: DrawingSidebarProps) {
const publicPath = publication.enabled ? `/p/${publication.slug}` : null;
const publicationStatus = publication.enabled
? `Published at ${publicPath}`
: "Not published";
return (
<>
<div
className={
open ? "sidebar-backdrop sidebar-backdrop-open" : "sidebar-backdrop"
}
onClick={onClose}
aria-hidden={!open}
/>
<aside
className={open ? "sidebar sidebar-open" : "sidebar"}
aria-hidden={!open}
>
<div className="sidebar-header">
<h1>excali-box</h1>
<div className="sidebar-actions">
<button type="button" className="primary-button" onClick={onCreate}>
New
</button>
<button
type="button"
className="icon-button"
onClick={onClose}
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 drawing-link-active">
<div className="drawing-item-header">
<input
className="title-input"
value={activeTitle}
onChange={(event) => onTitleChange(event.target.value)}
onBlur={onTitleSubmit}
onKeyDown={(event) => {
if (event.key === "Enter") {
event.currentTarget.blur();
}
}}
/>
<button
type="button"
className="icon-button drawing-delete-button"
onClick={() => onDelete(drawing.id)}
aria-label={`Delete ${drawing.title}`}
>
×
</button>
</div>
<div className="publication-panel">
<label
className="publication-label"
htmlFor="publication-slug"
>
Public name
</label>
<input
id="publication-slug"
className="title-input publication-input"
value={publicationSlug}
onChange={(event) =>
onPublicationSlugChange(event.target.value)
}
spellCheck={false}
autoCapitalize="none"
autoCorrect="off"
/>
<div className="publication-actions">
<button
type="button"
className="secondary-button"
onClick={onPublish}
disabled={publicationBusy}
>
{publication.enabled ? "Update link" : "Publish"}
</button>
<button
type="button"
className="secondary-button"
onClick={onDisablePublication}
disabled={!publication.enabled || publicationBusy}
>
Unpublish
</button>
</div>
<div className="publication-status">
{publication.enabled ? (
<a
href={publicPath ?? "#"}
target="_blank"
rel="noreferrer"
className="publication-link"
>
{publicationStatus}
</a>
) : (
<span>{publicationStatus}</span>
)}
</div>
</div>
</div>
) : (
<button
type="button"
className="drawing-link"
onClick={() => onSelect(drawing.id)}
>
<span className="drawing-title">{drawing.title}</span>
</button>
)}
{drawing.id !== activeId ? (
<button
type="button"
className="icon-button"
onClick={() => onDelete(drawing.id)}
aria-label={`Delete ${drawing.title}`}
>
×
</button>
) : null}
</div>
))}
</div>
</aside>
</>
);
}
+80
View File
@@ -0,0 +1,80 @@
import { memo } from "react";
import { Excalidraw } from "@excalidraw/excalidraw";
import type {
AppState,
BinaryFiles,
ExcalidrawImperativeAPI,
ExcalidrawInitialDataState,
ExcalidrawProps,
} from "@excalidraw/excalidraw/types";
import "../../../node_modules/@excalidraw/excalidraw/dist/prod/index.css";
import { type ScenePayload } from "../../core/shared";
import {
getCodeBlockSelectionState,
type CodeBlockSelectionState,
} from "../codeblock";
type EditorCanvasProps = {
activeId: string | null;
scene: ScenePayload | null;
loading: boolean;
error: string | null;
editorReloadNonce: number;
onSceneChange: (scene: ScenePayload) => void;
onSelectionStateChange: (selection: CodeBlockSelectionState) => void;
onEditorActivity: () => void;
onExcalidrawAPI: (api: ExcalidrawImperativeAPI) => void;
renderEmbeddable?: ExcalidrawProps["renderEmbeddable"];
};
function sceneToInitialData(scene: ScenePayload): ExcalidrawInitialDataState {
return scene as ExcalidrawInitialDataState;
}
function sceneFromEditor(
elements: readonly unknown[],
appState: AppState,
files: BinaryFiles,
): ScenePayload {
return {
// Excalidraw passes immutable arrays, avoid copying it on every onChange event
elements: elements as unknown[],
appState: appState as unknown as Record<string, unknown>,
files: files as unknown as Record<string, unknown>,
};
}
export const EditorCanvas = memo(function EditorCanvas({
activeId,
scene,
loading,
error,
editorReloadNonce,
onSceneChange,
onSelectionStateChange,
onEditorActivity,
onExcalidrawAPI,
renderEmbeddable,
}: EditorCanvasProps) {
if (loading || !scene) {
return <div className="editor-loading">{error ?? "Loading..."}</div>;
}
return (
<div className="editor-frame">
<Excalidraw
key={`${activeId}:${editorReloadNonce}`}
initialData={sceneToInitialData(scene)}
excalidrawAPI={onExcalidrawAPI}
renderEmbeddable={renderEmbeddable}
onChange={(elements, appState, files) => {
onEditorActivity();
onSceneChange(sceneFromEditor(elements, appState, files));
onSelectionStateChange(
getCodeBlockSelectionState(elements, appState),
);
}}
/>
</div>
);
});
+59
View File
@@ -0,0 +1,59 @@
import { memo } from "react";
import { Excalidraw } from "@excalidraw/excalidraw";
import type {
ExcalidrawInitialDataState,
ExcalidrawProps,
} from "@excalidraw/excalidraw/types";
import "../../../node_modules/@excalidraw/excalidraw/dist/prod/index.css";
import { type PublicDrawing } from "../../core/shared";
type PublicViewerProps = {
drawing: PublicDrawing | null;
loading: boolean;
error: string | null;
renderEmbeddable?: ExcalidrawProps["renderEmbeddable"];
};
function drawingToInitialData(
drawing: PublicDrawing,
): ExcalidrawInitialDataState {
return drawing as ExcalidrawInitialDataState;
}
const viewerUiOptions = {
canvasActions: {
changeViewBackgroundColor: false,
clearCanvas: false,
export: false,
loadScene: false,
saveAsImage: false,
saveToActiveFile: false,
toggleTheme: false,
},
tools: {
image: false,
},
} as const;
export const PublicViewer = memo(function PublicViewer({
drawing,
loading,
error,
renderEmbeddable,
}: PublicViewerProps) {
if (loading || !drawing) {
return <div className="editor-loading">{error ?? "Loading..."}</div>;
}
return (
<div className="public-viewer-shell">
<Excalidraw
initialData={drawingToInitialData(drawing)}
viewModeEnabled={true}
zenModeEnabled={true}
UIOptions={viewerUiOptions}
renderEmbeddable={renderEmbeddable}
/>
</div>
);
});
+617
View File
@@ -0,0 +1,617 @@
import { useCallback, useEffect, useRef, useState } from "react";
import {
DEFAULT_TITLE,
type DrawingMeta,
type DrawingPublication,
type ScenePayload,
} from "../../core/shared";
type DrawingResponse = DrawingMeta & ScenePayload;
type PublicationResponse = DrawingPublication;
type ApiErrorBody = { ok: false; error?: string; drawing?: DrawingMeta };
type UpdateResponse = { ok: true; drawing: DrawingMeta };
class RequestError extends Error {
constructor(
readonly status: number,
readonly body: ApiErrorBody,
) {
super(body.error ?? `Request failed: ${status}`);
}
}
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]);
}
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 & ApiErrorBody;
if (!response.ok) {
throw new RequestError(response.status, body);
}
return body;
}
function fetchDrawingList() {
return requestJson<DrawingMeta[]>("/api/drawings", { method: "GET" });
}
function isConflictError(
error: unknown,
): error is RequestError & { body: ApiErrorBody & { drawing: DrawingMeta } } {
return (
error instanceof RequestError &&
error.status === 409 &&
error.body.drawing !== undefined
);
}
function sceneFromDrawing(drawing: DrawingResponse): ScenePayload {
return {
elements: drawing.elements,
appState: drawing.appState,
files: drawing.files,
};
}
export function useDrawingSession() {
const [drawings, setDrawings] = useState<DrawingMeta[]>([]);
const [activeId, setActiveId] = useState<string | null>(pathToId());
const [activeTitle, setActiveTitleState] = useState(DEFAULT_TITLE);
const [scene, setScene] = useState<ScenePayload | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [toastMessage, setToastMessage] = useState<string | null>(null);
const [editorReloadNonce, setEditorReloadNonce] = useState(0);
const [publication, setPublication] = useState<DrawingPublication>({
enabled: false,
});
const [publicationSlug, setPublicationSlugState] = useState("");
const [publicationBusy, setPublicationBusy] = useState(false);
const currentIdRef = useRef<string | null>(activeId);
const currentTitleRef = useRef(activeTitle);
const latestSceneRef = useRef<ScenePayload | null>(null);
const publicationEnabledRef = useRef(false);
const ignoreChangeRef = useRef(false);
const saveTimeoutRef = useRef<number | null>(null);
const toastTimeoutRef = useRef<number | null>(null);
const inFlightSaveRef = useRef<Promise<void> | null>(null);
const loadVersionRef = useRef(0);
const loadedRevisionRef = useRef<number | null>(null);
const clearPendingSaveTimeout = useCallback(() => {
if (saveTimeoutRef.current !== null) {
clearTimeout(saveTimeoutRef.current);
saveTimeoutRef.current = null;
}
}, []);
const showToast = useCallback(
(message: string | null, timeoutMs?: number) => {
if (toastTimeoutRef.current !== null) {
clearTimeout(toastTimeoutRef.current);
toastTimeoutRef.current = null;
}
setToastMessage(message);
if (message !== null && timeoutMs !== undefined) {
toastTimeoutRef.current = window.setTimeout(
() => setToastMessage(null),
timeoutMs,
);
}
},
[],
);
const refreshList = useCallback(async () => {
const list = await fetchDrawingList();
setDrawings(sortDrawings(list));
return list;
}, []);
const applyPublication = useCallback(
(nextPublication: DrawingPublication) => {
publicationEnabledRef.current = nextPublication.enabled;
setPublication(nextPublication);
if (nextPublication.enabled) {
setPublicationSlugState(nextPublication.slug);
return;
}
setPublicationSlugState("");
},
[],
);
const loadDrawing = useCallback(
async (drawingId: string) => {
const version = ++loadVersionRef.current;
setLoading(true);
setError(null);
try {
const [list, drawing, nextPublication] = await Promise.all([
requestJson<DrawingMeta[]>("/api/drawings", { method: "GET" }),
requestJson<DrawingResponse>(`/api/drawings/${drawingId}`, {
method: "GET",
}),
requestJson<PublicationResponse>(
`/api/drawings/${drawingId}/publication`,
{ method: "GET" },
),
]);
if (version !== loadVersionRef.current) {
return;
}
const nextScene = sceneFromDrawing(drawing);
ignoreChangeRef.current = true;
currentIdRef.current = drawing.id;
currentTitleRef.current = drawing.title;
latestSceneRef.current = nextScene;
loadedRevisionRef.current = drawing.revision;
setDrawings(replaceMeta(list, drawing));
setActiveId(drawing.id);
setActiveTitleState(drawing.title);
setScene(nextScene);
applyPublication(nextPublication);
setEditorReloadNonce((current) => current + 1);
} catch (loadError) {
if (version !== loadVersionRef.current) {
return;
}
setError(
loadError instanceof Error
? loadError.message
: "Failed to load drawing",
);
} finally {
if (version === loadVersionRef.current) {
setLoading(false);
}
}
},
[applyPublication],
);
const saveScene = useCallback(
async (isManual = false) => {
const drawingId = currentIdRef.current;
const nextScene = latestSceneRef.current;
const expectedRevision = loadedRevisionRef.current;
if (!drawingId || !nextScene) {
return;
}
setError(null);
if (isManual) {
showToast("Saving...");
}
const promise = requestJson<UpdateResponse>(
`/api/drawings/${drawingId}`,
{
method: "PUT",
body: JSON.stringify({ ...nextScene, expectedRevision }),
},
)
.then((body) => {
setDrawings((current) => replaceMeta(current, body.drawing));
if (currentIdRef.current === drawingId) {
loadedRevisionRef.current = body.drawing.revision;
}
if (isManual) {
showToast("Saved", 2000);
}
})
.catch((saveError: unknown) => {
if (isConflictError(saveError)) {
clearPendingSaveTimeout();
setError(null);
setDrawings((current) =>
replaceMeta(current, saveError.body.drawing),
);
if (isManual) {
showToast("Reloading latest...");
}
return loadDrawing(drawingId);
}
setError(
saveError instanceof Error ? saveError.message : "Save failed",
);
if (isManual) {
showToast("Save failed", 2000);
}
throw saveError;
})
.finally(() => {
inFlightSaveRef.current = null;
});
inFlightSaveRef.current = promise;
await promise;
},
[clearPendingSaveTimeout, loadDrawing, showToast],
);
const flushPendingSave = useCallback(async () => {
if (saveTimeoutRef.current !== null) {
clearPendingSaveTimeout();
await saveScene();
return;
}
if (inFlightSaveRef.current) {
await inFlightSaveRef.current;
}
}, [clearPendingSaveTimeout, saveScene]);
const triggerManualSave = useCallback(async () => {
if (saveTimeoutRef.current !== null) {
clearPendingSaveTimeout();
}
if (inFlightSaveRef.current) {
await inFlightSaveRef.current;
}
await saveScene(true);
}, [clearPendingSaveTimeout, saveScene]);
const scheduleSave = useCallback(() => {
if (saveTimeoutRef.current !== null) {
clearTimeout(saveTimeoutRef.current);
}
saveTimeoutRef.current = window.setTimeout(() => {
saveTimeoutRef.current = null;
void saveScene();
}, 30000);
}, [saveScene]);
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 setActiveTitle = useCallback((title: string) => {
currentTitleRef.current = title;
setActiveTitleState(title);
}, []);
const submitTitle = useCallback(async () => {
const drawingId = currentIdRef.current;
if (!drawingId) {
return;
}
try {
const body = await requestJson<UpdateResponse>(
`/api/drawings/${drawingId}`,
{
method: "PUT",
body: JSON.stringify({
title: currentTitleRef.current,
expectedRevision: loadedRevisionRef.current,
}),
},
);
currentTitleRef.current = body.drawing.title;
loadedRevisionRef.current = body.drawing.revision;
setActiveTitleState(body.drawing.title);
setDrawings((current) => replaceMeta(current, body.drawing));
} catch (renameError) {
if (isConflictError(renameError)) {
clearPendingSaveTimeout();
setDrawings((current) =>
replaceMeta(current, renameError.body.drawing),
);
await loadDrawing(drawingId);
return;
}
setError(
renameError instanceof Error ? renameError.message : "Rename failed",
);
}
}, [clearPendingSaveTimeout, loadDrawing]);
const handleSceneChange = useCallback(
(nextScene: ScenePayload) => {
latestSceneRef.current = nextScene;
if (ignoreChangeRef.current) {
ignoreChangeRef.current = false;
return;
}
scheduleSave();
},
[scheduleSave],
);
const selectDrawing = useCallback(
async (drawingId: string) => {
await navigateToDrawing(drawingId);
},
[navigateToDrawing],
);
const setPublicationSlug = useCallback((slug: string) => {
setPublicationSlugState(slug);
}, []);
const publishPublication = useCallback(async () => {
const drawingId = currentIdRef.current;
if (!drawingId) {
return;
}
setPublicationBusy(true);
setError(null);
showToast("Publishing...");
try {
await flushPendingSave();
const currentDrawingId = currentIdRef.current;
if (!currentDrawingId) {
return;
}
const body = await requestJson<PublicationResponse>(
`/api/drawings/${currentDrawingId}/publication`,
{
method: "PUT",
body: JSON.stringify({
enabled: true,
slug: publicationSlug,
}),
},
);
applyPublication(body);
showToast("Published", 2000);
} catch (publishError) {
setError(
publishError instanceof Error ? publishError.message : "Publish failed",
);
showToast("Publish failed", 2000);
} finally {
setPublicationBusy(false);
}
}, [applyPublication, flushPendingSave, publicationSlug, showToast]);
const disablePublication = useCallback(async () => {
const drawingId = currentIdRef.current;
if (!drawingId) {
return;
}
setPublicationBusy(true);
setError(null);
showToast("Disabling...");
try {
const body = await requestJson<PublicationResponse>(
`/api/drawings/${drawingId}/publication`,
{
method: "PUT",
body: JSON.stringify({ enabled: false }),
},
);
applyPublication(body);
showToast("Publication disabled", 2000);
} catch (disableError) {
setError(
disableError instanceof Error ? disableError.message : "Disable failed",
);
showToast("Disable failed", 2000);
} finally {
setPublicationBusy(false);
}
}, [applyPublication, showToast]);
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(() => {
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(() => {
const checkForExternalChanges = async () => {
try {
const list = await refreshList();
const drawingId = currentIdRef.current;
const loadedRevision = loadedRevisionRef.current;
const serverDrawing = drawingId
? list.find((drawing) => drawing.id === drawingId)
: null;
if (
serverDrawing &&
loadedRevision !== null &&
serverDrawing.revision > loadedRevision
) {
clearPendingSaveTimeout();
await loadDrawing(serverDrawing.id);
}
} catch {
// Focus checks only discover external edits; direct save/load requests report failures.
}
};
const handleFocus = () => {
void checkForExternalChanges();
};
const handleVisibilityChange = () => {
if (document.visibilityState === "visible") {
void checkForExternalChanges();
}
};
window.addEventListener("focus", handleFocus);
document.addEventListener("visibilitychange", handleVisibilityChange);
return () => {
window.removeEventListener("focus", handleFocus);
document.removeEventListener("visibilitychange", handleVisibilityChange);
};
}, [clearPendingSaveTimeout, loadDrawing, refreshList]);
useEffect(() => {
return () => {
if (saveTimeoutRef.current !== null) {
clearTimeout(saveTimeoutRef.current);
}
if (toastTimeoutRef.current !== null) {
clearTimeout(toastTimeoutRef.current);
}
};
}, []);
return {
drawings,
activeId,
activeTitle,
scene,
loading,
error,
toastMessage,
editorReloadNonce,
publication,
publicationSlug,
publicationBusy,
setActiveTitle,
submitTitle,
handleSceneChange,
selectDrawing,
createDrawing,
deleteDrawing,
setPublicationSlug,
publishPublication,
disablePublication,
};
}
+82
View File
@@ -0,0 +1,82 @@
import { useEffect, useState } from "react";
import { type PublicDrawing } from "../../core/shared";
type PublicDrawingState = {
drawing: PublicDrawing | null;
loading: boolean;
error: string | null;
};
class RequestError extends Error {
constructor(
readonly status: number,
message: string,
) {
super(message);
}
}
async function requestPublicDrawing(slug: string): Promise<PublicDrawing> {
const response = await fetch(
`/api/public/drawings/${encodeURIComponent(slug)}`,
);
const body = (await response.json()) as PublicDrawing & { error?: string };
if (!response.ok) {
throw new RequestError(
response.status,
body.error ?? `Request failed: ${response.status}`,
);
}
return body;
}
export function usePublicDrawing(slug: string): PublicDrawingState {
const [drawing, setDrawing] = useState<PublicDrawing | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
let cancelled = false;
setLoading(true);
setError(null);
void requestPublicDrawing(slug)
.then((nextDrawing) => {
if (cancelled) {
return;
}
setDrawing(nextDrawing);
document.title = nextDrawing.title;
})
.catch((loadError: unknown) => {
if (cancelled) {
return;
}
setDrawing(null);
setError(
loadError instanceof Error
? loadError.message
: "Failed to load drawing",
);
})
.finally(() => {
if (!cancelled) {
setLoading(false);
}
});
return () => {
cancelled = true;
};
}, [slug]);
return {
drawing,
loading,
error,
};
}
+75
View File
@@ -0,0 +1,75 @@
import { useCallback, useEffect, useRef, type RefObject } from "react";
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;
export function useThemeTokenSync(
appShellRef: RefObject<HTMLDivElement | null>,
) {
const themeSyncFrameRef = useRef<number | null>(null);
const syncThemeTokens = useCallback(() => {
const appShell = appShellRef.current;
const excalidrawRoot = appShell?.querySelector<HTMLElement>(".excalidraw");
if (!appShell || !excalidrawRoot) {
return;
}
// Read all tokens first to avoid layout thrashing
const computedStyles = window.getComputedStyle(excalidrawRoot);
const updates: Array<[string, string]> = [];
for (const [sourceToken, targetToken] of EXCALIDRAW_THEME_TOKEN_MAP) {
const value = computedStyles.getPropertyValue(sourceToken).trim();
if (value) {
updates.push([targetToken, value]);
}
}
// Write in a separate pass, skipping unchanged values
for (const [targetToken, value] of updates) {
if (appShell.style.getPropertyValue(targetToken) !== value) {
appShell.style.setProperty(targetToken, value);
}
}
}, [appShellRef]);
const scheduleThemeTokenSync = useCallback(() => {
if (themeSyncFrameRef.current !== null) {
window.cancelAnimationFrame(themeSyncFrameRef.current);
}
themeSyncFrameRef.current = window.requestAnimationFrame(() => {
themeSyncFrameRef.current = null;
syncThemeTokens();
});
}, [syncThemeTokens]);
useEffect(() => {
return () => {
if (themeSyncFrameRef.current !== null) {
window.cancelAnimationFrame(themeSyncFrameRef.current);
}
};
}, []);
return scheduleThemeTokenSync;
}
+1 -1
View File
@@ -6,7 +6,7 @@
<!-- to resolve the hs and css from root -->
<base href="/" />
<title>excali-box</title>
<script type="module" src="./client.tsx"></script>
<script type="module" src="./main.tsx"></script>
</head>
<body>
<div id="root"></div>
+569
View File
@@ -0,0 +1,569 @@
: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,
select,
textarea {
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-link-active {
display: grid;
gap: 10px;
}
.drawing-item-header {
display: flex;
align-items: center;
gap: 8px;
}
.drawing-delete-button {
flex: 0 0 32px;
margin-left: auto;
}
.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;
}
.publication-panel {
display: grid;
gap: 8px;
padding: 8px 0 4px;
}
.publication-label,
.publication-status {
color: color-mix(in srgb, var(--app-text-color) 75%, white);
font-size: 0.75rem;
}
.publication-input {
font-family:
ui-monospace, SFMono-Regular, SFMono-Regular, Menlo, Monaco, Consolas,
"Liberation Mono", monospace;
}
.publication-actions {
display: flex;
gap: 8px;
}
.publication-link {
color: inherit;
text-decoration: none;
}
.publication-link:hover {
text-decoration: underline;
}
.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;
align-items: flex-end;
}
.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);
}
.codeblock-toolbar-button {
position: fixed;
top: max(16px, calc(env(safe-area-inset-top) + 16px));
left: calc(50% + 314px);
z-index: 20;
}
.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;
}
.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,
.public-viewer-shell,
.editor-loading {
height: 100%;
}
.codeblock-sidebar {
position: fixed;
top: 88px;
right: max(16px, calc(env(safe-area-inset-right) + 16px));
bottom: 16px;
z-index: 25;
display: grid;
grid-template-rows: auto minmax(0, 1fr) auto;
gap: 10px;
width: min(360px, calc(100vw - 32px));
padding: 24px 12px 12px;
border: 1px solid var(--app-sidebar-border);
border-radius: 16px;
background: color-mix(in srgb, var(--app-sidebar-bg) 92%, white);
box-shadow: var(--app-sidebar-shadow);
backdrop-filter: blur(14px);
}
.codeblock-language-select,
.codeblock-editor-surface {
border: 1px solid var(--app-input-border);
color: var(--app-input-color);
}
.codeblock-background-toggle {
display: inline-flex;
align-items: center;
gap: 8px;
color: var(--app-text-color);
font-size: var(--app-sidebar-font-size);
}
.codeblock-background-toggle input {
margin: 0;
}
.codeblock-language-select {
border-radius: 10px;
padding: 8px 10px;
background: var(--app-input-bg);
}
.codeblock-editor-surface {
position: relative;
min-height: 0;
overflow: hidden;
border-radius: 10px;
background: transparent;
}
.codeblock-editor-highlight,
.codeblock-editor-highlight-html,
.codeblock-editor-highlight-html :where(pre.shiki),
.codeblock-editor-highlight-fallback,
.codeblock-editor-textarea {
width: 100%;
height: 100%;
}
.codeblock-editor-highlight,
.codeblock-editor-textarea,
.codeblock-editor-highlight-html :where(pre.shiki),
.codeblock-editor-highlight-fallback {
font-family:
ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono",
monospace;
line-height: 1.5;
}
.codeblock-editor-highlight {
position: absolute;
inset: 0;
overflow: auto;
pointer-events: none;
}
.codeblock-editor-highlight-html :where(pre.shiki),
.codeblock-editor-highlight-fallback {
margin: 0;
padding: 12px;
overflow: hidden;
}
.codeblock-editor-highlight-html :where(code),
.codeblock-editor-highlight-fallback code {
display: block;
min-height: 100%;
white-space: pre;
}
.codeblock-editor-highlight-fallback,
.codeblock-embeddable-fallback {
color: #a6accd;
}
.codeblock-editor-textarea {
position: relative;
min-height: 0;
width: 100%;
height: 100%;
resize: none;
padding: 12px;
border: 0;
background: transparent;
color: transparent;
caret-color: #e4f0fb;
}
.codeblock-editor-textarea::selection {
background: rgba(113, 124, 180, 0.45);
}
.codeblock-editor-textarea:focus {
outline: none;
}
.codeblock-embeddable,
.codeblock-embeddable-html,
.codeblock-embeddable-html :where(pre.shiki),
.codeblock-embeddable-fallback {
width: 100%;
height: 100%;
}
.codeblock-embeddable {
overflow: auto;
border-radius: inherit;
background: transparent;
}
.codeblock-embeddable-html :where(pre.shiki),
.codeblock-embeddable-fallback {
margin: 0;
padding: 16px;
overflow: auto;
font-family:
ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono",
monospace;
line-height: 1.5;
}
.codeblock-embeddable-html :where(pre.shiki) {
background: transparent !important;
}
.codeblock-surface--background {
background: #1b1e28;
}
.codeblock-surface--background .codeblock-embeddable-html :where(pre.shiki),
.codeblock-surface--background
.codeblock-editor-highlight-html
:where(pre.shiki) {
background: #1b1e28 !important;
}
.codeblock-surface--background .codeblock-embeddable-fallback,
.codeblock-surface--background .codeblock-editor-highlight-fallback {
background: #1b1e28;
}
.codeblock-surface--transparent {
background: transparent;
}
.codeblock-surface--transparent .codeblock-embeddable-html :where(pre.shiki),
.codeblock-surface--transparent
.codeblock-editor-highlight-html
:where(pre.shiki) {
background: transparent !important;
}
.codeblock-surface--transparent .codeblock-embeddable-fallback,
.codeblock-surface--transparent .codeblock-editor-highlight-fallback {
background: transparent;
}
.codeblock-embeddable-html :where(code),
.codeblock-embeddable-fallback code {
display: block;
min-height: 100%;
white-space: pre;
}
.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);
}
.codeblock-toolbar-button {
left: calc(50% + 254px);
}
.codeblock-sidebar {
top: auto;
left: 16px;
right: 16px;
bottom: calc(max(16px, env(safe-area-inset-bottom)) + 72px);
width: auto;
height: min(44vh, 360px);
}
}
+25
View File
@@ -0,0 +1,25 @@
import { HttpError } from "./scene";
const RESERVED_SLUGS = new Set(["api", "d", "p", "mcp"]);
export function normalizePublicationSlug(input: unknown): string {
if (typeof input !== "string") {
throw new HttpError(400, "slug must be a string");
}
const normalized = input
.trim()
.toLowerCase()
.replace(/[^a-z0-9]+/g, "-")
.replace(/^-+|-+$/g, "");
if (normalized.length === 0) {
throw new HttpError(400, "slug is required");
}
if (RESERVED_SLUGS.has(normalized)) {
throw new HttpError(400, "slug is reserved");
}
return normalized;
}
+7 -1
View File
@@ -1,5 +1,11 @@
import { describe, expect, test } from "bun:test";
import { HttpError, MAX_SCENE_BYTES, emptyScene, normalizeScene, parseJsonBody } from "./scene";
import {
HttpError,
MAX_SCENE_BYTES,
emptyScene,
normalizeScene,
parseJsonBody,
} from "./scene";
describe("scene helpers", () => {
test("creates empty scenes with dark theme by default", () => {
+3 -9
View File
@@ -62,7 +62,9 @@ export function normalizeScene(input: unknown): ScenePayload {
}
const appState = Object.fromEntries(
Object.entries(input.appState).filter(([key]) => !VOLATILE_APP_STATE_KEYS.has(key)),
Object.entries(input.appState).filter(
([key]) => !VOLATILE_APP_STATE_KEYS.has(key),
),
);
return {
@@ -87,11 +89,3 @@ export function parseJsonBody<T = unknown>(text: string): T {
export function parseSceneText(text: string): ScenePayload {
return normalizeScene(parseJsonBody(text));
}
export function coerceStoredScene(input: unknown): ScenePayload {
try {
return normalizeScene(input);
} catch {
return emptyScene();
}
}
+14
View File
@@ -4,11 +4,25 @@ export type ScenePayload = {
files: Record<string, unknown>;
};
export type DrawingPublication =
| {
enabled: false;
}
| {
enabled: true;
slug: string;
};
export type PublicDrawing = {
title: string;
} & ScenePayload;
export type DrawingMeta = {
id: string;
title: string;
createdAt: string;
updatedAt: string;
revision: number;
};
export type DrawingRecord = DrawingMeta & {
-50
View File
@@ -1,50 +0,0 @@
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);
});
});
-209
View File
@@ -1,209 +0,0 @@
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;
}
+151
View File
@@ -0,0 +1,151 @@
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 { type ScenePayload } from "../core/shared";
import { createDrawingStore, type DrawingStore } from "../server/db";
import { createMcpToolHandlers, resolvePublicBaseUrl } from "./server";
const cleanup: Array<{ dir: string; store?: DrawingStore }> = [];
afterEach(async () => {
while (cleanup.length > 0) {
const item = cleanup.pop();
if (item) {
item.store?.close();
rmSync(item.dir, { recursive: true, force: true });
}
}
});
function createTempTools() {
const dir = mkdtempSync(join(tmpdir(), "excali-mcp-"));
const store = createDrawingStore(join(dir, "test.sqlite"));
cleanup.push({ dir, store });
return {
store,
tools: createMcpToolHandlers(store, "http://example.test"),
};
}
function testScene(): ScenePayload {
return {
elements: [
{ id: "one", type: "rectangle", x: 10, y: 20 },
{ id: "two", type: "text", text: "hello" },
],
appState: { theme: "dark" },
files: {},
};
}
describe("mcp tool handlers", () => {
test("PUBLIC_BASE_URL is required", () => {
expect(() => resolvePublicBaseUrl("")).toThrow(
"PUBLIC_BASE_URL is required",
);
});
test("create_drawing writes an explicit scene to a temp SQLite DB", () => {
const { store, tools } = createTempTools();
const scene = testScene();
const result = tools.create_drawing({ title: "explicit", scene });
const drawing = store.getDrawing(result.id);
expect(result).toEqual({
id: result.id,
title: "explicit",
revision: 1,
url: `http://example.test/d/${result.id}`,
});
expect(drawing?.revision).toBe(1);
expect(drawing?.scene).toEqual(scene);
});
test("get_drawing returns raw scene plus URL", () => {
const { tools } = createTempTools();
const scene = testScene();
const result = tools.create_drawing({ title: "read", scene });
const drawing = tools.get_drawing({ id: result.id });
expect(drawing).toMatchObject({
id: result.id,
title: "read",
revision: 1,
url: `http://example.test/d/${result.id}`,
scene,
});
});
test("replace_drawing updates the existing drawing", () => {
const { store, tools } = createTempTools();
const result = tools.create_drawing({
title: "replace",
scene: testScene(),
});
const nextScene: ScenePayload = {
elements: [{ id: "manual", type: "rectangle" }],
appState: { theme: "dark" },
files: {},
};
const replaced = tools.replace_drawing({
id: result.id,
title: "replaced",
scene: nextScene,
});
expect(store.listDrawings()).toHaveLength(1);
expect(replaced.revision).toBe(2);
expect(store.getDrawing(result.id)?.title).toBe("replaced");
expect(store.getDrawing(result.id)?.revision).toBe(2);
expect(store.getDrawing(result.id)?.scene).toEqual(nextScene);
});
test("replace_drawing keeps the revision when the scene is already stored", () => {
const { store, tools } = createTempTools();
const scene = testScene();
const result = tools.create_drawing({ title: "replace noop", scene });
const replaced = tools.replace_drawing({ id: result.id, scene });
expect(replaced.revision).toBe(1);
expect(store.getDrawing(result.id)?.revision).toBe(1);
expect(store.getDrawing(result.id)?.scene).toEqual(scene);
});
test("patch_drawing replaces an element property and removes an element", () => {
const { store, tools } = createTempTools();
const result = tools.create_drawing({ title: "patch", scene: testScene() });
const patched = tools.patch_drawing({
id: result.id,
patch: [
{ op: "replace", path: "/elements/0/x", value: 42 },
{ op: "remove", path: "/elements/1" },
],
});
expect(patched.revision).toBe(2);
expect(store.getDrawing(result.id)?.revision).toBe(2);
expect(store.getDrawing(result.id)?.scene.elements).toEqual([
{ id: "one", type: "rectangle", x: 42, y: 20 },
]);
});
test("missing drawing IDs return tool errors", () => {
const { tools } = createTempTools();
expect(() => tools.get_drawing({ id: "missing" })).toThrow(
"Drawing not found: missing",
);
expect(() =>
tools.replace_drawing({
id: "missing",
scene: testScene(),
}),
).toThrow("Drawing not found: missing");
});
});
+360
View File
@@ -0,0 +1,360 @@
import { applyPatch, type Operation } from "fast-json-patch";
import { accessSync, constants, readFileSync, statSync } from "node:fs";
import { extname, isAbsolute } from "node:path";
import { createMcpHandler } from "mcp-handler";
import { z } from "zod";
import { normalizeScene, normalizeTitle } from "../core/scene";
import { type ScenePayload } from "../core/shared";
import { createDrawingStore, type DrawingStore } from "../server/db";
const sceneSchema = z.object({
elements: z.array(z.unknown()),
appState: z.record(z.string(), z.unknown()),
files: z.record(z.string(), z.unknown()),
});
const patchOperationSchema = z.object({
op: z.enum(["add", "remove", "replace", "move", "copy", "test"]),
path: z.string(),
from: z.string().optional(),
value: z.unknown().optional(),
});
const idInput = z.object({ id: z.string().min(1) });
const createDrawingInput = z.object({ title: z.string(), scene: sceneSchema });
const replaceDrawingInput = z.object({
id: z.string().min(1),
title: z.string().optional(),
scene: sceneSchema,
});
const patchDrawingInput = z.object({
id: z.string().min(1),
title: z.string().optional(),
patch: z.array(patchOperationSchema).min(1),
});
type ToolResult = {
content: Array<{ type: "text"; text: string }>;
};
type StylesGuideResource = {
path: string;
};
export const DEFAULT_STYLES_GUIDE_PATH = "/config/styles-guide.md";
function trimTrailingSlash(url: string): string {
return url.replace(/\/+$/, "");
}
export function resolvePublicBaseUrl(
raw = process.env.PUBLIC_BASE_URL,
): string {
const value = raw?.trim();
if (!value) {
throw new Error("PUBLIC_BASE_URL is required for the MCP server");
}
try {
const url = new URL(value);
if (url.protocol === "http:" || url.protocol === "https:") {
return trimTrailingSlash(url.toString());
}
} catch {}
throw new Error("PUBLIC_BASE_URL must be an absolute http(s) URL");
}
export function resolveStylesGuidePath(
raw = DEFAULT_STYLES_GUIDE_PATH,
): StylesGuideResource | undefined {
const value = raw.trim();
if (!isAbsolute(value)) {
throw new Error("Styles guide path must be an absolute filesystem path");
}
let stats;
try {
stats = statSync(value);
} catch (error) {
if (
error &&
typeof error === "object" &&
"code" in error &&
error.code === "ENOENT"
) {
return undefined;
}
throw new Error(
error instanceof Error
? `Styles guide path could not be checked: ${error.message}`
: "Styles guide path could not be checked",
);
}
const extension = extname(value).toLowerCase();
if (extension !== ".md" && extension !== ".markdown") {
throw new Error(
"Styles guide path must point to a Markdown file (.md or .markdown)",
);
}
if (!stats.isFile()) {
throw new Error("Styles guide path must point to a regular file");
}
try {
accessSync(value, constants.R_OK);
} catch (error) {
throw new Error(
error instanceof Error
? `Styles guide path must point to a readable file: ${error.message}`
: "Styles guide path must point to a readable file",
);
}
return { path: value };
}
function drawingUrl(baseUrl: string, id: string): string {
return `${trimTrailingSlash(baseUrl)}/d/${id}`;
}
function jsonText(data: unknown): ToolResult {
return {
content: [
{
type: "text",
text: JSON.stringify(data, null, 2),
},
],
};
}
function notFound(id: string): never {
throw new Error(`Drawing not found: ${id}`);
}
function mutationResult(
publicBaseUrl: string,
drawing: { id: string; title: string; revision: number },
) {
return {
id: drawing.id,
title: drawing.title,
revision: drawing.revision,
url: drawingUrl(publicBaseUrl, drawing.id),
};
}
function updateExistingDrawing(
store: DrawingStore,
id: string,
update: { scene?: ScenePayload; title?: string },
) {
const result = store.updateDrawing(id, update);
if (!result.ok) {
return notFound(id);
}
return result.drawing;
}
function applyScenePatch(
scene: ScenePayload,
patch: Operation[],
): ScenePayload {
try {
const result = applyPatch(structuredClone(scene), patch, true, true, true);
return normalizeScene(result.newDocument);
} catch (error) {
throw new Error(
error instanceof Error
? `Invalid JSON Patch: ${error.message}`
: "Invalid JSON Patch",
);
}
}
export function createMcpToolHandlers(
store: DrawingStore,
publicBaseUrl: string,
) {
return {
list_drawings() {
return store.listDrawings();
},
get_drawing(input: unknown) {
const { id } = idInput.parse(input);
const drawing = store.getDrawing(id) ?? notFound(id);
return {
id: drawing.id,
title: drawing.title,
createdAt: drawing.createdAt,
updatedAt: drawing.updatedAt,
revision: drawing.revision,
url: drawingUrl(publicBaseUrl, drawing.id),
scene: drawing.scene,
};
},
create_drawing(input: unknown) {
const { title, scene } = createDrawingInput.parse(input);
const created = store.createDrawing(normalizeTitle(title));
const updated = updateExistingDrawing(store, created.id, {
title,
scene: normalizeScene(scene),
});
return mutationResult(publicBaseUrl, updated);
},
replace_drawing(input: unknown) {
const { id, title, scene } = replaceDrawingInput.parse(input);
const updated = updateExistingDrawing(store, id, {
title,
scene: normalizeScene(scene),
});
return mutationResult(publicBaseUrl, updated);
},
patch_drawing(input: unknown) {
const { id, title, patch } = patchDrawingInput.parse(input);
const drawing = store.getDrawing(id) ?? notFound(id);
const updated = updateExistingDrawing(store, id, {
title,
scene: applyScenePatch(drawing.scene, patch as Operation[]),
});
return mutationResult(publicBaseUrl, updated);
},
};
}
export function createExcaliMcpHandler(
store: DrawingStore,
publicBaseUrl: string,
stylesGuide?: StylesGuideResource,
) {
const tools = createMcpToolHandlers(store, publicBaseUrl);
return createMcpHandler(
(server) => {
if (stylesGuide) {
server.registerResource(
"styles-guide",
"excali://styles-guide",
{
title: "Styles Guide",
description:
"User-provided drawing styles guide for scene generation",
mimeType: "text/markdown",
},
async (uri) => ({
contents: [
{
uri: uri.toString(),
mimeType: "text/markdown",
text: readFileSync(stylesGuide.path, "utf8"),
},
],
}),
);
}
server.registerTool(
"list_drawings",
{
title: "List Drawings",
description: "Returns drawing metadata ordered like the app list.",
},
async () => jsonText(tools.list_drawings()),
);
server.registerTool(
"get_drawing",
{
title: "Get Drawing",
description:
"Returns drawing metadata, browser URL, and raw ScenePayload.",
inputSchema: idInput.shape,
},
async (input) => jsonText(tools.get_drawing(input)),
);
server.registerTool(
"create_drawing",
{
title: "Create Drawing",
description:
"Creates a drawing from an explicit Excalidraw ScenePayload.",
inputSchema: createDrawingInput.shape,
},
async (input) => jsonText(tools.create_drawing(input)),
);
server.registerTool(
"replace_drawing",
{
title: "Replace Drawing",
description: "Replaces a drawing scene and optionally its title.",
inputSchema: replaceDrawingInput.shape,
},
async (input) => jsonText(tools.replace_drawing(input)),
);
server.registerTool(
"patch_drawing",
{
title: "Patch Drawing",
description:
"Applies RFC 6902 JSON Patch operations to a drawing scene and optionally updates its title.",
inputSchema: patchDrawingInput.shape,
},
async (input) => jsonText(tools.patch_drawing(input)),
);
},
{
serverInfo: {
name: "excali",
version: "0.1.0",
},
},
{
basePath: "",
disableSse: true,
maxDuration: 60,
},
);
}
export function createMcpServer() {
const store = createDrawingStore(process.env.DATABASE_PATH);
const handler = createExcaliMcpHandler(
store,
resolvePublicBaseUrl(),
resolveStylesGuidePath(),
);
return {
port: Number(process.env.MCP_PORT ?? "3001"),
hostname: process.env.MCP_HOST ?? "127.0.0.1",
fetch(request: Request) {
const url = new URL(request.url);
if (url.pathname !== "/mcp") {
return Response.json(
{ ok: false, error: "Not found" },
{ status: 404 },
);
}
return handler(request);
},
} satisfies Parameters<typeof Bun.serve>[0];
}
if (import.meta.main) {
const server = Bun.serve(createMcpServer());
console.log(`MCP listening on http://${server.hostname}:${server.port}/mcp`);
}
+431
View File
@@ -0,0 +1,431 @@
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");
expect(created.revision).toBe(0);
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: {},
expectedRevision: created.revision,
}),
}),
{ params: { id: created.id } },
),
);
expect(response.status).toBe(200);
const body = await response.json();
expect(body.drawing.revision).toBe(1);
cleanup();
});
test("does not increment revisions for equivalent normalized scenes", async () => {
const { api, cleanup } = withApi();
const created = await api.createDrawing().json();
await api.updateDrawing(
Object.assign(
new Request(`http://local/api/drawings/${created.id}`, {
method: "PUT",
body: JSON.stringify({
elements: [{ id: "shape" }],
appState: { gridSize: 20 },
files: {},
expectedRevision: 0,
}),
}),
{ params: { id: created.id } },
),
);
const response = await api.updateDrawing(
Object.assign(
new Request(`http://local/api/drawings/${created.id}`, {
method: "PUT",
body: JSON.stringify({
elements: [{ id: "shape" }],
appState: { gridSize: 20, selectedElementIds: { shape: true } },
files: {},
expectedRevision: 0,
}),
}),
{ params: { id: created.id } },
),
);
expect(response.status).toBe(200);
const body = await response.json();
expect(body.drawing.revision).toBe(1);
cleanup();
});
test("updates scene and title with a matching expected revision", 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({
title: "Synced",
elements: [{ id: "shape" }],
appState: {},
files: {},
expectedRevision: 0,
}),
}),
{ params: { id: created.id } },
),
);
expect(response.status).toBe(200);
const body = await response.json();
expect(body.drawing.title).toBe("Synced");
expect(body.drawing.revision).toBe(1);
cleanup();
});
test("returns 409 for stale expected revisions without overwriting", async () => {
const { api, cleanup } = withApi();
const created = await api.createDrawing().json();
await api.updateDrawing(
Object.assign(
new Request(`http://local/api/drawings/${created.id}`, {
method: "PUT",
body: JSON.stringify({
elements: [{ id: "server" }],
appState: {},
files: {},
expectedRevision: 0,
}),
}),
{ params: { id: created.id } },
),
);
const conflict = await api.updateDrawing(
Object.assign(
new Request(`http://local/api/drawings/${created.id}`, {
method: "PUT",
body: JSON.stringify({
elements: [{ id: "stale" }],
appState: {},
files: {},
expectedRevision: 0,
}),
}),
{ params: { id: created.id } },
),
);
expect(conflict.status).toBe(409);
const body = await conflict.json();
expect(body).toEqual({
ok: false,
error: "Drawing changed externally",
drawing: {
id: created.id,
title: created.title,
createdAt: body.drawing.createdAt,
updatedAt: body.drawing.updatedAt,
revision: 1,
},
});
const stored = await api
.getDrawing(
Object.assign(new Request(`http://local/api/drawings/${created.id}`), {
params: { id: created.id },
}),
)
.json();
expect(stored.elements).toEqual([{ id: "server" }]);
cleanup();
});
test("returns disabled and enabled publication shapes", async () => {
const { api, cleanup } = withApi();
const created = await api.createDrawing().json();
const disabled = await api.getDrawingPublication(
Object.assign(
new Request(`http://local/api/drawings/${created.id}/publication`),
{ params: { id: created.id } },
),
);
expect(disabled.status).toBe(200);
expect(await disabled.json()).toEqual({ enabled: false });
const publish = await api.updateDrawingPublication(
Object.assign(
new Request(`http://local/api/drawings/${created.id}/publication`, {
method: "PUT",
body: JSON.stringify({
enabled: true,
slug: "Flow Chart",
}),
}),
{ params: { id: created.id } },
),
);
expect(publish.status).toBe(200);
expect(await publish.json()).toEqual({
enabled: true,
slug: "flow-chart",
});
const enabled = await api.getDrawingPublication(
Object.assign(
new Request(`http://local/api/drawings/${created.id}/publication`),
{ params: { id: created.id } },
),
);
expect(await enabled.json()).toEqual({
enabled: true,
slug: "flow-chart",
});
cleanup();
});
test("publishes, renames, and disables publications", async () => {
const { api, cleanup } = withApi();
const created = await api.createDrawing().json();
await api.updateDrawingPublication(
Object.assign(
new Request(`http://local/api/drawings/${created.id}/publication`, {
method: "PUT",
body: JSON.stringify({
enabled: true,
slug: "first",
}),
}),
{ params: { id: created.id } },
),
);
const renamed = await api.updateDrawingPublication(
Object.assign(
new Request(`http://local/api/drawings/${created.id}/publication`, {
method: "PUT",
body: JSON.stringify({
enabled: true,
slug: "second",
}),
}),
{ params: { id: created.id } },
),
);
expect(renamed.status).toBe(200);
expect(await renamed.json()).toEqual({
enabled: true,
slug: "second",
});
const disable = await api.updateDrawingPublication(
Object.assign(
new Request(`http://local/api/drawings/${created.id}/publication`, {
method: "PUT",
body: JSON.stringify({ enabled: false }),
}),
{ params: { id: created.id } },
),
);
expect(disable.status).toBe(200);
expect(await disable.json()).toEqual({ enabled: false });
cleanup();
});
test("returns 400 for invalid publication slugs", async () => {
const { api, cleanup } = withApi();
const created = await api.createDrawing().json();
const response = await api.updateDrawingPublication(
Object.assign(
new Request(`http://local/api/drawings/${created.id}/publication`, {
method: "PUT",
body: JSON.stringify({
enabled: true,
slug: "API",
}),
}),
{ params: { id: created.id } },
),
);
expect(response.status).toBe(400);
expect(await response.json()).toEqual({
ok: false,
error: "slug is reserved",
});
cleanup();
});
test("returns 409 for publication slug collisions", async () => {
const { api, cleanup } = withApi();
const first = await api.createDrawing().json();
const second = await api.createDrawing().json();
await api.updateDrawingPublication(
Object.assign(
new Request(`http://local/api/drawings/${first.id}/publication`, {
method: "PUT",
body: JSON.stringify({
enabled: true,
slug: "shared",
}),
}),
{ params: { id: first.id } },
),
);
const response = await api.updateDrawingPublication(
Object.assign(
new Request(`http://local/api/drawings/${second.id}/publication`, {
method: "PUT",
body: JSON.stringify({
enabled: true,
slug: "shared",
}),
}),
{ params: { id: second.id } },
),
);
expect(response.status).toBe(409);
expect(await response.json()).toEqual({
ok: false,
error: "Published slug already exists",
});
cleanup();
});
test("returns public scene data for enabled slugs and 404 otherwise", async () => {
const { api, cleanup } = withApi();
const created = await api.createDrawing().json();
await api.updateDrawing(
Object.assign(
new Request(`http://local/api/drawings/${created.id}`, {
method: "PUT",
body: JSON.stringify({
title: "Public Flow",
elements: [{ id: "shape" }],
appState: { theme: "dark" },
files: {},
expectedRevision: 0,
}),
}),
{ params: { id: created.id } },
),
);
await api.updateDrawingPublication(
Object.assign(
new Request(`http://local/api/drawings/${created.id}/publication`, {
method: "PUT",
body: JSON.stringify({
enabled: true,
slug: "public-flow",
}),
}),
{ params: { id: created.id } },
),
);
const enabled = await api.getPublicDrawing(
Object.assign(
new Request("http://local/api/public/drawings/public-flow"),
{ params: { slug: "public-flow" } },
),
);
expect(enabled.status).toBe(200);
expect(await enabled.json()).toEqual({
title: "Public Flow",
elements: [{ id: "shape" }],
appState: { theme: "dark" },
files: {},
});
const missing = await api.getPublicDrawing(
Object.assign(new Request("http://local/api/public/drawings/missing"), {
params: { slug: "missing" },
}),
);
expect(missing.status).toBe(404);
cleanup();
});
});
+275
View File
@@ -0,0 +1,275 @@
import { type DrawingStore } from "./db";
import { normalizePublicationSlug } from "../core/publication";
import { type DrawingMeta, type DrawingPublication } from "../core/shared";
import {
HttpError,
normalizeTitle,
parseJsonBody,
parseSceneText,
} from "../core/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 });
}
if (error instanceof Error && error.name === "AbortError") {
return json({ ok: false, error: "Request aborted" }, { status: 499 });
}
console.error(error);
return json({ ok: false, error: "Internal server error" }, { status: 500 });
}
function toMeta(drawing: DrawingMeta): DrawingMeta {
return {
id: drawing.id,
title: drawing.title,
createdAt: drawing.createdAt,
updatedAt: drawing.updatedAt,
revision: drawing.revision,
};
}
function parseExpectedRevision(
raw: Record<string, unknown>,
): number | undefined {
if (!Object.hasOwn(raw, "expectedRevision")) {
return undefined;
}
const value = raw.expectedRevision;
if (typeof value !== "number" || !Number.isSafeInteger(value) || value < 0) {
throw new HttpError(400, "expectedRevision must be a non-negative integer");
}
return value;
}
function jsonPublication(publication: DrawingPublication): Response {
return json(publication);
}
export function createApi(store: DrawingStore) {
return {
health() {
return json({ ok: true });
},
listDrawings() {
return json(store.listDrawings());
},
createDrawing() {
const drawing = store.createDrawing();
return json(
{
...toMeta(drawing),
...drawing.scene,
},
{ status: 201 },
);
},
getDrawing(request: RouteRequest) {
try {
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({
...toMeta(drawing),
...drawing.scene,
});
} catch (error) {
return errorResponse(error);
}
},
getPublicDrawing(request: RouteRequest) {
try {
const slug = request.params?.slug;
if (!slug) {
throw new HttpError(400, "Missing published slug");
}
const drawing = store.getPublicDrawing(slug);
if (!drawing) {
return json(
{ ok: false, error: "Drawing not found" },
{ status: 404 },
);
}
return json(drawing);
} catch (error) {
return errorResponse(error);
}
},
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");
const expectedRevision = parseExpectedRevision(raw);
if (!hasTitle && !hasScene) {
throw new HttpError(400, "Nothing to update");
}
const scene = hasScene ? parseSceneText(text) : undefined;
const result = store.updateDrawing(id, {
scene,
title: hasTitle ? normalizeTitle(raw.title) : undefined,
expectedRevision,
});
if (!result.ok && result.reason === "not_found") {
return json(
{ ok: false, error: "Drawing not found" },
{ status: 404 },
);
}
if (!result.ok && result.reason === "conflict") {
return json(
{
ok: false,
error: "Drawing changed externally",
drawing: toMeta(result.drawing),
},
{ status: 409 },
);
}
const updated = result.drawing;
return json({
ok: true,
drawing: toMeta(updated),
});
} 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 });
},
getDrawingPublication(request: RouteRequest) {
try {
const id = request.params?.id;
if (!id) {
throw new HttpError(400, "Missing drawing id");
}
const publication = store.getDrawingPublication(id);
if (!publication) {
return json(
{ ok: false, error: "Drawing not found" },
{ status: 404 },
);
}
return jsonPublication(publication);
} catch (error) {
return errorResponse(error);
}
},
async updateDrawingPublication(request: RouteRequest) {
try {
const id = request.params?.id;
if (!id) {
throw new HttpError(400, "Missing drawing id");
}
const raw = parseJsonBody<Record<string, unknown>>(
await request.text(),
);
if (raw.enabled === false) {
const result = store.disableDrawingPublication(id);
if (!result.ok) {
return json(
{ ok: false, error: "Drawing not found" },
{ status: 404 },
);
}
return jsonPublication(result.publication);
}
if (raw.enabled !== true) {
throw new HttpError(400, "enabled must be a boolean");
}
const drawing = store.getDrawing(id);
if (!drawing) {
return json(
{ ok: false, error: "Drawing not found" },
{ status: 404 },
);
}
const result = store.publishDrawing(id, {
slug: normalizePublicationSlug(raw.slug),
});
if (!result.ok && result.reason === "not_found") {
return json(
{ ok: false, error: "Drawing not found" },
{ status: 404 },
);
}
if (!result.ok && result.reason === "slug_conflict") {
return json(
{ ok: false, error: "Published slug already exists" },
{ status: 409 },
);
}
return jsonPublication(result.publication);
} catch (error) {
return errorResponse(error);
}
},
};
}
+249
View File
@@ -0,0 +1,249 @@
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.revision).toBe(0);
expect(created.scene.appState.theme).toBe("dark");
expect(store.listDrawings()).toHaveLength(1);
expect(store.listDrawings()[0]?.revision).toBe(0);
const updated = store.updateDrawing(created.id, {
title: "Flow",
scene: {
elements: [{ id: "one" }],
appState: { gridSize: 20 },
files: {},
},
});
expect(updated.ok).toBe(true);
expect(updated.ok ? updated.drawing.title : null).toBe("Flow");
expect(updated.ok ? updated.drawing.revision : null).toBe(1);
expect(updated.ok ? updated.drawing.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);
});
test("rejects stale expected revisions without overwriting the scene", () => {
const store = createTempStore();
const created = store.createDrawing();
const first = store.updateDrawing(created.id, {
expectedRevision: 0,
scene: {
elements: [{ id: "first" }],
appState: {},
files: {},
},
});
expect(first.ok).toBe(true);
const stale = store.updateDrawing(created.id, {
expectedRevision: 0,
scene: {
elements: [{ id: "stale" }],
appState: {},
files: {},
},
});
expect(stale.ok).toBe(false);
expect(stale.ok ? null : stale.reason).toBe("conflict");
expect(store.getDrawing(created.id)?.revision).toBe(1);
expect(store.getDrawing(created.id)?.scene.elements).toEqual([
{ id: "first" },
]);
});
test("keeps the revision unchanged when the scene is already stored", () => {
const store = createTempStore();
const created = store.createDrawing();
const scene = {
elements: [{ id: "same" }],
appState: {},
files: {},
};
const first = store.updateDrawing(created.id, { scene });
expect(first.ok ? first.drawing.revision : null).toBe(1);
const repeated = store.updateDrawing(created.id, { scene });
expect(repeated.ok).toBe(true);
expect(repeated.ok ? repeated.drawing.revision : null).toBe(1);
expect(store.getDrawing(created.id)?.revision).toBe(1);
});
test("accepts stale expected revisions when the scene is already stored", () => {
const store = createTempStore();
const created = store.createDrawing();
const scene = {
elements: [{ id: "same" }],
appState: {},
files: {},
};
const first = store.updateDrawing(created.id, {
expectedRevision: 0,
scene,
});
expect(first.ok ? first.drawing.revision : null).toBe(1);
const repeated = store.updateDrawing(created.id, {
expectedRevision: 0,
scene,
});
expect(repeated.ok).toBe(true);
expect(repeated.ok ? repeated.drawing.revision : null).toBe(1);
expect(store.getDrawing(created.id)?.scene.elements).toEqual([
{ id: "same" },
]);
});
test("increments the revision when the title changes but the scene does not", () => {
const store = createTempStore();
const created = store.createDrawing();
const scene = {
elements: [{ id: "same" }],
appState: {},
files: {},
};
const first = store.updateDrawing(created.id, {
expectedRevision: 0,
scene,
});
expect(first.ok ? first.drawing.revision : null).toBe(1);
const renamed = store.updateDrawing(created.id, {
expectedRevision: 1,
title: "Renamed",
scene,
});
expect(renamed.ok).toBe(true);
expect(renamed.ok ? renamed.drawing.title : null).toBe("Renamed");
expect(renamed.ok ? renamed.drawing.revision : null).toBe(2);
});
test("persists publication fields and exposes public drawings by slug", () => {
const store = createTempStore();
const created = store.createDrawing("Flow");
store.updateDrawing(created.id, {
scene: {
elements: [{ id: "one" }],
appState: { theme: "dark" },
files: {},
},
});
const published = store.publishDrawing(created.id, { slug: "flow" });
expect(published.ok).toBe(true);
expect(store.getDrawingPublication(created.id)).toEqual({
enabled: true,
slug: "flow",
});
expect(store.getPublicDrawing("flow")).toEqual({
title: "Flow",
elements: [{ id: "one" }],
appState: { theme: "dark" },
files: {},
});
});
test("enforces unique active publication slugs", () => {
const store = createTempStore();
const first = store.createDrawing("First");
const second = store.createDrawing("Second");
expect(
store.publishDrawing(first.id, {
slug: "shared",
}),
).toEqual({
ok: true,
publication: {
enabled: true,
slug: "shared",
},
});
expect(
store.publishDrawing(second.id, {
slug: "shared",
}),
).toEqual({
ok: false,
reason: "slug_conflict",
});
expect(store.disableDrawingPublication(first.id)).toEqual({
ok: true,
publication: { enabled: false },
});
expect(
store.publishDrawing(second.id, {
slug: "shared",
}),
).toEqual({
ok: true,
publication: {
enabled: true,
slug: "shared",
},
});
});
test("clears public fields on disable", () => {
const store = createTempStore();
const created = store.createDrawing();
expect(
store.publishDrawing(created.id, {
slug: "note",
}),
).toEqual({
ok: true,
publication: {
enabled: true,
slug: "note",
},
});
expect(store.disableDrawingPublication(created.id)).toEqual({
ok: true,
publication: { enabled: false },
});
expect(store.getDrawingPublication(created.id)).toEqual({ enabled: false });
expect(store.getPublicDrawing("note")).toBeNull();
});
});
+487
View File
@@ -0,0 +1,487 @@
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 DrawingPublication,
type DrawingRecord,
type ScenePayload,
} from "../core/shared";
import { emptyScene, normalizeScene, normalizeTitle } from "../core/scene";
type DrawingRow = {
id: string;
title: string;
data: string;
createdAt: string;
updatedAt: string;
revision: number;
};
type DrawingMetaRow = Omit<DrawingRow, "data">;
type DrawingPublicationRow = {
publicEnabled: number;
publicSlug: string | null;
};
type PublicDrawingRow = {
title: string;
data: string;
};
export type DrawingUpdateResult =
| { ok: true; drawing: DrawingRecord }
| { ok: false; reason: "not_found" }
| { ok: false; reason: "conflict"; drawing: DrawingRecord };
export type DrawingPublicationUpdateResult =
| { ok: true; publication: DrawingPublication }
| { ok: false; reason: "not_found" }
| { ok: false; reason: "slug_conflict" };
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,
revision: row.revision,
};
}
function readPublication(
row: DrawingPublicationRow | null | undefined,
): DrawingPublication | null {
if (!row) {
return null;
}
if (row.publicEnabled !== 1 || !row.publicSlug) {
return { enabled: false };
}
return {
enabled: true,
slug: row.publicSlug,
};
}
function readPublicDrawing(row: PublicDrawingRow | null | undefined) {
if (!row) {
return null;
}
let scene: ScenePayload;
try {
scene = normalizeScene(JSON.parse(row.data));
} catch (error) {
const message =
error instanceof Error ? error.message : "Unknown scene parsing error";
throw new Error(`Invalid stored public scene: ${message}`);
}
return {
title: row.title,
...scene,
};
}
function readRow(row: DrawingRow | null | undefined): DrawingRecord | null {
if (!row) {
return null;
}
let scene: ScenePayload;
try {
scene = normalizeScene(JSON.parse(row.data));
} catch (error) {
const message =
error instanceof Error ? error.message : "Unknown scene parsing error";
throw new Error(`Invalid stored scene for drawing ${row.id}: ${message}`);
}
return {
...readMeta(row)!,
scene,
};
}
function ensurePublicationColumns(db: Database) {
const columns = db.query("PRAGMA table_info(drawings)").all() as Array<{
name: string;
}>;
const names = new Set(columns.map((column) => column.name));
if (!names.has("public_enabled")) {
db.exec(
"ALTER TABLE drawings ADD COLUMN public_enabled INTEGER NOT NULL DEFAULT 0",
);
}
if (!names.has("public_slug")) {
db.exec("ALTER TABLE drawings ADD COLUMN public_slug TEXT");
}
}
function isSlugConflictError(error: unknown): boolean {
return (
error instanceof Error &&
error.message.includes("UNIQUE constraint failed: drawings.public_slug")
);
}
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,
revision INTEGER NOT NULL DEFAULT 0,
public_enabled INTEGER NOT NULL DEFAULT 0,
public_slug TEXT
);
CREATE INDEX IF NOT EXISTS drawings_updated_at_idx
ON drawings(updated_at DESC, created_at DESC);
`);
ensurePublicationColumns(db);
db.exec(`
CREATE UNIQUE INDEX IF NOT EXISTS drawings_public_slug_active_idx
ON drawings(public_slug)
WHERE public_enabled = 1 AND public_slug IS NOT NULL;
`);
const listQuery = db.query(`
SELECT
id,
title,
created_at AS createdAt,
updated_at AS updatedAt,
revision
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,
revision
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, revision = revision + 1
WHERE id = ?1
`);
const updateTitleQuery = db.query(`
UPDATE drawings
SET title = ?2, updated_at = CURRENT_TIMESTAMP, revision = revision + 1
WHERE id = ?1
`);
const updateBothQuery = db.query(`
UPDATE drawings
SET title = ?2, data = ?3, updated_at = CURRENT_TIMESTAMP, revision = revision + 1
WHERE id = ?1
`);
const updateSceneExpectedQuery = db.query(`
UPDATE drawings
SET data = ?2, updated_at = CURRENT_TIMESTAMP, revision = revision + 1
WHERE id = ?1 AND revision = ?3
`);
const updateTitleExpectedQuery = db.query(`
UPDATE drawings
SET title = ?2, updated_at = CURRENT_TIMESTAMP, revision = revision + 1
WHERE id = ?1 AND revision = ?3
`);
const updateBothExpectedQuery = db.query(`
UPDATE drawings
SET title = ?2, data = ?3, updated_at = CURRENT_TIMESTAMP, revision = revision + 1
WHERE id = ?1 AND revision = ?4
`);
const deleteQuery = db.query(`
DELETE FROM drawings
WHERE id = ?1
`);
const getPublicationQuery = db.query(`
SELECT
public_enabled AS publicEnabled,
public_slug AS publicSlug
FROM drawings
WHERE id = ?1
`);
const publishQuery = db.query(`
UPDATE drawings
SET
public_enabled = 1,
public_slug = ?2
WHERE id = ?1
`);
const disablePublicationQuery = db.query(`
UPDATE drawings
SET
public_enabled = 0,
public_slug = NULL
WHERE id = ?1
`);
const getPublicDrawingQuery = db.query(`
SELECT
title,
data
FROM drawings
WHERE public_enabled = 1 AND public_slug = ?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; expectedRevision?: number },
): DrawingUpdateResult {
const hasScene = update.scene !== undefined;
const hasTitle = update.title !== undefined;
const existingRow = getQuery.get(id) as DrawingRow | null | undefined;
if (!existingRow) {
return { ok: false, reason: "not_found" };
}
const existing = readRow(existingRow)!;
if (!hasScene && !hasTitle) {
return { ok: true, drawing: existing };
}
const nextTitle = hasTitle
? normalizeTitle(update.title)
: existingRow.title;
const nextScene = hasScene
? JSON.stringify(update.scene)
: existingRow.data;
const titleChanged = nextTitle !== existingRow.title;
const sceneChanged = nextScene !== existingRow.data;
if (!titleChanged && !sceneChanged) {
return { ok: true, drawing: existing };
}
if (
update.expectedRevision !== undefined &&
update.expectedRevision !== existingRow.revision
) {
return { ok: false, reason: "conflict", drawing: existing };
}
let changes: number;
if (sceneChanged && titleChanged) {
changes =
update.expectedRevision === undefined
? Number(updateBothQuery.run(id, nextTitle, nextScene).changes)
: Number(
updateBothExpectedQuery.run(
id,
nextTitle,
nextScene,
update.expectedRevision,
).changes,
);
} else if (sceneChanged) {
changes =
update.expectedRevision === undefined
? Number(updateSceneQuery.run(id, nextScene).changes)
: Number(
updateSceneExpectedQuery.run(
id,
nextScene,
update.expectedRevision,
).changes,
);
} else {
changes =
update.expectedRevision === undefined
? Number(updateTitleQuery.run(id, nextTitle).changes)
: Number(
updateTitleExpectedQuery.run(
id,
nextTitle,
update.expectedRevision,
).changes,
);
}
const drawing = getDrawing(id);
if (changes > 0 && drawing) {
return { ok: true, drawing };
}
if (!drawing) {
return { ok: false, reason: "not_found" };
}
return { ok: false, reason: "conflict", drawing };
}
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 getDrawingPublication(id: string): DrawingPublication | null {
return readPublication(
getPublicationQuery.get(id) as DrawingPublicationRow | null | undefined,
);
}
function publishDrawing(
id: string,
publication: { slug: string },
): DrawingPublicationUpdateResult {
try {
const changes = Number(publishQuery.run(id, publication.slug).changes);
if (changes === 0) {
return { ok: false, reason: "not_found" };
}
} catch (error) {
if (isSlugConflictError(error)) {
return { ok: false, reason: "slug_conflict" };
}
throw error;
}
return {
ok: true,
publication: getDrawingPublication(id)!,
};
}
function disableDrawingPublication(
id: string,
): DrawingPublicationUpdateResult {
const changes = Number(disablePublicationQuery.run(id).changes);
if (changes === 0) {
return { ok: false, reason: "not_found" };
}
return {
ok: true,
publication: { enabled: false },
};
}
function getPublicDrawing(slug: string) {
return readPublicDrawing(
getPublicDrawingQuery.get(slug) as PublicDrawingRow | null | undefined,
);
}
function close() {
db.close(false);
}
return {
databasePath,
close,
listDrawings,
getDrawing,
createDrawing,
getLatestDrawing,
ensureInitialDrawing,
updateDrawing,
deleteDrawing,
getDrawingPublication,
publishDrawing,
disableDrawingPublication,
getPublicDrawing,
};
}
let defaultStore: DrawingStore | null = null;
export function getDefaultDrawingStore(): DrawingStore {
defaultStore ??= createDrawingStore();
return defaultStore;
}
+11
View File
@@ -0,0 +1,11 @@
import { describe, expect, test } from "bun:test";
import { createDevServer } from "./dev-server";
describe("createDevServer", () => {
test("serves the spa for private and public drawing routes", () => {
const server = createDevServer();
expect(Object.hasOwn(server.routes, "/d/:id")).toBe(true);
expect(Object.hasOwn(server.routes, "/p/:slug")).toBe(true);
});
});
@@ -1,5 +1,5 @@
import index from "./index.html";
import { createServer } from "./server";
import index from "../client/index.html";
import { createServer } from "./http-server";
export function createDevServer() {
const server = createServer();
@@ -9,6 +9,7 @@ export function createDevServer() {
routes: {
...server.routes,
"/d/:id": index,
"/p/:slug": index,
},
} satisfies Parameters<typeof Bun.serve>[0];
}
@@ -3,7 +3,7 @@ import { mkdtempSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { createDrawingStore, type DrawingStore } from "./db";
import { createServer } from "./server";
import { createServer } from "./http-server";
const cleanup: string[] = [];
const stores: DrawingStore[] = [];
@@ -38,12 +38,16 @@ describe("createServer", () => {
test("redirects / to the latest drawing", () => {
const { server, store } = createServerFixture();
const response = server.routes["/"]?.(new Request("http://local/")) as Response;
const response = server.routes["/"]?.(
new Request("http://local/"),
) as Response;
const created = store.listDrawings();
expect(created).toHaveLength(1);
expect(response.status).toBe(302);
expect(response.headers.get("location")).toBe(`http://local/d/${created[0]?.id}`);
expect(response.headers.get("location")).toBe(
`http://local/d/${created[0]?.id}`,
);
});
test("keeps Bun responsible for API routes", async () => {
@@ -58,11 +62,49 @@ describe("createServer", () => {
const { server } = createServerFixture();
expect(Object.hasOwn(server.routes, "/d/:id")).toBe(false);
expect(Object.hasOwn(server.routes, "/p/:slug")).toBe(false);
});
test("serves public drawing data through the public api", async () => {
const { server, store } = createServerFixture();
const drawing = store.createDrawing("Published");
store.publishDrawing(drawing.id, { slug: "published-note" });
const response = await server.routes["/api/public/drawings/:slug"]?.GET?.(
Object.assign(
new Request("http://local/api/public/drawings/published-note"),
{
params: { slug: "published-note" },
},
),
);
expect(response?.status).toBe(200);
expect(await response?.json()).toEqual({
title: "Published",
elements: [],
appState: { theme: "dark" },
files: {},
});
});
test("returns 404 for unknown or unpublished public slugs", async () => {
const { server } = createServerFixture();
const response = await server.routes["/api/public/drawings/:slug"]?.GET?.(
Object.assign(new Request("http://local/api/public/drawings/missing"), {
params: { slug: "missing" },
}),
);
expect(response?.status).toBe(404);
});
test("returns JSON 404 for unmatched requests", async () => {
const { server } = createServerFixture();
const response = await server.fetch(new Request("http://local/index-abc123.js"));
const response = await server.fetch(
new Request("http://local/index-abc123.js"),
);
expect(response.status).toBe(404);
expect(await response.json()).toEqual({ ok: false, error: "Not found" });
+18 -4
View File
@@ -25,12 +25,26 @@ export function createServer(options: CreateServerOptions = {}) {
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),
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),
},
"/api/public/drawings/:slug": {
GET: (request: Request & { params: Record<string, string> }) =>
api.getPublicDrawing(request),
},
"/api/drawings/:id/publication": {
GET: (request: Request & { params: Record<string, string> }) =>
api.getDrawingPublication(request),
PUT: (request: Request & { params: Record<string, string> }) =>
api.updateDrawingPublication(request),
},
},
fetch: (_request: Request) => Response.json({ ok: false, error: "Not found" }, { status: 404 }),
fetch: (_request: Request) =>
Response.json({ ok: false, error: "Not found" }, { status: 404 }),
} satisfies Parameters<typeof Bun.serve>[0];
}
-297
View File
@@ -1,297 +0,0 @@
: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);
}
}