Compare commits

...

188 Commits

Author SHA1 Message Date
Jeff-Emmett b2cf4280ca failed attempt at audio and transcriptions 2025-02-22 12:45:38 -05:00
Jeff-Emmett 3a99af257d video fix 2025-02-16 11:35:05 +01:00
Jeff-Emmett 12256c5b9c working video calls 2025-02-13 20:38:01 +01:00
Jeff-Emmett 87854883c6 deploy embed minimize function 2025-02-12 18:20:33 +01:00
Jeff-Emmett ebe2d4c0a2 Fix localstorage error on worker, promptshape 2025-02-11 14:35:22 +01:00
Jeff-Emmett d733b61a66 fix llm prompt for mobile 2025-02-08 20:29:06 +01:00
Jeff-Emmett 61143d2c20 Fixed API key button placement & status update 2025-02-08 19:30:20 +01:00
Jeff-Emmett f47c3e0007 reduce file size for savetoPDF 2025-02-08 19:09:20 +01:00
Jeff-Emmett 536e1e7a87 update wrangler 2025-02-08 17:57:50 +01:00
Jeff-Emmett ab2a9f6a79 board backups to R2 2025-01-28 16:42:58 +01:00
Jeff-Emmett 9b33efdcb3 Clean up tool names 2025-01-28 16:38:41 +01:00
Jeff-Emmett 86b37b9cc8 llm edges 2025-01-23 22:49:55 +01:00
Jeff-Emmett 7805a1e961 working llm util 2025-01-23 22:38:27 +01:00
Jeff-Emmett fdb96b6ae1 slidedeck shape installed, still minor difficulty in keyboard arrow transition between slides (last slide + wraparound) 2025-01-23 14:14:04 +01:00
Jeff-Emmett 1783d1b6eb added scoped propagators (with javascript object on arrow edge to control) 2025-01-21 23:25:28 +07:00
Jeff-Emmett bfbe7b8325 expand board zoom & fixed embedshape focus on mobile 2025-01-18 01:57:54 +07:00
Jeff-Emmett e3e2c474ac implemented basic board text search function, added double click to zoom 2025-01-03 10:52:04 +07:00
Jeff-Emmett 7b1fe2b803 removed padding from printtoPDF, hid mycrozine template tool (need to fix sync), cleaned up redundancies between app & board, installed marked npm package, hid markdown tool (need to fix styles) 2025-01-03 09:42:53 +07:00
Jeff-Emmett 02f816e613 updated EmbedShape to default to drag rather than interact when selected 2024-12-29 22:50:20 +07:00
Jeff Emmett 198109a919 add debug logging for videochat render 2024-12-16 17:12:40 -05:00
Jeff Emmett c6370c0fde update Daily API in worker, add debug 2024-12-16 17:00:15 -05:00
Jeff Emmett c75acca85b added TODO for broadcast, fixed videochat 2024-12-16 16:36:36 -05:00
Jeff Emmett d7f4d61b55 fix local IP for dev, fix broadcast view 2024-12-14 14:12:31 -05:00
Jeff Emmett 221a453411 adding broadcast controls for view follow, and shared iframe state while broadcasting (attempt) 2024-12-12 23:37:14 -05:00
Jeff Emmett ce3063e9ba adding selected object resizing with ctrl+arrows 2024-12-12 23:22:35 -05:00
Jeff Emmett 7987c3a8e4 default embed proportions 2024-12-12 23:00:26 -05:00
Jeff Emmett 8f94ee3a6f remove markdown element from menu until fixed. Added copy link & open in new tab options in embedded element URL 2024-12-12 20:45:37 -05:00
Jeff Emmett 201e489cef create frame shortcut dropdown on context menu 2024-12-12 20:02:56 -05:00
Jeff Emmett d23dca3ba8 leave drag selected object for later 2024-12-12 19:45:39 -05:00
Jeff Emmett 42e5afbb21 adding arrow key movements and drag functionality on selected elements 2024-12-12 18:05:35 -05:00
Jeff Emmett 997f690d22 added URL below embedded elements 2024-12-12 17:09:00 -05:00
Jeff Emmett 7978772d7b fix map embed 2024-12-10 12:28:39 -05:00
Jeff Emmett 9f54400f18 updated medium embeds to link out to new tab 2024-12-09 20:19:35 -05:00
Jeff Emmett 34681a3f4f fixed map embeds to include directions, substack embeds, twitter embeds 2024-12-09 18:55:38 -05:00
Jeff Emmett 3bb7eda655 add github action deploy 2024-12-09 04:37:01 -05:00
Jeff Emmett 72a7a54866 fix? 2024-12-09 04:19:49 -05:00
Jeff Emmett e714233f67 remove package lock from gitignore 2024-12-09 04:15:35 -05:00
Jeff Emmett cca1a06b9f install github actions 2024-12-09 03:51:54 -05:00
Jeff Emmett 84e737216d videochat working 2024-12-09 03:42:44 -05:00
Jeff Emmett bf5b3239dd fix domain url 2024-12-08 23:14:22 -05:00
Jeff Emmett 5858775483 logging bugs 2024-12-08 20:55:09 -05:00
Jeff Emmett b74ae75fa8 turn off cloud recording due to plan 2024-12-08 20:52:17 -05:00
Jeff Emmett 6e1e03d05b video debug 2024-12-08 20:47:39 -05:00
Jeff Emmett ce50366985 fix video api key 2024-12-08 20:41:45 -05:00
Jeff Emmett d9fb9637bd video bugs 2024-12-08 20:21:16 -05:00
Jeff Emmett 5d39baaea8 fix videochat 2024-12-08 20:11:05 -05:00
Jeff Emmett 9def6c52b5 fix video 2024-12-08 20:02:14 -05:00
Jeff Emmett 1f6b693ec1 videochat debug 2024-12-08 19:57:25 -05:00
Jeff Emmett b2e06ad76b fix videochat bugs 2024-12-08 19:46:29 -05:00
Jeff Emmett ac69e09aca fix url characters for videochat app 2024-12-08 19:38:28 -05:00
Jeff Emmett 08f31a0bbd fix daily domain 2024-12-08 19:35:11 -05:00
Jeff Emmett 2bdd6a8dba fix daily API 2024-12-08 19:27:18 -05:00
Jeff Emmett 9ff366c80b fixing daily api and domain 2024-12-08 19:19:19 -05:00
Jeff Emmett cc216eb07f fixing daily domain on vite config 2024-12-08 19:10:39 -05:00
Jeff Emmett d2ff445ddf fixing daily domain on vite config 2024-12-08 19:08:40 -05:00
Jeff Emmett a8ca366bb6 videochat tool worker fix 2024-12-08 18:51:23 -05:00
Jeff Emmett 4901a56d61 videochat tool worker install 2024-12-08 18:32:39 -05:00
Jeff Emmett 2d562b3e4c videochat tool update 2024-12-08 18:13:47 -05:00
Jeff Emmett a9a23e27e3 fix vitejs plugin dependency 2024-12-08 14:01:30 -05:00
Jeff Emmett cee2bfa336 update package engine 2024-12-08 13:58:40 -05:00
Jeff Emmett 5924b0cc97 update jspdf package types 2024-12-08 13:54:58 -05:00
Jeff Emmett 4ec6b73fb3 PrintToPDF working 2024-12-08 13:39:07 -05:00
Jeff Emmett ce50026cc3 PrintToPDF integration 2024-12-08 13:31:53 -05:00
Jeff Emmett 0ff9c64908 same 2024-12-08 05:45:31 -05:00
Jeff Emmett cf722c2490 everything working but page load camera initialization 2024-12-08 05:45:16 -05:00
Jeff Emmett 64d7581e6b fixed lockCameraToFrame selection 2024-12-08 05:07:09 -05:00
Jeff Emmett 1190848222 lockCamera still almost working 2024-12-08 03:01:28 -05:00
Jeff Emmett 11c88ec0de lockCameraToFrame almost working 2024-12-08 02:43:19 -05:00
Jeff Emmett 95307ed453 cleanup 2024-12-07 23:22:10 -05:00
Jeff Emmett bfe6b238e9 cleanup 2024-12-07 23:03:42 -05:00
Jeff Emmett fe4b40a3fe
Merge pull request #3 from Jeff-Emmett/markdown-textbox
cleanup
2024-12-08 11:01:55 +07:00
Jeff Emmett 4fda800e8b cleanup 2024-12-07 23:00:30 -05:00
Jeff Emmett 7c28758204 cleanup 2024-12-07 22:50:55 -05:00
Jeff Emmett 75c769a774 fix dev script 2024-12-07 22:49:39 -05:00
Jeff Emmett 5d8781462d npm 2024-12-07 22:48:02 -05:00
Jeff Emmett b2d6b1599b bun 2024-12-07 22:23:19 -05:00
Jeff Emmett c81238c45a remove deps 2024-12-07 22:15:05 -05:00
Jeff Emmett f012632cde prettify and cleanup 2024-12-07 22:01:02 -05:00
Jeff Emmett 78e396d11e cleanup 2024-12-07 21:42:31 -05:00
Jeff Emmett cba62a453b remove homepage board 2024-12-07 21:28:45 -05:00
Jeff Emmett 923f61ac9e cleanup tools/menu/actions 2024-12-07 21:16:44 -05:00
Jeff Emmett 94bec533c4
Merge pull request #2 from Jeff-Emmett/main-fixed
Main fixed
2024-12-08 04:23:27 +07:00
Jeff Emmett e286a120f1 maybe this works 2024-12-07 16:02:10 -05:00
Jeff Emmett 2e0a05ab32 fix vite config 2024-12-07 15:50:37 -05:00
Jeff Emmett 110fc19b94 one more attempt 2024-12-07 15:35:53 -05:00
Jeff Emmett 111be03907 swap persistentboard with Tldraw native sync 2024-12-07 15:23:56 -05:00
Jeff Emmett 39e6cccc3f fix CORS 2024-12-07 15:10:25 -05:00
Jeff Emmett 08175d3a7c fix CORS 2024-12-07 15:03:53 -05:00
Jeff Emmett 3006e85375 fix prod env 2024-12-07 14:57:05 -05:00
Jeff Emmett 632e7979a2 fix CORS 2024-12-07 14:39:57 -05:00
Jeff Emmett 71fc07133a fix CORS for prod env 2024-12-07 14:33:31 -05:00
Jeff Emmett 97b00c1569 fix prod env 2024-12-07 13:43:56 -05:00
Jeff Emmett c4198e1faf add vite env types 2024-12-07 13:31:37 -05:00
Jeff Emmett 6f6c924f66 fix VITE_ worker URL 2024-12-07 13:27:37 -05:00
Jeff Emmett 0eb4407219 fix worker deployment 2024-12-07 13:15:38 -05:00
Jeff Emmett 3a2a38c0b6 fix CORS policy 2024-12-07 12:58:46 -05:00
Jeff Emmett 02124ce920 fix CORS policy 2024-12-07 12:58:25 -05:00
Jeff Emmett b700846a9c fixing production env 2024-12-07 12:52:20 -05:00
Jeff Emmett f7310919f8 fix camerarevert and default to select tool 2024-11-27 13:46:41 +07:00
Jeff Emmett 949062941f fix default to hand tool 2024-11-27 13:38:54 +07:00
Jeff Emmett 7f497ae8d8 fix camera history 2024-11-27 13:30:45 +07:00
Jeff Emmett 1d817c8e0f add all function shortcuts to contextmenu 2024-11-27 13:24:11 +07:00
Jeff Emmett 7dd045bb33 fix menus 2024-11-27 13:16:52 +07:00
Jeff Emmett 11d13a03d3 fix menus 2024-11-27 13:01:45 +07:00
Jeff Emmett 3bcfa83168 fix board camera controls 2024-11-27 12:47:52 +07:00
Jeff Emmett b0a3cd7328 remove copy file creating problems 2024-11-27 12:25:04 +07:00
Jeff Emmett c71b67e24c fix vercel 2024-11-27 12:13:29 +07:00
Jeff Emmett d582be49b2 Merge branch 'add-camera-controls-for-link-to-frame-and-screen-position' 2024-11-27 11:56:36 +07:00
Jeff Emmett 46ee4e7906 fix gitignore 2024-11-27 11:54:05 +07:00
Jeff Emmett c34418e964 fix durable object reference 2024-11-27 11:34:02 +07:00
Jeff Emmett 1c8909ce69 fix worker url 2024-11-27 11:31:16 +07:00
Jeff Emmett 5f2c90219d fix board 2024-11-27 11:27:59 +07:00
Jeff Emmett fef2ca0eb3 fixing final 2024-11-27 11:26:25 +07:00
Jeff Emmett eab574e130 fix underscore 2024-11-27 11:23:46 +07:00
Jeff Emmett b2656c911b fix durableobject 2024-11-27 11:21:33 +07:00
Jeff Emmett 6ba124b038 fix env vars in vite 2024-11-27 11:17:29 +07:00
Jeff Emmett 1cd7208ddf fix vite and asset upload 2024-11-27 11:14:52 +07:00
Jeff Emmett d555910c77 fixed wrangler.toml 2024-11-27 11:07:15 +07:00
Jeff Emmett d1a8407a9b swapped in daily.co video and removed whereby sdk, finished zoom and copylink except for context menu display 2024-11-27 10:39:33 +07:00
Jeff Emmett db3205f97a almost everything working, except maybe offline storage state (and browser reload) 2024-11-25 22:09:41 +07:00
Jeff Emmett 100b88268b CRDTs working, still finalizing local board state browser storage for offline board access 2024-11-25 16:18:05 +07:00
Jeff Emmett 202971f343 checkpoint before google auth 2024-11-21 17:00:46 +07:00
Jeff Emmett b26b9e6384 final copy fix 2024-10-22 19:19:47 -04:00
Jeff Emmett 4d69340a6b update copy 2024-10-22 19:13:14 -04:00
Jeff Emmett 14e0126995 site copy update 2024-10-21 12:12:22 -04:00
Jeff Emmett 04782854d2 fix board 2024-10-19 23:30:04 -04:00
Jeff Emmett 4eff918bd3 fixed a bunch of stuff 2024-10-19 23:21:42 -04:00
Jeff Emmett 4e2103aab2 fix mobile embed 2024-10-19 16:20:54 -04:00
Jeff Emmett 895d02a19c embeds work! 2024-10-19 00:42:23 -04:00
Jeff Emmett 375f69b365 CustomMainMenu 2024-10-18 23:54:28 -04:00
Jeff Emmett 09a729c787 remove old chatboxes 2024-10-18 23:37:27 -04:00
Jeff Emmett bb8a76026e fix 2024-10-18 23:14:18 -04:00
Jeff Emmett 4319a6b1ee fix chatbox 2024-10-18 23:09:25 -04:00
Jeff Emmett 2ca6705599 update 2024-10-18 22:55:35 -04:00
Jeff Emmett 07556dd53a remove old chatbox 2024-10-18 22:47:23 -04:00
Jeff Emmett c93b3066bd serializedRoom 2024-10-18 22:31:20 -04:00
Jeff Emmett d282f6b650 deploy logs 2024-10-18 22:23:28 -04:00
Jeff Emmett c34cae40b6 fixing worker 2024-10-18 22:14:48 -04:00
Jeff Emmett 46b54394ad remove old chat rooms 2024-10-18 21:58:29 -04:00
Jeff Emmett b05aa413e3 resize 2024-10-18 21:30:16 -04:00
Jeff Emmett 2435f3f495 resize 2024-10-18 21:26:53 -04:00
Jeff Emmett 49bca38b5f it works! 2024-10-18 21:04:53 -04:00
Jeff Emmett 0d7ee5889c fixing video 2024-10-18 20:59:46 -04:00
Jeff Emmett a0bba93055 update 2024-10-18 18:59:06 -04:00
Jeff Emmett a2d7ab4af0 remove prefix 2024-10-18 18:08:05 -04:00
Jeff Emmett 99f7f131ed fix 2024-10-18 17:54:45 -04:00
Jeff Emmett c369762001 revert 2024-10-18 17:41:50 -04:00
Jeff Emmett d81ae56de0
Merge pull request #1 from Jeff-Emmett/Video-Chat-Attempt
Video chat attempt
2024-10-18 17:38:25 -04:00
Jeff Emmett f384673cf9 replace all ChatBox with chatBox 2024-10-18 17:35:05 -04:00
Jeff Emmett 670c9ff0b0 maybe 2024-10-18 17:24:43 -04:00
Jeff Emmett 2ac4ec8de3 yay 2024-10-18 14:58:54 -04:00
Jeff Emmett 7e16f6e6b0 hi 2024-10-18 14:43:31 -04:00
Jeff Emmett 63cd76e919 add editor back in 2024-10-17 17:08:55 -04:00
Jeff Emmett 91df5214c6 remove editor in board.tsx 2024-10-17 17:00:48 -04:00
Jeff Emmett 900833c06c Fix live site 2024-10-17 16:21:00 -04:00
Jeff Emmett 700875434f good hygiene commit 2024-10-17 14:54:23 -04:00
Jeff Emmett 9d5d0d6655 big mess of a commit 2024-10-16 11:20:26 -04:00
Jeff Emmett 8ce8dec8f7 video chat attempt 2024-09-04 17:52:58 +02:00
Jeff Emmett 836d37df76 update msgboard UX 2024-08-31 16:17:05 +02:00
Jeff Emmett 2c35a0c53c fix stuff 2024-08-31 15:00:06 +02:00
Jeff Emmett a8c8d62e63 fix image/asset handling 2024-08-31 13:06:13 +02:00
Jeff Emmett 807637eae0 update gitignore 2024-08-31 12:50:29 +02:00
Jeff Emmett 572608f878 multiboard 2024-08-30 12:31:52 +02:00
Jeff Emmett 6747c5df02 move 2024-08-30 10:17:36 +02:00
Jeff Emmett 2c4b2f6c91 more stuff 2024-08-30 09:44:11 +02:00
Jeff Emmett 80cda32cba change 2024-08-29 22:07:38 +02:00
Jeff Emmett 032e4e1199 conf 2024-08-29 21:40:29 +02:00
Jeff Emmett 04676b3788 fix again 2024-08-29 21:35:13 +02:00
Jeff Emmett d6f3830884 fix plz 2024-08-29 21:33:48 +02:00
Jeff Emmett 50c7c52c3d update build step 2024-08-29 21:22:40 +02:00
Jeff Emmett a6eb2abed0 fixed? 2024-08-29 21:20:33 +02:00
Jeff Emmett 1c38cb1bdb multiplayer 2024-08-29 21:15:13 +02:00
Jeff Emmett 932c9935d5 multiplayer 2024-08-29 20:20:12 +02:00
Jeff Emmett 249031619d commit cal 2024-08-15 13:48:39 -04:00
Jeff Emmett 408df0d11e commit conviction voting 2024-08-11 20:37:10 -04:00
Jeff Emmett fc602ff943
Update Contact.tsx 2024-08-11 20:28:52 -04:00
Jeff Emmett d34e586215 poll for impox updates 2024-08-11 01:13:11 -04:00
Jeff Emmett ee2484f1d0 commit goat 2024-08-11 00:55:34 -04:00
Jeff Emmett 0ac03dec60 commit Books 2024-08-11 00:06:23 -04:00
Jeff Emmett 5f3cf2800c name update 2024-08-10 10:41:35 -04:00
Jeff Emmett 206d2a57ec cooptation 2024-08-10 10:27:38 -04:00
Jeff Emmett 87118b86d5 board commit 2024-08-10 01:53:56 -04:00
Jeff Emmett 58cb4da348 board commit 2024-08-10 01:47:58 -04:00
Jeff Emmett d087b61ce5 board commit 2024-08-10 01:43:09 -04:00
Jeff Emmett 9d73295702 oriomimicry 2024-08-09 23:14:58 -04:00
Jeff Emmett 3e6db31c69 Update 2024-08-09 18:34:12 -04:00
Jeff Emmett b8038a6a97 Merge branch 'main' of https://github.com/Jeff-Emmett/canvas-website 2024-08-09 18:27:38 -04:00
Jeff Emmett ee49689416
Update and rename page.html to index.html 2024-08-09 18:18:06 -04:00
76 changed files with 29283 additions and 3 deletions

13
.env.example Normal file
View File

@ -0,0 +1,13 @@
# Frontend (VITE) Public Variables
VITE_GOOGLE_CLIENT_ID='your_google_client_id'
VITE_GOOGLE_MAPS_API_KEY='your_google_maps_api_key'
VITE_DAILY_DOMAIN='your_daily_domain'
VITE_TLDRAW_WORKER_URL='your_worker_url'
# Worker-only Variables (Do not prefix with VITE_)
CLOUDFLARE_API_TOKEN='your_cloudflare_token'
CLOUDFLARE_ACCOUNT_ID='your_account_id'
CLOUDFLARE_ZONE_ID='your_zone_id'
R2_BUCKET_NAME='your_bucket_name'
R2_PREVIEW_BUCKET_NAME='your_preview_bucket_name'
DAILY_API_KEY=your_daily_api_key_here

5
.gitattributes vendored Normal file
View File

@ -0,0 +1,5 @@
*.pdf filter=lfs diff=lfs merge=lfs -text
*.mp4 filter=lfs diff=lfs merge=lfs -text
*.mov filter=lfs diff=lfs merge=lfs -text
*.png filter=lfs diff=lfs merge=lfs -text
*.gif filter=lfs diff=lfs merge=lfs -text

34
.github/workflows/deploy-worker.yml vendored Normal file
View File

@ -0,0 +1,34 @@
name: Deploy Worker
on:
push:
branches:
- main # or 'production' depending on your branch name
workflow_dispatch: # Allows manual triggering from GitHub UI
jobs:
deploy:
runs-on: ubuntu-latest
name: Deploy Worker
steps:
- uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: "20"
cache: "npm"
- name: Install Dependencies
run: npm ci
working-directory: ./worker
- name: Deploy to Cloudflare Workers
uses: cloudflare/wrangler-action@v3
with:
apiToken: ${{ secrets.CLOUDFLARE_API_TOKEN }}
workingDirectory: "worker"
command: deploy
env:
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}

177
.gitignore vendored Normal file
View File

@ -0,0 +1,177 @@
dist/
.DS_Store
bun.lockb
logs
_.log
npm-debug.log_
lerna-debug.log*
.pnpm-debug.log*
# Diagnostic reports (https://nodejs.org/api/report.html)
report.[0-9]_.[0-9]_.[0-9]_.[0-9]_.json
# Runtime data
pids
_.pid
_.seed
\*.pid.lock
# Directory for instrumented libs generated by jscoverage/JSCover
lib-cov
# Coverage directory used by tools like istanbul
coverage
\*.lcov
# nyc test coverage
.nyc_output
# Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files)
.grunt
# Bower dependency directory (https://bower.io/)
bower_components
# node-waf configuration
.lock-wscript
# Compiled binary addons (https://nodejs.org/api/addons.html)
build/Release
# Dependency directories
node_modules/
jspm_packages/
# Snowpack dependency directory (https://snowpack.dev/)
web_modules/
# TypeScript cache
\*.tsbuildinfo
# Optional npm cache directory
.npm
# Optional eslint cache
.eslintcache
# Optional stylelint cache
.stylelintcache
# Microbundle cache
.rpt2_cache/
.rts2_cache_cjs/
.rts2_cache_es/
.rts2_cache_umd/
# Optional REPL history
.node_repl_history
# Output of 'npm pack'
\*.tgz
# dotenv environment variable files
.env
.env.development.local
.env.test.local
.env.production.local
.env.local
# parcel-bundler cache (https://parceljs.org/)
.cache
.parcel-cache
# Next.js build output
.next
out
# Nuxt.js build / generate output
.nuxt
dist
# Gatsby files
.cache/
# Comment in the public line in if your project uses Gatsby and not Next.js
# https://nextjs.org/blog/next-9-1#public-directory-support
# public
# vuepress build output
.vuepress/dist
# vuepress v2.x temp and cache directory
.temp
.cache
# Docusaurus cache and generated files
.docusaurus
# Serverless directories
.serverless/
# FuseBox cache
.fusebox/
# DynamoDB Local files
.dynamodb/
# TernJS port file
.tern-port
# Stores VSCode versions used for testing VSCode extensions
.vscode-test
.wrangler/
# Vercel
.vercel/
.dev.vars
# Environment variables
.env*
.env.development
!.env.example
.vercel
# Environment files
.env
.env.local
.env.*.local
.dev.vars
.env.production

3
.npmrc Normal file
View File

@ -0,0 +1,3 @@
legacy-peer-deps=true
strict-peer-dependencies=false
auto-install-peers=true

4
.prettierrc.json Normal file
View File

@ -0,0 +1,4 @@
{
"semi": false,
"trailingComma": "all"
}

View File

@ -0,0 +1,30 @@
const urls = new Set();
function checkURL(request, init) {
const url =
request instanceof URL
? request
: new URL(
(typeof request === "string"
? new Request(request, init)
: request
).url
);
if (url.port && url.port !== "443" && url.protocol === "https:") {
if (!urls.has(url.toString())) {
urls.add(url.toString());
console.warn(
`WARNING: known issue with \`fetch()\` requests to custom HTTPS ports in published Workers:\n` +
` - ${url.toString()} - the custom port will be ignored when the Worker is published using the \`wrangler deploy\` command.\n`
);
}
}
}
globalThis.fetch = new Proxy(globalThis.fetch, {
apply(target, thisArg, argArray) {
const [request, init] = argArray;
checkURL(request, init);
return Reflect.apply(target, thisArg, argArray);
},
});

View File

@ -0,0 +1,11 @@
import worker, * as OTHER_EXPORTS from "C:\\Users\\jeffe\\Documents\\GitHub\\canvas-website\\worker\\worker.ts";
import * as __MIDDLEWARE_0__ from "C:\\Users\\jeffe\\Documents\\GitHub\\canvas-website\\node_modules\\wrangler\\templates\\middleware\\middleware-ensure-req-body-drained.ts";
import * as __MIDDLEWARE_1__ from "C:\\Users\\jeffe\\Documents\\GitHub\\canvas-website\\node_modules\\wrangler\\templates\\middleware\\middleware-miniflare3-json-error.ts";
export * from "C:\\Users\\jeffe\\Documents\\GitHub\\canvas-website\\worker\\worker.ts";
export const __INTERNAL_WRANGLER_MIDDLEWARE__ = [
__MIDDLEWARE_0__.default,__MIDDLEWARE_1__.default
]
export default worker;

View File

@ -0,0 +1,134 @@
// This loads all middlewares exposed on the middleware object and then starts
// the invocation chain. The big idea is that we can add these to the middleware
// export dynamically through wrangler, or we can potentially let users directly
// add them as a sort of "plugin" system.
import ENTRY, { __INTERNAL_WRANGLER_MIDDLEWARE__ } from "C:\\Users\\jeffe\\Documents\\GitHub\\canvas-website\\.wrangler\\tmp\\bundle-VlWfGj\\middleware-insertion-facade.js";
import { __facade_invoke__, __facade_register__, Dispatcher } from "C:\\Users\\jeffe\\Documents\\GitHub\\canvas-website\\node_modules\\wrangler\\templates\\middleware\\common.ts";
import type { WorkerEntrypointConstructor } from "C:\\Users\\jeffe\\Documents\\GitHub\\canvas-website\\.wrangler\\tmp\\bundle-VlWfGj\\middleware-insertion-facade.js";
// Preserve all the exports from the worker
export * from "C:\\Users\\jeffe\\Documents\\GitHub\\canvas-website\\.wrangler\\tmp\\bundle-VlWfGj\\middleware-insertion-facade.js";
class __Facade_ScheduledController__ implements ScheduledController {
readonly #noRetry: ScheduledController["noRetry"];
constructor(
readonly scheduledTime: number,
readonly cron: string,
noRetry: ScheduledController["noRetry"]
) {
this.#noRetry = noRetry;
}
noRetry() {
if (!(this instanceof __Facade_ScheduledController__)) {
throw new TypeError("Illegal invocation");
}
// Need to call native method immediately in case uncaught error thrown
this.#noRetry();
}
}
function wrapExportedHandler(worker: ExportedHandler): ExportedHandler {
// If we don't have any middleware defined, just return the handler as is
if (
__INTERNAL_WRANGLER_MIDDLEWARE__ === undefined ||
__INTERNAL_WRANGLER_MIDDLEWARE__.length === 0
) {
return worker;
}
// Otherwise, register all middleware once
for (const middleware of __INTERNAL_WRANGLER_MIDDLEWARE__) {
__facade_register__(middleware);
}
const fetchDispatcher: ExportedHandlerFetchHandler = function (
request,
env,
ctx
) {
if (worker.fetch === undefined) {
throw new Error("Handler does not export a fetch() function.");
}
return worker.fetch(request, env, ctx);
};
return {
...worker,
fetch(request, env, ctx) {
const dispatcher: Dispatcher = function (type, init) {
if (type === "scheduled" && worker.scheduled !== undefined) {
const controller = new __Facade_ScheduledController__(
Date.now(),
init.cron ?? "",
() => {}
);
return worker.scheduled(controller, env, ctx);
}
};
return __facade_invoke__(request, env, ctx, dispatcher, fetchDispatcher);
},
};
}
function wrapWorkerEntrypoint(
klass: WorkerEntrypointConstructor
): WorkerEntrypointConstructor {
// If we don't have any middleware defined, just return the handler as is
if (
__INTERNAL_WRANGLER_MIDDLEWARE__ === undefined ||
__INTERNAL_WRANGLER_MIDDLEWARE__.length === 0
) {
return klass;
}
// Otherwise, register all middleware once
for (const middleware of __INTERNAL_WRANGLER_MIDDLEWARE__) {
__facade_register__(middleware);
}
// `extend`ing `klass` here so other RPC methods remain callable
return class extends klass {
#fetchDispatcher: ExportedHandlerFetchHandler<Record<string, unknown>> = (
request,
env,
ctx
) => {
this.env = env;
this.ctx = ctx;
if (super.fetch === undefined) {
throw new Error("Entrypoint class does not define a fetch() function.");
}
return super.fetch(request);
};
#dispatcher: Dispatcher = (type, init) => {
if (type === "scheduled" && super.scheduled !== undefined) {
const controller = new __Facade_ScheduledController__(
Date.now(),
init.cron ?? "",
() => {}
);
return super.scheduled(controller);
}
};
fetch(request: Request<unknown, IncomingRequestCfProperties>) {
return __facade_invoke__(
request,
this.env,
this.ctx,
this.#dispatcher,
this.#fetchDispatcher
);
}
};
}
let WRAPPED_ENTRY: ExportedHandler | WorkerEntrypointConstructor | undefined;
if (typeof ENTRY === "object") {
WRAPPED_ENTRY = wrapExportedHandler(ENTRY);
} else if (typeof ENTRY === "function") {
WRAPPED_ENTRY = wrapWorkerEntrypoint(ENTRY);
}
export default WRAPPED_ENTRY;

