Compare commits

..

4 Commits

15 changed files with 1466 additions and 24 deletions
+4
View File
@@ -0,0 +1,4 @@
## 2024-06-06 - Missing HTTP Security Headers
**Vulnerability:** The Caddyfile configuration was missing standard HTTP security headers (X-Frame-Options, X-Content-Type-Options, Referrer-Policy), leaving the application susceptible to clickjacking, MIME-type sniffing, and sensitive data leakage via referrers.
**Learning:** The application relies on Caddy as a reverse proxy, and by default, Caddy doesn't automatically add these specific security headers unless explicitly configured.
**Prevention:** Ensure that reverse proxy configurations in new projects or updates to existing ones explicitly include essential HTTP security headers in a global scope to protect all served content.
+25 -19
View File
@@ -1,30 +1,36 @@
{
auto_https off
admin off
auto_https off
admin off
}
:80 {
encode zstd gzip
encode zstd gzip
handle /mcp {
reverse_proxy 127.0.0.1:3001
}
header {
X-Frame-Options "SAMEORIGIN"
X-Content-Type-Options "nosniff"
Referrer-Policy "strict-origin-when-cross-origin"
}
@app path / /api/*
handle @app {
reverse_proxy 127.0.0.1:3000
}
handle /mcp {
reverse_proxy 127.0.0.1:3001
}
handle {
root * /srv/public
@app path / /api/*
handle @app {
reverse_proxy 127.0.0.1:3000
}
@html path /index.html /d/* /p/*
header @html Cache-Control "no-cache"
handle {
root * /srv/public
@assets path_regexp assets \.(?:css|js|mjs|svg|png|jpg|jpeg|gif|webp|ico|woff2?|ttf|otf)$
header @assets Cache-Control "public, max-age=31536000, immutable"
@html path /index.html /d/* /p/*
header @html Cache-Control "no-cache"
try_files {path} /index.html
file_server
}
@assets path_regexp assets \.(?:css|js|mjs|svg|png|jpg|jpeg|gif|webp|ico|woff2?|ttf|otf)$
header @assets Cache-Control "public, max-age=31536000, immutable"
try_files {path} /index.html
file_server
}
}
+91
View File
@@ -11,6 +11,7 @@
"mcp-handler": "^1.1.0",
"react": "^19.2.6",
"react-dom": "^19.2.6",
"shiki": "^4.1.0",
"zod": "^4.4.3",
},
"devDependencies": {
@@ -136,6 +137,22 @@
"@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=="],
@@ -202,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=="],
@@ -210,6 +231,10 @@
"@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=="],
@@ -240,8 +265,14 @@
"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=="],
@@ -252,6 +283,8 @@
"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=="],
@@ -354,8 +387,12 @@
"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=="],
@@ -428,8 +465,14 @@
"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=="],
@@ -492,12 +535,24 @@
"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=="],
@@ -522,6 +577,10 @@
"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=="],
@@ -560,6 +619,8 @@
"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=="],
@@ -584,6 +645,12 @@
"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=="],
@@ -610,6 +677,8 @@
"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=="],
@@ -624,8 +693,12 @@
"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@0.3.2", "", {}, "sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA=="],
@@ -634,6 +707,8 @@
"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=="],
@@ -646,6 +721,16 @@
"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=="],
@@ -658,6 +743,10 @@
"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=="],
@@ -684,6 +773,8 @@
"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=="],
+1
View File
@@ -24,6 +24,7 @@
"mcp-handler": "^1.1.0",
"react": "^19.2.6",
"react-dom": "^19.2.6",
"shiki": "^4.1.0",
"zod": "^4.4.3"
},
"devDependencies": {
File diff suppressed because one or more lines are too long
+239 -2
View File
@@ -1,7 +1,30 @@
import { useCallback, useEffect, useRef, useState } from "react";
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";
@@ -23,7 +46,13 @@ function routeFromPath(pathname = window.location.pathname): AppRoute {
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,
@@ -48,6 +77,38 @@ function PrivateApp() {
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;
@@ -73,6 +134,62 @@ function PrivateApp() {
};
}, [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);
}, []);
@@ -94,6 +211,102 @@ function PrivateApp() {
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 && (
@@ -101,6 +314,10 @@ function PrivateApp() {
{toastMessage}
</div>
)}
<InsertCodeBlockButton
onClick={handleInsertCodeBlock}
disabled={loading || scene === null}
/>
<main className="editor-shell">
<div className="app-actions">
<DrawingsToggle onClick={openSidebar} />
@@ -113,7 +330,20 @@ function PrivateApp() {
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
@@ -141,7 +371,14 @@ function PrivateApp() {
function PublicApp({ slug }: { slug: string }) {
const { drawing, loading, error } = usePublicDrawing(slug);
return <PublicViewer drawing={drawing} loading={loading} error={error} />;
return (
<PublicViewer
drawing={drawing}
loading={loading}
error={error}
renderEmbeddable={renderCodeBlockEmbeddable}
/>
);
}
export function App() {
+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>
);
}
+19 -1
View File
@@ -3,10 +3,16 @@ 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;
@@ -15,7 +21,10 @@ type EditorCanvasProps = {
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 {
@@ -28,7 +37,8 @@ function sceneFromEditor(
files: BinaryFiles,
): ScenePayload {
return {
elements: [...elements],
// 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>,
};
@@ -41,7 +51,10 @@ export const EditorCanvas = memo(function EditorCanvas({
error,
editorReloadNonce,
onSceneChange,
onSelectionStateChange,
onEditorActivity,
onExcalidrawAPI,
renderEmbeddable,
}: EditorCanvasProps) {
if (loading || !scene) {
return <div className="editor-loading">{error ?? "Loading..."}</div>;
@@ -52,9 +65,14 @@ export const EditorCanvas = memo(function EditorCanvas({
<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>
+7 -1
View File
@@ -1,6 +1,9 @@
import { memo } from "react";
import { Excalidraw } from "@excalidraw/excalidraw";
import type { ExcalidrawInitialDataState } from "@excalidraw/excalidraw/types";
import type {
ExcalidrawInitialDataState,
ExcalidrawProps,
} from "@excalidraw/excalidraw/types";
import "../../../node_modules/@excalidraw/excalidraw/dist/prod/index.css";
import { type PublicDrawing } from "../../core/shared";
@@ -8,6 +11,7 @@ type PublicViewerProps = {
drawing: PublicDrawing | null;
loading: boolean;
error: string | null;
renderEmbeddable?: ExcalidrawProps["renderEmbeddable"];
};
function drawingToInitialData(
@@ -35,6 +39,7 @@ export const PublicViewer = memo(function PublicViewer({
drawing,
loading,
error,
renderEmbeddable,
}: PublicViewerProps) {
if (loading || !drawing) {
return <div className="editor-loading">{error ?? "Loading..."}</div>;
@@ -47,6 +52,7 @@ export const PublicViewer = memo(function PublicViewer({
viewModeEnabled={true}
zenModeEnabled={true}
UIOptions={viewerUiOptions}
renderEmbeddable={renderEmbeddable}
/>
</div>
);
+10
View File
@@ -33,10 +33,20 @@ export function useThemeTokenSync(
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);
}
}
+208 -1
View File
@@ -27,7 +27,9 @@ body {
}
button,
input {
input,
select,
textarea {
font: inherit;
}
@@ -211,6 +213,7 @@ input {
bottom: calc(max(16px, env(safe-area-inset-bottom)) + 48px);
z-index: 20;
display: flex;
align-items: flex-end;
}
.app-actions-toggle {
@@ -227,6 +230,13 @@ input {
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;
@@ -323,6 +333,190 @@ input {
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;
@@ -359,4 +553,17 @@ input {
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);
}
}