File diff suppressed because it is too large Load Diff

File diff suppressed because one or more lines are too long

3
README.md Normal file
View File

@ -0,0 +1,3 @@
A website.
Do `npm i` and `npm run dev`

43
index.html Normal file
View File

@ -0,0 +1,43 @@
<!DOCTYPE html>
<html>
<head>
<title>Jeff Emmett</title>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no">
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link
href="https://fonts.googleapis.com/css2?family=Recursive:slnt,wght,CASL,CRSV,MONO@-15..0,300..1000,0..1,0..1,0..1&display=swap"
rel="stylesheet">
<!-- Social Meta Tags -->
<meta name="description"
content="My research investigates the intersection of computing, human-system interfaces, and emancipatory politics. I am interested in the potential of computing as a medium for thought, as a tool for collective action, and as a means of emancipation.">
<meta property="og:url" content="https://jeffemmett.com">
<meta property="og:type" content="website">
<meta property="og:title" content="Jeff Emmett">
<meta property="og:description"
content="My research doesn't investigate the intersection of computing, human-system interfaces, and emancipatory politics. I am interested in the potential of computing as a medium for thought, as a tool for collective action, and as a means of emancipation.">
<meta property="og:image" content="/website-embed.png">
<meta name="twitter:card" content="summary_large_image">
<meta property="twitter:domain" content="jeffemmett.com">
<meta property="twitter:url" content="https://jeffemmett.com">
<meta name="twitter:title" content="Jeff Emmett">
<meta name="twitter:description"
content="My research doesn't investigate the intersection of computing, human-system interfaces, and emancipatory politics. I am interested in the potential of computing as a medium for thought, as a tool for collective action, and as a means of emancipation.">
<meta name="twitter:image" content="/website-embed.png">
<!-- Analytics -->
<script data-goatcounter="https://jeff.goatcounter.com/count" async src="//gc.zgo.at/count.js"></script>
<meta name="mobile-web-app-capable" content="yes">
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/App.tsx"></script>
</body>
</html>

12053
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

66
package.json Normal file
View File

@ -0,0 +1,66 @@
{
"name": "jeffemmett",
"version": "1.0.0",
"description": "Jeff Emmett's personal website",
"type": "module",
"scripts": {
"dev": "concurrently --kill-others --names client,worker --prefix-colors blue,red \"npm run dev:client\" \"npm run dev:worker\"",
"dev:client": "vite --host --port 5173",
"dev:worker": "wrangler dev --local --port 5172 --ip 0.0.0.0",
"build": "tsc && vite build",
"preview": "vite preview",
"deploy": "tsc && vite build && vercel deploy --prod && wrangler deploy",
"types": "tsc --noEmit"
},
"keywords": [],
"author": "Jeff Emmett",
"license": "ISC",
"dependencies": {
"@anthropic-ai/sdk": "^0.33.1",
"@daily-co/daily-js": "^0.60.0",
"@daily-co/daily-react": "^0.20.0",
"@tldraw/assets": "^3.6.0",
"@tldraw/sync": "^3.6.0",
"@tldraw/sync-core": "^3.6.0",
"@tldraw/tldraw": "^3.6.0",
"@tldraw/tlschema": "^3.6.0",
"@types/markdown-it": "^14.1.1",
"@types/marked": "^5.0.2",
"@uiw/react-md-editor": "^4.0.5",
"@vercel/analytics": "^1.2.2",
"ai": "^4.1.0",
"cherry-markdown": "^0.8.57",
"cloudflare-workers-unfurl": "^0.0.7",
"gray-matter": "^4.0.3",
"html2canvas": "^1.4.1",
"itty-router": "^5.0.17",
"jotai": "^2.6.0",
"jspdf": "^2.5.2",
"lodash.throttle": "^4.1.1",
"marked": "^15.0.4",
"openai": "^4.79.3",
"rbush": "^4.0.1",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"react-router-dom": "^7.0.2",
"recoil": "^0.7.7",
"tldraw": "^3.6.0",
"vercel": "^39.1.1"
},
"devDependencies": {
"@cloudflare/types": "^6.0.0",
"@cloudflare/workers-types": "^4.20250214.0",
"@types/lodash.throttle": "^4",
"@types/rbush": "^4.0.0",
"@types/react": "^19.0.1",
"@types/react-dom": "^19.0.1",
"@vitejs/plugin-react": "^4.0.3",
"concurrently": "^9.1.0",
"typescript": "^5.6.3",
"vite": "^6.0.3",
"wrangler": "^3.109.1"
},
"engines": {
"node": ">=18.0.0"
}
}

View File

35
src/App.tsx Normal file
View File

@ -0,0 +1,35 @@
import { inject } from "@vercel/analytics"
import "tldraw/tldraw.css"
import "@/css/style.css"
import "@uiw/react-md-editor/markdown-editor.css"
import "@uiw/react-markdown-preview/markdown.css"
import { Default } from "@/routes/Default"
import { BrowserRouter, Route, Routes } from "react-router-dom"
import { Contact } from "@/routes/Contact"
import { Board } from "./routes/Board"
import { Inbox } from "./routes/Inbox"
import { createRoot } from "react-dom/client"
import { DailyProvider } from "@daily-co/daily-react"
import Daily from "@daily-co/daily-js"
inject()
const callObject = Daily.createCallObject()
function App() {
return (
<DailyProvider callObject={callObject}>
<BrowserRouter>
<Routes>
<Route path="/" element={<Default />} />
<Route path="/contact" element={<Contact />} />
<Route path="/board/:slug" element={<Board />} />
<Route path="/inbox" element={<Inbox />} />
</Routes>
</BrowserRouter>
</DailyProvider>
)
}
createRoot(document.getElementById("root")!).render(<App />)

89
src/css/reset.css Normal file
View File

@ -0,0 +1,89 @@
/* Box sizing rules */
*,
*::before,
*::after {
box-sizing: border-box;
}
/* Prevent font size inflation */
html {
-moz-text-size-adjust: none;
-webkit-text-size-adjust: none;
text-size-adjust: none;
}
/* Remove default margin in favour of better control in authored CSS */
body,
h1,
h2,
h3,
h4,
p,
figure,
blockquote,
dl,
dd {
margin-block-end: 0;
}
/* Remove list styles on ul, ol elements with a list role, which suggests default styling will be removed */
ul[role="list"],
ol[role="list"] {
list-style: none;
}
/* Set core body defaults */
body {
min-height: 100vh;
line-height: 1.5;
}
/* Set shorter line heights on headings and interactive elements */
h1,
h2,
h3,
h4,
button,
input,
label {
line-height: 1.1;
}
/* Balance text wrapping on headings */
h1,
h2,
h3,
h4 {
text-wrap: balance;
}
/* A elements that don't have a class get default styles */
a:not([class]) {
text-decoration-skip-ink: auto;
color: currentColor;
}
/* Make images easier to work with */
img,
picture {
max-width: 100%;
display: block;
}
/* Inherit fonts for inputs and buttons */
input,
button,
textarea,
select {
font: inherit;
}
/* Make sure textareas without a rows attribute are not tiny */
textarea:not([rows]) {
min-height: 10em;
}
/* Anything that has been anchored to should have extra scroll margin */
:target {
scroll-margin-block: 5ex;
}

379
src/css/style.css Normal file
View File

@ -0,0 +1,379 @@
@import url("reset.css");
html,
body {
padding: 0;
margin: 0;
min-height: 100vh;
min-height: -webkit-fill-available;
height: 100%;
}
video {
width: 100%;
height: auto;
}
main {
max-width: 60em;
margin: 0 auto;
padding-left: 4em;
padding-right: 4em;
padding-top: 3em;
padding-bottom: 3em;
font-family: "Recursive";
font-variation-settings: "MONO" 1;
font-variation-settings: "CASL" 1;
color: #24292e;
}
h1 {
font-size: 2rem;
}
h2 {
font-size: 1.5rem;
}
h1,
h2,
h3,
h4,
h5,
h6 {
margin-top: 0;
margin-bottom: 0.5em;
}
header {
margin-bottom: 2em;
font-size: 1.5rem;
font-variation-settings: "MONO" 1;
font-variation-settings: "CASL" 1;
}
i {
font-variation-settings: "slnt" -15;
}
pre>code {
width: 100%;
padding: 1em;
display: block;
white-space: pre-wrap;
word-wrap: break-word;
}
code {
background-color: #e4e9ee;
width: 100%;
color: #38424c;
padding: 0.2em 0.4em;
border-radius: 4px;
}
b,
strong {
font-variation-settings: "wght" 600;
}
blockquote {
margin: -1em;
padding: 1em;
background-color: #f1f1f1;
margin-top: 1em;
margin-bottom: 1em;
border-radius: 4px;
& p {
font-variation-settings: "CASL" 1;
margin: 0;
}
}
p {
font-family: Recursive;
margin-top: 0;
margin-bottom: 1.5em;
font-size: 1.1em;
font-variation-settings: "wght" 350;
}
table {
width: 100%;
border-collapse: collapse;
text-align: left;
margin-bottom: 1em;
font-variation-settings: "mono" 1;
font-variation-settings: "casl" 0;
th,
td {
padding: 0.5em;
border: 1px solid #ddd;
}
th {
background-color: #f4f4f4;
font-weight: bold;
}
tr:nth-child(even) {
background-color: #f9f9f9;
}
}
a {
font-variation-settings: "CASL" 0;
&:hover {
animation: casl-forward 0.2s ease forwards;
}
&:not(:hover) {
/* text-decoration: none; */
animation: casl-reverse 0.2s ease backwards;
}
}
@keyframes casl-forward {
from {
font-variation-settings:
"CASL" 0,
"wght" 400;
}
to {
font-variation-settings:
"CASL" 1,
"wght" 600;
}
}
@keyframes casl-reverse {
from {
font-variation-settings:
"CASL" 1,
"wght" 600;
}
to {
font-variation-settings:
"CASL" 0,
"wght" 400;
}
}
p a {
text-decoration: underline;
}
.dinkus {
display: block;
text-align: center;
font-size: 1.1rem;
margin-top: 2em;
margin-bottom: 0em;
}
ol,
ul {
padding-left: 0;
margin-top: 0;
font-size: 1rem;
& li::marker {
color: rgba(0, 0, 0, 0.322);
}
}
img {
display: block;
margin: 0 auto;
}
@media (max-width: 600px) {
main {
padding: 2em;
}
header {
margin-bottom: 1em;
}
ol {
list-style-position: inside;
}
}
/* Some conditional spacing */
table:not(:has(+ p)) {
margin-bottom: 2em;
}
p:has(+ ul) {
margin-bottom: 0.5em;
}
p:has(+ ol) {
margin-bottom: 0.5em;
}
.loading {
font-family: "Recursive";
font-variation-settings: "CASL" 1;
font-size: 1rem;
text-align: center;
position: absolute;
top: 40%;
left: 50%;
transform: translate(-50%, -50%);
background-color: #f1f1f1;
border: 1px solid #c0c9d1;
padding: 0.5em;
border-radius: 4px;
}
/* CANVAS SHENANIGANS */
#toggle-physics,
#toggle-canvas {
position: fixed;
z-index: 999;
right: 10px;
width: 2.5rem;
height: 2.5rem;
background: none;
border: none;
cursor: pointer;
opacity: 0.25;
&:hover {
opacity: 1;
}
& img {
width: 100%;
height: 100%;
}
}
#toggle-canvas {
top: 10px;
}
#toggle-physics {
top: 60px;
display: none;
}
.tl-html-layer {
font-family: "Recursive";
font-variation-settings: "MONO" 1;
font-variation-settings: "CASL" 1;
& h1,
p,
span,
header,
ul,
ol {
margin: 0;
}
& header {
font-size: 1.5rem;
}
& p {
font-size: 1.1rem;
}
/* Markdown preview styles */
& h1 { font-size: 2em; margin: 0.67em 0; }
& h2 { font-size: 1.5em; margin: 0.75em 0; }
& h3 { font-size: 1.17em; margin: 0.83em 0; }
& h4 { margin: 1.12em 0; }
& h5 { font-size: 0.83em; margin: 1.5em 0; }
& h6 { font-size: 0.75em; margin: 1.67em 0; }
& ul, & ol {
padding-left: 2em;
margin: 1em 0;
}
& p {
margin: 1em 0;
}
& code {
background-color: #f5f5f5;
padding: 0.2em 0.4em;
border-radius: 3px;
font-family: monospace;
}
& pre {
background-color: #f5f5f5;
padding: 1em;
border-radius: 4px;
overflow-x: auto;
}
& blockquote {
margin: 1em 0;
padding-left: 1em;
border-left: 4px solid #ddd;
color: #666;
}
& table {
border-collapse: collapse;
margin: 1em 0;
}
& th, & td {
border: 1px solid #ddd;
padding: 6px 13px;
}
& tr:nth-child(2n) {
background-color: #f8f8f8;
}
}
.transparent {
opacity: 0 !important;
transition: opacity 0.25s ease-in-out;
}
.canvas-mode {
overflow: hidden;
& #toggle-physics {
display: block;
}
}
.tldraw__editor {
overscroll-behavior: none;
position: fixed;
inset: 0px;
overflow: hidden;
touch-action: none;
-webkit-touch-callout: none;
-webkit-tap-highlight-color: transparent;
}
.tl-background {
background-color: transparent;
}
.tlui-debug-panel {
display: none;
}
.overflowing {
box-shadow: 0 0px 16px rgba(0, 0, 0, 0.15);
overflow: hidden;
background-color: white;
}

View File

@ -0,0 +1,166 @@
import { useEffect } from "react"
import { Editor, TLEventMap, TLFrameShape, TLParentId, TLShapeId } from "tldraw"
import { useSearchParams } from "react-router-dom"
// Define camera state interface
interface CameraState {
x: number
y: number
z: number
}
const MAX_HISTORY = 10
let cameraHistory: CameraState[] = []
// TODO: use this
// Improved camera change tracking with debouncing
const trackCameraChange = (editor: Editor) => {
const currentCamera = editor.getCamera()
const lastPosition = cameraHistory[cameraHistory.length - 1]
// Store any viewport change that's not from a revert operation
if (
!lastPosition ||
currentCamera.x !== lastPosition.x ||
currentCamera.y !== lastPosition.y ||
currentCamera.z !== lastPosition.z
) {
cameraHistory.push({ ...currentCamera })
if (cameraHistory.length > MAX_HISTORY) {
cameraHistory.shift()
}
}
}
export function useCameraControls(editor: Editor | null) {
const [searchParams] = useSearchParams()
// Handle URL-based camera positioning
useEffect(() => {
if (!editor || !editor.store || !editor.getInstanceState().isFocused) {
console.log("Editor not ready:", {
editor: !!editor,
store: !!editor?.store,
isFocused: editor?.getInstanceState().isFocused,
})
return
}
const x = searchParams.get("x")
const y = searchParams.get("y")
const zoom = searchParams.get("zoom")
const frameId = searchParams.get("frameId")
const isLocked = searchParams.get("isLocked") === "true"
console.log("Setting camera:", { x, y, zoom, frameId, isLocked })
// Set camera position if coordinates exist
if (x && y && zoom) {
const position = {
x: Math.round(parseFloat(x)),
y: Math.round(parseFloat(y)),
z: Math.round(parseFloat(zoom)),
}
console.log("Camera position:", position)
requestAnimationFrame(() => {
editor.setCamera(position, { animation: { duration: 0 } })
// Apply camera lock immediately after setting position if needed
if (isLocked) {
editor.setCameraOptions({ isLocked: true })
}
console.log("Current camera:", editor.getCamera())
})
}
// Handle frame-specific logic
if (frameId) {
const frame = editor.getShape(frameId as TLShapeId)
if (frame) {
editor.select(frameId as TLShapeId)
// If x/y/zoom are not provided in URL, zoom to frame bounds
if (!x || !y || !zoom) {
const bounds = editor.getShapePageBounds(frame)!
const viewportPageBounds = editor.getViewportPageBounds()
const targetZoom = Math.min(
viewportPageBounds.width / bounds.width,
viewportPageBounds.height / bounds.height,
1, // Cap at 1x zoom, matching lockCameraToFrame
)
editor.zoomToBounds(bounds, {
animation: { duration: 0 },
targetZoom,
})
}
// Apply camera lock after camera is positioned
if (isLocked) {
requestAnimationFrame(() => {
editor.setCameraOptions({ isLocked: true })
})
}
}
}
}, [editor, searchParams])
// Track camera changes
useEffect(() => {
if (!editor) return
const handler = () => {
trackCameraChange(editor)
}
// Track both viewport changes and user interaction end
editor.on("viewportChange" as keyof TLEventMap, handler)
editor.on("userChangeEnd" as keyof TLEventMap, handler)
return () => {
editor.off("viewportChange" as keyof TLEventMap, handler)
editor.off("userChangeEnd" as keyof TLEventMap, handler)
}
}, [editor])
// Enhanced camera control functions
return {
zoomToFrame: (frameId: string) => {
if (!editor) return
const frame = editor.getShape(frameId as TLParentId) as TLFrameShape
if (!frame) return
editor.zoomToBounds(editor.getShapePageBounds(frame)!, {
inset: 32,
animation: { duration: 500 },
})
},
copyLocationLink: () => {
if (!editor) return
const camera = editor.getCamera()
const url = new URL(window.location.href)
url.searchParams.set("x", Math.round(camera.x).toString())
url.searchParams.set("y", Math.round(camera.y).toString())
url.searchParams.set("zoom", Math.round(camera.z).toString())
navigator.clipboard.writeText(url.toString())
},
copyFrameLink: (frameId: string) => {
const url = new URL(window.location.href)
url.searchParams.set("frameId", frameId)
navigator.clipboard.writeText(url.toString())
},
revertCamera: () => {
if (!editor || cameraHistory.length === 0) return
const previousCamera = cameraHistory.pop()
if (previousCamera) {
editor.setCamera(previousCamera, { animation: { duration: 200 } })
}
},
}
}

61
src/lib/settings.tsx Normal file
View File

@ -0,0 +1,61 @@
import { atom } from 'tldraw'
import { SYSTEM_PROMPT } from '@/prompt'
export const PROVIDERS = [
{
id: 'openai',
name: 'OpenAI',
models: ['gpt-4o', 'gpt-4o-mini', 'gpt-4-turbo'], // 'o1-preview', 'o1-mini'],
help: 'https://tldraw.notion.site/Make-Real-Help-93be8b5273d14f7386e14eb142575e6e#a9b75e58b1824962a1a69a2f29ace9be',
validate: (key: string) => key.startsWith('sk-'),
},
{
id: 'anthropic',
name: 'Anthropic',
models: [
'claude-3-5-sonnet-20241022',
'claude-3-5-sonnet-20240620',
'claude-3-opus-20240229',
'claude-3-sonnet-20240229',
'claude-3-haiku-20240307',
],
help: 'https://tldraw.notion.site/Make-Real-Help-93be8b5273d14f7386e14eb142575e6e#3444b55a2ede405286929956d0be6e77',
validate: (key: string) => key.startsWith('sk-'),
},
// { id: 'google', name: 'Google', model: 'Gemeni 1.5 Flash', validate: (key: string) => true },
]
export const makeRealSettings = atom('make real settings', {
provider: 'openai' as (typeof PROVIDERS)[number]['id'] | 'all',
models: Object.fromEntries(PROVIDERS.map((provider) => [provider.id, provider.models[0]])),
keys: {
openai: '',
anthropic: '',
google: '',
},
prompts: {
system: SYSTEM_PROMPT,
},
})
export function applySettingsMigrations(settings: any) {
const { keys, prompts, ...rest } = settings
const settingsWithModelsProperty = {
provider: 'openai',
models: Object.fromEntries(PROVIDERS.map((provider) => [provider.id, provider.models[0]])),
keys: {
openai: '',
anthropic: '',
google: '',
...keys,
},
prompts: {
system: SYSTEM_PROMPT,
...prompts,
},
...rest,
}
return settingsWithModelsProperty
}

24
src/prompt.ts Normal file
View File

@ -0,0 +1,24 @@
export const SYSTEM_PROMPT = `You are an expert web developer who specializes in building working website prototypes from low-fidelity wireframes. Your job is to accept low-fidelity designs and turn them into high-fidelity interactive and responsive working prototypes. When sent new designs, you should reply with a high-fidelity working prototype as a single HTML file.
- Use tailwind (via \`cdn.tailwindcss.com\`) for styling.
- Put any JavaScript in a script tag with \`type="module"\`.
- Use unpkg or skypack to import any required JavaScript dependencies.
- Use Google fonts to pull in any open source fonts you require.
- If you have any images, load them from Unsplash or use solid colored rectangles as placeholders.
- Create SVGs as needed for any icons.
The designs may include flow charts, diagrams, labels, arrows, sticky notes, screenshots of other applications, or even previous designs. Treat all of these as references for your prototype.
The designs may include structural elements (such as boxes that represent buttons or content) as well as annotations or figures that describe interactions, behavior, or appearance. Use your best judgement to determine what is an annotation and what should be included in the final result. Annotations are commonly made in the color red. Do NOT include any of those annotations in your final result.
If there are any questions or underspecified features, use what you know about applications, user experience, and website design patterns to "fill in the blanks". If you're unsure of how the designs should work, take a guess—it's better for you to get it wrong than to leave things incomplete.
Your prototype should look and feel much more complete and advanced than the wireframes provided. Flesh it out, make it real!
Remember: you love your designers and want them to be happy. The more complete and impressive your prototype, the happier they will be. You are evaluated on 1) whether your prototype resembles the designs, 2) whether your prototype is interactive and responsive, and 3) whether your prototype is complete and impressive.`
export const USER_PROMPT =
'Here are the latest wireframes. Please reply with a high-fidelity working prototype as a single HTML file.'
export const USER_PROMPT_WITH_PREVIOUS_DESIGN =
"Here are the latest wireframes. There are also some previous outputs here. We have run their code through an 'HTML to screenshot' library to generate a screenshot of the page. The generated screenshot may have some inaccuracies so please use your knowledge of HTML and web development to figure out what any annotations are referring to, which may be different to what is visible in the generated screenshot. Make a new high-fidelity prototype based on your previous work and any new designs or annotations. Again, you should reply with a high-fidelity working prototype as a single HTML file."

View File

@ -0,0 +1,23 @@
export class DeltaTime {
private static lastTime = Date.now()
private static initialized = false
private static _dt = 0
static get dt(): number {
if (!DeltaTime.initialized) {
DeltaTime.lastTime = Date.now()
DeltaTime.initialized = true
window.requestAnimationFrame(DeltaTime.tick)
return 0
}
const clamp = (min: number, max: number, value: number) => Math.min(max, Math.max(min, value))
return clamp(0, 100, DeltaTime._dt)
}
static tick(nowish: number) {
DeltaTime._dt = nowish - DeltaTime.lastTime
DeltaTime.lastTime = nowish
window.requestAnimationFrame(DeltaTime.tick)
}
}

119
src/propagators/Geo.ts Normal file
View File

@ -0,0 +1,119 @@
import { SpatialIndex } from "@/propagators/SpatialIndex"
import { Box, Editor, TLShape, TLShapeId, VecLike, polygonsIntersect } from "tldraw"
export class Geo {
editor: Editor
spatialIndex: SpatialIndex
constructor(editor: Editor) {
this.editor = editor
this.spatialIndex = new SpatialIndex(editor)
}
intersects(shape: TLShape | TLShapeId): boolean {
const id = typeof shape === 'string' ? shape : shape?.id ?? null
if (!id) return false
const sourceTransform = this.editor.getShapePageTransform(id)
const sourceGeo = this.editor.getShapeGeometry(id)
const sourcePagespace = sourceTransform.applyToPoints(sourceGeo.vertices)
const sourceBounds = this.editor.getShapePageBounds(id)
const shapesInBounds = this.spatialIndex.getShapeIdsInsideBounds(sourceBounds as Box)
for (const boundsShapeId of shapesInBounds) {
if (boundsShapeId === id) continue
const pageShape = this.editor.getShape(boundsShapeId)
if (!pageShape) continue
if (pageShape.type === 'arrow') continue
const pageShapeGeo = this.editor.getShapeGeometry(pageShape)
const pageShapeTransform = this.editor.getShapePageTransform(pageShape)
const pageShapePagespace = pageShapeTransform.applyToPoints(pageShapeGeo.vertices)
const pageShapeBounds = this.editor.getShapePageBounds(boundsShapeId)
if (polygonsIntersect(sourcePagespace, pageShapePagespace) || sourceBounds?.contains(pageShapeBounds as Box) || pageShapeBounds?.contains(sourceBounds as Box)) {
return true
}
}
return false
}
distance(a: TLShape | TLShapeId, b: TLShape | TLShapeId): VecLike {
const idA = typeof a === 'string' ? a : a?.id ?? null
const idB = typeof b === 'string' ? b : b?.id ?? null
if (!idA || !idB) return { x: 0, y: 0 }
const shapeA = this.editor.getShape(idA)
const shapeB = this.editor.getShape(idB)
if (!shapeA || !shapeB) return { x: 0, y: 0 }
return { x: shapeA.x - shapeB.x, y: shapeA.y - shapeB.y }
}
distanceCenter(a: TLShape | TLShapeId, b: TLShape | TLShapeId): VecLike {
const idA = typeof a === 'string' ? a : a?.id ?? null
const idB = typeof b === 'string' ? b : b?.id ?? null
if (!idA || !idB) return { x: 0, y: 0 }
const aBounds = this.editor.getShapePageBounds(idA)
const bBounds = this.editor.getShapePageBounds(idB)
if (!aBounds || !bBounds) return { x: 0, y: 0 }
const aCenter = aBounds.center
const bCenter = bBounds.center
return { x: aCenter.x - bCenter.x, y: aCenter.y - bCenter.y }
}
getIntersects(shape: TLShape | TLShapeId): TLShape[] {
const id = typeof shape === 'string' ? shape : shape?.id ?? null
if (!id) return []
const sourceTransform = this.editor.getShapePageTransform(id)
const sourceGeo = this.editor.getShapeGeometry(id)
const sourcePagespace = sourceTransform.applyToPoints(sourceGeo.vertices)
const sourceBounds = this.editor.getShapePageBounds(id)
const boundsShapes = this.spatialIndex.getShapeIdsInsideBounds(sourceBounds as Box)
const overlaps: TLShape[] = []
for (const boundsShapeId of boundsShapes) {
if (boundsShapeId === id) continue
const pageShape = this.editor.getShape(boundsShapeId)
if (!pageShape) continue
if (pageShape.type === 'arrow') continue
const pageShapeGeo = this.editor.getShapeGeometry(pageShape)
const pageShapeTransform = this.editor.getShapePageTransform(pageShape)
const pageShapePagespace = pageShapeTransform.applyToPoints(pageShapeGeo.vertices)
const pageShapeBounds = this.editor.getShapePageBounds(boundsShapeId)
if (polygonsIntersect(sourcePagespace, pageShapePagespace) || sourceBounds?.contains(pageShapeBounds as Box) || pageShapeBounds?.contains(sourceBounds as Box )) {
overlaps.push(pageShape)
}
}
return overlaps
}
contains(shape: TLShape | TLShapeId): boolean {
const id = typeof shape === 'string' ? shape : shape?.id ?? null
if (!id) return false
const sourceBounds = this.editor.getShapePageBounds(id)
const boundsShapes = this.spatialIndex.getShapeIdsInsideBounds(sourceBounds as Box)
for (const boundsShapeId of boundsShapes) {
if (boundsShapeId === id) continue
const pageShape = this.editor.getShape(boundsShapeId)
if (!pageShape) continue
if (pageShape.type !== 'geo') continue
const pageShapeBounds = this.editor.getShapePageBounds(boundsShapeId)
if (sourceBounds?.contains(pageShapeBounds as Box)) {
return true
}
}
return false
}
getContains(shape: TLShape | TLShapeId): TLShape[] {
const id = typeof shape === 'string' ? shape : shape?.id ?? null
if (!id) return []
const sourceBounds = this.editor.getShapePageBounds(id)
const boundsShapes = this.spatialIndex.getShapeIdsInsideBounds(sourceBounds as Box)
const contains: TLShape[] = []
for (const boundsShapeId of boundsShapes) {
if (boundsShapeId === id) continue
const pageShape = this.editor.getShape(boundsShapeId)
if (!pageShape) continue
if (pageShape.type !== 'geo') continue
const pageShapeBounds = this.editor.getShapePageBounds(boundsShapeId)
if (sourceBounds?.contains(pageShapeBounds as Box)) {
contains.push(pageShape)
}
}
return contains
}
}

View File

@ -0,0 +1,300 @@
import { DeltaTime } from "@/propagators/DeltaTime"
import { Geo } from "@/propagators/Geo"
import { Edge, getArrowsFromShape, getEdge } from "@/propagators/tlgraph"
import { isShapeOfType, updateProps } from "@/propagators/utils"
import { Editor, TLArrowShape, TLBinding, TLGroupShape, TLShape, TLShapeId } from "tldraw"
type Prefix = 'click' | 'tick' | 'geo' | ''
export function registerDefaultPropagators(editor: Editor) {
registerPropagators(editor, [
ChangePropagator,
ClickPropagator,
TickPropagator,
SpatialPropagator,
])
}
function isPropagatorOfType(arrow: TLShape, prefix: Prefix) {
if (!isShapeOfType<TLArrowShape>(arrow, 'arrow')) return false
const regex = new RegExp(`^\\s*${prefix}\\s*\\{`)
return regex.test(arrow.props.text)
}
function isExpandedPropagatorOfType(arrow: TLShape, prefix: Prefix) {
if (!isShapeOfType<TLArrowShape>(arrow, 'arrow')) return false
const regex = new RegExp(`^\\s*${prefix}\\s*\\(\\)\\s*\\{`)
return regex.test(arrow.props.text)
}
class ArrowFunctionCache {
private cache: Map<string, Function | null> = new Map<string, Function | null>()
/** returns undefined if the function could not be found or created */
get(editor: Editor, edge: Edge, prefix: Prefix): Function | undefined {
if (this.cache.has(edge.arrowId)) {
return this.cache.get(edge.arrowId) as Function | undefined
}
return this.set(editor, edge, prefix)
}
/** returns undefined if the function could not be created */
set(editor: Editor, edge: Edge, prefix: Prefix): Function | undefined {
try {
const arrowShape = editor.getShape(edge.arrowId)
if (!arrowShape) throw new Error('Arrow shape not found')
const textWithoutPrefix = edge.text?.replace(prefix, '')
const isExpanded = isExpandedPropagatorOfType(arrowShape, prefix)
const body = isExpanded ? textWithoutPrefix?.trim().replace(/^\s*\(\)\s*{|}$/g, '') : `
const mapping = ${textWithoutPrefix}
editor.updateShape(_unpack({...to, ...mapping}))
`
const func = new Function('editor', 'from', 'to', 'G', 'bounds', 'dt', '_unpack', body as string);
this.cache.set(edge.arrowId, func)
return func
} catch (error) {
this.cache.set(edge.arrowId, null)
return undefined
}
}
delete(edge: Edge): void {
this.cache.delete(edge.arrowId)
}
}
const packShape = (shape: TLShape) => {
return {
id: shape.id,
type: shape.type,
x: shape.x,
y: shape.y,
rotation: shape.rotation,
...shape.props,
m: shape.meta,
}
}
const unpackShape = (shape: any) => {
const { id, type, x, y, rotation, m, ...props } = shape
const cast = (prop: any, constructor: (value: any) => any) => {
return prop !== undefined ? constructor(prop) : undefined;
};
return {
id,
type,
x: Number(x),
y: Number(y),
rotation: Number(rotation),
props: {
...props,
text: cast(props.text, String),
},
meta: m,
}
}
function setArrowColor(editor: Editor, arrow: TLArrowShape, color: TLArrowShape['props']['color']): void {
editor.updateShape({
...arrow,
props: {
...arrow.props,
color: color,
}
})
}
export function registerPropagators(editor: Editor, propagators: (new (editor: Editor) => Propagator)[]) {
const _propagators = propagators.map((PropagatorClass) => new PropagatorClass(editor))
for (const prop of _propagators) {
for (const shape of editor.getCurrentPageShapes()) {
if (isShapeOfType<TLArrowShape>(shape, 'arrow')) {
prop.onArrowChange(editor, shape)
}
}
editor.sideEffects.registerAfterChangeHandler<"shape">("shape", (_, next) => {
if (isShapeOfType<TLGroupShape>(next, 'group')) {
const childIds = editor.getSortedChildIdsForParent(next.id)
for (const childId of childIds) {
const child = editor.getShape(childId)
prop.afterChangeHandler?.(editor, child as TLShape)
}
return
}
prop.afterChangeHandler?.(editor, next)
if (isShapeOfType<TLArrowShape>(next, 'arrow')) {
prop.onArrowChange(editor, next)
}
})
function updateOnBindingChange(editor: Editor, binding: TLBinding) {
if (binding.type !== 'arrow') return
const arrow = editor.getShape(binding.fromId)
if (!arrow) return
if (!isShapeOfType<TLArrowShape>(arrow, 'arrow')) return
prop.onArrowChange(editor, arrow)
}
// TODO: remove this when binding creation
editor.sideEffects.registerAfterCreateHandler<"binding">("binding", (binding) => {
updateOnBindingChange(editor, binding)
})
// TODO: remove this when binding creation
editor.sideEffects.registerAfterDeleteHandler<"binding">("binding", (binding) => {
updateOnBindingChange(editor, binding)
})
editor.on('event', (event) => {
prop.eventHandler?.(event)
})
editor.on('tick', () => {
prop.tickHandler?.()
})
}
}
// TODO: separate generic propagator setup from scope registration
// TODO: handle cycles
export abstract class Propagator {
abstract prefix: Prefix
protected listenerArrows: Set<TLShapeId> = new Set<TLShapeId>()
protected listenerShapes: Set<TLShapeId> = new Set<TLShapeId>()
protected arrowFunctionCache: ArrowFunctionCache = new ArrowFunctionCache()
protected editor: Editor
protected geo: Geo
protected validateOnArrowChange: boolean = false
constructor(editor: Editor) {
this.editor = editor
this.geo = new Geo(editor)
}
/** function to check if any listeners need to be added/removed
* called on mount and when an arrow changes
*/
onArrowChange(editor: Editor, arrow: TLArrowShape): void {
const edge = getEdge(arrow, editor)
if (!edge) return
const isPropagator = isPropagatorOfType(arrow, this.prefix) || isExpandedPropagatorOfType(arrow, this.prefix)
if (isPropagator) {
if (this.validateOnArrowChange && !this.propagate(editor, arrow.id)) {
this.removeListener(arrow.id, edge)
return
}
this.addListener(arrow.id, edge)
// TODO: find a way to do this properly so we can run arrow funcs on change without chaos...
// this.arrowFunc(editor, arrow.id)
} else {
this.removeListener(arrow.id, edge)
}
}
private addListener(arrowId: TLShapeId, edge: Edge): void {
this.listenerArrows.add(arrowId)
this.listenerShapes.add(edge.from)
this.listenerShapes.add(edge.to)
this.arrowFunctionCache.set(this.editor, edge, this.prefix)
}
private removeListener(arrowId: TLShapeId, edge: Edge): void {
this.listenerArrows.delete(arrowId)
this.arrowFunctionCache.delete(edge)
}
/** the function to be called when side effect / event is triggered */
propagate(editor: Editor, arrow: TLShapeId): boolean {
const edge = getEdge(editor.getShape(arrow), editor)
if (!edge) return false
const arrowShape = editor.getShape(arrow) as TLArrowShape
const fromShape = editor.getShape(edge.from)
const toShape = editor.getShape(edge.to)
const fromShapePacked = packShape(fromShape as TLShape)
const toShapePacked = packShape(toShape as TLShape)
const bounds = (shape: TLShape) => editor.getShapePageBounds(shape.id)
try {
const func = this.arrowFunctionCache.get(editor, edge, this.prefix)
const result = func?.(editor, fromShapePacked, toShapePacked, this.geo, bounds, DeltaTime.dt, unpackShape);
if (result) {
editor.updateShape(unpackShape({ ...toShapePacked, ...result }))
}
setArrowColor(editor, arrowShape, 'black')
return true
} catch (error) {
setArrowColor(editor, arrowShape, 'orange')
return false
}
}
/** called after every shape change */
afterChangeHandler?(editor: Editor, next: TLShape): void
/** called on every editor event */
eventHandler?(event: any): void
/** called every tick */
tickHandler?(): void
}
export class ClickPropagator extends Propagator {
prefix: Prefix = 'click'
eventHandler(event: any): void {
if (event.type !== 'pointer' || event.name !== 'pointer_down') return;
const shapeAtPoint = this.editor.getShapeAtPoint(this.editor.inputs.currentPagePoint, { filter: (shape) => shape.type === 'geo' });
if (!shapeAtPoint) return
if (!this.listenerShapes.has(shapeAtPoint.id)) return
const edgesFromHovered = getArrowsFromShape(this.editor, shapeAtPoint.id)
const visited = new Set<TLShapeId>()
for (const edge of edgesFromHovered) {
if (this.listenerArrows.has(edge) && !visited.has(edge)) {
this.propagate(this.editor, edge)
visited.add(edge)
}
}
}
}
export class ChangePropagator extends Propagator {
prefix: Prefix = ''
afterChangeHandler(editor: Editor, next: TLShape): void {
if (this.listenerShapes.has(next.id)) {
const arrowsFromShape = getArrowsFromShape(editor, next.id)
for (const arrow of arrowsFromShape) {
if (this.listenerArrows.has(arrow)) {
const bindings = editor.getBindingsInvolvingShape(arrow)
if (bindings.length !== 2) continue
// don't run func if its pointing to itself to avoid change-induced recursion error
if (bindings[0].toId === bindings[1].toId) continue
this.propagate(editor, arrow)
}
}
}
}
}
export class TickPropagator extends Propagator {
prefix: Prefix = 'tick'
validateOnArrowChange = true
tickHandler(): void {
for (const arrow of this.listenerArrows) {
this.propagate(this.editor, arrow)
}
}
}
export class SpatialPropagator extends Propagator {
prefix: Prefix = 'geo'
// TODO: make this smarter, and scale sublinearly
afterChangeHandler(editor: Editor, next: TLShape): void {
if (next.type === 'arrow') return
for (const arrowId of this.listenerArrows) {
this.propagate(editor, arrowId)
}
}
}

View File

@ -0,0 +1,165 @@
import { RESET_VALUE, computed, isUninitialized } from '@tldraw/state'
import { TLPageId, TLShapeId, isShape, isShapeId } from '@tldraw/tlschema'
import RBush from 'rbush'
import { Box, Editor } from 'tldraw'
type Element = {
minX: number
minY: number
maxX: number
maxY: number
id: TLShapeId
}
export class SpatialIndex {
private readonly spatialIndex: ReturnType<typeof this.createSpatialIndex>
private lastPageId: TLPageId | null = null
private shapesInTree: Map<TLShapeId, Element>
private rBush: RBush<Element>
constructor(private editor: Editor) {
this.spatialIndex = this.createSpatialIndex()
this.shapesInTree = new Map<TLShapeId, Element>()
this.rBush = new RBush<Element>()
}
private addElement(id: TLShapeId, a: Element[], existingBounds?: Box) {
const e = this.getElement(id, existingBounds)
if (!e) return
a.push(e)
this.shapesInTree.set(id, e)
}
private getElement(id: TLShapeId, existingBounds?: Box): Element | null {
const bounds = existingBounds ?? this.editor.getShapeMaskedPageBounds(id)
if (!bounds) return null
return {
minX: bounds.minX,
minY: bounds.minY,
maxX: bounds.maxX,
maxY: bounds.maxY,
id,
}
}
private fromScratch(lastComputedEpoch: number) {
this.lastPageId = this.editor.getCurrentPageId()
this.shapesInTree = new Map<TLShapeId, Element>()
const elementsToAdd: Element[] = []
this.editor.getCurrentPageShapeIds().forEach((id) => {
this.addElement(id, elementsToAdd)
})
this.rBush = new RBush<Element>().load(elementsToAdd)
return lastComputedEpoch
}
private createSpatialIndex() {
const shapeHistory = this.editor.store.query.filterHistory('shape')
return computed<number>('spatialIndex', (prevValue, lastComputedEpoch) => {
if (isUninitialized(prevValue)) {
return this.fromScratch(lastComputedEpoch)
}
const diff = shapeHistory.getDiffSince(lastComputedEpoch)
if (diff === RESET_VALUE) {
return this.fromScratch(lastComputedEpoch)
}
const currentPageId = this.editor.getCurrentPageId()
if (!this.lastPageId || this.lastPageId !== currentPageId) {
return this.fromScratch(lastComputedEpoch)
}
let isDirty = false
for (const changes of diff) {
const elementsToAdd: Element[] = []
for (const record of Object.values(changes.added)) {
if (isShape(record)) {
this.addElement(record.id, elementsToAdd)
}
}
for (const [_from, to] of Object.values(changes.updated)) {
if (isShape(to)) {
const currentElement = this.shapesInTree.get(to.id)
const newBounds = this.editor.getShapeMaskedPageBounds(to.id)
if (currentElement) {
if (
newBounds?.minX === currentElement.minX &&
newBounds.minY === currentElement.minY &&
newBounds.maxX === currentElement.maxX &&
newBounds.maxY === currentElement.maxY
) {
continue
}
this.shapesInTree.delete(to.id)
this.rBush.remove(currentElement)
isDirty = true
}
this.addElement(to.id, elementsToAdd, newBounds)
}
}
if (elementsToAdd.length) {
this.rBush.load(elementsToAdd)
isDirty = true
}
for (const id of Object.keys(changes.removed)) {
if (isShapeId(id)) {
const currentElement = this.shapesInTree.get(id)
if (currentElement) {
this.shapesInTree.delete(id)
this.rBush.remove(currentElement)
isDirty = true
}
}
}
}
return isDirty ? lastComputedEpoch : prevValue
})
}
private _getVisibleShapes() {
return computed<Set<TLShapeId>>('visible shapes', (prevValue) => {
// Make sure the spatial index is up to date
const _index = this.spatialIndex.get()
const newValue = this.rBush.search(this.editor.getViewportPageBounds()).map((s: Element) => s.id)
if (isUninitialized(prevValue)) {
return new Set(newValue)
}
const isSame = prevValue.size === newValue.length && newValue.every((id: TLShapeId) => prevValue.has(id))
return isSame ? prevValue : new Set(newValue)
})
}
getVisibleShapes() {
return this._getVisibleShapes().get()
}
_getNotVisibleShapes() {
return computed<Set<TLShapeId>>('not visible shapes', (prevValue) => {
const visibleShapes = this._getVisibleShapes().get()
const pageShapes = this.editor.getCurrentPageShapeIds()
const nonVisibleShapes = [...pageShapes].filter((id) => !visibleShapes.has(id))
if (isUninitialized(prevValue)) return new Set(nonVisibleShapes)
const isSame =
prevValue.size === nonVisibleShapes.length &&
nonVisibleShapes.every((id) => prevValue.has(id))
return isSame ? prevValue : new Set(nonVisibleShapes)
})
}
getNotVisibleShapes() {
return this._getNotVisibleShapes().get()
}
getShapeIdsInsideBounds(bounds: Box) {
// Make sure the spatial index is up to date
const _index = this.spatialIndex.get()
return this.rBush.search(bounds).map((s: Element) => s.id)
}
}

115
src/propagators/tlgraph.ts Normal file
View File

@ -0,0 +1,115 @@
import { isShapeOfType } from "@/propagators/utils";
import { Editor, TLArrowBinding, TLArrowShape, TLShape, TLShapeId } from "tldraw";
export interface Edge {
arrowId: TLShapeId
from: TLShapeId
to: TLShapeId
text?: string
}
export interface Graph {
nodes: TLShapeId[]
edges: Edge[]
}
export function getEdge(shape: TLShape | undefined, editor: Editor): Edge | undefined {
if (!shape || !isShapeOfType<TLArrowShape>(shape, 'arrow')) return undefined
const bindings = editor.getBindingsInvolvingShape<TLArrowBinding>(shape.id)
if (!bindings || bindings.length !== 2) return undefined
if (bindings[0].props.terminal === "end") {
return {
arrowId: shape.id,
from: bindings[1].toId,
to: bindings[0].toId,
text: shape.props.text
}
}
return {
arrowId: shape.id,
from: bindings[0].toId,
to: bindings[1].toId,
text: shape.props.text
}
}
/**
* Returns the graph(s) of edges and nodes from a list of shapes
*/
export function getGraph(shapes: TLShape[], editor: Editor): Graph {
const nodes: Set<TLShapeId> = new Set<TLShapeId>()
const edges: Edge[] = []
for (const shape of shapes) {
const edge = getEdge(shape, editor)
if (edge) {
edges.push({
arrowId: edge.arrowId,
from: edge.from,
to: edge.to,
text: edge.text
})
nodes.add(edge.from)
nodes.add(edge.to)
}
}
return { nodes: Array.from(nodes), edges }
}
/**
* Returns the start and end nodes of a topologically sorted graph
*/
export function sortGraph(graph: Graph): { startNodes: TLShapeId[], endNodes: TLShapeId[] } {
const targetNodes = new Set<TLShapeId>(graph.edges.map(e => e.to));
const sourceNodes = new Set<TLShapeId>(graph.edges.map(e => e.from));
const startNodes = [];
const endNodes = [];
for (const node of graph.nodes) {
if (sourceNodes.has(node) && !targetNodes.has(node)) {
startNodes.push(node);
} else if (targetNodes.has(node) && !sourceNodes.has(node)) {
endNodes.push(node);
}
}
return { startNodes, endNodes };
}
/**
* Returns the arrows starting from the given shape
*/
export function getArrowsFromShape(editor: Editor, shapeId: TLShapeId): TLShapeId[] {
const bindings = editor.getBindingsToShape<TLArrowBinding>(shapeId, 'arrow')
return bindings.filter(edge => edge.props.terminal === 'start').map(edge => edge.fromId)
}
/**
* Returns the arrows ending at the given shape
*/
export function getArrowsToShape(editor: Editor, shapeId: TLShapeId): TLShapeId[] {
const bindings = editor.getBindingsToShape<TLArrowBinding>(shapeId, 'arrow')
return bindings.filter(edge => edge.props.terminal === 'end').map(edge => edge.fromId)
}
/**
* Returns the arrows which share the same start shape as the given arrow
*/
export function getSiblingArrowIds(editor: Editor, arrow: TLShape): TLShapeId[] {
if (arrow.type !== 'arrow') return [];
const bindings = editor.getBindingsInvolvingShape<TLArrowBinding>(arrow.id);
if (!bindings || bindings.length !== 2) return [];
const startShapeId = bindings.find(binding => binding.props.terminal === 'start')?.toId;
if (!startShapeId) return [];
const siblingBindings = editor.getBindingsToShape<TLArrowBinding>(startShapeId, 'arrow');
const siblingArrows = siblingBindings
.filter(binding => binding.props.terminal === 'start' && binding.fromId !== arrow.id)
.map(binding => binding.fromId);
return siblingArrows;
}

22
src/propagators/utils.ts Normal file
View File

@ -0,0 +1,22 @@
import { Editor, TLShape, TLShapePartial } from "tldraw";
/**
* @returns true if the shape is of the given type
* @example
* ```ts
* isShapeOfType<TLArrowShape>(shape, 'arrow')
* ```
*/
export function isShapeOfType<T extends TLShape>(shape: TLShape, type: T['type']): shape is T {
return shape.type === type;
}
export function updateProps<T extends TLShape>(editor: Editor, shape: T, props: Partial<T['props']>) {
editor.updateShape({
...shape,
props: {
...shape.props,
...props
},
} as TLShapePartial)
}

128
src/routes/Board.tsx Normal file
View File

@ -0,0 +1,128 @@
import { useSync } from "@tldraw/sync"
import { useMemo, useEffect, useState } from "react"
import { Tldraw, Editor } from "tldraw"
import { useParams } from "react-router-dom"
import { ChatBoxTool } from "@/tools/ChatBoxTool"
import { ChatBoxShape } from "@/shapes/ChatBoxShapeUtil"
import { VideoChatTool } from "@/tools/VideoChatTool"
import { VideoChatShape } from "@/shapes/VideoChatShapeUtil"
import { multiplayerAssetStore } from "../utils/multiplayerAssetStore"
import { EmbedShape } from "@/shapes/EmbedShapeUtil"
import { EmbedTool } from "@/tools/EmbedTool"
import { MarkdownShape } from "@/shapes/MarkdownShapeUtil"
import { MarkdownTool } from "@/tools/MarkdownTool"
import { defaultShapeUtils, defaultBindingUtils } from "tldraw"
import { components } from "@/ui/components"
import { overrides } from "@/ui/overrides"
import { unfurlBookmarkUrl } from "../utils/unfurlBookmarkUrl"
import { handleInitialPageLoad } from "@/utils/handleInitialPageLoad"
import { MycrozineTemplateTool } from "@/tools/MycrozineTemplateTool"
import { MycrozineTemplateShape } from "@/shapes/MycrozineTemplateShapeUtil"
import {
registerPropagators,
ChangePropagator,
TickPropagator,
ClickPropagator,
} from "@/propagators/ScopedPropagators"
import { SlideShapeTool } from "@/tools/SlideShapeTool"
import { SlideShape } from "@/shapes/SlideShapeUtil"
import { makeRealSettings, applySettingsMigrations } from "@/lib/settings"
import { PromptShapeTool } from "@/tools/PromptShapeTool"
import { PromptShape } from "@/shapes/PromptShapeUtil"
import { llm } from "@/utils/llmUtils"
// Default to production URL if env var isn't available
export const WORKER_URL = "https://jeffemmett-canvas.jeffemmett.workers.dev"
const customShapeUtils = [
ChatBoxShape,
VideoChatShape,
EmbedShape,
SlideShape,
MycrozineTemplateShape,
MarkdownShape,
PromptShape,
]
const customTools = [
ChatBoxTool,
VideoChatTool,
EmbedTool,
SlideShapeTool,
MycrozineTemplateTool,
MarkdownTool,
PromptShapeTool,
]
export function Board() {
const { slug } = useParams<{ slug: string }>()
const roomId = slug || "default-room"
const storeConfig = useMemo(
() => ({
uri: `${WORKER_URL}/connect/${roomId}`,
assets: multiplayerAssetStore,
shapeUtils: [...defaultShapeUtils, ...customShapeUtils],
bindingUtils: [...defaultBindingUtils],
}),
[roomId],
)
const store = useSync(storeConfig)
const [editor, setEditor] = useState<Editor | null>(null)
useEffect(() => {
const value = localStorage.getItem("makereal_settings_2")
if (value) {
const json = JSON.parse(value)
const migratedSettings = applySettingsMigrations(json)
localStorage.setItem(
"makereal_settings_2",
JSON.stringify(migratedSettings),
)
makeRealSettings.set(migratedSettings)
}
}, [])
return (
<div style={{ position: "fixed", inset: 0 }}>
<Tldraw
store={store.store}
shapeUtils={customShapeUtils}
tools={customTools}
components={components}
overrides={overrides}
cameraOptions={{
zoomSteps: [
0.001, // Min zoom
0.0025,
0.005,
0.01,
0.025,
0.05,
0.1,
0.25,
0.5,
1,
2,
4,
8,
16,
32,
64, // Max zoom
],
}}
onMount={(editor) => {
setEditor(editor)
editor.registerExternalAssetHandler("url", unfurlBookmarkUrl)
editor.setCurrentTool("hand")
handleInitialPageLoad(editor)
registerPropagators(editor, [
TickPropagator,
ChangePropagator,
ClickPropagator,
])
}}
/>
</div>
)
}

29
src/routes/Contact.tsx Normal file
View File

@ -0,0 +1,29 @@
export function Contact() {
return (
<main>
<header>
<a href="/">Jeff Emmett</a>
</header>
<h1>Contact</h1>
<p>
Twitter: <a href="https://twitter.com/jeffemmett">@jeffemmett</a>
</p>
<p>
BlueSky:{" "}
<a href="https://bsky.app/profile/jeffemmett.bsky.social">
@jeffemnmett.bsky.social
</a>
</p>
<p>
Mastodon:{" "}
<a href="https://social.coop/@jeffemmett">@jeffemmett@social.coop</a>
</p>
<p>
Email: <a href="mailto:jeffemmett@gmail.com">jeffemmett@gmail.com</a>
</p>
<p>
GitHub: <a href="https://github.com/Jeff-Emmett">Jeff-Emmett</a>
</p>
</main>
)
}

106
src/routes/Default.tsx Normal file
View File

@ -0,0 +1,106 @@
export function Default() {
return (
<main>
<header>Jeff Emmett</header>
<h2>Hello! 👋🍄</h2>
<p>
My research investigates the intersection of mycelium and emancipatory
technologies. I am interested in the potential of new convivial tooling
as a medium for group consensus building and collective action, in order
to empower communities of practice to address their own challenges.
</p>
<p>
My current focus is basic research into the nature of digital
organisation, developing prototype toolkits to improve shared
infrastructure, and applying this research to the design of new systems
and protocols which support the self-organisation of knowledge and
emergent response to local needs.
</p>
<h2>My work</h2>
<p>
Alongside my independent work, I am a researcher and engineering
communicator at <a href="https://block.science/">Block Science</a>, an
advisor to the Active Inference Lab, Commons Stack, and the Trusted
Seed. I am also an occasional collaborator with{" "}
<a href="https://economicspace.agency/">ECSA</a>.
</p>
<h2>Get in touch</h2>
<p>
I am on Twitter <a href="https://twitter.com/jeffemmett">@jeffemmett</a>
, Mastodon{" "}
<a href="https://social.coop/@jeffemmett">@jeffemmett@social.coop</a>{" "}
and GitHub <a href="https://github.com/Jeff-Emmett">@Jeff-Emmett</a>.
</p>
<span className="dinkus">***</span>
<h2>Talks</h2>
<ol reversed>
<li>
<a href="https://www.teamhuman.fm/episodes/238-jeff-emmett">
MycoPunk Futures on Team Human with Douglas Rushkoff
</a>{" "}
(<a href="artifact/tft-rocks-integration-domain.pdf">slides</a>)
</li>
<li>
<a href="https://www.youtube.com/watch?v=AFJFDajuCSg">
Exploring MycoFi on the Greenpill Network with Kevin Owocki
</a>{" "}
(<a href="artifact/tft-rocks-integration-domain.pdf">slides</a>)
</li>
<li>
<a href="https://youtu.be/9ad2EJhMbZ8">
Re-imagining Human Value on the Telos Podcast with Rieki &
Brandonfrom SEEDS
</a>{" "}
(<a href="artifact/tft-rocks-integration-domain.pdf">slides</a>)
</li>
<li>
<a href="https://www.youtube.com/watch?v=i8qcg7FfpLM&t=1348s">
Move Slow & Fix Things: Design Patterns from Nature
</a>{" "}
(<a href="artifact/tft-rocks-integration-domain.pdf">slides</a>)
</li>
<li>
<a href="https://podcasters.spotify.com/pod/show/theownershipeconomy/episodes/Episode-009---Localized-Democracy-and-Public-Goods-with-Token-Engineering--with-Jeff-Emmett-of-The-Commons-Stack--BlockScience-Labs-e1ggkqo">
Localized Democracy and Public Goods with Token Engineering on the
Ownership Economy
</a>{" "}
(<a href="artifact/tft-rocks-integration-domain.pdf">slides</a>)
</li>
<li>
<a href="https://youtu.be/kxcat-XBWas">
A Discussion on Warm Data with Nora Bateson on Systems Innovation
</a>
</li>
</ol>
<h2>Writing</h2>
<ol reversed>
<li>
<a href="https://www.mycofi.art">
Exploring MycoFi: Mycelial Design Patterns for Web3 & Beyond
</a>
</li>
<li>
<a href="https://www.frontiersin.org/journals/blockchain/articles/10.3389/fbloc.2021.578721/full">
Challenges & Approaches to Scaling the Global Commons
</a>
</li>
<li>
<a href="https://allthingsdecent.substack.com/p/mycoeconomics-and-permaculture-currencies">
From Monoculture to Permaculture Currencies: A Glimpse of the
Myco-Economic Future
</a>
</li>
<li>
<a href="https://medium.com/good-audience/rewriting-the-story-of-human-collaboration-c33a8a4cd5b8">
Rewriting the Story of Human Collaboration
</a>
</li>
</ol>
</main>
)
}

88
src/routes/Inbox.tsx Normal file
View File

@ -0,0 +1,88 @@
import {
createShapeId,
Editor,
Tldraw,
TLGeoShape,
TLShapePartial,
} from "tldraw"
import { useEffect, useRef } from "react"
export function Inbox() {
const editorRef = useRef<Editor | null>(null)
const updateEmails = async (editor: Editor) => {
try {
const response = await fetch("https://jeffemmett-canvas.web.val.run", {
method: "GET",
})
const messages = (await response.json()) as {
id: string
from: string
subject: string
text: string
}[]
for (let i = 0; i < messages.length; i++) {
const message = messages[i]
const messageId = message.id
const parsedEmailName =
message.from.match(/^([^<]+)/)?.[1]?.trim() ||
message.from.match(/[^<@]+(?=@)/)?.[0] ||
message.from
const messageText = `from: ${parsedEmailName}\nsubject: ${message.subject}\n\n${message.text}`
const shapeWidth = 500
const shapeHeight = 300
const spacing = 50
const shape: TLShapePartial<TLGeoShape> = {
id: createShapeId(),
type: "geo",
x: shapeWidth * (i % 5) + spacing * (i % 5),
y: shapeHeight * Math.floor(i / 5) + spacing * Math.floor(i / 5),
props: {
w: shapeWidth,
h: shapeHeight,
text: messageText,
align: "start",
verticalAlign: "start",
},
meta: {
id: messageId,
},
}
let found = false
for (const s of editor.getCurrentPageShapes()) {
if (s.meta.id === messageId) {
found = true
break
}
}
if (!found) {
editor.createShape(shape)
}
}
} catch (error) {
console.error("Error fetching data:", error)
}
}
useEffect(() => {
const intervalId = setInterval(() => {
if (editorRef.current) {
updateEmails(editorRef.current)
}
}, 5 * 1000)
return () => clearInterval(intervalId)
}, [])
return (
<div className="tldraw__editor">
<Tldraw
onMount={(editor: Editor) => {
editorRef.current = editor
updateEmails(editor)
}}
/>
</div>
)
}

View File

@ -0,0 +1,153 @@
Yes, it is possible to allow users of your website to render their own Google Docs securely, but it requires additional steps to ensure privacy, user authentication, and proper permissions. Here's how you can set it up:
---
### Steps to Enable Users to Render Their Own Google Docs
#### 1. Enable Google Sign-In for Your Website
- Users need to authenticate with their Google account to grant your app access to their documents.
- Use the [Google Sign-In library](https://developers.google.com/identity/sign-in/web) to implement OAuth authentication.
Steps:
- Include the Google Sign-In button on your site:
<script src="https://apis.google.com/js/platform.js" async defer></script>
<meta name="google-signin-client_id" content="YOUR_CLIENT_ID.apps.googleusercontent.com">
<div class="g-signin2" data-onsuccess="onSignIn"></div>
- Handle the user's authentication token on sign-in:
function onSignIn(googleUser) {
var profile = googleUser.getBasicProfile();
var idToken = googleUser.getAuthResponse().id_token;
// Send the token to your backend to authenticate and fetch user-specific documents
fetch('/api/authenticate', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ token: idToken }),
}).then(response => response.json())
.then(data => console.log(data));
}
---
#### 2. Request Google Docs API Permissions
- Once the user is authenticated, request permissions for the Google Docs API.
- Scopes needed:
https://www.googleapis.com/auth/documents.readonly
- Example request for API access:
function requestDocsAccess() {
gapi.auth2.getAuthInstance().signIn({
scope: 'https://www.googleapis.com/auth/documents.readonly',
}).then(() => {
console.log('API access granted');
});
}
---
#### 3. Fetch User's Document Content
- After receiving user authorization, fetch their document content using the Google Docs API.
- Example using JavaScript:
gapi.client.load('docs', 'v1', function () {
var request = gapi.client.docs.documents.get({
documentId: 'USER_DOCUMENT_ID',
});
request.execute(function (response) {
console.log(response);
// Render document content on your website
document.getElementById('doc-container').innerHTML = response.body.content.map(
item => item.paragraph.elements.map(
el => el.textRun.content
).join('')
).join('<br>');
});
});
- Ensure that USER_DOCUMENT_ID is input by the user (e.g., through a form field).
---
#### 4. Secure Your Backend
- Create an API endpoint to handle requests for fetching document content.
- Validate the user's Google token on your server using Google's token verification endpoint.
- Use their authenticated token to call the Google Docs API and fetch the requested document.
Example in Python (using Flask):
from google.oauth2 import id_token
from google.auth.transport import requests
from googleapiclient.discovery import build
@app.route('/api/fetch-doc', methods=['POST'])
def fetch_doc():
token = request.json.get('token')
document_id = request.json.get('document_id')
# Verify token
idinfo = id_token.verify_oauth2_token(token, requests.Request(), CLIENT_ID)
if idinfo['iss'] not in ['accounts.google.com', 'https://accounts.google.com']:
return 'Invalid token', 401
# Fetch the document
creds = id_token.Credentials(token=token)
service = build('docs', 'v1', credentials=creds)
doc = service.documents().get(documentId=document_id).execute()
return jsonify(doc)
---
rohan mehta, [2024-11-21 4:42 PM]
#### 5. Provide a Frontend UI
- Allow users to input their Google Doc ID through a form.
- Example:
<input type="text" id="doc-id" placeholder="Enter your Google Doc ID">
<button onclick="fetchDoc()">Render Doc</button>
<div id="doc-container"></div>
- JavaScript to send the document ID to your backend:
function fetchDoc() {
const docId = document.getElementById('doc-id').value;
fetch('/api/fetch-doc', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ token: userToken, document_id: docId }),
})
.then(response => response.json())
.then(data => {
document.getElementById('doc-container').innerHTML = JSON.stringify(data);
});
}
---
### Security and Privacy Considerations
1. Authentication:
- Verify each user's Google token before processing their request.
- Only fetch documents they own or have shared with them.
2. Rate Limiting:
- Implement rate limiting on your backend API to prevent abuse.
3. Permission Scope:
- Use the minimal scope (documents.readonly) to ensure you can only read documents, not modify them.
4. Data Handling:
- Never store user document content unless explicitly required and with user consent.
---
With this approach, each user will be able to render their own Google Docs securely while maintaining privacy. Let me know if youd like a more detailed implementation in any specific programming language!

View File

@ -0,0 +1,191 @@
import { useEffect, useRef, useState } from "react"
import { BaseBoxShapeUtil, TLBaseShape } from "tldraw"
export type IChatBoxShape = TLBaseShape<
"ChatBox",
{
w: number
h: number
roomId: string
userName: string
}
>
export class ChatBoxShape extends BaseBoxShapeUtil<IChatBoxShape> {
static override type = "ChatBox"
getDefaultProps(): IChatBoxShape["props"] {
return {
roomId: "default-room",
w: 100,
h: 100,
userName: "",
}
}
indicator(shape: IChatBoxShape) {
return <rect x={0} y={0} width={shape.props.w} height={shape.props.h} />
}
component(shape: IChatBoxShape) {
return (
<ChatBox
roomId={shape.props.roomId}
w={shape.props.w}
h={shape.props.h}
userName=""
/>
)
}
}
interface Message {
id: string
username: string
content: string
timestamp: Date
}
// Update the ChatBox component to accept userName
export const ChatBox: React.FC<IChatBoxShape["props"]> = ({
roomId,
w,
h,
userName,
}) => {
const [messages, setMessages] = useState<Message[]>([])
const [inputMessage, setInputMessage] = useState("")
const [username, setUsername] = useState(userName)
const messagesEndRef = useRef(null)
useEffect(() => {
const storedUsername = localStorage.getItem("chatUsername")
if (storedUsername) {
setUsername(storedUsername)
} else {
const newUsername = `User${Math.floor(Math.random() * 1000)}`
setUsername(newUsername)
localStorage.setItem("chatUsername", newUsername)
}
fetchMessages(roomId)
const interval = setInterval(() => fetchMessages(roomId), 2000)
return () => clearInterval(interval)
}, [roomId])
useEffect(() => {
if (messagesEndRef.current) {
;(messagesEndRef.current as HTMLElement).scrollIntoView({
behavior: "smooth",
})
}
}, [messages])
const fetchMessages = async (roomId: string) => {
try {
const response = await fetch(
`https://jeffemmett-realtimechatappwithpolling.web.val.run?action=getMessages&roomId=${roomId}`,
)
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`)
}
const newMessages = (await response.json()) as Message[]
setMessages(
newMessages.map((msg) => ({
...msg,
timestamp: new Date(msg.timestamp),
})),
)
} catch (error) {
console.error("Error fetching messages:", error)
}
}
const sendMessage = async (e: React.FormEvent) => {
e.preventDefault()
if (!inputMessage.trim()) return
await sendMessageToChat(roomId, username, inputMessage)
setInputMessage("")
fetchMessages(roomId)
}
return (
<div
className="chat-container"
style={{
pointerEvents: "all",
width: `${w}px`,
height: `${h}px`,
overflow: "auto",
touchAction: "auto",
}}
>
<div className="messages-container">
{messages.map((msg) => (
<div
key={msg.id}
className={`message ${
msg.username === username ? "own-message" : ""
}`}
>
<div className="message-header">
<strong>{msg.username}</strong>
<span className="timestamp">
{new Date(msg.timestamp).toLocaleTimeString()}
</span>
</div>
<div className="message-content">{msg.content}</div>
</div>
))}
<div ref={messagesEndRef} />
</div>
<form onSubmit={sendMessage} className="input-form">
<input
type="text"
value={inputMessage}
onChange={(e) => setInputMessage(e.target.value)}
placeholder="Type a message..."
className="message-input"
style={{ touchAction: "manipulation" }}
/>
<button
type="submit"
style={{ pointerEvents: "all", touchAction: "manipulation" }}
onPointerDown={(e) => e.stopPropagation()}
onTouchStart={(e) => e.stopPropagation()}
className="send-button"
>
Send
</button>
</form>
</div>
)
}
async function sendMessageToChat(
roomId: string,
username: string,
content: string,
): Promise<void> {
const apiUrl = "https://jeffemmett-realtimechatappwithpolling.web.val.run" // Replace with your actual Val Town URL
try {
const response = await fetch(`${apiUrl}?action=sendMessage`, {
method: "POST",
mode: "no-cors",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
roomId,
username,
content,
}),
})
const result = await response.text()
console.log("Message sent successfully:", result)
} catch (error) {
console.error("Error sending message:", error)
}
}

View File

@ -0,0 +1,597 @@
import { BaseBoxShapeUtil, TLBaseShape } from "tldraw"
import { useCallback, useState } from "react"
//import Embed from "react-embed"
export type IEmbedShape = TLBaseShape<
"Embed",
{
w: number
h: number
url: string | null
isMinimized?: boolean
interactionState?: {
scrollPosition?: { x: number; y: number }
currentTime?: number // for videos
// other state you want to sync
}
}
>
const transformUrl = (url: string): string => {
// YouTube
const youtubeMatch = url.match(
/(?:youtube\.com\/(?:[^\/]+\/.+\/|(?:v|e(?:mbed)?)\/|.*[?&]v=)|youtu\.be\/)([^"&?\/\s]{11})/,
)
if (youtubeMatch) {
return `https://www.youtube.com/embed/${youtubeMatch[1]}`
}
// Google Maps
if (url.includes("google.com/maps") || url.includes("goo.gl/maps")) {
// If it's already an embed URL, return as is
if (url.includes("google.com/maps/embed")) {
return url
}
// Handle directions
const directionsMatch = url.match(/dir\/([^\/]+)\/([^\/]+)/)
if (directionsMatch || url.includes("/dir/")) {
const origin = url.match(/origin=([^&]+)/)?.[1] || directionsMatch?.[1]
const destination =
url.match(/destination=([^&]+)/)?.[1] || directionsMatch?.[2]
if (origin && destination) {
return `https://www.google.com/maps/embed/v1/directions?key=${
import.meta.env["VITE_GOOGLE_MAPS_API_KEY"]
}&origin=${encodeURIComponent(origin)}&destination=${encodeURIComponent(
destination,
)}&mode=driving`
}
}
// Extract place ID
const placeMatch = url.match(/[?&]place_id=([^&]+)/)
if (placeMatch) {
return `https://www.google.com/maps/embed?pb=!1m18!1m12!1m3!1d2!2d0!3d0!2m3!1f0!2f0!3f0!3m2!1i1024!2i768!4f13.1!3m3!1m2!1s${placeMatch[1]}!2s!5e0!3m2!1sen!2s!4v1`
}
// For all other map URLs
return `https://www.google.com/maps/embed/v1/place?key=${
import.meta.env.VITE_GOOGLE_MAPS_API_KEY
}&q=${encodeURIComponent(url)}`
}
// Twitter/X
const xMatch = url.match(
/(?:twitter\.com|x\.com)\/([^\/\s?]+)(?:\/(?:status|tweets)\/(\d+)|$)/,
)
if (xMatch) {
const [, username, tweetId] = xMatch
if (tweetId) {
// For tweets
return `https://platform.x.com/embed/Tweet.html?id=${tweetId}`
} else {
// For profiles, return about:blank and handle display separately
return "about:blank"
}
}
// Medium - return about:blank to prevent iframe loading
if (url.includes("medium.com")) {
return "about:blank"
}
// Gather.town
if (url.includes("app.gather.town")) {
return url.replace("app.gather.town", "gather.town/embed")
}
return url
}
const getDefaultDimensions = (url: string): { w: number; h: number } => {
// YouTube default dimensions (16:9 ratio)
if (url.match(/(?:youtube\.com|youtu\.be)/)) {
return { w: 800, h: 450 }
}
// Twitter/X default dimensions
if (url.match(/(?:twitter\.com|x\.com)/)) {
if (url.match(/\/status\/|\/tweets\//)) {
return { w: 800, h: 600 } // For individual tweets
}
}
// Google Maps default dimensions
if (url.includes("google.com/maps") || url.includes("goo.gl/maps")) {
return { w: 800, h: 600 }
}
// Gather.town default dimensions
if (url.includes("gather.town")) {
return { w: 800, h: 600 }
}
// Default dimensions for other embeds
return { w: 800, h: 600 }
}
const getFaviconUrl = (url: string): string => {
try {
const urlObj = new URL(url)
return `https://www.google.com/s2/favicons?domain=${urlObj.hostname}&sz=32`
} catch {
return '' // Return empty if URL is invalid
}
}
const getDisplayTitle = (url: string): string => {
try {
const urlObj = new URL(url)
// Handle special cases
if (urlObj.hostname.includes('youtube.com')) {
return 'YouTube'
}
if (urlObj.hostname.includes('twitter.com') || urlObj.hostname.includes('x.com')) {
return 'Twitter/X'
}
if (urlObj.hostname.includes('google.com/maps')) {
return 'Google Maps'
}
// Default: return clean hostname
return urlObj.hostname.replace('www.', '')
} catch {
return url // Return original URL if parsing fails
}
}
export class EmbedShape extends BaseBoxShapeUtil<IEmbedShape> {
static override type = "Embed"
getDefaultProps(): IEmbedShape["props"] {
return {
url: null,
w: 800,
h: 600,
isMinimized: false,
}
}
indicator(shape: IEmbedShape) {
return (
<rect
x={0}
y={0}
width={shape.props.w}
height={shape.props.isMinimized ? 40 : shape.props.h}
fill="none"
/>
)
}
component(shape: IEmbedShape) {
const isSelected = this.editor.getSelectedShapeIds().includes(shape.id)
const [inputUrl, setInputUrl] = useState(shape.props.url || "")
const [error, setError] = useState("")
const [copyStatus, setCopyStatus] = useState(false)
const handleSubmit = useCallback(
(e: React.FormEvent) => {
e.preventDefault()
let completedUrl =
inputUrl.startsWith("http://") || inputUrl.startsWith("https://")
? inputUrl
: `https://${inputUrl}`
// Basic URL validation
const isValidUrl = completedUrl.match(/(^\w+:|^)\/\//)
if (!isValidUrl) {
setError("Invalid URL")
return
}
this.editor.updateShape<IEmbedShape>({
id: shape.id,
type: "Embed",
props: { ...shape.props, url: completedUrl },
})
setError("")
},
[inputUrl],
)
const handleIframeInteraction = (
newState: typeof shape.props.interactionState,
) => {
this.editor.updateShape<IEmbedShape>({
id: shape.id,
type: "Embed",
props: {
...shape.props,
interactionState: newState,
},
})
}
const contentStyle = {
pointerEvents: isSelected ? "none" as const : "all" as const,
width: "100%",
height: "100%",
border: "1px solid #D3D3D3",
backgroundColor: "#FFFFFF",
display: "flex",
justifyContent: "center",
alignItems: "center",
overflow: "hidden",
}
const wrapperStyle = {
position: 'relative' as const,
width: `${shape.props.w}px`,
height: `${shape.props.isMinimized ? 40 : shape.props.h}px`,
backgroundColor: "#F0F0F0",
borderRadius: "4px",
transition: "height 0.3s, width 0.3s",
overflow: "hidden",
}
// Update control button styles
const controlButtonStyle = {
border: "none",
background: "#666666", // Grey background
color: "white", // White text
padding: "4px 12px",
margin: "0 4px",
borderRadius: "4px",
cursor: "pointer",
fontSize: "12px",
pointerEvents: "all" as const,
whiteSpace: "nowrap" as const,
transition: "background-color 0.2s",
"&:hover": {
background: "#4D4D4D", // Darker grey on hover
}
}
const controlsContainerStyle = {
position: "absolute" as const,
top: "8px",
right: "8px",
display: "flex",
gap: "8px",
zIndex: 1,
}
const handleToggleMinimize = (e: React.MouseEvent) => {
e.preventDefault()
e.stopPropagation()
this.editor.updateShape<IEmbedShape>({
id: shape.id,
type: "Embed",
props: {
...shape.props,
isMinimized: !shape.props.isMinimized,
},
})
}
const controls = (url: string) => (
<div style={controlsContainerStyle}>
<button
onClick={() => navigator.clipboard.writeText(url)}
style={controlButtonStyle}
onPointerDown={(e) => e.stopPropagation()}
>
Copy Link
</button>
<button
onClick={() => window.open(url, '_blank')}
style={controlButtonStyle}
onPointerDown={(e) => e.stopPropagation()}
>
Open in Tab
</button>
<button
onClick={handleToggleMinimize}
style={controlButtonStyle}
onPointerDown={(e) => e.stopPropagation()}
>
{shape.props.isMinimized ? "Maximize" : "Minimize"}
</button>
</div>
)
// For minimized state, show URL and all controls
if (shape.props.url && shape.props.isMinimized) {
return (
<div style={wrapperStyle}>
<div
style={{
...contentStyle,
height: "40px",
alignItems: "center",
padding: "0 15px",
position: "relative",
display: "flex",
gap: "8px",
}}
>
<img
src={getFaviconUrl(shape.props.url)}
alt=""
style={{
width: "16px",
height: "16px",
flexShrink: 0,
}}
onError={(e) => {
// Hide broken favicon
(e.target as HTMLImageElement).style.display = 'none'
}}
/>
<div
style={{
display: "flex",
flexDirection: "column",
overflow: "hidden",
flex: 1,
}}
>
<span
style={{
fontWeight: 500,
color: "#333",
overflow: "hidden",
textOverflow: "ellipsis",
whiteSpace: "nowrap",
}}
>
{getDisplayTitle(shape.props.url)}
</span>
<span
style={{
fontSize: "11px",
color: "#666",
overflow: "hidden",
textOverflow: "ellipsis",
whiteSpace: "nowrap",
}}
>
{shape.props.url}
</span>
</div>
{controls(shape.props.url)}
</div>
</div>
)
}
// For empty state
if (!shape.props.url) {
return (
<div style={wrapperStyle}>
{controls("")}
<div style={contentStyle}>
<form
onSubmit={handleSubmit}
style={{ width: "100%", height: "100%", padding: "10px" }}
>
<input
type="text"
value={inputUrl}
onChange={(e) => setInputUrl(e.target.value)}
placeholder="Enter URL"
style={{
width: "100%",
height: "100%",
border: "none",
padding: "10px",
}}
onKeyDown={(e) => {
if (e.key === "Enter") {
handleSubmit(e)
}
}}
/>
{error && (
<div style={{ color: "red", marginTop: "10px" }}>{error}</div>
)}
</form>
</div>
</div>
)
}
// For medium.com and twitter profile views
if (shape.props.url?.includes("medium.com") ||
(shape.props.url && shape.props.url.match(/(?:twitter\.com|x\.com)\/[^\/]+$/))) {
return (
<div style={wrapperStyle}>
{controls(shape.props.url)}
<div
style={{
...contentStyle,
flexDirection: "column",
gap: "12px",
padding: "20px",
textAlign: "center",
pointerEvents: "all",
}}
>
<p>
Medium's content policy does not allow for embedding articles in
iframes.
</p>
<a
href={shape.props.url}
target="_blank"
rel="noopener noreferrer"
style={{
color: "#1976d2",
textDecoration: "none",
cursor: "pointer",
}}
>
Open article in new tab
</a>
</div>
</div>
)
}
// For normal embed view
return (
<div style={wrapperStyle}>
<div
style={{
height: "40px",
position: "relative",
backgroundColor: "#F0F0F0",
borderTopLeftRadius: "4px",
borderTopRightRadius: "4px",
display: "flex",
alignItems: "center",
padding: "0 8px",
}}
>
{controls(shape.props.url)}
</div>
{!shape.props.isMinimized && (
<>
<div style={{
...contentStyle,
height: `${shape.props.h - 80}px`,
}}>
<iframe
src={transformUrl(shape.props.url)}
width="100%"
height="100%"
style={{ border: "none" }}
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture"
allowFullScreen
loading="lazy"
referrerPolicy="no-referrer"
onLoad={(e) => {
// Only add listener if we have a valid iframe
const iframe = e.currentTarget as HTMLIFrameElement
if (!iframe) return;
const messageHandler = (event: MessageEvent) => {
if (event.source === iframe.contentWindow) {
handleIframeInteraction(event.data)
}
}
window.addEventListener("message", messageHandler)
// Clean up listener when iframe changes
return () => window.removeEventListener("message", messageHandler)
}}
/>
</div>
<div
style={{
display: "flex",
justifyContent: "space-between",
alignItems: "center",
padding: "8px",
height: "40px",
fontSize: "12px",
backgroundColor: "rgba(255, 255, 255, 0.9)",
borderRadius: "4px",
position: "absolute",
bottom: 0,
left: 0,
right: 0,
}}
>
<span
style={{
overflow: "hidden",
textOverflow: "ellipsis",
whiteSpace: "nowrap",
flex: 1,
marginRight: "8px",
color: "#666",
}}
>
{shape.props.url}
</span>
</div>
</>
)}
</div>
)
}
override onDoubleClick = (shape: IEmbedShape) => {
// If no URL is set, focus the input field
if (!shape.props.url) {
const input = document.querySelector('input')
input?.focus()
return
}
// For Medium articles and Twitter profiles that show alternative content
if (
shape.props.url.includes('medium.com') ||
(shape.props.url && shape.props.url.match(/(?:twitter\.com|x\.com)\/[^\/]+$/))
) {
window.top?.open(shape.props.url, '_blank', 'noopener,noreferrer')
return
}
// For other embeds, enable interaction by temporarily removing pointer-events: none
const iframe = document.querySelector(`[data-shape-id="${shape.id}"] iframe`) as HTMLIFrameElement
if (iframe) {
iframe.style.pointerEvents = 'all'
// Reset pointer-events after interaction
const cleanup = () => {
iframe.style.pointerEvents = 'none'
window.removeEventListener('pointerdown', cleanup)
}
window.addEventListener('pointerdown', cleanup)
}
}
// Add new method to handle all pointer interactions
onPointerDown = (shape: IEmbedShape) => {
if (!shape.props.url) {
const input = document.querySelector('input')
input?.focus()
}
}
// Add a method to handle URL updates
override onBeforeCreate = (shape: IEmbedShape) => {
if (shape.props.url) {
const dimensions = getDefaultDimensions(shape.props.url)
return {
...shape,
props: {
...shape.props,
w: dimensions.w,
h: dimensions.h,
},
}
}
return shape
}
// Handle URL updates after creation
override onBeforeUpdate = (prev: IEmbedShape, next: IEmbedShape) => {
if (next.props.url && prev.props.url !== next.props.url) {
const dimensions = getDefaultDimensions(next.props.url)
return {
...next,
props: {
...next.props,
w: dimensions.w,
h: dimensions.h,
},
}
}
return next
}
}

View File

@ -0,0 +1,202 @@
/** TODO: build this */
import { BaseBoxShapeUtil, TLBaseBoxShape, TLBaseShape, StyleProp, T, DefaultSizeStyle, DefaultFontStyle, DefaultColorStyle } from "tldraw"
import { useEffect, useRef, useState } from "react"
import { marked } from "marked"
// Uncomment and use these style definitions
const MarkdownColor = StyleProp.defineEnum('markdown:color', {
defaultValue: 'black',
values: ['black', 'blue', 'green', 'grey', 'light-blue', 'light-green', 'light-red', 'light-violet', 'orange', 'red', 'violet', 'yellow'],
})
const MarkdownSize = StyleProp.defineEnum('markdown:size', {
defaultValue: 'medium',
values: ['small', 'medium', 'large'],
})
const MarkdownFont = StyleProp.defineEnum('markdown:font', {
defaultValue: 'draw',
values: ['draw', 'sans', 'serif', 'mono'],
})
//const MarkdownHorizontalAlign = StyleProp.define('markdown:horizontalalign', { defaultValue: 'start' })
//const MarkdownVerticalAlign = StyleProp.define('markdown:verticalalign', { defaultValue: 'start' })
export type IMarkdownShape = TLBaseShape<
"MarkdownTool",
{
content: string
isPreview: boolean
w: number
h: number
color: string
size: string
font: string
}
>
export class MarkdownShape extends BaseBoxShapeUtil<
IMarkdownShape & TLBaseBoxShape
> {
static override type = "MarkdownTool"
static styles = {
color: DefaultColorStyle,
size: DefaultSizeStyle,
font: DefaultFontStyle,
} as any // Type assertion to allow dynamic property addition
styles = MarkdownShape.styles
constructor(props: any) {
super(props)
console.log('MarkdownShape constructor - styles:', this.styles)
// Add a fallback get method if it doesn't exist
if (!this.styles.get) {
this.styles.get = function(style: string) {
console.log('Fallback get called for style:', style)
return this[style] || null
}
}
}
getDefaultProps(): IMarkdownShape["props"] & { w: number; h: number } {
console.log('getDefaultProps called');
const props = {
content: "",
isPreview: false,
w: 400,
h: 300,
color: 'black',
size: 'medium',
font: 'draw'
};
console.log('Default props:', props);
return props;
}
indicator(shape: IMarkdownShape) {
return (
<g>
<rect x={0} y={0} width={shape.props.w} height={shape.props.h} />
</g>
)
}
component(shape: IMarkdownShape) {
console.log('Component rendering with shape:', shape);
console.log('Available styles:', this.styles);
const editor = this.editor
return <MarkdownEditor shape={shape} editor={editor} />
}
}
function MarkdownEditor({ shape, editor }: { shape: IMarkdownShape; editor: any }) {
console.log('MarkdownEditor mounted with shape:', shape);
console.log('Editor instance:', editor);
const textareaRef = useRef<HTMLTextAreaElement>(null)
const [isPreview, setIsPreview] = useState(shape.props.isPreview)
const [renderedContent, setRenderedContent] = useState("")
useEffect(() => {
if (textareaRef.current && textareaRef.current.value !== shape.props.content) {
textareaRef.current.value = shape.props.content
}
}, [shape.props.content])
useEffect(() => {
const html = marked.parse(shape.props.content, { breaks: true }) as string
setRenderedContent(html)
}, [shape.props.content])
const togglePreview = () => {
const newPreviewState = !isPreview
setIsPreview(newPreviewState)
editor.updateShape(shape.id, {
props: {
...shape.props,
isPreview: newPreviewState
}
})
}
return (
<div style={{
width: '100%',
height: '100%',
display: 'flex',
flexDirection: 'column',
backgroundColor: 'white',
border: '1px solid #e0e0e0',
borderRadius: '4px',
overflow: 'hidden'
}}>
{/* Toolbar */}
<div style={{
padding: '4px 8px',
borderBottom: '1px solid #e0e0e0',
backgroundColor: '#f5f5f5',
display: 'flex',
gap: '8px',
alignItems: 'center'
}}>
<button
onClick={togglePreview}
style={{
padding: '4px 8px',
border: '1px solid #ccc',
borderRadius: '4px',
backgroundColor: 'white',
cursor: 'pointer',
fontSize: '12px'
}}
>
{isPreview ? 'Edit' : 'Preview'}
</button>
</div>
{/* Editor/Preview Area */}
<div style={{
flex: 1,
overflow: 'auto',
position: 'relative'
}}>
{isPreview ? (
<div
style={{
padding: '8px',
height: '100%',
overflow: 'auto'
}}
dangerouslySetInnerHTML={{
__html: marked(shape.props.content, { breaks: true }) as string
}}
/>
) : (
<textarea
ref={textareaRef}
defaultValue={shape.props.content}
style={{
width: '100%',
height: '100%',
resize: 'none',
border: 'none',
padding: '8px',
fontFamily: 'inherit',
}}
onChange={(e) => {
editor.updateShape(shape.id, {
props: {
...shape.props,
content: e.target.value
}
})
}}
/>
)}
</div>
</div>
)
}

View File

@ -0,0 +1,81 @@
import { BaseBoxShapeUtil, TLBaseShape, TLResizeInfo} from "@tldraw/tldraw"
export type IMycrozineTemplateShape = TLBaseShape<
"MycrozineTemplate",
{
w: number
h: number
}
>
export class MycrozineTemplateShape extends BaseBoxShapeUtil<IMycrozineTemplateShape> {
static override type = "MycrozineTemplate"
getDefaultProps(): IMycrozineTemplateShape["props"] {
// 8.5" × 11" at 300 DPI = 2550 × 3300 pixels
const props = {
w: 2550,
h: 3300,
}
console.log('MycrozineTemplate - Default props:', props)
return props
}
indicator(shape: IMycrozineTemplateShape) {
return (
<g>
<rect x={0} y={0} width={shape.props.w} height={shape.props.h} />
</g>
)
}
containerStyle = {
position: 'relative' as const,
backgroundColor: 'transparent',
border: '1px solid #666',
borderRadius: '2px',
}
verticalGuideStyle = {
position: 'absolute' as const,
left: '50%',
top: 0,
bottom: 0,
borderLeft: '1px dashed #666',
}
horizontalGuideStyle = {
position: 'absolute' as const,
left: 0,
right: 0,
borderTop: '1px dashed #666',
}
component(shape: IMycrozineTemplateShape) {
const { w, h } = shape.props
const isSelected = this.editor.getSelectedShapeIds().includes(shape.id)
return (
<div
style={{
...this.containerStyle,
width: `${w}px`,
height: `${h}px`,
pointerEvents: isSelected ? 'none' : 'all'
}}
>
<div style={this.verticalGuideStyle} />
{[0.25, 0.5, 0.75].map((ratio, index) => (
<div
key={index}
style={{
...this.horizontalGuideStyle,
top: `${ratio * 100}%`,
}}
/>
))}
</div>
)
}
}

View File

@ -0,0 +1,168 @@
import {
BaseBoxShapeUtil,
HTMLContainer,
TLBaseShape,
TLGeoShape,
TLShape,
} from "tldraw"
import { getEdge } from "@/propagators/tlgraph"
import { llm } from "@/utils/llmUtils"
import { isShapeOfType } from "@/propagators/utils"
type IPrompt = TLBaseShape<
"Prompt",
{
w: number
h: number
prompt: string
value: string
agentBinding: string | null
}
>
export class PromptShape extends BaseBoxShapeUtil<IPrompt> {
static override type = "Prompt" as const
FIXED_HEIGHT = 50 as const
MIN_WIDTH = 150 as const
PADDING = 4 as const
getDefaultProps(): IPrompt["props"] {
return {
w: 300,
h: 50,
prompt: "",
value: "",
agentBinding: null,
}
}
// override onResize: TLResizeHandle<IPrompt> = (
// shape,
// { scaleX, initialShape },
// ) => {
// const { x, y } = shape
// const w = initialShape.props.w * scaleX
// return {
// x,
// y,
// props: {
// ...shape.props,
// w: Math.max(Math.abs(w), this.MIN_WIDTH),
// h: this.FIXED_HEIGHT,
// },
// }
// }
component(shape: IPrompt) {
const arrowBindings = this.editor.getBindingsInvolvingShape(
shape.id,
"arrow",
)
const arrows = arrowBindings.map((binding) =>
this.editor.getShape(binding.fromId),
)
const inputMap = arrows.reduce((acc, arrow) => {
const edge = getEdge(arrow, this.editor)
if (edge) {
const sourceShape = this.editor.getShape(edge.from)
if (sourceShape && edge.text) {
acc[edge.text] = sourceShape
}
}
return acc
}, {} as Record<string, TLShape>)
const generateText = async (prompt: string) => {
await llm(prompt, localStorage.getItem("openai_api_key") || "", (partial: string, done: boolean) => {
console.log("DONE??", done)
this.editor.updateShape<IPrompt>({
id: shape.id,
type: "Prompt",
props: { value: partial, agentBinding: done ? null : "someone" },
})
})
}
const handlePrompt = () => {
if (shape.props.agentBinding) {
return
}
let processedPrompt = shape.props.prompt
for (const [key, sourceShape] of Object.entries(inputMap)) {
const pattern = `{${key}}`
if (processedPrompt.includes(pattern)) {
if (isShapeOfType<TLGeoShape>(sourceShape, "geo")) {
processedPrompt = processedPrompt.replace(
pattern,
sourceShape.props.text,
)
}
}
}
//console.log(processedPrompt)
generateText(processedPrompt)
}
return (
<HTMLContainer
style={{
borderRadius: 6,
border: "1px solid lightgrey",
padding: this.PADDING,
height: this.FIXED_HEIGHT,
width: shape.props.w,
pointerEvents: "all",
backgroundColor: "#efefef",
overflow: "visible",
display: "flex",
justifyContent: "center",
alignItems: "center",
outline: shape.props.agentBinding ? "2px solid orange" : "none",
}}
>
<input
style={{
width: "100%",
height: "100%",
overflow: "visible",
backgroundColor: "rgba(0, 0, 0, 0.05)",
border: "1px solid rgba(0, 0, 0, 0.05)",
borderRadius: 6 - this.PADDING,
fontSize: 16,
}}
type="text"
placeholder="Enter prompt..."
value={shape.props.prompt}
onChange={(text) => {
this.editor.updateShape<IPrompt>({
id: shape.id,
type: "Prompt",
props: { prompt: text.target.value },
})
}}
/>
<button
style={{
width: 100,
height: "100%",
marginLeft: 5,
pointerEvents: "all",
}}
onPointerDown={(e) => {
e.stopPropagation()
}}
type="button"
onClick={handlePrompt}
>
Prompt
</button>
</HTMLContainer>
)
}
indicator(shape: IPrompt) {
return <rect width={shape.props.w} height={shape.props.h} rx={5} />
}
}

View File

@ -0,0 +1,136 @@
import { useCallback } from "react"
import {
BaseBoxShapeUtil,
Geometry2d,
RecordProps,
Rectangle2d,
SVGContainer,
ShapeUtil,
T,
TLBaseShape,
getPerfectDashProps,
resizeBox,
useValue,
} from "tldraw"
import { moveToSlide, useSlides } from "@/slides/useSlides"
export type ISlideShape = TLBaseShape<
"Slide",
{
w: number
h: number
}
>
export class SlideShape extends BaseBoxShapeUtil<ISlideShape> {
static override type = "Slide"
// static override props = {
// w: T.number,
// h: T.number,
// }
override canBind = () => false
override hideRotateHandle = () => true
getDefaultProps(): ISlideShape["props"] {
return {
w: 720,
h: 480,
}
}
getGeometry(shape: ISlideShape): Geometry2d {
return new Rectangle2d({
width: shape.props.w,
height: shape.props.h,
isFilled: false,
})
}
override onRotate = (initial: ISlideShape) => initial
override onResize(shape: ISlideShape, info: any) {
return resizeBox(shape, info)
}
override onDoubleClick = (shape: ISlideShape) => {
moveToSlide(this.editor, shape)
this.editor.selectNone()
}
override onDoubleClickEdge = (shape: ISlideShape) => {
moveToSlide(this.editor, shape)
this.editor.selectNone()
}
component(shape: ISlideShape) {
const bounds = this.editor.getShapeGeometry(shape).bounds
// eslint-disable-next-line react-hooks/rules-of-hooks
const zoomLevel = useValue("zoom level", () => this.editor.getZoomLevel(), [
this.editor,
])
// eslint-disable-next-line react-hooks/rules-of-hooks
const slides = useSlides()
const index = slides.findIndex((s) => s.id === shape.id)
// eslint-disable-next-line react-hooks/rules-of-hooks
const handleLabelPointerDown = useCallback(
() => this.editor.select(shape.id),
[shape.id],
)
if (!bounds) return null
return (
<>
<div
onPointerDown={handleLabelPointerDown}
className="slide-shape-label"
>
{`Slide ${index + 1}`}
</div>
<SVGContainer>
<g
style={{
stroke: "var(--color-text)",
strokeWidth: "calc(1px * var(--tl-scale))",
opacity: 0.25,
}}
pointerEvents="none"
strokeLinecap="round"
strokeLinejoin="round"
>
{bounds.sides.map((side, i) => {
const { strokeDasharray, strokeDashoffset } = getPerfectDashProps(
side[0].dist(side[1]),
1 / zoomLevel,
{
style: "dashed",
lengthRatio: 6,
},
)
return (
<line
key={i}
x1={side[0].x}
y1={side[0].y}
x2={side[1].x}
y2={side[1].y}
strokeDasharray={strokeDasharray}
strokeDashoffset={strokeDashoffset}
/>
)
})}
</g>
</SVGContainer>
</>
)
}
indicator(shape: ISlideShape) {
return <rect width={shape.props.w} height={shape.props.h} />
}
}

View File

@ -0,0 +1,846 @@
import { BaseBoxShapeUtil, TLBaseShape } from "tldraw"
import { useEffect, useState, useRef } from "react"
import { useParticipantCounts, DailyProvider, useDaily, useTranscription } from '@daily-co/daily-react';
interface DailyApiResponse {
url: string;
}
interface DailyRecordingResponse {
id: string;
}
interface TranscriptResponse {
data: TranscriptItem[];
}
interface TranscriptItem {
user_name?: string;
text: string;
transcriptId: string;
}
export type IVideoChatShape = TLBaseShape<
"VideoChat",
{
w: number
h: number
roomUrl: string | null
allowCamera: boolean
allowMicrophone: boolean
enableRecording: boolean
recordingId: string | null
isTranscribing: boolean
}
>
export class VideoChatShape extends BaseBoxShapeUtil<IVideoChatShape> {
static override type = "VideoChat"
indicator(_shape: IVideoChatShape) {
return null
}
getDefaultProps(): IVideoChatShape["props"] {
return {
roomUrl: null,
w: 800,
h: 600,
allowCamera: false,
allowMicrophone: false,
enableRecording: true,
recordingId: null,
isTranscribing: false
}
}
async ensureRoomExists(shape: IVideoChatShape) {
const boardId = this.editor.getCurrentPageId();
if (!boardId) {
throw new Error('Board ID is undefined');
}
// Try to get existing room URL from localStorage first
const storageKey = `videoChat_room_${boardId}`;
const existingRoomUrl = localStorage.getItem(storageKey);
if (existingRoomUrl && existingRoomUrl !== 'undefined') {
console.log("Using existing room from storage:", existingRoomUrl);
await this.editor.updateShape<IVideoChatShape>({
id: shape.id,
type: shape.type,
props: {
...shape.props,
roomUrl: existingRoomUrl,
},
});
return;
}
if (shape.props.roomUrl !== null && shape.props.roomUrl !== 'undefined') {
console.log("Room already exists:", shape.props.roomUrl);
localStorage.setItem(storageKey, shape.props.roomUrl);
return;
}
try {
const workerUrl = import.meta.env.VITE_TLDRAW_WORKER_URL;
const apiKey = import.meta.env.VITE_DAILY_API_KEY;
if (!apiKey) {
throw new Error('Daily.co API key not configured');
}
if (!workerUrl) {
throw new Error('Worker URL is not configured');
}
// Create room name based on board ID and timestamp
const roomName = `board_${boardId}_${Date.now()}`;
const response = await fetch(`${workerUrl}/daily/rooms`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${apiKey}`
},
body: JSON.stringify({
name: roomName,
properties: {
enable_chat: true,
enable_screenshare: true,
start_video_off: true,
start_audio_off: true,
enable_recording: "cloud",
start_cloud_recording: true,
start_cloud_recording_opts: {
layout: {
preset: "audio-only"
},
format: "mp4",
mode: "audio-only"
},
auto_start_transcription: true,
recordings_template: "{room_name}/audio-{epoch_time}.mp4",
permissions: {
hasPresence: true,
canSend: true,
canAdmin: false
},
enable_transcription: true,
}
})
});
if (!response.ok) {
const error = await response.json()
throw new Error(`Failed to create room (${response.status}): ${JSON.stringify(error)}`)
}
const data = (await response.json()) as DailyApiResponse;
const url = data.url;
if (!url) throw new Error("Room URL is missing")
// Store the room URL in localStorage
localStorage.setItem(storageKey, url);
console.log("Room created successfully:", url)
console.log("Updating shape with new URL")
await this.editor.updateShape<IVideoChatShape>({
id: shape.id,
type: shape.type,
props: {
...shape.props,
roomUrl: url,
},
})
console.log("Shape updated:", this.editor.getShape(shape.id))
} catch (error) {
console.error("Error in ensureRoomExists:", error)
throw error
}
}
async startRecording(shape: IVideoChatShape) {
if (!shape.props.roomUrl) return;
const workerUrl = import.meta.env.VITE_TLDRAW_WORKER_URL;
const apiKey = import.meta.env.VITE_DAILY_API_KEY;
try {
const url = new URL(shape.props.roomUrl);
const roomName = url.pathname.substring(1);
// Get the board name and timestamp
const boardName = this.editor.getCurrentPage().name || 'untitled';
const timestamp = new Date().toISOString()
.replace('T', '_')
.replace(/:/g, '-')
.slice(0, 16);
console.log('Starting recording for room:', roomName);
console.log('Recording access URL will be available at:',
`https://api.daily.co/v1/recordings?room=${roomName}`);
console.log('Making request to:', `${workerUrl}/daily/recordings/${roomName}/start`);
const response = await fetch(`${workerUrl}/daily/recordings/${roomName}/start`, {
method: 'POST',
headers: {
'Authorization': `Bearer ${apiKey}`,
'Content-Type': 'application/json'
},
body: JSON.stringify({
layout: {
preset: "audio-only"
},
recording_name: `${boardName}_${timestamp}_audio`
})
});
console.log('Recording start response status:', response.status);
const responseData = await response.json();
console.log('Recording start response data:', responseData);
if (!response.ok) {
throw new Error(`Failed to start recording: ${response.statusText} (${JSON.stringify(responseData)})`);
}
const data = responseData as DailyRecordingResponse;
await this.editor.updateShape<IVideoChatShape>({
id: shape.id,
type: shape.type,
props: {
...shape.props,
recordingId: data.id || null
}
});
} catch (error) {
console.error('Error starting recording:', error);
throw error;
}
}
async stopRecording(shape: IVideoChatShape) {
if (!shape.props.roomUrl) return;
const workerUrl = import.meta.env.VITE_TLDRAW_WORKER_URL;
const apiKey = import.meta.env.VITE_DAILY_API_KEY;
try {
// Parse the URL to get just the room name
const url = new URL(shape.props.roomUrl);
const roomName = url.pathname.substring(1); // Remove leading slash
// Verify we have a room name
if (!roomName) {
throw new Error('Room name not found in URL');
}
const response = await fetch(`${workerUrl}/daily/recordings/${roomName}/stop`, {
method: 'POST',
headers: {
'Authorization': `Bearer ${apiKey}`,
'Content-Type': 'application/json'
}
});
if (!response.ok) {
throw new Error(`Failed to stop recording: ${response.statusText}`);
}
await this.editor.updateShape<IVideoChatShape>({
id: shape.id,
type: shape.type,
props: {
...shape.props,
recordingId: null
}
});
} catch (error) {
console.error('Error stopping recording:', error);
throw error;
}
}
async startTranscription(shape: IVideoChatShape) {
// Get room URL directly from shape props
const roomUrl = shape.props.roomUrl;
if (!roomUrl) {
console.log('Cannot start transcription: no room URL');
return;
}
const workerUrl = import.meta.env.VITE_TLDRAW_WORKER_URL;
const apiKey = import.meta.env.VITE_DAILY_API_KEY;
try {
// Ensure we have a valid URL and extract the room name
const url = new URL(roomUrl); // Use roomUrl from props
const roomName = url.pathname.substring(1);
console.log('Starting transcription for room:', roomName);
console.log('Making request to:', `${workerUrl}/daily/transcription/${roomName}/start`);
const response = await fetch(`${workerUrl}/daily/transcription/${roomName}/start`, {
method: 'POST',
headers: {
'Authorization': `Bearer ${apiKey}`,
'Content-Type': 'application/json'
},
body: JSON.stringify({
language: 'en',
punctuate: true,
profanity_filter: true
})
});
console.log('Transcription start response status:', response.status);
const responseData = await response.json();
console.log('Transcription start response data:', responseData);
if (!response.ok) {
throw new Error(`Failed to start transcription: ${response.statusText} (${JSON.stringify(responseData)})`);
}
console.log('Updating shape with transcription status');
await this.editor.updateShape<IVideoChatShape>({
id: shape.id,
type: shape.type,
props: {
...shape.props,
isTranscribing: true
}
});
console.log('Transcription started successfully');
} catch (error) {
console.error('Error starting transcription:', error);
throw error;
}
}
async stopTranscription(shape: IVideoChatShape) {
const roomUrl = shape.props.roomUrl;
if (!roomUrl) {
console.log('Cannot stop transcription: no room URL');
return;
}
const workerUrl = import.meta.env.VITE_TLDRAW_WORKER_URL;
const apiKey = import.meta.env.VITE_DAILY_API_KEY;
try {
const url = new URL(roomUrl);
const roomName = url.pathname.substring(1);
console.log('Stopping transcription for room:', roomName);
// First, stop the transcription
const stopResponse = await fetch(`${workerUrl}/daily/transcription/${roomName}/stop`, {
method: 'POST',
headers: {
'Authorization': `Bearer ${apiKey}`,
'Content-Type': 'application/json'
}
});
if (!stopResponse.ok) {
throw new Error(`Failed to stop transcription: ${stopResponse.statusText}`);
}
console.log('Transcription stopped, waiting for processing...');
// Increase wait time for processing
await new Promise(resolve => setTimeout(resolve, 10000));
// Get list of transcripts
console.log('Fetching transcripts list...');
const transcriptsResponse = await fetch(`${workerUrl}/daily/transcription/${roomName}`, {
headers: {
'Authorization': `Bearer ${apiKey}`,
'Content-Type': 'application/json'
}
});
if (!transcriptsResponse.ok) {
console.error('Transcripts list response:', await transcriptsResponse.text());
throw new Error(`Failed to fetch transcripts list: ${transcriptsResponse.statusText}`);
}
const transcriptsData = await transcriptsResponse.json() as TranscriptResponse;
console.log('Transcripts list:', transcriptsData);
// Get the most recent transcript ID
if (!transcriptsData.data || transcriptsData.data.length === 0) {
console.warn('No transcripts found in response');
throw new Error('No transcripts found');
}
// Sort by most recent and get the first one
const latestTranscript = transcriptsData.data
.sort((a: any, b: any) => new Date(b.created_at || 0).getTime() - new Date(a.created_at || 0).getTime())[0];
const transcriptId = latestTranscript.transcriptId;
console.log('Latest transcript ID:', transcriptId);
// Fetch the actual transcript content
console.log('Fetching transcript content...');
const contentResponse = await fetch(`${workerUrl}/daily/transcription/${roomName}/${transcriptId}`, {
headers: {
'Authorization': `Bearer ${apiKey}`,
'Content-Type': 'application/json'
}
});
if (!contentResponse.ok) {
console.error('Content response:', await contentResponse.text());
throw new Error(`Failed to fetch transcript content: ${contentResponse.statusText}`);
}
const contentData = await contentResponse.json() as TranscriptResponse;
console.log('Transcript content:', contentData);
// Create a text note with the transcript
if (contentData.data && contentData.data.length > 0) {
const transcriptText = contentData.data
.filter((item: any) => item.text) // Filter out items with no text
.map((item: any) => {
const speaker = item.user_name || 'Speaker';
const text = item.text.trim();
return `${speaker}: ${text}`;
})
.join('\n');
if (!transcriptText) {
console.warn('Transcript text is empty after processing');
return;
}
// Get the current shape's position
const currentShape = this.editor.getShape(shape.id) as IVideoChatShape;
if (!currentShape) {
console.error('Failed to find current shape for positioning transcript');
throw new Error('Shape not found');
}
console.log('Creating transcript note at position:', {
x: currentShape.x,
y: currentShape.y + currentShape.props.h + 20
});
console.log('Transcript text to be added:', transcriptText);
try {
const newShape = await this.editor.createShape({
type: 'note',
x: currentShape.x,
y: currentShape.y + currentShape.props.h + 20,
props: {
text: `Transcript from ${new Date().toLocaleString()}\n\n${transcriptText}`,
color: 'blue',
size: 'l',
},
});
console.log('Successfully created transcript note:', newShape);
} catch (error) {
console.error('Failed to create transcript shape:', error);
throw new Error('Failed to create transcript shape');
}
} else {
console.warn('No valid transcript content found in response:', contentData);
}
// Update shape transcription status
await this.editor.updateShape<IVideoChatShape>({
id: shape.id,
type: shape.type,
props: {
...shape.props,
isTranscribing: false
}
});
console.log('Transcription stopped successfully');
} catch (error) {
console.error('Error stopping transcription:', error);
throw error;
}
}
component(shape: IVideoChatShape) {
const [roomUrl, setRoomUrl] = useState<string | null>(shape.props.roomUrl)
const daily = useDaily()
const { present, hidden } = useParticipantCounts()
const [hasPermissions, setHasPermissions] = useState(false)
const [error, setError] = useState<Error | null>(null)
const [isLoading, setIsLoading] = useState(true)
const [isRecording, setIsRecording] = useState(!!shape.props.recordingId)
const [isTranscribing, setIsTranscribing] = useState(shape.props.isTranscribing)
// Add loading states for buttons
const [isRecordingLoading, setIsRecordingLoading] = useState(false)
const [isTranscribingLoading, setIsTranscribingLoading] = useState(false)
// Add timeout ref to handle stuck states
const transcriptionTimeoutRef = useRef<NodeJS.Timeout | undefined>(undefined)
// Cleanup timeout on unmount
useEffect(() => {
return () => {
if (transcriptionTimeoutRef.current) {
clearTimeout(transcriptionTimeoutRef.current)
}
}
}, [])
// Updated recording toggle handler
const handleRecordingToggle = async () => {
if (isRecordingLoading) return; // Prevent multiple clicks
try {
setIsRecordingLoading(true);
if (isRecording) {
await this.stopRecording(shape);
setIsRecording(false);
} else {
await this.startRecording(shape);
setIsRecording(true);
}
} catch (err) {
console.error("Recording toggle failed:", err);
} finally {
setIsRecordingLoading(false);
}
};
// Add useTranscription hook with callbacks
const {
isTranscribing: dailyTranscribing,
transcriptions,
error: transcriptionError,
startTranscription: dailyStartTranscription,
stopTranscription: dailyStopTranscription
} = useTranscription({
onTranscriptionStarted: () => {
console.log('Transcription started');
},
onTranscriptionStopped: async () => {
console.log('Transcription stopped, transcriptions:', transcriptions);
if (transcriptions && transcriptions.length > 0) {
// Create transcript text from the transcriptions array
const transcriptText = transcriptions
.map((t: any) => `${t.user_name || 'Speaker'}: ${t.text}`)
.join('\n');
// Get the current shape for positioning
const currentShape = this.editor.getShape(shape.id) as IVideoChatShape;
if (!currentShape) throw new Error('Shape not found');
try {
const newShape = await this.editor.createShape({
type: 'note',
x: currentShape.x,
y: currentShape.y + currentShape.props.h + 20,
props: {
text: `Transcript from ${new Date().toLocaleString()}\n\n${transcriptText}`,
color: 'blue',
size: 'l',
},
});
console.log('Created transcript note:', newShape);
} catch (error) {
console.error('Failed to create transcript shape:', error);
}
}
},
onTranscriptionError: (error) => {
console.error('Transcription error:', error);
}
});
// Update the transcription toggle handler
const handleTranscriptionToggle = async () => {
if (isTranscribingLoading) return;
try {
setIsTranscribingLoading(true);
if (isTranscribing) {
await dailyStopTranscription();
} else {
await dailyStartTranscription({
language: 'en',
punctuate: true,
profanity_filter: true
});
}
setIsTranscribing(!isTranscribing);
} catch (err) {
console.error("Transcription toggle failed:", err);
} finally {
setIsTranscribingLoading(false);
}
};
// Add a useEffect to monitor and reset stuck states
useEffect(() => {
if (isTranscribingLoading) {
const timeout = setTimeout(() => {
console.warn('Forcing reset of transcription loading state');
setIsTranscribingLoading(false);
}, 15000); // 15 second failsafe
return () => clearTimeout(timeout);
}
}, [isTranscribingLoading]);
useEffect(() => {
let mounted = true;
const createRoom = async () => {
try {
setIsLoading(true);
await this.ensureRoomExists(shape);
// Get the updated shape after room creation
const updatedShape = this.editor.getShape(shape.id);
if (mounted && updatedShape) {
setRoomUrl((updatedShape as IVideoChatShape).props.roomUrl);
}
} catch (err) {
if (mounted) {
console.error("Error creating room:", err);
setError(err as Error);
}
} finally {
if (mounted) {
setIsLoading(false);
}
}
};
createRoom();
return () => {
mounted = false;
};
}, [shape.id]); // Only re-run if shape.id changes
useEffect(() => {
let mounted = true;
const requestPermissions = async () => {
try {
if (shape.props.allowCamera || shape.props.allowMicrophone) {
const constraints = {
video: shape.props.allowCamera,
audio: shape.props.allowMicrophone,
}
await navigator.mediaDevices.getUserMedia(constraints)
if (mounted) {
setHasPermissions(true)
}
}
} catch (err) {
console.error("Permission request failed:", err)
if (mounted) {
setHasPermissions(false)
}
}
}
requestPermissions()
return () => {
mounted = false;
}
}, [shape.props.allowCamera, shape.props.allowMicrophone])
// Add effect to log participant counts
useEffect(() => {
console.log('Participant counts:', { present, hidden })
console.log('Daily instance:', daily)
console.log('Current participants:', daily?.participants())
}, [present, hidden, daily])
// Add effect to join the room when Daily is ready
useEffect(() => {
if (daily && roomUrl) {
console.log('Attempting to join room:', roomUrl);
daily.join({ url: roomUrl }).then(() => {
console.log('Successfully joined room');
console.log('Initial participants:', daily.participants());
}).catch(error => {
console.error('Failed to join room:', error);
});
}
// Cleanup: leave the room when component unmounts
return () => {
if (daily) {
console.log('Leaving room');
daily.leave();
}
};
}, [daily, roomUrl]);
// Add effect to track participant changes
useEffect(() => {
if (!daily) return;
const handleParticipantJoined = (event: any) => {
console.log('Participant joined:', event.participant);
console.log('All participants:', daily.participants());
};
const handleParticipantLeft = (event: any) => {
console.log('Participant left:', event.participant);
console.log('All participants:', daily.participants());
};
daily.on('participant-joined', handleParticipantJoined);
daily.on('participant-left', handleParticipantLeft);
return () => {
daily.off('participant-joined', handleParticipantJoined);
daily.off('participant-left', handleParticipantLeft);
};
}, [daily]);
if (error || isLoading || !roomUrl) {
return (
<div
style={{
width: shape.props.w,
height: shape.props.h,
display: "flex",
alignItems: "center",
justifyContent: "center",
backgroundColor: "#f0f0f0",
borderRadius: "4px",
}}
>
{isLoading ? "Creating room... Please wait" : "Error: No room URL available"}
</div>
)
}
// Add recording UI parameter to the URL
const roomUrlWithParams = new URL(roomUrl)
roomUrlWithParams.searchParams.set("show_record_button", "true")
roomUrlWithParams.searchParams.set("allow_camera", String(shape.props.allowCamera))
roomUrlWithParams.searchParams.set("allow_mic", String(shape.props.allowMicrophone))
return (
<DailyProvider>
<div
style={{
width: `${shape.props.w}px`,
display: "flex",
flexDirection: "column",
pointerEvents: "all",
}}
>
{/* Video container */}
<div
style={{
width: "100%",
height: `${shape.props.h}px`,
position: "relative",
overflow: "hidden",
}}
>
<iframe
src={roomUrlWithParams.toString()}
width="100%"
height="100%"
style={{
border: "none",
position: "absolute",
top: 0,
left: 0,
right: 0,
bottom: 0,
}}
allow={`camera ${shape.props.allowCamera ? "self" : ""}; microphone ${
shape.props.allowMicrophone ? "self" : ""
}`}
></iframe>
</div>
{/* Controls container below video */}
<div
style={{
width: "100%",
display: "flex",
justifyContent: "space-between",
alignItems: "center",
padding: "8px",
background: "#ffffff",
borderTop: "1px solid #eee",
marginTop: "4px",
}}
>
<p
style={{
margin: 0,
padding: "4px 8px",
borderRadius: "4px",
fontSize: "12px",
userSelect: "text",
}}
>
url: {roomUrl}
</p>
<div style={{ display: 'flex', gap: '8px' }}>
{shape.props.enableRecording && (
<button
onClick={handleRecordingToggle}
onPointerDown={(e) => e.stopPropagation()}
disabled={isRecordingLoading}
style={{
padding: "4px 8px",
borderRadius: "4px",
border: "1px solid #ccc",
background: isRecording ? "#ff4444" : "#ffffff",
cursor: isRecordingLoading ? "not-allowed" : "pointer",
fontSize: "12px",
pointerEvents: "auto",
opacity: isRecordingLoading ? 0.7 : 1,
}}
>
{isRecordingLoading
? (isRecording ? "Stopping..." : "Starting...")
: (isRecording ? "Stop Recording" : "Start Recording")}
</button>
)}
<button
onClick={handleTranscriptionToggle}
onPointerDown={(e) => e.stopPropagation()}
disabled={isTranscribingLoading}
style={{
padding: "4px 8px",
borderRadius: "4px",
border: "1px solid #ccc",
background: isTranscribing ? "#ff4444" : "#ffffff",
cursor: isTranscribingLoading ? "not-allowed" : "pointer",
fontSize: "12px",
pointerEvents: "auto",
opacity: isTranscribingLoading ? 0.7 : 1,
}}
>
{isTranscribingLoading
? (isTranscribing ? "Stopping..." : "Starting...")
: (isTranscribing ? "Stop Transcription" : "Start Transcription")}
</button>
</div>
</div>
</div>
</DailyProvider>
)
}
}

View File

@ -0,0 +1,37 @@
import { TldrawUiButton, stopEventPropagation, track, useEditor, useValue } from 'tldraw'
import { moveToSlide, useCurrentSlide, useSlides } from '@/slides/useSlides'
export const SlidesPanel = track(() => {
const editor = useEditor()
const slides = useSlides()
const currentSlide = useCurrentSlide()
const selectedShapes = useValue('selected shapes', () => editor.getSelectedShapes(), [editor])
if (slides.length === 0) return null
return (
<div className="slides-panel scroll-light" onPointerDown={(e) => stopEventPropagation(e)}>
{slides.map((slide, i) => {
const isSelected = selectedShapes.includes(slide)
return (
<TldrawUiButton
key={'slides-panel-button:' + slide.id}
type="normal"
className="slides-panel-button"
onClick={() => {
moveToSlide(editor, slide)
// Switch to select tool and select the slide shape
editor.setCurrentTool('select')
editor.select(slide)
}}
style={{
background: currentSlide?.id === slide.id ? 'var(--color-background)' : 'transparent',
outline: isSelected ? 'var(--color-selection-stroke) solid 1.5px' : 'none',
}}
>
{`Slide ${i + 1}`}
</TldrawUiButton>
)
})}
</div>
)
})

32
src/slides/slides.css Normal file
View File

@ -0,0 +1,32 @@
.slides-panel {
display: flex;
flex-direction: column;
gap: 4px;
max-height: calc(100% - 110px);
margin: 50px 0px;
padding: 4px;
background-color: var(--color-low);
pointer-events: all;
border-top-right-radius: var(--radius-4);
border-bottom-right-radius: var(--radius-4);
overflow: auto;
border-right: 2px solid var(--color-background);
border-bottom: 2px solid var(--color-background);
border-top: 2px solid var(--color-background);
}
.slides-panel-button {
border-radius: var(--radius-4);
outline-offset: -1px;
}
.slide-shape-label {
pointer-events: all;
position: absolute;
background: var(--color-low);
padding: calc(12px * var(--tl-scale));
border-bottom-right-radius: calc(var(--radius-4) * var(--tl-scale));
font-size: calc(12px * var(--tl-scale));
color: var(--color-text);
white-space: nowrap;
}

31
src/slides/useSlides.tsx Normal file
View File

@ -0,0 +1,31 @@
import { EASINGS, Editor, atom, useEditor, useValue } from 'tldraw'
import { ISlideShape } from '@/shapes/SlideShapeUtil'
export const $currentSlide = atom<ISlideShape | null>('current slide', null)
export function moveToSlide(editor: Editor, slide: ISlideShape) {
const bounds = editor.getShapePageBounds(slide.id)
if (!bounds) return
$currentSlide.set(slide)
editor.selectNone()
editor.zoomToBounds(bounds, {
animation: { duration: 500, easing: EASINGS.easeInOutCubic },
inset: 0,
})
}
export function useSlides() {
const editor = useEditor()
return useValue<ISlideShape[]>('slide shapes', () => getSlides(editor), [editor])
}
export function useCurrentSlide() {
return useValue($currentSlide)
}
export function getSlides(editor: Editor) {
return editor
.getSortedChildIdsForParent(editor.getCurrentPageId())
.map((id) => editor.getShape(id))
.filter((s) => s?.type === 'Slide') as ISlideShape[]
}

210
src/snapshot.json Normal file
View File

@ -0,0 +1,210 @@
{
"store": {
"document:document": {
"gridSize": 10,
"name": "",
"meta": {},
"id": "document:document",
"typeName": "document"
},
"page:page": {
"meta": {},
"id": "page:page",
"name": "Page 1",
"index": "a1",
"typeName": "page"
},
"shape:f4LKGB_8M2qsyWGpHR5Dq": {
"x": 30.9375,
"y": 69.48828125,
"rotation": 0,
"isLocked": false,
"opacity": 1,
"meta": {},
"id": "shape:f4LKGB_8M2qsyWGpHR5Dq",
"type": "container",
"parentId": "page:page",
"index": "a1",
"props": {
"width": 644,
"height": 148
},
"typeName": "shape"
},
"shape:2oThF4kJ4v31xqKN5lvq2": {
"x": 550.9375,
"y": 93.48828125,
"rotation": 0,
"isLocked": false,
"opacity": 1,
"meta": {},
"id": "shape:2oThF4kJ4v31xqKN5lvq2",
"type": "element",
"props": {
"color": "#5BCEFA"
},
"parentId": "page:page",
"index": "a2",
"typeName": "shape"
},
"shape:K2vk_VTaNh-ANaRNOAvgY": {
"x": 426.9375,
"y": 93.48828125,
"rotation": 0,
"isLocked": false,
"opacity": 1,
"meta": {},
"id": "shape:K2vk_VTaNh-ANaRNOAvgY",
"type": "element",
"props": {
"color": "#F5A9B8"
},
"parentId": "page:page",
"index": "a3",
"typeName": "shape"
},
"shape:6uouhIK7PvyIRNQHACf-d": {
"x": 302.9375,
"y": 93.48828125,
"rotation": 0,
"isLocked": false,
"opacity": 1,
"meta": {},
"id": "shape:6uouhIK7PvyIRNQHACf-d",
"type": "element",
"props": {
"color": "#FFFFFF"
},
"parentId": "page:page",
"index": "a4",
"typeName": "shape"
},
"shape:GTQq2qxkWPHEK7KMIRtsh": {
"x": 54.9375,
"y": 93.48828125,
"rotation": 0,
"isLocked": false,
"opacity": 1,
"meta": {},
"id": "shape:GTQq2qxkWPHEK7KMIRtsh",
"type": "element",
"props": {
"color": "#5BCEFA"
},
"parentId": "page:page",
"index": "a5",
"typeName": "shape"
},
"shape:05jMujN6A0sIp6zzHMpbV": {
"x": 178.9375,
"y": 93.48828125,
"rotation": 0,
"isLocked": false,
"opacity": 1,
"meta": {},
"id": "shape:05jMujN6A0sIp6zzHMpbV",
"type": "element",
"props": {
"color": "#F5A9B8"
},
"parentId": "page:page",
"index": "a6",
"typeName": "shape"
},
"binding:iOBENBUHvzD8N7mBdIM5l": {
"meta": {},
"id": "binding:iOBENBUHvzD8N7mBdIM5l",
"type": "layout",
"fromId": "shape:f4LKGB_8M2qsyWGpHR5Dq",
"toId": "shape:05jMujN6A0sIp6zzHMpbV",
"props": {
"index": "a2",
"placeholder": false
},
"typeName": "binding"
},
"binding:YTIeOALEmHJk6dczRpQmE": {
"meta": {},
"id": "binding:YTIeOALEmHJk6dczRpQmE",
"type": "layout",
"fromId": "shape:f4LKGB_8M2qsyWGpHR5Dq",
"toId": "shape:GTQq2qxkWPHEK7KMIRtsh",
"props": {
"index": "a1",
"placeholder": false
},
"typeName": "binding"
},
"binding:n4LY_pVuLfjV1qpOTZX-U": {
"meta": {},
"id": "binding:n4LY_pVuLfjV1qpOTZX-U",
"type": "layout",
"fromId": "shape:f4LKGB_8M2qsyWGpHR5Dq",
"toId": "shape:6uouhIK7PvyIRNQHACf-d",
"props": {
"index": "a3",
"placeholder": false
},
"typeName": "binding"
},
"binding:8XayRsWB_nxAH2833SYg1": {
"meta": {},
"id": "binding:8XayRsWB_nxAH2833SYg1",
"type": "layout",
"fromId": "shape:f4LKGB_8M2qsyWGpHR5Dq",
"toId": "shape:2oThF4kJ4v31xqKN5lvq2",
"props": {
"index": "a5",
"placeholder": false
},
"typeName": "binding"
},
"binding:MTYuIRiEVTn2DyVChthry": {
"meta": {},
"id": "binding:MTYuIRiEVTn2DyVChthry",
"type": "layout",
"fromId": "shape:f4LKGB_8M2qsyWGpHR5Dq",
"toId": "shape:K2vk_VTaNh-ANaRNOAvgY",
"props": {
"index": "a4",
"placeholder": false
},
"typeName": "binding"
}
},
"schema": {
"schemaVersion": 2,
"sequences": {
"com.tldraw.store": 4,
"com.tldraw.asset": 1,
"com.tldraw.camera": 1,
"com.tldraw.document": 2,
"com.tldraw.instance": 25,
"com.tldraw.instance_page_state": 5,
"com.tldraw.page": 1,
"com.tldraw.instance_presence": 5,
"com.tldraw.pointer": 1,
"com.tldraw.shape": 4,
"com.tldraw.asset.bookmark": 2,
"com.tldraw.asset.image": 4,
"com.tldraw.asset.video": 4,
"com.tldraw.shape.group": 0,
"com.tldraw.shape.text": 2,
"com.tldraw.shape.bookmark": 2,
"com.tldraw.shape.draw": 2,
"com.tldraw.shape.geo": 9,
"com.tldraw.shape.note": 7,
"com.tldraw.shape.line": 5,
"com.tldraw.shape.frame": 0,
"com.tldraw.shape.arrow": 5,
"com.tldraw.shape.highlight": 1,
"com.tldraw.shape.embed": 4,
"com.tldraw.shape.image": 3,
"com.tldraw.shape.video": 2,
"com.tldraw.shape.container": 0,
"com.tldraw.shape.element": 0,
"com.tldraw.binding.arrow": 0,
"com.tldraw.binding.layout": 0
}
}
}

7
src/tools/ChatBoxTool.ts Normal file
View File

@ -0,0 +1,7 @@
import { BaseBoxShapeTool } from "tldraw"
export class ChatBoxTool extends BaseBoxShapeTool {
static override id = "ChatBox"
shapeType = "ChatBox"
override initial = "idle"
}

7
src/tools/EmbedTool.ts Normal file
View File

@ -0,0 +1,7 @@
import { BaseBoxShapeTool } from "tldraw"
export class EmbedTool extends BaseBoxShapeTool {
static override id = "Embed"
shapeType = "Embed"
override initial = "idle"
}

View File

@ -0,0 +1,7 @@
import { BaseBoxShapeTool } from "tldraw"
export class MarkdownTool extends BaseBoxShapeTool {
static override id = "Markdown"
shapeType = "Markdown"
override initial = "idle"
}

View File

@ -0,0 +1,7 @@
import { BaseBoxShapeTool } from "tldraw"
export class MycrozineTemplateTool extends BaseBoxShapeTool {
static override id = "MycrozineTemplate"
shapeType = "MycrozineTemplate"
override initial = "idle"
}

View File

@ -0,0 +1,8 @@
import { BaseBoxShapeTool } from 'tldraw'
export class PromptShapeTool extends BaseBoxShapeTool {
static override id = 'Prompt'
static override initial = 'idle'
override shapeType = 'Prompt'
}

View File

@ -0,0 +1,12 @@
import { BaseBoxShapeTool } from 'tldraw'
export class SlideShapeTool extends BaseBoxShapeTool {
static override id = 'Slide'
static override initial = 'idle'
override shapeType = 'Slide'
constructor(editor: any) {
super(editor)
//console.log('SlideShapeTool constructed', { id: this.id, shapeType: this.shapeType })
}
}

View File

@ -0,0 +1,7 @@
import { BaseBoxShapeTool } from "tldraw"
export class VideoChatTool extends BaseBoxShapeTool {
static override id = "VideoChat"
shapeType = "VideoChat"
override initial = "idle"
}

View File

@ -0,0 +1,294 @@
// FIX DEFAULT MENU (copy, paste, etc)
import {
Editor,
TldrawUiMenuActionItem,
TldrawUiMenuItem,
TldrawUiMenuSubmenu,
TLGeoShape,
TLShape,
} from "tldraw"
import { TldrawUiMenuGroup } from "tldraw"
import { DefaultContextMenu } from "tldraw"
import { TLUiContextMenuProps, useEditor } from "tldraw"
import {
cameraHistory,
copyLinkToCurrentView,
lockCameraToFrame,
revertCamera,
zoomToSelection,
} from "./cameraUtils"
import { useState, useEffect } from "react"
import { saveToPdf } from "../utils/pdfUtils"
import { TLFrameShape } from "tldraw"
import { searchText } from "../utils/searchUtils"
import { llm } from "../utils/llmUtils"
import { getEdge } from "@/propagators/tlgraph"
const getAllFrames = (editor: Editor) => {
return editor
.getCurrentPageShapes()
.filter((shape): shape is TLFrameShape => shape.type === "frame")
.map((frame) => ({
id: frame.id,
title: frame.props.name || "Untitled Frame",
}))
}
export function CustomContextMenu(props: TLUiContextMenuProps) {
const editor = useEditor()
const [selectedShapes, setSelectedShapes] = useState<TLShape[]>([])
const [selectedIds, setSelectedIds] = useState<string[]>([])
// Update selection state more frequently
useEffect(() => {
const updateSelection = () => {
setSelectedShapes(editor.getSelectedShapes())
setSelectedIds(editor.getSelectedShapeIds())
}
// Initial update
updateSelection()
// Subscribe to selection changes
const unsubscribe = editor.addListener("change", updateSelection)
return () => {
if (typeof unsubscribe === "function") {
;(unsubscribe as () => void)()
}
}
}, [editor])
const hasSelection = selectedIds.length > 0
const hasCameraHistory = cameraHistory.length > 0
// Check if exactly one frame is selected
const hasFrameSelected =
selectedShapes.length === 1 && selectedShapes[0].type === "frame"
return (
<DefaultContextMenu {...props}>
{props.children}
{/* Camera Controls Group */}
<TldrawUiMenuGroup id="camera-controls">
<TldrawUiMenuItem
id="zoom-to-selection"
label="Zoom to Selection"
icon="zoom-in"
kbd="z"
disabled={!hasSelection}
onSelect={() => zoomToSelection(editor)}
/>
<TldrawUiMenuItem
id="copy-link-to-current-view"
label="Copy Link to Current View"
icon="link"
kbd="alt+c"
onSelect={() => copyLinkToCurrentView(editor)}
/>
<TldrawUiMenuItem
id="revert-camera"
label="Revert Camera"
icon="undo"
kbd="alt+b"
disabled={!hasCameraHistory}
onSelect={() => revertCamera(editor)}
/>
<TldrawUiMenuItem
id="save-to-pdf"
label="Save Selection as PDF"
icon="file"
kbd="alt+p"
disabled={!hasSelection}
onSelect={() => saveToPdf(editor)}
/>
<TldrawUiMenuItem
id="run-llm-prompt"
label="Run LLM Prompt"
icon="file"
kbd="g"
disabled={!hasSelection}
onSelect={() => {
const selectedShape = editor.getSelectedShapes()[0];
if (!selectedShape || selectedShape.type !== 'arrow') return;
const edge = getEdge(selectedShape, editor);
if (!edge) return;
const sourceShape = editor.getShape(edge.from);
const sourceText =
sourceShape && sourceShape.type === "geo"
? (sourceShape as TLGeoShape).props.text
: "";
llm(
`Instruction: ${edge.text}
${sourceText ? `Context: ${sourceText}` : ""}`,
localStorage.getItem("openai_api_key") || "",
(partialResponse: string) => {
editor.updateShape({
id: edge.to,
type: "geo",
props: {
...(editor.getShape(edge.to) as TLGeoShape).props,
text: partialResponse
}
});
}
)
}}
/>
</TldrawUiMenuGroup>
{/* Creation Tools Group */}
<TldrawUiMenuGroup id="creation-tools">
<TldrawUiMenuItem
id="VideoChat"
label="Create Video Chat"
icon="video"
kbd="alt+v"
disabled={hasSelection}
onSelect={() => {
editor.setCurrentTool("VideoChat")
}}
/>
<TldrawUiMenuItem
id="ChatBox"
label="Create Chat Box"
icon="chat"
kbd="alt+c"
disabled={hasSelection}
onSelect={() => {
editor.setCurrentTool("ChatBox")
}}
/>
<TldrawUiMenuItem
id="Embed"
label="Create Embed"
icon="embed"
kbd="alt+e"
disabled={hasSelection}
onSelect={() => {
editor.setCurrentTool("Embed")
}}
/>
<TldrawUiMenuItem
id="Slide"
label="Create Slide"
icon="slides"
kbd="alt+s"
disabled={hasSelection}
onSelect={() => {
editor.setCurrentTool("Slide")
}}
/>
<TldrawUiMenuItem
id="MycrozineTemplate"
label="Create Mycrozine Template"
icon="rectangle"
kbd="m"
disabled={hasSelection}
onSelect={() => {
editor.setCurrentTool("MycrozineTemplate")
}}
/>
<TldrawUiMenuItem
id="Markdown"
label="Create Markdown"
icon="markdown"
kbd="alt+m"
disabled={hasSelection}
onSelect={() => {
editor.setCurrentTool("Markdown")
}}
/>
<TldrawUiMenuItem
id="Prompt"
label="Create Prompt"
icon="prompt"
kbd="alt+p"
disabled={hasSelection}
onSelect={() => {
editor.setCurrentTool("Prompt")
}}
/>
</TldrawUiMenuGroup>
{/* Frame Controls */}
<TldrawUiMenuGroup id="frame-controls">
<TldrawUiMenuItem
id="lock-to-frame"
label="Lock to Frame"
icon="lock"
kbd="shift+l"
disabled={!hasFrameSelected}
onSelect={() => lockCameraToFrame(editor)}
/>
</TldrawUiMenuGroup>
<TldrawUiMenuGroup id="frames-list">
<TldrawUiMenuSubmenu id="frames-dropdown" label="Shortcut to Frames">
{getAllFrames(editor).map((frame) => (
<TldrawUiMenuItem
key={frame.id}
id={`frame-${frame.id}`}
label={frame.title}
onSelect={() => {
const shape = editor.getShape(frame.id)
if (shape) {
editor.zoomToBounds(editor.getShapePageBounds(shape)!, {
animation: { duration: 400, easing: (t) => t * (2 - t) },
})
editor.select(frame.id)
}
}}
/>
))}
</TldrawUiMenuSubmenu>
</TldrawUiMenuGroup>
<TldrawUiMenuGroup id="broadcast-controls">
<TldrawUiMenuItem
id="broadcast-view"
label="Start Broadcasting View"
icon="broadcast"
kbd="alt+b"
onSelect={() => {
const otherUsers = Array.from(editor.store.allRecords()).filter(
(record) =>
record.typeName === "instance_presence" &&
record.id !== editor.user.getId(),
)
otherUsers.forEach((user) => editor.startFollowingUser(user.id))
}}
/>
<TldrawUiMenuItem
id="stop-broadcast"
label="Stop Broadcasting View"
icon="broadcast-off"
kbd="alt+shift+b"
onSelect={() => {
const otherUsers = Array.from(editor.store.allRecords()).filter(
(record) =>
record.typeName === "instance_presence" &&
record.id !== editor.user.getId(),
)
otherUsers.forEach((_user) => editor.stopFollowingUser())
}}
/>
</TldrawUiMenuGroup>
<TldrawUiMenuGroup id="search-controls">
<TldrawUiMenuItem
id="search-text"
label="Search Text"
icon="search"
kbd="s"
onSelect={() => searchText(editor)}
/>
</TldrawUiMenuGroup>
</DefaultContextMenu>
)
}

59
src/ui/CustomMainMenu.tsx Normal file
View File

@ -0,0 +1,59 @@
import {
DefaultMainMenu,
TldrawUiMenuItem,
Editor,
TLContent,
DefaultMainMenuContent,
useEditor,
useExportAs,
} from "tldraw";
export function CustomMainMenu() {
const editor = useEditor()
const exportAs = useExportAs()
const importJSON = (editor: Editor) => {
const input = document.createElement("input");
input.type = "file";
input.accept = ".json";
input.onchange = (event) => {
const file = (event.target as HTMLInputElement).files?.[0];
const reader = new FileReader();
reader.onload = (event) => {
if (typeof event.target?.result !== 'string') {
return
}
const jsonData = JSON.parse(event.target.result) as TLContent
editor.putContentOntoCurrentPage(jsonData, { select: true })
};
if (file) {
reader.readAsText(file);
}
};
input.click();
};
const exportJSON = (editor: Editor) => {
const exportName = `props-${Math.round(+new Date() / 1000).toString().slice(5)}`
exportAs(Array.from(editor.getCurrentPageShapeIds()), 'json', exportName)
};
return (
<DefaultMainMenu>
<DefaultMainMenuContent />
<TldrawUiMenuItem
id="export"
label="Export JSON"
icon="external-link"
readonlyOk
onSelect={() => exportJSON(editor)}
/>
<TldrawUiMenuItem
id="import"
label="Import JSON"
icon="external-link"
readonlyOk
onSelect={() => importJSON(editor)}
/>
</DefaultMainMenu>
)
}

174
src/ui/CustomToolbar.tsx Normal file
View File

@ -0,0 +1,174 @@
import { TldrawUiMenuItem } from "tldraw"
import { DefaultToolbar, DefaultToolbarContent } from "tldraw"
import { useTools } from "tldraw"
import { useEditor } from "tldraw"
import { useState, useEffect } from "react"
import { useDialogs } from "tldraw"
import { SettingsDialog } from "./SettingsDialog"
export function CustomToolbar() {
const editor = useEditor()
const tools = useTools()
const [isReady, setIsReady] = useState(false)
const [hasApiKey, setHasApiKey] = useState(false)
const { addDialog, removeDialog } = useDialogs()
useEffect(() => {
if (editor && tools) {
setIsReady(true)
}
}, [editor, tools])
const checkApiKeys = () => {
const settings = localStorage.getItem("openai_api_key")
try {
if (settings) {
try {
const { keys } = JSON.parse(settings)
const hasValidKey = keys && Object.values(keys).some(key => typeof key === 'string' && key.trim() !== '')
setHasApiKey(hasValidKey)
} catch (e) {
const hasValidKey = typeof settings === 'string' && settings.trim() !== ''
setHasApiKey(hasValidKey)
}
} else {
setHasApiKey(false)
}
} catch (e) {
setHasApiKey(false)
}
}
// Initial check
useEffect(() => {
checkApiKeys()
}, [])
// Periodic check
useEffect(() => {
const interval = setInterval(checkApiKeys, 5000)
return () => clearInterval(interval)
}, [])
if (!isReady) return null
return (
<div style={{ position: "relative" }}>
<div
style={{
position: "fixed",
top: "4px",
left: "350px",
zIndex: 99999,
pointerEvents: "auto",
display: "flex",
gap: "8px",
}}
>
<button
onClick={() => {
addDialog({
id: "api-keys",
component: ({ onClose }: { onClose: () => void }) => (
<SettingsDialog
onClose={() => {
onClose()
removeDialog("api-keys")
const settings = localStorage.getItem("openai_api_key")
if (settings) {
const { keys } = JSON.parse(settings)
setHasApiKey(Object.values(keys).some((key) => key))
}
}}
/>
),
})
}}
style={{
padding: "8px 16px",
borderRadius: "4px",
background: hasApiKey ? "#6B7280" : "#2F80ED",
color: "white",
border: "none",
cursor: "pointer",
fontWeight: 500,
transition: "background 0.2s ease",
boxShadow: "0 2px 4px rgba(0,0,0,0.1)",
whiteSpace: "nowrap",
userSelect: "none",
}}
onMouseEnter={(e) => {
e.currentTarget.style.background = hasApiKey ? "#4B5563" : "#1366D6"
}}
onMouseLeave={(e) => {
e.currentTarget.style.background = hasApiKey ? "#6B7280" : "#2F80ED"
}}
>
Keys {hasApiKey ? "✅" : "❌"}
</button>
</div>
<DefaultToolbar>
<DefaultToolbarContent />
{tools["VideoChat"] && (
<TldrawUiMenuItem
{...tools["VideoChat"]}
icon="video"
label="Video Chat"
isSelected={tools["VideoChat"].id === editor.getCurrentToolId()}
/>
)}
{tools["ChatBox"] && (
<TldrawUiMenuItem
{...tools["ChatBox"]}
icon="chat"
label="Chat"
isSelected={tools["ChatBox"].id === editor.getCurrentToolId()}
/>
)}
{tools["Embed"] && (
<TldrawUiMenuItem
{...tools["Embed"]}
icon="embed"
label="Embed"
isSelected={tools["Embed"].id === editor.getCurrentToolId()}
/>
)}
{tools["SlideShape"] && (
<TldrawUiMenuItem
{...tools["SlideShape"]}
icon="slides"
label="Slide"
isSelected={tools["SlideShape"].id === editor.getCurrentToolId()}
/>
)}
{tools["Markdown"] && (
<TldrawUiMenuItem
{...tools["Markdown"]}
icon="markdown"
label="Markdown"
isSelected={tools["Markdown"].id === editor.getCurrentToolId()}
/>
)}
{tools["MycrozineTemplate"] && (
<TldrawUiMenuItem
{...tools["MycrozineTemplate"]}
icon="mycrozinetemplate"
label="MycrozineTemplate"
isSelected={
tools["MycrozineTemplate"].id === editor.getCurrentToolId()
}
/>
)}
{tools["Prompt"] && (
<TldrawUiMenuItem
{...tools["Prompt"]}
icon="prompt"
label="Prompt"
isSelected={tools["Prompt"].id === editor.getCurrentToolId()}
/>
)}
</DefaultToolbar>
</div>
)
}

47
src/ui/SettingsDialog.tsx Normal file
View File

@ -0,0 +1,47 @@
import {
TLUiDialogProps,
TldrawUiButton,
TldrawUiButtonLabel,
TldrawUiDialogBody,
TldrawUiDialogCloseButton,
TldrawUiDialogFooter,
TldrawUiDialogHeader,
TldrawUiDialogTitle,
TldrawUiInput,
} from "tldraw"
import React from "react"
export function SettingsDialog({ onClose }: TLUiDialogProps) {
const [apiKey, setApiKey] = React.useState(() => {
return localStorage.getItem("openai_api_key") || ""
})
const handleChange = (value: string) => {
setApiKey(value)
localStorage.setItem("openai_api_key", value)
}
return (
<>
<TldrawUiDialogHeader>
<TldrawUiDialogTitle>API Keys</TldrawUiDialogTitle>
<TldrawUiDialogCloseButton />
</TldrawUiDialogHeader>
<TldrawUiDialogBody style={{ maxWidth: 350 }}>
<div style={{ display: "flex", flexDirection: "column", gap: 8 }}>
<label>OpenAI API Key</label>
<TldrawUiInput
value={apiKey}
placeholder="Enter your OpenAI API key"
onValueChange={handleChange}
/>
</div>
</TldrawUiDialogBody>
<TldrawUiDialogFooter>
<TldrawUiButton type="primary" onClick={onClose}>
<TldrawUiButtonLabel>Close</TldrawUiButtonLabel>
</TldrawUiButton>
</TldrawUiDialogFooter>
</>
)
}

224
src/ui/cameraUtils.ts Normal file
View File

@ -0,0 +1,224 @@
import { Editor } from "tldraw"
export const cameraHistory: { x: number; y: number; z: number }[] = []
const MAX_HISTORY = 10 // Keep last 10 camera positions
// Helper function to store camera position
const storeCameraPosition = (editor: Editor) => {
const currentCamera = editor.getCamera()
// Only store if there's a meaningful change from the last position
const lastPosition = cameraHistory[cameraHistory.length - 1]
if (
!lastPosition ||
Math.abs(lastPosition.x - currentCamera.x) > 1 ||
Math.abs(lastPosition.y - currentCamera.y) > 1 ||
Math.abs(lastPosition.z - currentCamera.z) > 0.1
) {
cameraHistory.push({ ...currentCamera })
if (cameraHistory.length > MAX_HISTORY) {
cameraHistory.shift()
}
console.log("Stored camera position:", currentCamera)
}
}
export const zoomToSelection = (editor: Editor) => {
// Store camera position before zooming
storeCameraPosition(editor)
// Get all selected shape IDs
const selectedIds = editor.getSelectedShapeIds()
if (selectedIds.length === 0) return
// Get the common bounds that encompass all selected shapes
const commonBounds = editor.getSelectionPageBounds()
if (!commonBounds) return
// Calculate viewport dimensions
const viewportPageBounds = editor.getViewportPageBounds()
// Calculate the ratio of selection size to viewport size
const widthRatio = commonBounds.width / viewportPageBounds.width
const heightRatio = commonBounds.height / viewportPageBounds.height
// Calculate target zoom based on selection size
let targetZoom
if (widthRatio < 0.1 || heightRatio < 0.1) {
// For very small selections, zoom in up to 20x
targetZoom = Math.min(
(viewportPageBounds.width * 0.8) / commonBounds.width,
(viewportPageBounds.height * 0.8) / commonBounds.height,
40, // Max zoom of 20x for small selections
)
} else if (widthRatio > 1 || heightRatio > 1) {
// For selections larger than viewport, zoom out more
targetZoom = Math.min(
(viewportPageBounds.width * 0.7) / commonBounds.width,
(viewportPageBounds.height * 0.7) / commonBounds.height,
0.125, // Min zoom of 1/8x for large selections (reciprocal of 8)
)
} else {
// For medium-sized selections, allow up to 10x zoom
targetZoom = Math.min(
(viewportPageBounds.width * 0.8) / commonBounds.width,
(viewportPageBounds.height * 0.8) / commonBounds.height,
20, // Medium zoom level
)
}
// Zoom to the common bounds
editor.zoomToBounds(commonBounds, {
targetZoom,
inset: widthRatio > 1 || heightRatio > 1 ? 20 : 50, // Less padding for large selections
animation: {
duration: 400,
easing: (t) => t * (2 - t),
},
})
// Update URL with new camera position and first selected shape ID
const newCamera = editor.getCamera()
const url = new URL(window.location.href)
url.searchParams.set("shapeId", selectedIds[0].toString())
url.searchParams.set("x", newCamera.x.toString())
url.searchParams.set("y", newCamera.y.toString())
url.searchParams.set("zoom", newCamera.z.toString())
window.history.replaceState(null, "", url.toString())
}
export const revertCamera = (editor: Editor) => {
if (cameraHistory.length > 0) {
const previousCamera = cameraHistory.pop()
if (previousCamera) {
// Get current viewport bounds
const viewportPageBounds = editor.getViewportPageBounds()
// Create bounds that center on the previous camera position
const targetBounds = {
x: previousCamera.x - viewportPageBounds.width / 2 / previousCamera.z,
y: previousCamera.y - viewportPageBounds.height / 2 / previousCamera.z,
w: viewportPageBounds.width / previousCamera.z,
h: viewportPageBounds.height / previousCamera.z,
}
// Use the same zoom animation as zoomToShape
editor.zoomToBounds(targetBounds, {
targetZoom: previousCamera.z,
animation: {
duration: 400,
easing: (t) => t * (2 - t),
},
})
console.log("Reverted to camera position:", previousCamera)
}
} else {
console.log("No camera history available")
}
}
export const copyLinkToCurrentView = async (editor: Editor) => {
console.log("Starting copyLinkToCurrentView")
if (!editor.store.serialize()) {
console.warn("Store not ready")
return
}
try {
const baseUrl = `${window.location.origin}${window.location.pathname}`
console.log("Base URL:", baseUrl)
const url = new URL(baseUrl)
const camera = editor.getCamera()
console.log("Current camera position:", {
x: camera.x,
y: camera.y,
zoom: camera.z,
})
// Set camera parameters first
url.searchParams.set("x", camera.x.toString())
url.searchParams.set("y", camera.y.toString())
url.searchParams.set("zoom", camera.z.toString())
// Add shape ID last if needed
const selectedIds = editor.getSelectedShapeIds()
if (selectedIds.length > 0) {
url.searchParams.set("shapeId", selectedIds[0].toString())
}
const finalUrl = url.toString()
console.log("Final URL to copy:", finalUrl)
if (navigator.clipboard && window.isSecureContext) {
console.log("Using modern clipboard API...")
await navigator.clipboard.writeText(finalUrl)
console.log("URL copied successfully using clipboard API")
} else {
console.log("Falling back to legacy clipboard method...")
const textArea = document.createElement("textarea")
textArea.value = finalUrl
document.body.appendChild(textArea)
try {
await navigator.clipboard.writeText(textArea.value)
} catch (err) {
console.error("Clipboard API failed:", err)
}
document.body.removeChild(textArea)
}
} catch (error) {
console.error("Failed to copy to clipboard:", error)
alert("Failed to copy link. Please check clipboard permissions.")
}
}
/** TODO: doesnt UNlock */
export const lockCameraToFrame = async (editor: Editor) => {
const selectedShapes = editor.getSelectedShapes()
if (selectedShapes.length === 0) return
const selectedShape = selectedShapes[0]
const isFrame = selectedShape.type === "frame"
const bounds = editor.getShapePageBounds(selectedShape)
if (!isFrame || !bounds) return
try {
const baseUrl = `${window.location.origin}${window.location.pathname}`
const url = new URL(baseUrl)
// Calculate zoom level to fit the frame (for URL only)
const viewportPageBounds = editor.getViewportPageBounds()
const targetZoom = Math.min(
viewportPageBounds.width / bounds.width,
viewportPageBounds.height / bounds.height,
1, // Cap at 1x zoom
)
// Set URL parameters without affecting the current view
url.searchParams.set("x", Math.round(bounds.x).toString())
url.searchParams.set("y", Math.round(bounds.y).toString())
url.searchParams.set(
"zoom",
(Math.round(targetZoom * 100) / 100).toString(),
)
url.searchParams.set("frameId", selectedShape.id)
url.searchParams.set("isLocked", "true")
const finalUrl = url.toString()
// Copy URL to clipboard without modifying the current view
if (navigator.clipboard && window.isSecureContext) {
await navigator.clipboard.writeText(finalUrl)
} else {
const textArea = document.createElement("textarea")
textArea.value = finalUrl
document.body.appendChild(textArea)
textArea.select()
document.execCommand("copy")
document.body.removeChild(textArea)
}
} catch (error) {
console.error("Failed to copy frame link:", error)
alert("Failed to copy frame link. Please check clipboard permissions.")
}
}

27
src/ui/components.tsx Normal file
View File

@ -0,0 +1,27 @@
import { CustomMainMenu } from "./CustomMainMenu"
import { CustomToolbar } from "./CustomToolbar"
import { CustomContextMenu } from "./CustomContextMenu"
import {
DefaultKeyboardShortcutsDialog,
DefaultKeyboardShortcutsDialogContent,
TLComponents,
TldrawUiMenuItem,
useTools,
} from "tldraw"
import { SlidesPanel } from "@/slides/SlidesPanel"
export const components: TLComponents = {
Toolbar: CustomToolbar,
MainMenu: CustomMainMenu,
ContextMenu: CustomContextMenu,
HelperButtons: SlidesPanel,
KeyboardShortcutsDialog: (props: any) => {
const tools = useTools()
return (
<DefaultKeyboardShortcutsDialog {...props}>
<TldrawUiMenuItem {...tools["Slide"]} />
<DefaultKeyboardShortcutsDialogContent />
</DefaultKeyboardShortcutsDialog>
)
},
}

489
src/ui/overrides.tsx Normal file
View File

@ -0,0 +1,489 @@
import {
shapeIdValidator,
TLArrowShape,
TLGeoShape,
TLUiOverrides,
} from "tldraw"
import {
cameraHistory,
copyLinkToCurrentView,
lockCameraToFrame,
revertCamera,
zoomToSelection,
} from "./cameraUtils"
import { saveToPdf } from "../utils/pdfUtils"
import { searchText } from "../utils/searchUtils"
import { EmbedShape, IEmbedShape } from "@/shapes/EmbedShapeUtil"
import { moveToSlide } from "@/slides/useSlides"
import { ISlideShape } from "@/shapes/SlideShapeUtil"
import { getEdge } from "@/propagators/tlgraph"
import { llm } from "@/utils/llmUtils"
export const overrides: TLUiOverrides = {
tools(editor, tools) {
return {
...tools,
select: {
...tools.select,
onPointerDown: (info: any) => {
const shape = editor.getShapeAtPoint(info.point)
if (shape && editor.getSelectedShapeIds().includes(shape.id)) {
// If clicking on a selected shape, initiate drag behavior
editor.dispatch({
type: "pointer",
name: "pointer_down",
point: info.point,
button: info.button,
shiftKey: info.shiftKey,
altKey: info.altKey,
ctrlKey: info.ctrlKey,
metaKey: info.metaKey,
pointerId: info.pointerId,
target: "shape",
shape,
isPen: false,
accelKey: info.ctrlKey || info.metaKey,
})
return
}
// Otherwise, use default select tool behavior
;(tools.select as any).onPointerDown?.(info)
},
//TODO: Fix double click to zoom on selector tool later...
onDoubleClick: (info: any) => {
const shape = editor.getShapeAtPoint(info.point)
if (shape?.type === "Embed") {
// Let the Embed shape handle its own double-click behavior
const util = editor.getShapeUtil(shape) as EmbedShape
util?.onDoubleClick?.(shape as IEmbedShape)
return
}
// Handle all pointer types (mouse, touch, pen)
const point = info.point || (info.touches && info.touches[0]) || info
// Zoom in at the clicked/touched point
editor.zoomIn(point, { animation: { duration: 200 } })
// Stop event propagation and prevent default handling
info.stopPropagation?.()
return false
},
},
VideoChat: {
id: "VideoChat",
icon: "video",
label: "Video Chat",
kbd: "alt+v",
readonlyOk: true,
type: "VideoChat",
onSelect: () => editor.setCurrentTool("VideoChat"),
},
ChatBox: {
id: "ChatBox",
icon: "chat",
label: "Chat",
kbd: "alt+c",
readonlyOk: true,
type: "ChatBox",
onSelect: () => editor.setCurrentTool("ChatBox"),
},
Embed: {
id: "Embed",
icon: "embed",
label: "Embed",
kbd: "alt+e",
readonlyOk: true,
type: "Embed",
onSelect: () => editor.setCurrentTool("Embed"),
},
SlideShape: {
id: "Slide",
icon: "slides",
label: "Slide",
kbd: "alt+s",
type: "Slide",
readonlyOk: true,
onSelect: () => {
editor.setCurrentTool("Slide")
},
},
Markdown: {
id: "Markdown",
icon: "markdown",
label: "Markdown",
kbd: "alt+m",
readonlyOk: true,
type: "Markdown",
onSelect: () => editor.setCurrentTool("Markdown"),
},
MycrozineTemplate: {
id: "MycrozineTemplate",
icon: "rectangle",
label: "Mycrozine Template",
type: "MycrozineTemplate",
kbd: "m",
readonlyOk: true,
onSelect: () => editor.setCurrentTool("MycrozineTemplate"),
},
Prompt: {
id: "Prompt",
icon: "prompt",
label: "Prompt",
type: "Prompt",
kdb: "p",
readonlyOk: true,
onSelect: () => editor.setCurrentTool("Prompt"),
},
hand: {
...tools.hand,
onDoubleClick: (info: any) => {
editor.zoomIn(info.point, { animation: { duration: 200 } })
},
},
}
},
actions(editor, actions) {
return {
...actions,
zoomToSelection: {
id: "zoom-to-selection",
label: "Zoom to Selection",
kbd: "z",
onSelect: () => {
if (editor.getSelectedShapeIds().length > 0) {
zoomToSelection(editor)
}
},
readonlyOk: true,
},
copyLinkToCurrentView: {
id: "copy-link-to-current-view",
label: "Copy Link to Current View",
kbd: "alt+c",
onSelect: () => {
copyLinkToCurrentView(editor)
},
readonlyOk: true,
},
revertCamera: {
id: "revert-camera",
label: "Revert Camera",
kbd: "alt+b",
onSelect: () => {
if (cameraHistory.length > 0) {
revertCamera(editor)
}
},
readonlyOk: true,
},
lockToFrame: {
id: "lock-to-frame",
label: "Lock to Frame",
kbd: "shift+l",
onSelect: () => lockCameraToFrame(editor),
},
saveToPdf: {
id: "save-to-pdf",
label: "Save Selection as PDF",
kbd: "alt+p",
onSelect: () => {
if (editor.getSelectedShapeIds().length > 0) {
saveToPdf(editor)
}
},
readonlyOk: true,
},
moveSelectedLeft: {
id: "move-selected-left",
label: "Move Left",
kbd: "ArrowLeft",
onSelect: () => {
const selectedShapes = editor.getSelectedShapes()
if (selectedShapes.length > 0) {
selectedShapes.forEach((shape) => {
editor.updateShape({
id: shape.id,
type: shape.type,
x: shape.x - 50,
y: shape.y,
})
})
}
},
},
moveSelectedRight: {
id: "move-selected-right",
label: "Move Right",
kbd: "ArrowRight",
onSelect: () => {
const selectedShapes = editor.getSelectedShapes()
if (selectedShapes.length > 0) {
selectedShapes.forEach((shape) => {
editor.updateShape({
id: shape.id,
type: shape.type,
x: shape.x + 50,
y: shape.y,
})
})
}
},
},
moveSelectedUp: {
id: "move-selected-up",
label: "Move Up",
kbd: "ArrowUp",
onSelect: () => {
const selectedShapes = editor.getSelectedShapes()
if (selectedShapes.length > 0) {
selectedShapes.forEach((shape) => {
editor.updateShape({
id: shape.id,
type: shape.type,
x: shape.x,
y: shape.y - 50,
})
})
}
},
},
moveSelectedDown: {
id: "move-selected-down",
label: "Move Down",
kbd: "ArrowDown",
onSelect: () => {
const selectedShapes = editor.getSelectedShapes()
if (selectedShapes.length > 0) {
selectedShapes.forEach((shape) => {
editor.updateShape({
id: shape.id,
type: shape.type,
x: shape.x,
y: shape.y + 50,
})
})
}
},
},
// TODO: FIX THIS
resizeSelectedUp: {
id: "resize-selected-up",
label: "Resize Up",
kbd: "ctrl+ArrowUp",
onSelect: () => {
const selectedShapes = editor.getSelectedShapes()
if (selectedShapes.length > 0) {
selectedShapes.forEach((shape) => {
const bounds = editor.getShapeGeometry(shape).bounds
editor.updateShape({
id: shape.id,
type: shape.type,
//y: shape.y - 50,
props: {
...shape.props,
h: bounds.height + 50,
},
})
})
}
},
},
resizeSelectedDown: {
id: "resize-selected-down",
label: "Resize Down",
kbd: "ctrl+ArrowDown",
onSelect: () => {
const selectedShapes = editor.getSelectedShapes()
if (selectedShapes.length > 0) {
selectedShapes.forEach((shape) => {
const bounds = editor.getShapeGeometry(shape).bounds
editor.updateShape({
id: shape.id,
type: shape.type,
props: {
...shape.props,
h: bounds.height + 50,
},
})
})
}
},
},
resizeSelectedLeft: {
id: "resize-selected-left",
label: "Resize Left",
kbd: "ctrl+ArrowLeft",
onSelect: () => {
const selectedShapes = editor.getSelectedShapes()
if (selectedShapes.length > 0) {
selectedShapes.forEach((shape) => {
const bounds = editor.getShapeGeometry(shape).bounds
editor.updateShape({
id: shape.id,
type: shape.type,
props: {
...shape.props,
w: bounds.width + 50,
},
})
})
}
},
},
resizeSelectedRight: {
id: "resize-selected-right",
label: "Resize Right",
kbd: "ctrl+ArrowRight",
onSelect: () => {
const selectedShapes = editor.getSelectedShapes()
if (selectedShapes.length > 0) {
selectedShapes.forEach((shape) => {
const bounds = editor.getShapeGeometry(shape).bounds
editor.updateShape({
id: shape.id,
type: shape.type,
props: {
...shape.props,
w: bounds.width + 50,
},
})
})
}
},
},
//TODO: MAKE THIS WORK, ADD USER PERMISSIONING TO JOIN BROADCAST?
broadcastView: {
id: "broadcast-view",
label: "Broadcast View",
kbd: "alt+b",
readonlyOk: true,
onSelect: () => {
const collaborators = editor.getCollaborators()
collaborators
.filter((user) => user.id !== editor.user.getId())
.forEach((user) => {
editor.startFollowingUser(user.id)
})
},
},
stopBroadcast: {
id: "stop-broadcast",
label: "Stop Broadcasting",
kbd: "alt+shift+b",
readonlyOk: true,
onSelect: () => {
editor.stopFollowingUser()
},
},
searchShapes: {
id: "search-shapes",
label: "Search Shapes",
kbd: "s",
readonlyOk: true,
onSelect: () => searchText(editor),
},
llm: {
id: "llm",
label: "LLM",
kbd: "g",
readonlyOk: true,
onSelect: () => {
const selectedShapes = editor.getSelectedShapes()
if (selectedShapes.length > 0) {
const selectedShape = selectedShapes[0] as TLArrowShape
if (selectedShape.type !== "arrow") {
return
}
const edge = getEdge(selectedShape, editor)
if (!edge) {
return
}
const sourceShape = editor.getShape(edge.from)
const sourceText =
sourceShape && sourceShape.type === "geo"
? (sourceShape as TLGeoShape).props.text
: ""
llm(
`Instruction: ${edge.text}
${sourceText ? `Context: ${sourceText}` : ""}`,
localStorage.getItem("openai_api_key") || "",
(partialResponse: string) => {
editor.updateShape({
id: edge.to,
type: "geo",
props: {
...(editor.getShape(edge.to) as TLGeoShape).props,
text: partialResponse,
},
})
},
)
}
},
},
"next-slide": {
id: "next-slide",
label: "Next slide",
kbd: "right",
onSelect() {
const slides = editor
.getCurrentPageShapes()
.filter((shape) => shape.type === "Slide")
if (slides.length === 0) return
const currentSlide = editor
.getSelectedShapes()
.find((shape) => shape.type === "Slide")
const currentIndex = currentSlide
? slides.findIndex((slide) => slide.id === currentSlide.id)
: -1
// Calculate next index with wraparound
const nextIndex =
currentIndex === -1
? 0
: currentIndex >= slides.length - 1
? 0
: currentIndex + 1
const nextSlide = slides[nextIndex]
editor.select(nextSlide.id)
editor.stopCameraAnimation()
moveToSlide(editor, nextSlide as ISlideShape)
},
},
"previous-slide": {
id: "previous-slide",
label: "Previous slide",
kbd: "left",
onSelect() {
const slides = editor
.getCurrentPageShapes()
.filter((shape) => shape.type === "Slide")
if (slides.length === 0) return
const currentSlide = editor
.getSelectedShapes()
.find((shape) => shape.type === "Slide")
const currentIndex = currentSlide
? slides.findIndex((slide) => slide.id === currentSlide.id)
: -1
// Calculate previous index with wraparound
const previousIndex =
currentIndex <= 0 ? slides.length - 1 : currentIndex - 1
const previousSlide = slides[previousIndex]
editor.select(previousSlide.id)
editor.stopCameraAnimation()
moveToSlide(editor, previousSlide as ISlideShape)
},
},
}
},
}

View File

@ -0,0 +1,18 @@
import { Editor, TLEventMap, TLInstancePresence } from "tldraw"
export const handleInitialPageLoad = async (editor: Editor) => {
// Wait for editor to be ready
while (!editor.store || !editor.getInstanceState().isFocused) {
await new Promise((resolve) => requestAnimationFrame(resolve))
}
try {
// Set initial tool
editor.setCurrentTool("hand")
// Force a re-render of the toolbar
editor.emit("toolsChange" as keyof TLEventMap)
} catch (error) {
console.error("Error during initial page load:", error)
}
}

33
src/utils/llmUtils.ts Normal file
View File

@ -0,0 +1,33 @@
import OpenAI from "openai";
export async function llm(
//systemPrompt: string,
userPrompt: string,
apiKey: string,
onToken: (partialResponse: string, done: boolean) => void,
) {
if (!apiKey) {
throw new Error("No API key found")
}
//console.log("System Prompt:", systemPrompt);
//console.log("User Prompt:", userPrompt);
let partial = "";
const openai = new OpenAI({
apiKey,
dangerouslyAllowBrowser: true,
});
const stream = await openai.chat.completions.create({
model: "gpt-4o",
messages: [
{ role: "system", content: 'You are a helpful assistant.' },
{ role: "user", content: userPrompt },
],
stream: true,
});
for await (const chunk of stream) {
partial += chunk.choices[0]?.delta?.content || "";
onToken(partial, false);
}
console.log("Generated:", partial);
onToken(partial, true);
}

View File

@ -0,0 +1,25 @@
import { TLAssetStore, uniqueId } from 'tldraw'
import { WORKER_URL } from '../routes/Board'
export const multiplayerAssetStore: TLAssetStore = {
async upload(_asset, file) {
const id = uniqueId()
const objectName = `${id}-${file.name}`.replace(/[^a-zA-Z0-9.]/g, '-')
const url = `${WORKER_URL}/uploads/${objectName}`
const response = await fetch(url, {
method: 'POST',
body: file,
})
if (!response.ok) {
throw new Error(`Failed to upload asset: ${response.statusText}`)
}
return url
},
resolve(asset) {
return asset.props.src
},
}

61
src/utils/pdfUtils.ts Normal file
View File

@ -0,0 +1,61 @@
import { Editor, TLShapeId } from "tldraw"
import { jsPDF } from "jspdf"
import { exportToBlob } from "tldraw"
export const saveToPdf = async (editor: Editor) => {
const selectedIds = editor.getSelectedShapeIds()
if (selectedIds.length === 0) return
try {
// Get common bounds of selected shapes
const selectionBounds = editor.getSelectionPageBounds()
if (!selectionBounds) return
// Get blob using the editor's export functionality
const blob = await exportToBlob({
editor,
ids: selectedIds,
format: "png",
opts: {
scale: 2,
background: true,
padding: 0,
preserveAspectRatio: "true",
},
})
if (!blob) return
// Create PDF with proper dimensions
const pdf = new jsPDF({
orientation: selectionBounds.width > selectionBounds.height ? "l" : "p",
unit: "px",
format: [selectionBounds.width, selectionBounds.height],
})
// Convert blob directly to base64
const reader = new FileReader()
const imageData = await new Promise<string>((resolve, reject) => {
reader.onload = () => resolve(reader.result as string)
reader.onerror = reject
reader.readAsDataURL(blob)
})
// Add the image to the PDF with compression
pdf.addImage(
imageData,
"PNG",
0,
0,
selectionBounds.width,
selectionBounds.height,
undefined,
'FAST'
)
pdf.save("canvas-selection.pdf")
} catch (error) {
console.error("Failed to generate PDF:", error)
alert("Failed to generate PDF. Please try again.")
}
}

87
src/utils/searchUtils.ts Normal file
View File

@ -0,0 +1,87 @@
import { Editor } from "tldraw"
export const searchText = (editor: Editor) => {
// Switch to select tool first
editor.setCurrentTool('select')
const searchTerm = prompt("Enter search text:")
if (!searchTerm) return
const shapes = editor.getCurrentPageShapes()
const matchingShapes = shapes.filter(shape => {
if (!shape.props) return false
const textProperties = [
(shape.props as any).text,
(shape.props as any).name,
(shape.props as any).value,
(shape.props as any).url,
(shape.props as any).description,
(shape.props as any).content,
]
const termLower = searchTerm.toLowerCase()
return textProperties.some(prop =>
typeof prop === 'string' &&
prop.toLowerCase().includes(termLower)
)
})
if (matchingShapes.length > 0) {
editor.selectNone()
editor.setSelectedShapes(matchingShapes)
const commonBounds = editor.getSelectionPageBounds()
if (!commonBounds) return
// Calculate viewport dimensions
const viewportPageBounds = editor.getViewportPageBounds()
// Calculate the ratio of selection size to viewport size
const widthRatio = commonBounds.width / viewportPageBounds.width
const heightRatio = commonBounds.height / viewportPageBounds.height
// Calculate target zoom based on selection size
let targetZoom
if (widthRatio < 0.1 || heightRatio < 0.1) {
targetZoom = Math.min(
(viewportPageBounds.width * 0.8) / commonBounds.width,
(viewportPageBounds.height * 0.8) / commonBounds.height,
40
)
} else if (widthRatio > 1 || heightRatio > 1) {
targetZoom = Math.min(
(viewportPageBounds.width * 0.7) / commonBounds.width,
(viewportPageBounds.height * 0.7) / commonBounds.height,
0.125
)
} else {
targetZoom = Math.min(
(viewportPageBounds.width * 0.8) / commonBounds.width,
(viewportPageBounds.height * 0.8) / commonBounds.height,
20
)
}
// Zoom to the common bounds
editor.zoomToBounds(commonBounds, {
targetZoom,
inset: widthRatio > 1 || heightRatio > 1 ? 20 : 50,
animation: {
duration: 400,
easing: (t) => t * (2 - t),
},
})
// Update URL with new camera position and first selected shape ID
const newCamera = editor.getCamera()
const url = new URL(window.location.href)
url.searchParams.set("shapeId", matchingShapes[0].id)
url.searchParams.set("x", newCamera.x.toString())
url.searchParams.set("y", newCamera.y.toString())
url.searchParams.set("zoom", newCamera.z.toString())
window.history.replaceState(null, "", url.toString())
} else {
alert("No matches found")
}
}

View File

@ -0,0 +1,43 @@
import { TLBookmarkAsset, AssetRecordType, getHashForString } from "tldraw"
import { WORKER_URL } from "../routes/Board"
export async function unfurlBookmarkUrl({
url,
}: {
url: string
}): Promise<TLBookmarkAsset> {
const asset: TLBookmarkAsset = {
id: AssetRecordType.createId(getHashForString(url)),
typeName: "asset",
type: "bookmark",
meta: {},
props: {
src: url,
description: "",
image: "",
favicon: "",
title: "",
},
}
try {
const response = await fetch(
`${WORKER_URL}/unfurl?url=${encodeURIComponent(url)}`,
)
const data = (await response.json()) as {
description: string
image: string
favicon: string
title: string
}
asset.props.description = data?.description ?? ""
asset.props.image = data?.image ?? ""
asset.props.favicon = data?.favicon ?? ""
asset.props.title = data?.title ?? ""
} catch (e) {
console.error(e)
}
return asset
}

12
src/vite-env.d.ts vendored Normal file
View File

@ -0,0 +1,12 @@
/// <reference types="vite/client" />
interface ImportMetaEnv {
readonly VITE_TLDRAW_WORKER_URL: string
readonly VITE_GOOGLE_MAPS_API_KEY: string
readonly VITE_GOOGLE_CLIENT_ID: string
readonly VITE_DAILY_DOMAIN: string
}
interface ImportMeta {
readonly env: ImportMetaEnv
}

31
tsconfig.json Normal file
View File

@ -0,0 +1,31 @@
{
"compilerOptions": {
"baseUrl": "./src",
"paths": {
"@/*": ["*"],
"src/*": ["./src/*"],
},
"target": "ES2020",
"useDefineForClassFields": true,
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"module": "ESNext",
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"jsx": "react-jsx",
/* Linting */
"strict": true,
"noUnusedLocals": false,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true
},
"include": ["src", "worker", "src/client"],
"references": [{ "path": "./tsconfig.node.json" }]
}

10
tsconfig.node.json Normal file
View File

@ -0,0 +1,10 @@
{
"compilerOptions": {
"composite": true,
"skipLibCheck": true,
"module": "ESNext",
"moduleResolution": "bundler",
"allowSyntheticDefaultImports": true
},
"include": ["vite.config.ts"]
}

17
tsconfig.worker.json Normal file
View File

@ -0,0 +1,17 @@
{
"compilerOptions": {
"composite": true,
"skipLibCheck": true,
"lib": ["ES2022"],
"module": "ES2022",
"moduleResolution": "bundler",
"allowSyntheticDefaultImports": true,
"types": ["@cloudflare/workers-types"],
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true,
"target": "ES2022"
},
"include": ["worker/*.ts"]
}

View File

@ -1,5 +1,31 @@
{
"buildCommand": "",
"framework" : "other",
"outputDirectory": "."
"buildCommand": "npm run build",
"installCommand": "npm install",
"framework": "vite",
"outputDirectory": "dist",
"rewrites": [
{
"source": "/board/(.*)",
"destination": "/"
},
{
"source": "/board",
"destination": "/"
},
{
"source": "/inbox",
"destination": "/"
}
],
"headers": [
{
"source": "/assets/(.*)",
"headers": [
{
"key": "Cache-Control",
"value": "public, max-age=31536000, immutable"
}
]
}
]
}

31
vite.config.ts Normal file
View File

@ -0,0 +1,31 @@
import { defineConfig, loadEnv } from "vite"
import react from "@vitejs/plugin-react"
export default defineConfig(({ mode }) => {
// Load env file based on `mode` in the current working directory.
// Set the third parameter to '' to load all env regardless of the `VITE_` prefix.
const env = loadEnv(mode, process.cwd(), '')
return {
envPrefix: ["VITE_"],
plugins: [react()],
server: {
host: "0.0.0.0",
port: 5173,
},
build: {
sourcemap: true,
},
base: "/",
publicDir: "src/public",
resolve: {
alias: {
"@": "/src",
},
},
define: {
__WORKER_URL__: JSON.stringify(env.VITE_TLDRAW_WORKER_URL),
__DAILY_API_KEY__: JSON.stringify(env.VITE_DAILY_API_KEY)
}
}
})

View File

@ -0,0 +1,44 @@
import type { Environment } from './types';
interface RecordingPayload {
recordingUrl: string;
roomName: string;
recordingId: string;
}
export class RecordingProcessor {
async fetch(request: Request, env: Environment) {
const payload = await request.json() as RecordingPayload;
const { recordingUrl, roomName, recordingId } = payload;
// Step 1: Download the recording
const response = await fetch(recordingUrl, {
headers: {
'Authorization': `Bearer ${env.DAILY_API_KEY}`
}
});
if (!response.ok) {
throw new Error(`Failed to download recording: ${response.statusText}`);
}
const downloadResult = await response.arrayBuffer();
// Step 2: Upload to R2
const bucket = env.TLDRAW_BUCKET;
const key = `uploads/recordings/${roomName}/${recordingId}.mp4`;
await bucket.put(key, downloadResult, {
httpMetadata: {
contentType: 'video/mp4',
}
});
return new Response(JSON.stringify({
status: "success",
location: key
}), {
headers: { 'Content-Type': 'application/json' }
});
}
}

View File

@ -0,0 +1,286 @@
/// <reference types="@cloudflare/workers-types" />
import { RoomSnapshot, TLSocketRoom } from "@tldraw/sync-core"
import {
TLRecord,
TLShape,
createTLSchema,
defaultBindingSchemas,
defaultShapeSchemas,
shapeIdValidator,
} from "@tldraw/tlschema"
import { AutoRouter, IRequest, error } from "itty-router"
import throttle from "lodash.throttle"
import { Environment } from "./types"
import { ChatBoxShape } from "@/shapes/ChatBoxShapeUtil"
import { VideoChatShape } from "@/shapes/VideoChatShapeUtil"
import { EmbedShape } from "@/shapes/EmbedShapeUtil"
import { MarkdownShape } from "@/shapes/MarkdownShapeUtil"
import { MycrozineTemplateShape } from "@/shapes/MycrozineTemplateShapeUtil"
import { SlideShape } from "@/shapes/SlideShapeUtil"
import { PromptShape } from "@/shapes/PromptShapeUtil"
// add custom shapes and bindings here if needed:
export const customSchema = createTLSchema({
shapes: {
...defaultShapeSchemas,
ChatBox: {
props: ChatBoxShape.props,
migrations: ChatBoxShape.migrations,
},
VideoChat: {
props: VideoChatShape.props,
migrations: VideoChatShape.migrations,
},
Embed: {
props: EmbedShape.props,
migrations: EmbedShape.migrations,
},
Markdown: {
props: MarkdownShape.props,
migrations: MarkdownShape.migrations,
},
MycrozineTemplate: {
props: MycrozineTemplateShape.props,
migrations: MycrozineTemplateShape.migrations,
},
Slide: {
props: SlideShape.props,
migrations: SlideShape.migrations,
},
Prompt: {
props: PromptShape.props,
migrations: PromptShape.migrations,
},
},
bindings: defaultBindingSchemas,
})
// each whiteboard room is hosted in a DurableObject:
// https://developers.cloudflare.com/durable-objects/
// there's only ever one durable object instance per room. it keeps all the room state in memory and
// handles websocket connections. periodically, it persists the room state to the R2 bucket.
export class TldrawDurableObject {
private r2: R2Bucket
// the room ID will be missing whilst the room is being initialized
private roomId: string | null = null
// when we load the room from the R2 bucket, we keep it here. it's a promise so we only ever
// load it once.
private roomPromise: Promise<TLSocketRoom<TLRecord, void>> | null = null
constructor(private readonly ctx: DurableObjectState, env: Environment) {
this.r2 = env.TLDRAW_BUCKET
ctx.blockConcurrencyWhile(async () => {
this.roomId = ((await this.ctx.storage.get("roomId")) ?? null) as
| string
| null
})
}
private readonly router = AutoRouter({
catch: (e) => {
console.log(e)
return error(e)
},
})
// when we get a connection request, we stash the room id if needed and handle the connection
.get("/connect/:roomId", async (request) => {
if (!this.roomId) {
await this.ctx.blockConcurrencyWhile(async () => {
await this.ctx.storage.put("roomId", request.params.roomId)
this.roomId = request.params.roomId
})
}
return this.handleConnect(request)
})
.get("/room/:roomId", async (request) => {
const room = await this.getRoom()
const snapshot = room.getCurrentSnapshot()
return new Response(JSON.stringify(snapshot.documents), {
headers: {
"Content-Type": "application/json",
"Access-Control-Allow-Origin": request.headers.get("Origin") || "*",
"Access-Control-Allow-Methods": "GET, POST, OPTIONS",
"Access-Control-Allow-Headers": "Content-Type",
"Access-Control-Max-Age": "86400",
},
})
})
.post("/room/:roomId", async (request) => {
const records = (await request.json()) as TLRecord[]
return new Response(JSON.stringify(Array.from(records)), {
headers: {
"Content-Type": "application/json",
"Access-Control-Allow-Origin": request.headers.get("Origin") || "*",
"Access-Control-Allow-Methods": "GET, POST, OPTIONS",
"Access-Control-Allow-Headers": "Content-Type",
"Access-Control-Max-Age": "86400",
},
})
})
// `fetch` is the entry point for all requests to the Durable Object
fetch(request: Request): Response | Promise<Response> {
try {
return this.router.fetch(request)
} catch (err) {
console.error("Error in DO fetch:", err)
return new Response(
JSON.stringify({
error: "Internal Server Error",
message: (err as Error).message,
}),
{
status: 500,
headers: {
"Content-Type": "application/json",
"Access-Control-Allow-Origin": "*",
"Access-Control-Allow-Methods": "GET, POST, OPTIONS, UPGRADE",
"Access-Control-Allow-Headers":
"Content-Type, Authorization, Upgrade, Connection",
"Access-Control-Max-Age": "86400",
"Access-Control-Allow-Credentials": "true",
},
},
)
}
}
// what happens when someone tries to connect to this room?
async handleConnect(request: IRequest): Promise<Response> {
if (!this.roomId) {
return new Response("Room not initialized", { status: 400 })
}
const sessionId = request.query.sessionId as string
if (!sessionId) {
return new Response("Missing sessionId", { status: 400 })
}
const { 0: clientWebSocket, 1: serverWebSocket } = new WebSocketPair()
try {
serverWebSocket.accept()
const room = await this.getRoom()
// Handle socket connection with proper error boundaries
room.handleSocketConnect({
sessionId,
socket: {
send: serverWebSocket.send.bind(serverWebSocket),
close: serverWebSocket.close.bind(serverWebSocket),
addEventListener:
serverWebSocket.addEventListener.bind(serverWebSocket),
removeEventListener:
serverWebSocket.removeEventListener.bind(serverWebSocket),
readyState: serverWebSocket.readyState,
},
})
return new Response(null, {
status: 101,
webSocket: clientWebSocket,
headers: {
"Access-Control-Allow-Origin": request.headers.get("Origin") || "*",
"Access-Control-Allow-Methods": "GET, POST, OPTIONS, UPGRADE",
"Access-Control-Allow-Headers": "*",
"Access-Control-Allow-Credentials": "true",
Upgrade: "websocket",
Connection: "Upgrade",
},
})
} catch (error) {
console.error("WebSocket connection error:", error)
serverWebSocket.close(1011, "Failed to initialize connection")
return new Response("Failed to establish WebSocket connection", {
status: 500,
})
}
}
getRoom() {
const roomId = this.roomId
if (!roomId) throw new Error("Missing roomId")
if (!this.roomPromise) {
this.roomPromise = (async () => {
// fetch the room from R2
const roomFromBucket = await this.r2.get(`rooms/${roomId}`)
// if it doesn't exist, we'll just create a new empty room
const initialSnapshot = roomFromBucket
? ((await roomFromBucket.json()) as RoomSnapshot)
: undefined
if (initialSnapshot) {
initialSnapshot.documents = initialSnapshot.documents.filter(
(record) => {
const shape = record.state as TLShape
return shape.type !== "ChatBox"
},
)
}
// create a new TLSocketRoom. This handles all the sync protocol & websocket connections.
// it's up to us to persist the room state to R2 when needed though.
return new TLSocketRoom<TLRecord, void>({
schema: customSchema,
initialSnapshot,
onDataChange: () => {
// and persist whenever the data in the room changes
this.schedulePersistToR2()
console.log("Persisting", this.roomId, "to R2")
},
})
})()
}
return this.roomPromise
}
// we throttle persistance so it only happens every 10 seconds
schedulePersistToR2 = throttle(async () => {
if (!this.roomPromise || !this.roomId) return
const room = await this.getRoom()
// convert the room to JSON and upload it to R2
const snapshot = JSON.stringify(room.getCurrentSnapshot())
await this.r2.put(`rooms/${this.roomId}`, snapshot)
}, 10_000)
// Add CORS headers for WebSocket upgrade
handleWebSocket(request: Request) {
const upgradeHeader = request.headers.get("Upgrade")
if (!upgradeHeader || upgradeHeader !== "websocket") {
return new Response("Expected Upgrade: websocket", { status: 426 })
}
const webSocketPair = new WebSocketPair()
const [client, server] = Object.values(webSocketPair)
server.accept()
// Add error handling and reconnection logic
server.addEventListener("error", (err) => {
console.error("WebSocket error:", err)
})
server.addEventListener("close", () => {
if (this.roomPromise) {
this.getRoom().then((room) => {
// Update store to ensure all changes are persisted
room.updateStore(() => {})
})
}
})
return new Response(null, {
status: 101,
webSocket: client,
headers: {
"Access-Control-Allow-Origin": "*",
"Access-Control-Allow-Headers": "*",
},
})
}
}

109
worker/assetUploads.ts Normal file
View File

@ -0,0 +1,109 @@
/// <reference types="@cloudflare/workers-types" />
import { IRequest, error } from 'itty-router'
import { Environment } from './types'
// assets are stored in the bucket under the /uploads path
function getAssetObjectName(uploadId: string) {
return `uploads/${uploadId.replace(/[^a-zA-Z0-9\_\-]+/g, '_')}`
}
// when a user uploads an asset, we store it in the bucket. we only allow image and video assets.
export async function handleAssetUpload(request: IRequest, env: Environment) {
try {
const objectName = getAssetObjectName(request.params.uploadId)
const contentType = request.headers.get('content-type') ?? ''
if (!contentType.startsWith('image/') && !contentType.startsWith('video/')) {
return error(400, 'Invalid content type')
}
if (await env.TLDRAW_BUCKET.head(objectName)) {
return error(409, 'Upload already exists')
}
await env.TLDRAW_BUCKET.put(objectName, request.body, {
httpMetadata: request.headers,
})
return { ok: true }
} catch (error) {
console.error('Asset upload failed:', error);
return new Response(`Upload failed: ${(error as Error).message}`, { status: 500 });
}
}
// when a user downloads an asset, we retrieve it from the bucket. we also cache the response for performance.
export async function handleAssetDownload(
request: IRequest,
env: Environment,
ctx: ExecutionContext
) {
const objectName = getAssetObjectName(request.params.uploadId)
// if we have a cached response for this request (automatically handling ranges etc.), return it
const cacheKey = new Request(request.url, { headers: request.headers })
// @ts-ignore
const cachedResponse = await caches.default.match(cacheKey)
if (cachedResponse) {
return cachedResponse
}
// if not, we try to fetch the asset from the bucket
const object = await env.TLDRAW_BUCKET.get(objectName, {
range: request.headers,
onlyIf: request.headers,
})
if (!object) {
return error(404)
}
// write the relevant metadata to the response headers
const headers = new Headers()
object.writeHttpMetadata(headers)
// assets are immutable, so we can cache them basically forever:
headers.set('cache-control', 'public, max-age=31536000, immutable')
headers.set('etag', object.httpEtag)
// we set CORS headers so all clients can access assets. we do this here so our `cors` helper in
// worker.ts doesn't try to set extra cors headers on responses that have been read from the
// cache, which isn't allowed by cloudflare.
headers.set('access-control-allow-origin', '*')
// cloudflare doesn't set the content-range header automatically in writeHttpMetadata, so we
// need to do it ourselves.
let contentRange
if (object.range) {
if ('suffix' in object.range) {
const start = object.size - object.range.suffix
const end = object.size - 1
contentRange = `bytes ${start}-${end}/${object.size}`
} else {
const start = object.range.offset ?? 0
const end = object.range.length ? start + object.range.length - 1 : object.size - 1
if (start !== 0 || end !== object.size - 1) {
contentRange = `bytes ${start}-${end}/${object.size}`
}
}
}
if (contentRange) {
headers.set('content-range', contentRange)
}
// make sure we get the correct body/status for the response
const body = 'body' in object && object.body ? object.body : null
const status = body ? (contentRange ? 206 : 200) : 304
// we only cache complete (200) responses
if (status === 200) {
const [cacheBody, responseBody] = body!.tee()
// @ts-ignore
ctx.waitUntil(caches.default.put(cacheKey, new Response(cacheBody, { headers, status })))
return new Response(responseBody, { headers, status })
}
return new Response(body, { headers, status })
}

12
worker/types.ts Normal file
View File

@ -0,0 +1,12 @@
// the contents of the environment should mostly be determined by wrangler.toml. These entries match
// the bindings defined there.
/// <reference types="@cloudflare/workers-types" />
export interface Environment {
TLDRAW_BUCKET: R2Bucket
BOARD_BACKUPS_BUCKET: R2Bucket
TLDRAW_DURABLE_OBJECT: DurableObjectNamespace
DAILY_API_KEY: string;
DAILY_DOMAIN: string;
//RECORDING_PROCESSOR: Workflow;
}

537
worker/worker.ts Normal file
View File

@ -0,0 +1,537 @@
import { handleUnfurlRequest } from "cloudflare-workers-unfurl"
import { AutoRouter, cors, error, IRequest } from "itty-router"
import { handleAssetDownload, handleAssetUpload } from "./assetUploads"
import { Environment } from "./types"
import { RecordingProcessor } from './RecordingProcessor'
// At the top with other exports
export { RecordingProcessor } from './RecordingProcessor'
// make sure our sync durable object is made available to cloudflare
export { TldrawDurableObject } from "./TldrawDurableObject"
// Define security headers
const securityHeaders = {
"Content-Security-Policy":
"default-src 'self'; connect-src 'self' wss: https:; img-src 'self' data: blob: https:; script-src 'self' 'unsafe-inline' 'unsafe-eval'; style-src 'self' 'unsafe-inline';",
"X-Content-Type-Options": "nosniff",
"X-Frame-Options": "DENY",
"X-XSS-Protection": "1; mode=block",
"Strict-Transport-Security": "max-age=31536000; includeSubDomains",
"Referrer-Policy": "strict-origin-when-cross-origin",
"Permissions-Policy": "camera=(), microphone=(), geolocation=()",
}
// we use itty-router (https://itty.dev/) to handle routing. in this example we turn on CORS because
// we're hosting the worker separately to the client. you should restrict this to your own domain.
const { preflight, corsify } = cors({
origin: (origin) => {
const allowedOrigins = [
"https://jeffemmett.com",
"https://www.jeffemmett.com",
"https://jeffemmett-canvas.jeffemmett.workers.dev",
"https://jeffemmett.com/board/*",
]
// Always allow if no origin (like from a local file)
if (!origin) return "*"
// Check exact matches
if (allowedOrigins.includes(origin)) {
return origin
}
// For development - check if it's a localhost or local IP
if (
origin.match(
/^http:\/\/(localhost|127\.0\.0\.1|192\.168\.|169\.254\.|10\.255\.255\.254|10\.)/,
)
) {
return origin
}
return undefined
},
allowMethods: ["GET", "POST", "OPTIONS", "UPGRADE"],
allowHeaders: [
"Content-Type",
"Authorization",
"Upgrade",
"Connection",
"Sec-WebSocket-Key",
"Sec-WebSocket-Version",
"Sec-WebSocket-Extensions",
"Sec-WebSocket-Protocol",
],
maxAge: 86400,
credentials: true,
})
const router = AutoRouter<IRequest, [env: Environment, ctx: ExecutionContext]>({
before: [preflight],
finally: [
(response) => {
// Add security headers to all responses except WebSocket upgrades
if (response.status !== 101) {
Object.entries(securityHeaders).forEach(([key, value]) => {
response.headers.set(key, value)
})
}
return corsify(response)
},
],
catch: (e: Error) => {
// Silently handle WebSocket errors, but log other errors
if (e.message?.includes("WebSocket")) {
console.debug("WebSocket error:", e)
return new Response(null, { status: 400 })
}
console.error(e)
return error(e)
},
})
// requests to /connect are routed to the Durable Object, and handle realtime websocket syncing
.get("/connect/:roomId", (request, env) => {
const id = env.TLDRAW_DURABLE_OBJECT.idFromName(request.params.roomId)
const room = env.TLDRAW_DURABLE_OBJECT.get(id)
return room.fetch(request.url, {
headers: request.headers,
body: request.body,
method: request.method,
})
})
// assets can be uploaded to the bucket under /uploads:
.post("/uploads/:uploadId", handleAssetUpload)
// they can be retrieved from the bucket too:
.get("/uploads/:uploadId", handleAssetDownload)
// bookmarks need to extract metadata from pasted URLs:
.get("/unfurl", handleUnfurlRequest)
.get("/room/:roomId", (request, env) => {
const id = env.TLDRAW_DURABLE_OBJECT.idFromName(request.params.roomId)
const room = env.TLDRAW_DURABLE_OBJECT.get(id)
return room.fetch(request.url, {
headers: request.headers,
body: request.body,
method: request.method,
})
})
.post("/room/:roomId", async (request, env) => {
const id = env.TLDRAW_DURABLE_OBJECT.idFromName(request.params.roomId)
const room = env.TLDRAW_DURABLE_OBJECT.get(id)
return room.fetch(request.url, {
method: "POST",
body: request.body,
})
})
.post("/daily/rooms", async (req) => {
const apiKey = req.headers.get('Authorization')?.split('Bearer ')[1]
if (!apiKey) {
return new Response(JSON.stringify({ error: 'No API key provided' }), {
status: 401,
headers: { 'Content-Type': 'application/json' }
})
}
try {
const response = await fetch('https://api.daily.co/v1/rooms', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${apiKey}`
}
})
const data = await response.json()
return new Response(JSON.stringify(data), {
headers: { 'Content-Type': 'application/json' }
})
} catch (error) {
return new Response(JSON.stringify({ error: (error as Error).message }), {
status: 500,
headers: { 'Content-Type': 'application/json' }
})
}
})
// Update the recording start endpoint to use room name in the URL
.post("/daily/recordings/:roomName/start", async (req) => {
const apiKey = req.headers.get('Authorization')?.split('Bearer ')[1]
const roomName = req.params.roomName
if (!apiKey) {
return new Response(JSON.stringify({ error: 'No API key provided' }), {
status: 401,
headers: { 'Content-Type': 'application/json' }
})
}
try {
const body = await req.json() as { layout?: { preset: string } };
const requestBody = {
layout: body.layout
};
const response = await fetch(`https://api.daily.co/v1/rooms/${roomName}/recordings/start`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${apiKey}`
},
body: JSON.stringify(requestBody)
});
const data = await response.json();
// Return the response with the same status code
return new Response(JSON.stringify(data), {
status: response.status,
headers: { 'Content-Type': 'application/json' }
});
} catch (error) {
return new Response(JSON.stringify({ error: (error as Error).message }), {
status: 500,
headers: { 'Content-Type': 'application/json' }
});
}
})
.post("/daily/recordings/:roomName/stop", async (req) => {
const apiKey = req.headers.get('Authorization')?.split('Bearer ')[1]
const roomName = req.params.roomName
if (!apiKey) {
return new Response(JSON.stringify({ error: 'No API key provided' }), {
status: 401,
headers: { 'Content-Type': 'application/json' }
})
}
try {
const response = await fetch(`https://api.daily.co/v1/rooms/${roomName}/recordings/stop`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${apiKey}`
}
})
const data = await response.json()
return new Response(JSON.stringify(data), {
status: response.status,
headers: { 'Content-Type': 'application/json' }
})
} catch (error) {
return new Response(JSON.stringify({ error: (error as Error).message }), {
status: 500,
headers: { 'Content-Type': 'application/json' }
})
}
})
// Add new endpoint to list recordings for a room
.get("/daily/recordings/:roomName", async (req) => {
const apiKey = req.headers.get('Authorization')?.split('Bearer ')[1]
const roomName = req.params.roomName
if (!apiKey) {
return new Response(JSON.stringify({ error: 'No API key provided' }), {
status: 401,
headers: { 'Content-Type': 'application/json' }
})
}
try {
// Add query parameters to get recent recordings
const timeframeStart = new Date(Date.now() - 24 * 60 * 60 * 1000).toISOString(); // Last 24 hours
const response = await fetch(`https://api.daily.co/v1/recordings?room_name=${roomName}&timeframe_start=${timeframeStart}`, {
method: 'GET',
headers: {
'Authorization': `Bearer ${apiKey}`,
'Content-Type': 'application/json'
}
});
if (!response.ok) {
const errorData = await response.json();
throw new Error(`Failed to fetch recordings: ${JSON.stringify(errorData)}`);
}
const data = await response.json();
return new Response(JSON.stringify(data), {
headers: { 'Content-Type': 'application/json' }
});
} catch (error) {
console.error('Fetching recordings failed:', error);
return new Response(JSON.stringify({ error: (error as Error).message }), {
status: 500,
headers: { 'Content-Type': 'application/json' }
});
}
});
interface DailyWebhookPayload {
event: string;
recording: {
download_url: string;
id: string;
};
room: {
name: string;
};
}
// Add a new webhook endpoint for Daily.co
router
.post("/webhooks/daily", async (request: IRequest, env: Environment) => {
// 1. Verify webhook signature
const signature = request.headers.get('X-Webhook-Signature');
const timestamp = request.headers.get('X-Webhook-Timestamp');
if (!signature || !timestamp) {
return new Response('Missing webhook signature headers', { status: 401 });
}
const payload = await request.json() as DailyWebhookPayload;
// Only process recording-ready events
if (payload.event !== 'recording-ready') {
return new Response('Ignored non-recording event', { status: 200 });
}
try {
const processor = new RecordingProcessor();
const response = await processor.fetch(new Request(payload.recording.download_url), env);
const result = await response.json() as { location: string };
return Response.json({
success: true,
location: result.location
});
} catch (error) {
console.error('Failed to process recording:', error);
return Response.json({
success: false,
error: (error as Error).message
}, { status: 500 });
}
})
async function backupAllBoards(env: Environment) {
try {
// List all room files from TLDRAW_BUCKET
const roomsList = await env.TLDRAW_BUCKET.list({ prefix: 'rooms/' })
const date = new Date().toISOString().split('T')[0]
// Process each room
for (const room of roomsList.objects) {
try {
// Get the room data
const roomData = await env.TLDRAW_BUCKET.get(room.key)
if (!roomData) continue
// Get the data as text since it's already stringified JSON
const jsonData = await roomData.text()
// Create backup key with date only
const backupKey = `${date}/${room.key}`
// Store in backup bucket as JSON
await env.BOARD_BACKUPS_BUCKET.put(backupKey, jsonData)
console.log(`Backed up ${room.key} to ${backupKey}`)
} catch (error) {
console.error(`Failed to backup room ${room.key}:`, error)
}
}
// Clean up old backups (keep last 30 days)
const thirtyDaysAgo = new Date()
thirtyDaysAgo.setDate(thirtyDaysAgo.getDate() - 30)
const oldBackups = await env.BOARD_BACKUPS_BUCKET.list({
prefix: thirtyDaysAgo.toISOString().split('T')[0]
})
for (const backup of oldBackups.objects) {
await env.BOARD_BACKUPS_BUCKET.delete(backup.key)
}
return { success: true, message: 'Backup completed successfully' }
} catch (error) {
console.error('Backup failed:', error)
return { success: false, message: (error as Error).message }
}
}
router
.get("/backup", async (_, env) => {
const result = await backupAllBoards(env)
return new Response(JSON.stringify(result), {
headers: { 'Content-Type': 'application/json' }
})
})
// Start transcription
router
.post('/daily/transcription/:room/start', async (req, _env) => {
const { room } = req.params;
const apiKey = req.headers.get('Authorization')?.split(' ')[1];
if (!apiKey) {
return new Response('API key required', { status: 401 });
}
try {
const response = await fetch(`https://api.daily.co/v1/rooms/${room}/transcription/start`, {
method: 'POST',
headers: {
'Authorization': `Bearer ${apiKey}`,
'Content-Type': 'application/json'
},
body: JSON.stringify(req.body)
});
const data = await response.json();
if (!response.ok) {
throw new Error((data as { error?: string })?.error || 'Failed to start transcription');
}
return new Response(JSON.stringify(data), {
headers: { 'Content-Type': 'application/json' }
});
} catch (error) {
console.error('Error starting transcription:', error);
return new Response(JSON.stringify({ error: 'Failed to start transcription' }), {
status: 500,
headers: { 'Content-Type': 'application/json' }
});
}
})
// Stop transcription
router
.post('/daily/transcription/:room/stop', async (req, _env) => {
const { room } = req.params;
const apiKey = req.headers.get('Authorization')?.split(' ')[1];
if (!apiKey) {
return new Response('API key required', { status: 401 });
}
try {
const response = await fetch(`https://api.daily.co/v1/rooms/${room}/transcription/stop`, {
method: 'POST',
headers: {
'Authorization': `Bearer ${apiKey}`,
'Content-Type': 'application/json'
}
});
const data = await response.json();
if (!response.ok) {
throw new Error((data as { error?: string })?.error || 'Failed to stop transcription');
}
return new Response(JSON.stringify(data), {
headers: { 'Content-Type': 'application/json' }
});
} catch (error) {
console.error('Error stopping transcription:', error);
return new Response(JSON.stringify({ error: 'Failed to stop transcription' }), {
status: 500,
headers: { 'Content-Type': 'application/json' }
});
}
})
// Add new endpoint to list transcripts for a room
router
.get("/daily/transcription/:roomName", async (req) => {
const apiKey = req.headers.get('Authorization')?.split('Bearer ')[1]
const roomName = req.params.roomName
if (!apiKey) {
return new Response(JSON.stringify({ error: 'No API key provided' }), {
status: 401,
headers: { 'Content-Type': 'application/json' }
})
}
try {
// Use the correct query parameter name: room_name instead of roomId
const response = await fetch(`https://api.daily.co/v1/transcript?room_name=${roomName}`, {
method: 'GET',
headers: {
'Authorization': `Bearer ${apiKey}`,
'Content-Type': 'application/json'
}
});
if (!response.ok) {
const errorData = await response.json();
throw new Error(`Failed to fetch transcripts: ${JSON.stringify(errorData)}`);
}
const data = await response.json();
return new Response(JSON.stringify(data), {
headers: { 'Content-Type': 'application/json' }
});
} catch (error) {
console.error('Fetching transcripts failed:', error);
return new Response(JSON.stringify({ error: (error as Error).message }), {
status: 500,
headers: { 'Content-Type': 'application/json' }
});
}
});
// Add new endpoint to get transcript access link
router.get("/daily/transcription/:id/access-link", async (req) => {
const apiKey = req.headers.get('Authorization')?.split('Bearer ')[1];
const transcriptId = req.params.id;
if (!apiKey) {
return new Response(JSON.stringify({ error: 'No API key provided' }), {
status: 401,
headers: { 'Content-Type': 'application/json' }
});
}
try {
const response = await fetch(`https://api.daily.co/v1/transcript/${transcriptId}/access-link`, {
method: 'GET',
headers: {
'Authorization': `Bearer ${apiKey}`,
'Content-Type': 'application/json'
}
});
if (!response.ok) {
const errorData = await response.json();
throw new Error(`Failed to get transcript link: ${JSON.stringify(errorData)}`);
}
const data = await response.json();
return new Response(JSON.stringify(data), {
headers: { 'Content-Type': 'application/json' }
});
} catch (error) {
console.error('Getting transcript link failed:', error);
return new Response(JSON.stringify({ error: (error as Error).message }), {
status: 500,
headers: { 'Content-Type': 'application/json' }
});
}
});
// export our router for cloudflare
export default router

58
wrangler.toml Normal file
View File

@ -0,0 +1,58 @@
main = "worker/worker.ts"
compatibility_date = "2024-07-01"
name = "jeffemmett-canvas"
account_id = "0e7b3338d5278ed1b148e6456b940913"
[vars]
# Environment variables are managed in Cloudflare Dashboard
# Workers & Pages → jeffemmett-canvas → Settings → Variables
DAILY_DOMAIN = "mycopunks.daily.co"
[dev]
port = 5172
ip = "0.0.0.0"
local_protocol = "http"
upstream_protocol = "https"
[durable_objects]
bindings = [
{ name = "TLDRAW_DURABLE_OBJECT", class_name = "TldrawDurableObject" },
]
[[migrations]]
tag = "v1"
new_classes = ["TldrawDurableObject"]
[[r2_buckets]]
binding = 'TLDRAW_BUCKET'
bucket_name = 'jeffemmett-canvas'
preview_bucket_name = 'jeffemmett-canvas-preview'
[[r2_buckets]]
binding = 'BOARD_BACKUPS_BUCKET'
bucket_name = 'board-backups'
preview_bucket_name = 'board-backups-preview'
# [[workflows]]
# name = "recording-processor"
# binding = "RECORDING_PROCESSOR"
# class_name = "RecordingProcessor"
[miniflare]
kv_persist = true
r2_persist = true
durable_objects_persist = true
[observability]
enabled = true
head_sampling_rate = 1
[triggers]
crons = ["0 0 * * *"] # Run at midnight UTC every day
# crons = ["*/10 * * * *"] # Run every 10 minutes
# Secrets should be set using `wrangler secret put` command
# DO NOT put these directly in wrangler.toml:
# - DAILY_API_KEY
# - CLOUDFLARE_API_TOKEN
# etc.