Compare commits

...

221 Commits

Author SHA1 Message Date
Jeff Emmett 59ab53a8f7 example 2025-07-29 17:34:46 -04:00
Jeff Emmett de7de98ee8 automerge beginnings 2025-07-29 17:34:18 -04:00
Jeff Emmett 5eb5789c23 add in gestures and ctrl+space command tool (TBD add global LLM) 2025-07-29 16:02:51 -04:00
Jeff Emmett 15fa9b8d19 implemented collections and graph layout tool 2025-07-29 14:52:57 -04:00
Jeff Emmett 7e3cca656e update spelling 2025-07-29 12:46:23 -04:00
Jeff Emmett 6e373e57f1 update presentations and added resilience subpage 2025-07-29 12:41:15 -04:00
Jeff Emmett 545372dcba update presentations page 2025-07-27 17:52:23 -04:00
Jeff Emmett d7fcf121f8 update contact page with calendar booking & added presentations 2025-07-27 16:07:44 -04:00
Shawn Anderson bb144428d0 Revert "updated website copy, installed locked-view function (coordinates break when locked tho), trying to get video transcripts working"
This reverts commit d7b1e348e9.
2025-04-16 13:05:57 -07:00
Shawn Anderson 33f1aa4e90 Revert "Update Daily API key in production env"
This reverts commit 30c0dfc3ba.
2025-04-16 13:05:55 -07:00
Shawn Anderson 411fc99201 Revert "fix daily API key in prod"
This reverts commit 49f11dc6e5.
2025-04-16 13:05:54 -07:00
Shawn Anderson 4364743555 Revert "update wrangler"
This reverts commit b0beefe516.
2025-04-16 13:05:52 -07:00
Shawn Anderson 6dd387613b Revert "Fix cron job connection to daily board backup"
This reverts commit e936d1c597.
2025-04-16 13:05:51 -07:00
Shawn Anderson 04705665f5 Revert "update website main page and repo readme, add scroll bar to markdown tool"
This reverts commit 7b84d34c98.
2025-04-16 13:05:50 -07:00
Shawn Anderson c13d8720d2 Revert "update readme"
This reverts commit 52736e9812.
2025-04-16 13:05:44 -07:00
Shawn Anderson df72890577 Revert "remove footer"
This reverts commit 4e88428706.
2025-04-16 13:05:31 -07:00
Jeff Emmett 4e88428706 remove footer 2025-04-15 23:04:17 -07:00
Jeff Emmett 52736e9812 update readme 2025-04-15 22:47:51 -07:00
Jeff Emmett 7b84d34c98 update website main page and repo readme, add scroll bar to markdown tool 2025-04-15 22:35:02 -07:00
Jeff-Emmett e936d1c597 Fix cron job connection to daily board backup 2025-04-08 15:49:34 -07:00
Jeff-Emmett b0beefe516 update wrangler 2025-04-08 15:32:37 -07:00
Jeff-Emmett 49f11dc6e5 fix daily API key in prod 2025-04-08 14:45:54 -07:00
Jeff-Emmett 30c0dfc3ba Update Daily API key in production env 2025-04-08 14:39:29 -07:00
Jeff-Emmett d7b1e348e9 updated website copy, installed locked-view function (coordinates break when locked tho), trying to get video transcripts working 2025-04-08 14:32:15 -07:00
Jeff-Emmett 2a3b79df15 fix asset upload rendering errors 2025-03-19 18:30:15 -07:00
Jeff-Emmett b11aecffa4 Fixed asset upload CORS for broken links, updated markdown tool, changed keyboard shortcuts & menu ordering 2025-03-19 17:24:22 -07:00
Jeff-Emmett 4b5ba9eab3 Markdown tool working, console log cleanup 2025-03-15 14:57:57 -07:00
Jeff-Emmett 0add9bd514 lock & unlock shapes, clean up overrides & context menu, make embed element easier to interact with 2025-03-15 01:03:55 -07:00
Jeff-Emmett a770d516df hide broadcast from context menu 2025-03-05 18:06:22 -05:00
Jeff-Emmett 47db716af3 camera initialization fixed 2025-02-26 09:48:17 -05:00
Jeff-Emmett e7e911c5bb prompt shape working, fix indicator & scroll later 2025-02-25 17:53:36 -05:00
Jeff-Emmett 1126fc4a1c LLM prompt tool operational, fixed keyboard shortcut conflicts 2025-02-25 15:48:29 -05:00
Jeff-Emmett 59e9025336 changed zoom shortcut to ctrl+up & ctrl+down, savetoPDF to alt+s 2025-02-25 15:24:41 -05:00
Jeff-Emmett 7d6afb6c6b Fix context menu with defaults 2025-02-25 11:38:53 -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
99 changed files with 34482 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

75
GESTURES.md Normal file
View File

@ -0,0 +1,75 @@
# Gesture Recognition Tool
This document describes all available gestures in the Canvas application. Use the gesture tool (press `g` or select from toolbar) to draw these gestures and trigger their actions.
## How to Use
1. **Activate the Gesture Tool**: Press `g` or select the gesture tool from the toolbar
2. **Draw a Gesture**: Use your mouse, pen, or finger to draw one of the gestures below
3. **Release**: The gesture will be recognized and the corresponding action will be performed
## Available Gestures
### Basic Gestures (Default Mode)
| Gesture | Description | Action |
|---------|-------------|---------|
| **X** | Draw an "X" shape | Deletes selected shapes |
| **Rectangle** | Draw a rectangle outline | Creates a rectangle shape at the gesture location |
| **Circle** | Draw a circle/oval | Selects and highlights shapes under the gesture |
| **Check** | Draw a checkmark (✓) | Changes color of shapes under the gesture to green |
| **Caret** | Draw a caret (^) pointing up | Aligns selected shapes to the top |
| **V** | Draw a "V" shape pointing down | Aligns selected shapes to the bottom |
| **Delete** | Draw a delete symbol (similar to X) | Deletes selected shapes |
| **Pigtail** | Draw a pigtail/spiral shape | Selects shapes under gesture and rotates them 90° counterclockwise |
### Layout Gestures (Hold Shift + Draw)
| Gesture | Description | Action |
|---------|-------------|---------|
| **Circle Layout** | Draw a circle while holding Shift | Arranges selected shapes in a circle around the gesture center |
| **Triangle Layout** | Draw a triangle while holding Shift | Arranges selected shapes in a triangle around the gesture center |
## Gesture Tips
- **Accuracy**: Draw gestures clearly and completely for best recognition
- **Size**: Gestures work at various sizes, but avoid extremely small or large drawings
- **Speed**: Draw at a natural pace - not too fast or too slow
- **Shift Key**: Hold Shift while drawing to access layout gestures
- **Selection**: Most gestures work on selected shapes, so select shapes first if needed
## Keyboard Shortcut
- **`g`**: Activate the gesture tool
## Troubleshooting
- If a gesture isn't recognized, try drawing it more clearly or at a different size
- Make sure you're using the gesture tool (cursor should change to a cross)
- For layout gestures, remember to hold Shift while drawing
- Some gestures require shapes to be selected first
## Examples
### Deleting Shapes
1. Select the shapes you want to delete
2. Press `g` to activate gesture tool
3. Draw an "X" over the shapes
4. Release - the shapes will be deleted
### Creating a Rectangle
1. Press `g` to activate gesture tool
2. Draw a rectangle outline where you want the shape
3. Release - a rectangle will be created
### Arranging Shapes in a Circle
1. Select the shapes you want to arrange
2. Press `g` to activate gesture tool
3. Hold Shift and draw a circle
4. Release - the shapes will be arranged in a circle
### Rotating Shapes
1. Select the shapes you want to rotate
2. Press `g` to activate gesture tool
3. Draw a pigtail/spiral over the shapes
4. Release - the shapes will rotate 90° counterclockwise

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>

12890
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

73
package.json Normal file
View File

@ -0,0 +1,73 @@
{
"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",
"@automerge/automerge": "^3.1.1",
"@automerge/automerge-repo": "^2.2.0",
"@automerge/automerge-repo-react-hooks": "^2.2.0",
"@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",
"automerge": "^0.14.2",
"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-cmdk": "^1.3.9",
"react-dom": "^18.2.0",
"react-markdown": "^10.1.0",
"react-router-dom": "^7.0.2",
"recoil": "^0.7.7",
"tldraw": "^3.6.0",
"vercel": "^39.1.1",
"webcola": "^3.4.0"
},
"devDependencies": {
"@cloudflare/types": "^6.0.0",
"@cloudflare/workers-types": "^4.20240821.1",
"@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.107.3"
},
"engines": {
"node": ">=18.0.0"
}
}

View File

37
src/App.tsx Normal file
View File

@ -0,0 +1,37 @@
import { inject } from "@vercel/analytics"
import "tldraw/tldraw.css"
import "@/css/style.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 { Presentations } from "./routes/Presentations"
import { Resilience } from "./routes/Resilience"
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 />} />
<Route path="/presentations" element={<Presentations />} />
<Route path="/presentations/resilience" element={<Resilience />} />
</Routes>
</BrowserRouter>
</DailyProvider>
)
}
createRoot(document.getElementById("root")!).render(<App />)

121
src/AutomergeToTLStore.ts Normal file
View File

@ -0,0 +1,121 @@
import { TLRecord, RecordId, TLStore } from "@tldraw/tldraw"
import { Patch } from "@automerge/automerge"
export function applyAutomergePatchesToTLStore(
patches: Patch[],
store: TLStore
) {
const toRemove: TLRecord["id"][] = []
const updatedObjects: { [id: string]: TLRecord } = {}
patches.forEach((patch) => {
if (!isStorePatch(patch)) return
const id = pathToId(patch.path)
const record =
updatedObjects[id] || JSON.parse(JSON.stringify(store.get(id) || {}))
switch (patch.action) {
case "insert": {
updatedObjects[id] = applyInsertToObject(patch, record)
break
}
case "put":
updatedObjects[id] = applyPutToObject(patch, record)
break
case "splice": {
updatedObjects[id] = applySpliceToObject(patch, record)
break
}
case "del": {
const id = pathToId(patch.path)
toRemove.push(id as TLRecord["id"])
break
}
default: {
console.log("Unsupported patch:", patch)
}
}
})
const toPut = Object.values(updatedObjects)
// put / remove the records in the store
console.log({ patches, toPut })
store.mergeRemoteChanges(() => {
if (toRemove.length) store.remove(toRemove)
if (toPut.length) store.put(toPut)
})
}
const isStorePatch = (patch: Patch): boolean => {
return patch.path[0] === "store" && patch.path.length > 1
}
// path: ["store", "camera:page:page", "x"] => "camera:page:page"
const pathToId = (path: (string | number)[]): RecordId<any> => {
return path[1] as RecordId<any>
}
const applyInsertToObject = (patch: any, object: any): TLRecord => {
const { path, values } = patch
let current = object
const insertionPoint = path[path.length - 1]
const pathEnd = path[path.length - 2]
const parts = path.slice(2, -2)
for (const part of parts) {
if (current[part] === undefined) {
throw new Error("NO WAY")
}
current = current[part]
}
// splice is a mutator... yay.
const clone = current[pathEnd].slice(0)
clone.splice(insertionPoint, 0, ...values)
current[pathEnd] = clone
return object
}
const applyPutToObject = (patch: any, object: any): TLRecord => {
const { path, value } = patch
let current = object
// special case
if (path.length === 2) {
// this would be creating the object, but we have done
return object
}
const parts = path.slice(2, -2)
const property = path[path.length - 1]
const target = path[path.length - 2]
if (path.length === 3) {
return { ...object, [property]: value }
}
// default case
for (const part of parts) {
current = current[part]
}
current[target] = { ...current[target], [property]: value }
return object
}
const applySpliceToObject = (patch: any, object: any): TLRecord => {
const { path, value } = patch
let current = object
const insertionPoint = path[path.length - 1]
const pathEnd = path[path.length - 2]
const parts = path.slice(2, -2)
for (const part of parts) {
if (current[part] === undefined) {
throw new Error("NO WAY")
}
current = current[part]
}
// TODO: we're not supporting actual splices yet because TLDraw won't generate them natively
if (insertionPoint !== 0) {
throw new Error("Splices are not supported yet")
}
current[pathEnd] = value // .splice(insertionPoint, 0, value)
return object
}

290
src/CmdK.tsx Normal file
View File

@ -0,0 +1,290 @@
import CommandPalette, { filterItems, getItemIndex } from "react-cmdk"
import { Fragment, useEffect, useState } from "react"
import {
Editor,
TLShape,
TLShapeId,
unwrapLabel,
useActions,
useEditor,
useLocalStorageState,
useTranslation,
useValue,
} from "tldraw"
// import { generateText } from "@/utils/llmUtils"
import "@/css/style.css"
function toNearest(n: number, places = 2) {
return Math.round(n * 10 ** places) / 10 ** places
}
interface SimpleShape {
type: string
x: number
y: number
rotation: string
properties: unknown
}
function simplifiedShape(editor: Editor, shape: TLShape): SimpleShape {
const bounds = editor.getShapePageBounds(shape.id)
return {
type: shape.type,
x: toNearest(shape.x),
y: toNearest(shape.y),
rotation: `${toNearest(shape.rotation, 3)} radians`,
properties: {
...shape.props,
w: toNearest(bounds?.width || 0),
h: toNearest(bounds?.height || 0),
},
}
}
export const CmdK = () => {
const editor = useEditor()
const actions = useActions()
const trans = useTranslation()
const [inputRefs, setInputRefs] = useState<Set<string>>(new Set())
const [response, setResponse] = useLocalStorageState("response", "")
const [open, setOpen] = useState<boolean>(false)
const [input, setInput] = useLocalStorageState("input", "")
const [page, setPage] = useLocalStorageState<"search" | "llm">(
"page",
"search",
)
const availableRefs = useValue<Map<string, TLShapeId[]>>(
"avaiable refs",
() => {
const nameToShapeIdMap = new Map<string, TLShapeId[]>(
editor
.getCurrentPageShapes()
.filter((shape) => shape.meta.name)
.map((shape) => [shape.meta.name as string, [shape.id]]),
)
const selected = editor.getSelectedShapeIds()
const inView = editor
.getShapesAtPoint(editor.getViewportPageBounds().center, {
margin: 1200,
})
.map((o) => o.id)
return new Map([
...nameToShapeIdMap,
["selected", selected],
["here", inView],
])
},
[editor],
)
/** Track the shapes we are referencing in the input */
useEffect(() => {
const namesInInput = input
.split(" ")
.filter((name) => name.startsWith("@"))
.map((name) => name.slice(1).match(/^[a-zA-Z0-9]+/)?.[0])
.filter(Boolean)
setInputRefs(new Set(namesInInput as string[]))
}, [input])
/** Handle keyboard shortcuts for Opening and closing the command bar in search/llm mode */
useEffect(() => {
const down = (e: KeyboardEvent) => {
if (e.key === " " && (e.metaKey || e.ctrlKey)) {
e.preventDefault()
e.stopPropagation()
setPage("search")
setOpen(true)
}
if (e.key === "j" && (e.metaKey || e.ctrlKey)) {
e.preventDefault()
e.stopPropagation()
setPage("llm")
setOpen(true)
}
}
document.addEventListener("keydown", down)
return () => document.removeEventListener("keydown", down)
}, [setPage])
const menuItems = filterItems(
[
{
heading: "Actions",
id: "actions",
items: Object.entries(actions).map(([key, action]) => ({
id: key,
children: trans(unwrapLabel(action.label)),
onClick: () => action.onSelect("unknown"),
itemType: "foobar",
})),
},
{
heading: "Other",
id: "other",
items: [
{
id: "llm",
children: "LLM",
icon: "ArrowRightOnRectangleIcon",
closeOnSelect: false,
onClick: () => {
setInput("")
setPage("llm")
},
},
],
},
],
input,
)
type ContextItem =
| { name: string; shape: SimpleShape; shapes?: never }
| { name: string; shape?: never; shapes: SimpleShape[] }
const handlePromptSubmit = () => {
const cleanedPrompt = input.trim()
const context: ContextItem[] = []
for (const name of inputRefs) {
if (!availableRefs.has(name)) continue
const shapes = availableRefs.get(name)?.map((id) => editor.getShape(id))
if (!shapes || shapes.length < 1) continue
if (shapes.length === 1) {
const contextShape: SimpleShape = simplifiedShape(editor, shapes[0]!)
context.push({ name, shape: contextShape })
} else {
const contextShapes: SimpleShape[] = []
for (const shape of shapes) {
contextShapes.push(simplifiedShape(editor, shape!))
}
context.push({ name, shapes: contextShapes })
}
}
const systemPrompt = `You are a helpful assistant. Respond in plaintext.
Context:
${JSON.stringify(context)}
`
setResponse("🤖...")
// generateText(cleanedPrompt, systemPrompt, (partialResponse, _) => {
// setResponse(partialResponse)
// })
}
const ContextPrefix = ({ inputRefs }: { inputRefs: Set<string> }) => {
return inputRefs.size > 0 ? (
<span>Ask with: </span>
) : (
<span style={{ opacity: 0.5 }}>No references</span>
)
}
const LLMView = () => {
return (
<>
<CommandPalette.ListItem
className="references"
index={0}
showType={false}
onClick={handlePromptSubmit}
closeOnSelect={false}
>
<ContextPrefix inputRefs={inputRefs} />
{Array.from(inputRefs).map((name, index, array) => {
const refShapeIds = availableRefs.get(name)
if (!refShapeIds) return null
return (
<Fragment key={name}>
<span
className={refShapeIds ? "reference" : "reference-missing"}
onKeyDown={() => {}}
onClick={(e) => {
e.stopPropagation()
e.preventDefault()
if (!refShapeIds) return
editor.setSelectedShapes(refShapeIds)
editor.zoomToSelection({
animation: {
duration: 200,
easing: (t: number) => t * t * (3 - 2 * t),
},
})
}}
>
{name}
</span>
{index < array.length - 1 && (
<span style={{ marginLeft: "0em" }}>,</span>
)}
</Fragment>
)
})}
</CommandPalette.ListItem>
{response && (
<>
<CommandPalette.ListItem
disabled={true}
className="llm-response"
index={1}
showType={false}
>
{response}
</CommandPalette.ListItem>
</>
)}
</>
)
}
const SearchView = () => {
return (
<>
{menuItems.length ? (
menuItems.map((list) => (
<CommandPalette.List key={list.id} heading={list.heading}>
{list.items.map(({ id, ...rest }) => (
<CommandPalette.ListItem
key={id}
index={getItemIndex(menuItems, id)}
{...rest}
/>
))}
</CommandPalette.List>
))
) : (
<CommandPalette.FreeSearchAction label="Search for" />
)}
</>
)
}
return (
<CommandPalette
placeholder={page === "search" ? "Search..." : "Ask..."}
onChangeSearch={setInput}
onChangeOpen={setOpen}
search={input}
isOpen={open}
page={page}
>
<CommandPalette.Page id="search">
<SearchView />
</CommandPalette.Page>
<CommandPalette.Page id="llm">
<LLMView />
</CommandPalette.Page>
</CommandPalette>
)
}

498
src/GestureTool.ts Normal file
View File

@ -0,0 +1,498 @@
import { DEFAULT_GESTURES, ALT_GESTURES } from "@/default_gestures"
import { DollarRecognizer } from "@/gestures"
import {
StateNode,
TLDefaultSizeStyle,
TLDrawShape,
TLDrawShapeSegment,
TLEventHandlers,
TLHighlightShape,
TLPointerEventInfo,
TLShapePartial,
TLTextShape,
Vec,
createShapeId,
uniqueId,
} from "tldraw"
const STROKE_WIDTH = 10
const SHOW_LABELS = true
const PRESSURE = 0.5
export class GestureTool extends StateNode {
static override id = "gesture"
static override initial = "idle"
static override children = () => [Idle, Drawing]
static recognizer = new DollarRecognizer(DEFAULT_GESTURES)
static recognizerAlt = new DollarRecognizer(ALT_GESTURES)
override shapeType = "draw"
override onExit = () => {
const drawingState = this.children!.drawing as Drawing
drawingState.initialShape = undefined
}
}
export class Idle extends StateNode {
static override id = "idle"
tooltipElement?: HTMLDivElement
tooltipTimeout?: NodeJS.Timeout
mouseMoveHandler?: (e: MouseEvent) => void
override onPointerDown: TLEventHandlers["onPointerDown"] = (info) => {
this.parent.transition("drawing", info)
}
override onEnter = () => {
this.editor.setCursor({ type: "cross", rotation: 0 })
// Create tooltip element
this.tooltipElement = document.createElement('div')
this.tooltipElement.style.cssText = `
position: fixed;
background: rgba(0, 0, 0, 0.8);
color: white;
padding: 12px 16px;
border-radius: 8px;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
font-size: 14px;
line-height: 1.4;
white-space: pre-line;
z-index: 10000;
pointer-events: none;
max-width: 300px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
border: 1px solid rgba(255, 255, 255, 0.1);
`
// Set tooltip content
this.tooltipElement.innerHTML = `
<strong>Gesture Tool Active</strong><br><br>
<strong>Basic Gestures:</strong><br>
X, Rectangle, Circle, Check<br>
Caret, V, Delete, Pigtail<br><br>
<strong>Shift + Draw:</strong><br>
Circle Layout, Triangle Layout<br><br>
Press 'g' again or select another tool to exit
`
// Add tooltip to DOM
document.body.appendChild(this.tooltipElement)
// Function to update tooltip position
this.mouseMoveHandler = (e: MouseEvent) => {
if (this.tooltipElement) {
const x = e.clientX + 20
const y = e.clientY - 20
// Keep tooltip within viewport bounds
const rect = this.tooltipElement.getBoundingClientRect()
const viewportWidth = window.innerWidth
const viewportHeight = window.innerHeight
let finalX = x
let finalY = y
// Adjust if tooltip would go off the right edge
if (x + rect.width > viewportWidth) {
finalX = e.clientX - rect.width - 20
}
// Adjust if tooltip would go off the bottom edge
if (y + rect.height > viewportHeight) {
finalY = e.clientY - rect.height - 20
}
// Ensure tooltip doesn't go off the top or left
finalX = Math.max(10, finalX)
finalY = Math.max(10, finalY)
this.tooltipElement.style.left = `${finalX}px`
this.tooltipElement.style.top = `${finalY}px`
}
}
// Add mouse move listener
document.addEventListener('mousemove', this.mouseMoveHandler)
// Set initial position
if (this.mouseMoveHandler) {
this.mouseMoveHandler({ clientX: 100, clientY: 100 } as MouseEvent)
}
// Remove the tooltip after 5 seconds
this.tooltipTimeout = setTimeout(() => {
this.cleanupTooltip()
}, 5000)
}
override onCancel = () => {
this.editor.setCurrentTool("select")
}
override onExit = () => {
this.cleanupTooltip()
}
private cleanupTooltip = () => {
// Clear timeout
if (this.tooltipTimeout) {
clearTimeout(this.tooltipTimeout)
this.tooltipTimeout = undefined
}
// Remove mouse move listener
if (this.mouseMoveHandler) {
document.removeEventListener('mousemove', this.mouseMoveHandler)
this.mouseMoveHandler = undefined
}
// Remove tooltip element
if (this.tooltipElement) {
document.body.removeChild(this.tooltipElement)
this.tooltipElement = undefined
}
}
}
type DrawableShape = TLDrawShape | TLHighlightShape
export class Drawing extends StateNode {
static override id = "drawing"
info = {} as TLPointerEventInfo
initialShape?: DrawableShape
override shapeType =
this.parent.id === "highlight" ? ("highlight" as const) : ("draw" as const)
util = this.editor.getShapeUtil(this.shapeType)
isPen = false
isPenOrStylus = false
didJustShiftClickToExtendPreviousShapeLine = false
pagePointWhereCurrentSegmentChanged = {} as Vec
pagePointWhereNextSegmentChanged = null as Vec | null
lastRecordedPoint = {} as Vec
mergeNextPoint = false
currentLineLength = 0
canDraw = false
markId = null as null | string
override onEnter = (info: TLPointerEventInfo) => {
this.markId = null
this.info = info
this.canDraw = !this.editor.getIsMenuOpen()
this.lastRecordedPoint = this.editor.inputs.currentPagePoint.clone()
if (this.canDraw) {
this.startShape()
}
}
onGestureEnd = () => {
const shape = this.editor.getShape(this.initialShape?.id!) as TLDrawShape
const ps = shape.props.segments[0].points.map((s) => ({ x: s.x, y: s.y }))
const gesture = this.editor.inputs.shiftKey ? GestureTool.recognizerAlt.recognize(ps) : GestureTool.recognizer.recognize(ps)
const score_pass = gesture.score > 0.2
const score_confident = gesture.score > 0.65
let score_color: "green" | "red" | "yellow" = "green"
if (!score_pass) {
score_color = "red"
} else if (!score_confident) {
score_color = "yellow"
}
if (score_pass) {
gesture.onComplete?.(this.editor, shape)
}
let opacity = 1
const labelShape: TLShapePartial<TLTextShape> = {
id: createShapeId(),
type: "text",
x: this.editor.inputs.currentPagePoint.x + 20,
y: this.editor.inputs.currentPagePoint.y,
props: {
size: "xl",
text: gesture.name,
color: score_color,
},
}
if (SHOW_LABELS) {
this.editor.createShape(labelShape)
}
const intervalId = setInterval(() => {
if (opacity > 0) {
this.editor.updateShape({
...shape,
opacity: opacity,
props: {
...shape.props,
color: score_color,
},
})
this.editor.updateShape({
...labelShape,
opacity: opacity,
props: {
...labelShape.props,
color: score_color,
},
})
opacity = Math.max(0, opacity - 0.025)
} else {
clearInterval(intervalId)
this.editor.deleteShape(shape.id)
if (SHOW_LABELS) {
this.editor.deleteShape(labelShape.id)
}
}
}, 20)
}
override onPointerMove: TLEventHandlers["onPointerMove"] = () => {
const { inputs } = this.editor
if (this.isPen && !inputs.isPen) {
// The user made a palm gesture before starting a pen gesture;
// ideally we'd start the new shape here but we could also just bail
// as the next interaction will work correctly
if (this.markId) {
this.editor.bailToMark(this.markId)
this.startShape()
return
}
} else {
// If we came in from a menu but have no started dragging...
if (!this.canDraw && inputs.isDragging) {
this.startShape()
this.canDraw = true // bad name
}
}
if (this.canDraw) {
if (this.isPenOrStylus) {
// Don't update the shape if we haven't moved far enough from the last time we recorded a point
if (
Vec.Dist(inputs.currentPagePoint, this.lastRecordedPoint) >=
1 / this.editor.getZoomLevel()
) {
this.lastRecordedPoint = inputs.currentPagePoint.clone()
this.mergeNextPoint = false
} else {
this.mergeNextPoint = true
}
} else {
this.mergeNextPoint = false
}
this.updateDrawingShape()
}
}
override onExit? = () => {
this.onGestureEnd()
this.editor.snaps.clearIndicators()
this.pagePointWhereCurrentSegmentChanged =
this.editor.inputs.currentPagePoint.clone()
}
canClose() {
return this.shapeType !== "highlight"
}
getIsClosed(segments: TLDrawShapeSegment[], size: TLDefaultSizeStyle) {
if (!this.canClose()) return false
const strokeWidth = STROKE_WIDTH
const firstPoint = segments[0].points[0]
const lastSegment = segments[segments.length - 1]
const lastPoint = lastSegment.points[lastSegment.points.length - 1]
return (
firstPoint !== lastPoint &&
this.currentLineLength > strokeWidth * 4 &&
Vec.DistMin(firstPoint, lastPoint, strokeWidth * 2)
)
}
private startShape() {
const {
inputs: { originPagePoint },
} = this.editor
this.markId = this.editor.markHistoryStoppingPoint()
this.didJustShiftClickToExtendPreviousShapeLine = false
this.lastRecordedPoint = originPagePoint.clone()
this.pagePointWhereCurrentSegmentChanged = originPagePoint.clone()
const id = createShapeId()
this.editor.createShapes<DrawableShape>([
{
id,
type: this.shapeType,
x: originPagePoint.x,
y: originPagePoint.y,
opacity: 0.5,
props: {
isPen: this.isPenOrStylus,
segments: [
{
type: "free",
points: [
{
x: 0,
y: 0,
z: PRESSURE,
},
],
},
],
},
},
])
this.currentLineLength = 0
this.initialShape = this.editor.getShape<DrawableShape>(id)
}
private updateDrawingShape() {
const { initialShape } = this
const { inputs } = this.editor
if (!initialShape) return
const {
id,
props: { size },
} = initialShape
const shape = this.editor.getShape<DrawableShape>(id)!
if (!shape) return
const { segments } = shape.props
const { x, y, z } = this.editor
.getPointInShapeSpace(shape, inputs.currentPagePoint)
.toFixed()
const newPoint = {
x,
y,
z: this.isPenOrStylus ? +(z! * 1.25).toFixed(2) : 0.5,
}
const newSegments = segments.slice()
const newSegment = newSegments[newSegments.length - 1]
const newPoints = [...newSegment.points]
if (newPoints.length && this.mergeNextPoint) {
const { z } = newPoints[newPoints.length - 1]
newPoints[newPoints.length - 1] = {
x: newPoint.x,
y: newPoint.y,
z: z ? Math.max(z, newPoint.z) : newPoint.z,
}
} else {
this.currentLineLength += Vec.Dist(
newPoints[newPoints.length - 1],
newPoint,
)
newPoints.push(newPoint)
}
newSegments[newSegments.length - 1] = {
...newSegment,
points: newPoints,
}
if (this.currentLineLength < STROKE_WIDTH * 4) {
this.currentLineLength = this.getLineLength(newSegments)
}
const shapePartial: TLShapePartial<DrawableShape> = {
id,
type: this.shapeType,
props: {
segments: newSegments,
},
}
if (this.canClose()) {
; (shapePartial as TLShapePartial<TLDrawShape>).props!.isClosed =
this.getIsClosed(newSegments, size)
}
this.editor.updateShapes([shapePartial])
}
private getLineLength(segments: TLDrawShapeSegment[]) {
let length = 0
for (const segment of segments) {
for (let i = 0; i < segment.points.length - 1; i++) {
const A = segment.points[i]
const B = segment.points[i + 1]
length += Vec.Dist2(B, A)
}
}
return Math.sqrt(length)
}
override onPointerUp: TLEventHandlers["onPointerUp"] = () => {
this.complete()
}
override onCancel: TLEventHandlers["onCancel"] = () => {
this.cancel()
}
override onComplete: TLEventHandlers["onComplete"] = () => {
this.complete()
}
override onInterrupt: TLEventHandlers["onInterrupt"] = () => {
if (this.editor.inputs.isDragging) {
return
}
if (this.markId) {
this.editor.bailToMark(this.markId)
}
this.cancel()
}
complete() {
if (!this.canDraw) {
this.cancel()
return
}
const { initialShape } = this
if (!initialShape) return
this.editor.updateShapes([
{
id: initialShape.id,
type: initialShape.type,
props: { isComplete: true },
},
])
this.parent.transition("idle")
}
cancel() {
this.parent.transition("idle", this.info)
}
}

View File

@ -0,0 +1,49 @@
import { type DocHandle } from "@automerge/automerge-repo"
import { type TLStoreSnapshot, Tldraw, defaultShapeUtils, track, useEditor } from "@tldraw/tldraw"
import "@tldraw/tldraw/tldraw.css"
import { useAutomergeStore } from "./useAutomergeStore"
interface TLDrawAutomergeExampleProps {
handle: DocHandle<TLStoreSnapshot>
userId: string
}
export function TLDrawAutomergeExample({
handle,
userId,
}: TLDrawAutomergeExampleProps) {
const store = useAutomergeStore({ handle, userId, shapeUtils: [...defaultShapeUtils, ...customShapeUtils] })
return (
<div className="tldraw__editor">
<Tldraw autoFocus store={store} />
</div>
)
}
const NameEditor = track(() => {
const editor = useEditor()
return (
<div style={{ pointerEvents: "all", display: "flex" }}>
<input
type="color"
value={editor.user.getUserPreferences().color}
onChange={(e) => {
editor.user.updateUserPreferences({
color: e.currentTarget.value,
})
}}
/>
<input
value={editor.user.getUserPreferences().name}
onChange={(e) => {
editor.user.updateUserPreferences({
name: e.currentTarget.value,
})
}}
/>
</div>
)
})

69
src/TLStoreToAutomerge.ts Normal file
View File

@ -0,0 +1,69 @@
import { RecordsDiff, TLRecord } from "@tldraw/tldraw"
import _ from "lodash"
export function applyTLStoreChangesToAutomerge(
doc: any,
changes: RecordsDiff<TLRecord>
) {
Object.values(changes.added).forEach((record) => {
doc.store[record.id] = record
})
Object.values(changes.updated).forEach(([_, record]) => {
deepCompareAndUpdate(doc.store[record.id], record)
})
Object.values(changes.removed).forEach((record) => {
delete doc.store[record.id]
})
}
function deepCompareAndUpdate(objectA: any, objectB: any) {
// eslint-disable-line
if (_.isArray(objectB)) {
if (!_.isArray(objectA)) {
// if objectA is not an array, replace it with objectB
objectA = objectB.slice()
} else {
// compare and update array elements
for (let i = 0; i < objectB.length; i++) {
if (i >= objectA.length) {
objectA.push(objectB[i])
} else {
if (_.isObject(objectB[i]) || _.isArray(objectB[i])) {
// if element is an object or array, recursively compare and update
deepCompareAndUpdate(objectA[i], objectB[i])
} else if (objectA[i] !== objectB[i]) {
// update the element
objectA[i] = objectB[i]
}
}
}
// remove extra elements
if (objectA.length > objectB.length) {
objectA.splice(objectB.length)
}
}
} else if (_.isObject(objectB)) {
_.forIn(objectB, (value: any, key: any) => {
if (objectA[key] === undefined) {
// if key is not in objectA, add it
objectA[key] = value
} else {
if (_.isObject(value) || _.isArray(value)) {
// if value is an object or array, recursively compare and update
deepCompareAndUpdate(objectA[key], value)
} else if (objectA[key] !== value) {
// update the value
objectA[key] = value
}
}
})
_.forIn(objectA, (_: any, key: string) => {
if ((objectB as any)[key] === undefined) {
// if key is not in objectB, remove it
delete objectA[key]
}
})
}
}

View File

@ -0,0 +1,107 @@
import { Editor, TLShape, TLShapeId } from '@tldraw/tldraw';
/**
* A PoC abstract collections class for @tldraw.
*/
export abstract class BaseCollection {
/** A unique identifier for the collection. */
abstract id: string;
/** A map containing the shapes that belong to this collection, keyed by their IDs. */
protected shapes: Map<TLShapeId, TLShape> = new Map();
/** A reference to the \@tldraw Editor instance. */
protected editor: Editor;
/** A set of listeners to be notified when the collection changes. */
private listeners = new Set<() => void>();
// TODO: Maybe pass callback to replace updateShape so only CollectionProvider can call it
public constructor(editor: Editor) {
this.editor = editor;
}
/**
* Called when shapes are added to the collection.
* @param shapes The shapes being added to the collection.
*/
protected onAdd(_shapes: TLShape[]): void { }
/**
* Called when shapes are removed from the collection.
* @param shapes The shapes being removed from the collection.
*/
protected onRemove(_shapes: TLShape[]) { }
/**
* Called when the membership of the collection changes (i.e., when shapes are added or removed).
*/
protected onMembershipChange() { }
/**
* Called when the properties of a shape belonging to the collection change.
* @param prev The previous version of the shape before the change.
* @param next The updated version of the shape after the change.
*/
protected onShapeChange(_prev: TLShape, _next: TLShape) { }
/**
* Adds the specified shapes to the collection.
* @param shapes The shapes to add to the collection.
*/
public add(shapes: TLShape[]) {
shapes.forEach(shape => {
this.shapes.set(shape.id, shape)
});
this.onAdd(shapes);
this.onMembershipChange();
this.notifyListeners();
}
/**
* Removes the specified shapes from the collection.
* @param shapes The shapes to remove from the collection.
*/
public remove(shapes: TLShape[]) {
shapes.forEach(shape => {
this.shapes.delete(shape.id);
});
this.onRemove(shapes);
this.onMembershipChange();
this.notifyListeners();
}
/**
* Clears all shapes from the collection.
*/
public clear() {
this.remove([...this.shapes.values()])
}
/**
* Returns the map of shapes in the collection.
* @returns The map of shapes in the collection, keyed by their IDs.
*/
public getShapes(): Map<TLShapeId, TLShape> {
return this.shapes;
}
public get size(): number {
return this.shapes.size;
}
public _onShapeChange(prev: TLShape, next: TLShape) {
this.shapes.set(next.id, next)
this.onShapeChange(prev, next)
this.notifyListeners();
}
private notifyListeners() {
for (const listener of this.listeners) {
listener();
}
}
public subscribe(listener: () => void): () => void {
this.listeners.add(listener);
return () => this.listeners.delete(listener);
}
}

View File

@ -0,0 +1,111 @@
import React, { createContext, useContext, useEffect, useMemo, useState } from 'react';
import { TLShape, Editor } from '@tldraw/tldraw';
import { BaseCollection } from './BaseCollection';
interface CollectionContextValue {
get: (id: string) => BaseCollection | undefined;
}
type Collection = (new (editor: Editor) => BaseCollection)
interface CollectionContextWrapperProps {
editor: Editor | null;
collections: Collection[];
children: React.ReactNode;
}
const CollectionContext = createContext<CollectionContextValue | undefined>(undefined);
export const CollectionContextWrapper: React.FC<CollectionContextWrapperProps> = ({
editor,
collections: collectionClasses,
children
}) => {
const [collections, setCollections] = useState<Map<string, BaseCollection> | null>(null);
// Handle shape property changes
const handleShapeChange = (prev: TLShape, next: TLShape) => {
if (!collections) return;
for (const collection of collections.values()) {
if (collection.getShapes().has(next.id)) {
collection._onShapeChange(prev, next);
}
}
};
// Handle shape deletions
const handleShapeDelete = (shape: TLShape) => {
if (!collections) return;
for (const collection of collections.values()) {
collection.remove([shape]);
}
};
useEffect(() => {
if (editor) {
const initializedCollections = new Map<string, BaseCollection>();
for (const ColClass of collectionClasses) {
const instance = new ColClass(editor);
initializedCollections.set(instance.id, instance);
}
setCollections(initializedCollections);
}
}, [editor, collectionClasses]);
// Subscribe to shape changes in the editor
useEffect(() => {
if (editor && collections) {
editor.sideEffects.registerAfterChangeHandler('shape', (prev, next) => {
handleShapeChange(prev, next);
});
}
}, [editor, collections]);
// Subscribe to shape deletions in the editor
useEffect(() => {
if (editor && collections) {
editor.sideEffects.registerAfterDeleteHandler('shape', (prev) => {
handleShapeDelete(prev);
});
}
}, [editor, collections]);
const value = useMemo(() => ({
get: (id: string) => collections?.get(id),
}), [collections]);
return (
<CollectionContext.Provider value={value}>
{children}
</CollectionContext.Provider>
);
};
// Hook to use collection context within the wrapper
export const useCollectionContext = <T extends BaseCollection = BaseCollection>(
collectionId: string
): { collection: T | null; size: number } => {
const context = useContext(CollectionContext);
if (!context) {
return { collection: null, size: 0 };
}
const collection = context.get(collectionId);
if (!collection) {
return { collection: null, size: 0 };
}
const [size, setSize] = useState<number>(collection.size);
useEffect(() => {
const unsubscribe = collection.subscribe(() => {
setSize(collection.size);
});
setSize(collection.size);
return unsubscribe;
}, [collection]);
return { collection: collection as T, size };
};

View File

@ -0,0 +1,82 @@
import React, { createContext, useEffect, useMemo, useState } from 'react';
import { TLShape, TLRecord, Editor, useEditor } from '@tldraw/tldraw';
import { BaseCollection } from './BaseCollection';
interface CollectionContextValue {
get: (id: string) => BaseCollection | undefined;
}
type Collection = (new (editor: Editor) => BaseCollection)
interface CollectionProviderProps {
editor: Editor | null;
collections: Collection[];
children: React.ReactNode;
}
const CollectionContext = createContext<CollectionContextValue | undefined>(undefined);
const CollectionProvider: React.FC<CollectionProviderProps> = ({ editor, collections: collectionClasses, children }) => {
const [collections, setCollections] = useState<Map<string, BaseCollection> | null>(null);
// Handle shape property changes
const handleShapeChange = (prev: TLShape, next: TLShape) => {
if (!collections) return; // Ensure collections is not null
for (const collection of collections.values()) {
if (collection.getShapes().has(next.id)) {
collection._onShapeChange(prev, next);
}
}
};
// Handle shape deletions
const handleShapeDelete = (shape: TLShape) => {
if (!collections) return; // Ensure collections is not null
for (const collection of collections.values()) {
collection.remove([shape]);
}
};
useEffect(() => {
if (editor) {
const initializedCollections = new Map<string, BaseCollection>();
for (const ColClass of collectionClasses) {
const instance = new ColClass(editor);
initializedCollections.set(instance.id, instance);
}
setCollections(initializedCollections);
}
}, [editor, collectionClasses]);
// Subscribe to shape changes in the editor
useEffect(() => {
if (editor && collections) {
editor.sideEffects.registerAfterChangeHandler('shape', (prev, next) => {
handleShapeChange(prev, next);
});
}
}, [editor, collections]);
// Subscribe to shape deletions in the editor
useEffect(() => {
if (editor && collections) {
editor.sideEffects.registerAfterDeleteHandler('shape', (prev) => {
handleShapeDelete(prev);
});
}
}, [editor, collections]);
const value = useMemo(() => ({
get: (id: string) => collections?.get(id),
}), [collections]);
return (
<CollectionContext.Provider value={value}>
{collections ? children : null}
</CollectionContext.Provider>
);
};
export { CollectionContext, CollectionProvider, type Collection };

View File

@ -0,0 +1,110 @@
import { useEffect, useState } from 'react';
import { Editor, TLShape } from '@tldraw/tldraw';
import { BaseCollection } from './BaseCollection';
type Collection = (new (editor: Editor) => BaseCollection)
class GlobalCollectionManager {
private static instance: GlobalCollectionManager;
private collections: Map<string, BaseCollection> = new Map();
private editor: Editor | null = null;
private listeners: Set<() => void> = new Set();
static getInstance(): GlobalCollectionManager {
if (!GlobalCollectionManager.instance) {
GlobalCollectionManager.instance = new GlobalCollectionManager();
}
return GlobalCollectionManager.instance;
}
initialize(editor: Editor, collectionClasses: Collection[]) {
this.editor = editor;
this.collections.clear();
for (const ColClass of collectionClasses) {
const instance = new ColClass(editor);
this.collections.set(instance.id, instance);
}
// Subscribe to shape changes
editor.sideEffects.registerAfterChangeHandler('shape', (prev, next) => {
this.handleShapeChange(prev, next);
});
// Subscribe to shape deletions
editor.sideEffects.registerAfterDeleteHandler('shape', (prev) => {
this.handleShapeDelete(prev);
});
this.notifyListeners();
}
private handleShapeChange(prev: TLShape, next: TLShape) {
for (const collection of this.collections.values()) {
if (collection.getShapes().has(next.id)) {
collection._onShapeChange(prev, next);
}
}
}
private handleShapeDelete(shape: TLShape) {
for (const collection of this.collections.values()) {
collection.remove([shape]);
}
}
getCollection(id: string): BaseCollection | undefined {
return this.collections.get(id);
}
subscribe(listener: () => void): () => void {
this.listeners.add(listener);
return () => {
this.listeners.delete(listener);
};
}
private notifyListeners() {
this.listeners.forEach(listener => listener());
}
}
// Hook to use the global collection manager
export const useGlobalCollection = (collectionId: string) => {
const [collection, setCollection] = useState<BaseCollection | null>(null);
const [size, setSize] = useState<number>(0);
useEffect(() => {
const manager = GlobalCollectionManager.getInstance();
const unsubscribe = manager.subscribe(() => {
const newCollection = manager.getCollection(collectionId);
setCollection(newCollection || null);
setSize(newCollection?.size || 0);
});
// Initial setup
const initialCollection = manager.getCollection(collectionId);
setCollection(initialCollection || null);
setSize(initialCollection?.size || 0);
return unsubscribe;
}, [collectionId]);
useEffect(() => {
if (collection) {
const unsubscribe = collection.subscribe(() => {
setSize(collection.size);
});
return unsubscribe;
}
}, [collection]);
return { collection, size };
};
// Function to initialize the global collection manager
export const initializeGlobalCollections = (editor: Editor, collectionClasses: Collection[]) => {
const manager = GlobalCollectionManager.getInstance();
manager.initialize(editor, collectionClasses);
};

152
src/collections/README.md Normal file
View File

@ -0,0 +1,152 @@
# Collections System
This directory contains a proof-of-concept collections system for @tldraw that allows you to group and track shapes with custom logic.
## Overview
The collections system provides a way to:
- Group shapes together with custom logic
- React to shape additions, removals, and changes
- Subscribe to collection changes in React components
- Maintain collections across shape modifications
## Files
- `BaseCollection.ts` - Abstract base class for all collections
- `CollectionProvider.tsx` - React context provider for collections
- `useCollection.ts` - React hook for accessing collections
- `ExampleCollection.ts` - Example collection implementation
- `ExampleCollectionComponent.tsx` - Example React component using collections
- `index.ts` - Exports all collection-related modules
## Usage
### 1. Create a Collection
Extend `BaseCollection` to create your own collection:
```typescript
import { BaseCollection } from '@/collections';
import { TLShape } from '@tldraw/tldraw';
export class MyCollection extends BaseCollection {
id = 'my-collection';
protected onAdd(shapes: TLShape[]): void {
console.log(`Added ${shapes.length} shapes to my collection`);
// Add your custom logic here
}
protected onRemove(shapes: TLShape[]): void {
console.log(`Removed ${shapes.length} shapes from my collection`);
// Add your custom logic here
}
protected onShapeChange(prev: TLShape, next: TLShape): void {
console.log('Shape changed in my collection:', { prev, next });
// Add your custom logic here
}
protected onMembershipChange(): void {
console.log(`My collection membership changed. Total shapes: ${this.size}`);
// Add your custom logic here
}
}
```
### 2. Set up the CollectionProvider
Wrap your Tldraw component with the CollectionProvider:
```typescript
import { CollectionProvider } from '@/collections';
function MyComponent() {
const [editor, setEditor] = useState<Editor | null>(null);
return (
<div>
{editor && (
<CollectionProvider editor={editor} collections={[MyCollection]}>
<Tldraw
onMount={(editor) => setEditor(editor)}
// ... other props
/>
</CollectionProvider>
)}
</div>
);
}
```
### 3. Use Collections in React Components
Use the `useCollection` hook to access collections:
```typescript
import { useCollection } from '@/collections';
function MyComponent() {
const { collection, size } = useCollection<MyCollection>('my-collection');
const handleAddShapes = () => {
const selectedShapes = collection.editor.getSelectedShapes();
if (selectedShapes.length > 0) {
collection.add(selectedShapes);
}
};
return (
<div>
<p>Collection size: {size}</p>
<button onClick={handleAddShapes}>Add Selected Shapes</button>
</div>
);
}
```
## API Reference
### BaseCollection
#### Methods
- `add(shapes: TLShape[])` - Add shapes to the collection
- `remove(shapes: TLShape[])` - Remove shapes from the collection
- `clear()` - Remove all shapes from the collection
- `getShapes(): Map<TLShapeId, TLShape>` - Get all shapes in the collection
- `subscribe(listener: () => void): () => void` - Subscribe to collection changes
#### Properties
- `size: number` - Number of shapes in the collection
- `editor: Editor` - Reference to the tldraw editor
#### Protected Methods (Override these)
- `onAdd(shapes: TLShape[])` - Called when shapes are added
- `onRemove(shapes: TLShape[])` - Called when shapes are removed
- `onShapeChange(prev: TLShape, next: TLShape)` - Called when a shape changes
- `onMembershipChange()` - Called when collection membership changes
### useCollection Hook
```typescript
const { collection, size } = useCollection<T extends BaseCollection>(collectionId: string)
```
Returns:
- `collection: T` - The collection instance
- `size: number` - Current number of shapes in the collection
## Example
See `ExampleCollection.ts` and `ExampleCollectionComponent.tsx` for a complete working example that demonstrates:
- Creating a custom collection
- Setting up the CollectionProvider
- Using the useCollection hook
- Adding/removing shapes from collections
- Reacting to collection changes
The example is integrated into the Board component and provides a UI for testing the collection functionality.

5
src/collections/index.ts Normal file
View File

@ -0,0 +1,5 @@
export * from './BaseCollection';
export * from './CollectionProvider';
export * from './CollectionContextWrapper';
export * from './GlobalCollectionManager';
export * from './useCollection';

View File

@ -0,0 +1,32 @@
import { useContext, useEffect, useState } from "react";
import { CollectionContext } from "./CollectionProvider";
import { BaseCollection } from "./BaseCollection";
export const useCollection = <T extends BaseCollection = BaseCollection>(collectionId: string): { collection: T | null; size: number } => {
const context = useContext(CollectionContext);
if (!context) {
return { collection: null, size: 0 };
}
const collection = context.get(collectionId);
if (!collection) {
return { collection: null, size: 0 };
}
const [size, setSize] = useState<number>(collection.size);
useEffect(() => {
// Subscribe to collection changes
const unsubscribe = collection.subscribe(() => {
setSize(collection.size);
});
// Set initial size
setSize(collection.size);
return unsubscribe; // Cleanup on unmount
}, [collection]);
return { collection: collection as T, size };
};

34
src/css/dev-ui.css Normal file
View File

@ -0,0 +1,34 @@
.custom-layout {
position: absolute;
inset: 0px;
z-index: 300;
pointer-events: none;
}
.custom-toolbar {
position: absolute;
top: 0px;
left: 0px;
width: 100%;
display: flex;
align-items: center;
justify-content: center;
padding: 8px;
gap: 8px;
}
.custom-button {
pointer-events: all;
padding: 4px 12px;
background-color: white;
border: 1px solid rgba(0, 0, 0, 0.2);
border-radius: 64px;
&:hover {
background-color: rgb(240, 240, 240);
}
}
.custom-button[data-isactive="true"] {
background-color: black;
color: white;
}

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;
}

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

@ -0,0 +1,799 @@
@import url("reset.css");
:root {
--border-radius: 10px;
}
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: 1em;
font-size: 1.5rem;
font-variation-settings: "MONO" 1;
font-variation-settings: "CASL" 1;
}
.main-nav {
margin-bottom: 2em;
display: flex;
gap: 2em;
align-items: center;
}
.nav-link {
color: #0366d6;
text-decoration: none;
font-weight: 500;
padding: 0.5em 1em;
border-radius: 6px;
transition: all 0.2s ease;
border: 1px solid transparent;
}
.nav-link:hover {
background-color: #f6f8fa;
border-color: #e1e4e8;
text-decoration: none;
}
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;
}
.lock-indicator {
position: absolute;
width: 24px;
height: 24px;
background: white;
border-radius: 4px;
box-shadow: 0 1px 3px rgba(0,0,0,0.2);
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
z-index: 1000;
transition: transform 0.2s ease;
}
.lock-indicator:hover {
transform: scale(1.1) !important;
background: #f0f0f0;
}
/* Presentations Page Styles */
.presentations-grid {
display: grid;
grid-template-columns: 1fr;
gap: 3em;
margin: 2em 0;
}
.presentation-card {
border: 1px solid #e1e4e8;
border-radius: 8px;
padding: 1.5em;
background-color: #fafbfc;
transition: all 0.2s ease;
}
.presentation-card:hover {
border-color: #0366d6;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
}
.presentation-card h3 {
margin-top: 0;
margin-bottom: 0.5em;
color: #24292e;
font-size: 1.3rem;
}
.presentation-card p {
margin-bottom: 1em;
color: #586069;
font-size: 1rem;
}
.presentation-embed {
margin: 1.5em 0;
border-radius: 6px;
overflow: hidden;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
}
.presentation-embed iframe {
display: block;
border: none;
background-color: #fff;
}
.presentation-meta {
display: flex;
justify-content: space-between;
align-items: center;
margin-top: 1em;
padding-top: 1em;
border-top: 1px solid #e1e4e8;
font-size: 0.9rem;
}
.presentation-meta span {
color: #586069;
font-style: italic;
}
.presentation-meta a {
color: #0366d6;
text-decoration: none;
font-weight: 500;
}
.presentation-meta a:hover {
text-decoration: underline;
}
.presentations-info {
margin-top: 3em;
padding: 2em;
background-color: #f6f8fa;
border-radius: 8px;
border-left: 4px solid #0366d6;
}
.presentations-info h3 {
margin-top: 0;
color: #24292e;
}
.presentations-info p {
margin-bottom: 1em;
color: #586069;
}
.presentations-info a {
color: #0366d6;
text-decoration: none;
}
.presentations-info a:hover {
text-decoration: underline;
}
/* Responsive design for presentations */
@media (max-width: 768px) {
.presentations-grid {
gap: 2em;
}
.presentation-card {
padding: 1em;
}
.presentation-meta {
flex-direction: column;
align-items: flex-start;
gap: 0.5em;
}
.presentation-embed iframe {
height: 400px;
}
}
/* Resilience page styles */
.presentation-info {
margin-bottom: 3rem;
padding: 2rem;
background-color: #f8f9fa;
border-radius: 8px;
border-left: 4px solid #0366d6;
}
.presentation-info h1 {
margin-bottom: 1rem;
color: #24292e;
}
.presentation-info p {
margin-bottom: 1rem;
line-height: 1.6;
}
.video-clips {
margin-top: 3rem;
}
.video-clips h2 {
margin-bottom: 2rem;
color: #24292e;
}
.video-section {
margin-bottom: 3rem;
}
.video-section h3 {
margin-bottom: 1rem;
color: #24292e;
font-size: 1.2rem;
}
.video-container {
position: relative;
width: 100%;
max-width: 560px;
margin: 0 auto;
}
.video-container iframe {
width: 100%;
height: 315px;
border-radius: 8px;
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
}
.presentation-embed h2 {
margin-bottom: 1rem;
color: #24292e;
}
.presentation-meta {
margin-top: 3rem;
padding: 2rem;
background-color: #f8f9fa;
border-radius: 8px;
border-top: 4px solid #0366d6;
}
.presentation-meta p {
margin-bottom: 1rem;
line-height: 1.6;
}
.presentation-meta a {
color: #0366d6;
text-decoration: none;
font-weight: 500;
}
.presentation-meta a:hover {
text-decoration: underline;
}
@media (max-width: 768px) {
.video-container iframe {
height: 200px;
}
.presentation-info,
.presentation-meta {
padding: 1rem;
margin-left: -1rem;
margin-right: -1rem;
}
}
/* Command Palette Styles */
[cmdk-root] {
z-index: 9999 !important;
position: fixed !important;
top: 0 !important;
left: 0 !important;
right: 0 !important;
bottom: 0 !important;
}
[cmdk-dialog] {
padding: 0.5em;
width: 100%;
max-width: 35em;
border: 1px solid #c7c7c7;
border-radius: var(--border-radius);
box-shadow:
0 4px 6px -1px rgba(0, 0, 0, 0.1),
0 2px 4px -2px rgba(0, 0, 0, 0.1);
background-color: white;
position: fixed;
top: 30%;
left: 50%;
transform: translate(-50%, 0);
z-index: 9999 !important;
& input {
font-size: 1.4em;
width: 100%;
background-color: transparent;
border: none;
outline: none;
padding: 0.2em;
background-color: #f8f8f8;
margin-bottom: 0.2em;
&:focus {
outline: none;
border-radius: 3px;
background-color: #f0f0f0;
}
}
}
[cmdk-group-heading] {
font-size: 1.2em;
opacity: 0.5;
padding: 0.2em;
}
[cmdk-item] {
padding: 0.2em;
font-size: 1.2em;
& .tlui-kbd {
border: 1px solid #c7c7c7;
border-radius: 3px;
padding: 0.2em;
padding-bottom: 0.1em;
font-size: 0.8em;
opacity: 0.5;
}
}
[cmdk-item]:hover {
border-radius: 3px;
background-color: #f0f0f0;
}
[cmdk-empty] {
font-size: 1.2em;
opacity: 0.5;
padding: 0.2em;
}
[cmdk-overlay] {
z-index: 9998 !important;
position: fixed !important;
top: 0 !important;
left: 0 !important;
right: 0 !important;
bottom: 0 !important;
background: rgba(0, 0, 0, 0.5) !important;
}
/* Ensure command palette renders above Tldraw canvas */
.tldraw__editor [cmdk-root] {
position: fixed !important;
z-index: 9999 !important;
}
.tldraw__editor [cmdk-dialog] {
position: fixed !important;
z-index: 9999 !important;
}
.tldraw__editor [cmdk-overlay] {
position: fixed !important;
z-index: 9998 !important;
}
/* Command Palette Specific Styles */
.command-palette .duration-300 {
transition-duration: 0s; /* Set your desired duration */
}
.command-palette .duration-200 {
transition-duration: 0s; /* Set your desired duration */
}
.command-palette .bg-opacity-80 {
display: none;
}
.command-palette .llm-response {
display: block;
height: 100%;
width: 100%;
opacity: 1;
}
.llm-response {
margin-top: 0 !important;
}
.references {
opacity: 1 !important;
}
.command-palette .llm-response div {
display: block;
height: 100%;
width: 100%;
}
.command-palette .llm-response span {
height: 500px;
white-space: pre-line;
}
.references * {
color: white;
}
.reference {
color: #40cf66;
margin-left: 0.2em !important;
padding-right: 0.1em;
padding-left: 0.1em;
&:hover {
background-color: #40cf664d;
}
border-radius: 3px;
}
.reference-missing {
margin-left: 0.2em !important;
padding-right: 0.1em;
padding-left: 0.1em;
color: #fc8958;
}

802
src/default_gestures.ts Normal file
View File

@ -0,0 +1,802 @@
import { Gesture } from "@/gestures"
import { Editor, TLDrawShape, TLShape, VecLike, createShapeId } from "tldraw"
const getShapesUnderGesture = (editor: Editor, gesture: TLDrawShape) => {
const bounds = editor.getShapePageBounds(gesture.id)
return editor.getShapesAtPoint(bounds?.center!, {
margin: (bounds?.width! + bounds?.height!) / 4,
}).filter((shape) => shape.id !== gesture.id)
}
/** Returns shapes arranged in a circle around the given origin */
const circleDistribution = (editor: Editor, shapes: TLShape[], origin: VecLike, radius: number): TLShape[] => {
const angleStep = (2 * Math.PI) / shapes.length;
return shapes.map((shape, index) => {
const { w, h } = editor.getShapeGeometry(shape.id).bounds
const angle = index * angleStep;
const pointOnCircle = {
x: origin.x + radius * Math.cos(angle),
y: origin.y + radius * Math.sin(angle),
};
const shapeAngle = angle + Math.PI / 2;
const pos = posFromRotatedCenter(pointOnCircle, w, h, shapeAngle);
return {
...shape,
x: pos.x,
y: pos.y,
rotation: shapeAngle,
};
});
}
/** Returns shapes arranged in a triangle around the given origin */
const triangleDistribution = (editor: Editor, shapes: TLShape[], origin: VecLike, radius: number): TLShape[] => {
const vertices = [
{ x: origin.x - radius, y: origin.y + radius }, // Bottom left
{ x: origin.x + radius, y: origin.y + radius }, // Bottom right
{ x: origin.x, y: origin.y - radius }, // Top middle
];
const totalShapes = shapes.length;
const shapesPerEdge = Math.ceil(totalShapes / 3);
return shapes.map((shape, index) => {
const edgeIndex = Math.floor(index / shapesPerEdge);
const edgeStart = vertices[edgeIndex];
const edgeEnd = vertices[(edgeIndex + 1) % 3];
const t = (index % shapesPerEdge) / shapesPerEdge;
const pointOnEdge = {
x: edgeStart.x + t * (edgeEnd.x - edgeStart.x),
y: edgeStart.y + t * (edgeEnd.y - edgeStart.y),
};
let shapeAngle;
if (index % shapesPerEdge === 0) {
// Shape is at a vertex, adjust angle to face away from the triangle
const vertex = vertices[edgeIndex];
shapeAngle = Math.atan2(vertex.y - origin.y, vertex.x - origin.x);
} else {
// Shape is on an edge
shapeAngle = Math.atan2(edgeEnd.y - edgeStart.y, edgeEnd.x - edgeStart.x);
}
const { w, h } = editor.getShapeGeometry(shape.id).bounds;
const pos = posFromRotatedCenter(pointOnEdge, w, h, shapeAngle);
return {
...shape,
x: pos.x,
y: pos.y,
rotation: shapeAngle,
};
});
}
/** Calculates the top-left position of a shape given a center point, width, height, and rotation (radians) */
/** its origin x/y and rotation are around the top-left corner of the shape */
const posFromRotatedCenter = (center: VecLike, w: number, h: number, rotation: number): VecLike => {
const halfWidth = w / 2;
const halfHeight = h / 2;
const cosTheta = Math.cos(rotation);
const sinTheta = Math.sin(rotation);
const topLeftX = center.x - (halfWidth * cosTheta - halfHeight * sinTheta);
const topLeftY = center.y - (halfWidth * sinTheta + halfHeight * cosTheta);
return { x: topLeftX, y: topLeftY };
}
export const DEFAULT_GESTURES: Gesture[] = [
{
name: "x",
onComplete(editor) {
editor.deleteShapes(editor.getSelectedShapes())
},
points: [
{ x: 87, y: 142 },
{ x: 89, y: 145 },
{ x: 91, y: 148 },
{ x: 93, y: 151 },
{ x: 96, y: 155 },
{ x: 98, y: 157 },
{ x: 100, y: 160 },
{ x: 102, y: 162 },
{ x: 106, y: 167 },
{ x: 108, y: 169 },
{ x: 110, y: 171 },
{ x: 115, y: 177 },
{ x: 119, y: 183 },
{ x: 123, y: 189 },
{ x: 127, y: 193 },
{ x: 129, y: 196 },
{ x: 133, y: 200 },
{ x: 137, y: 206 },
{ x: 140, y: 209 },
{ x: 143, y: 212 },
{ x: 146, y: 215 },
{ x: 151, y: 220 },
{ x: 153, y: 222 },
{ x: 155, y: 223 },
{ x: 157, y: 225 },
{ x: 158, y: 223 },
{ x: 157, y: 218 },
{ x: 155, y: 211 },
{ x: 154, y: 208 },
{ x: 152, y: 200 },
{ x: 150, y: 189 },
{ x: 148, y: 179 },
{ x: 147, y: 170 },
{ x: 147, y: 158 },
{ x: 147, y: 148 },
{ x: 147, y: 141 },
{ x: 147, y: 136 },
{ x: 144, y: 135 },
{ x: 142, y: 137 },
{ x: 140, y: 139 },
{ x: 135, y: 145 },
{ x: 131, y: 152 },
{ x: 124, y: 163 },
{ x: 116, y: 177 },
{ x: 108, y: 191 },
{ x: 100, y: 206 },
{ x: 94, y: 217 },
{ x: 91, y: 222 },
{ x: 89, y: 225 },
{ x: 87, y: 226 },
{ x: 87, y: 224 },
],
},
{
name: "rectangle",
onComplete(editor, gesture?: TLDrawShape) {
const bounds = editor.getShapePageBounds(gesture?.id!)
const { w, h, center } = bounds!
editor.createShape({
id: createShapeId(),
type: "geo",
x: center?.x! - w / 2,
y: center?.y! - h / 2,
props: {
fill: "solid",
w: w,
h: h,
},
})
},
points: [
{ x: 78, y: 149 },
{ x: 78, y: 153 },
{ x: 78, y: 157 },
{ x: 78, y: 160 },
{ x: 79, y: 162 },
{ x: 79, y: 164 },
{ x: 79, y: 167 },
{ x: 79, y: 169 },
{ x: 79, y: 173 },
{ x: 79, y: 178 },
{ x: 79, y: 183 },
{ x: 80, y: 189 },
{ x: 80, y: 193 },
{ x: 80, y: 198 },
{ x: 80, y: 202 },
{ x: 81, y: 208 },
{ x: 81, y: 210 },
{ x: 81, y: 216 },
{ x: 82, y: 222 },
{ x: 82, y: 224 },
{ x: 82, y: 227 },
{ x: 83, y: 229 },
{ x: 83, y: 231 },
{ x: 85, y: 230 },
{ x: 88, y: 232 },
{ x: 90, y: 233 },
{ x: 92, y: 232 },
{ x: 94, y: 233 },
{ x: 99, y: 232 },
{ x: 102, y: 233 },
{ x: 106, y: 233 },
{ x: 109, y: 234 },
{ x: 117, y: 235 },
{ x: 123, y: 236 },
{ x: 126, y: 236 },
{ x: 135, y: 237 },
{ x: 142, y: 238 },
{ x: 145, y: 238 },
{ x: 152, y: 238 },
{ x: 154, y: 239 },
{ x: 165, y: 238 },
{ x: 174, y: 237 },
{ x: 179, y: 236 },
{ x: 186, y: 235 },
{ x: 191, y: 235 },
{ x: 195, y: 233 },
{ x: 197, y: 233 },
{ x: 200, y: 233 },
{ x: 201, y: 235 },
{ x: 201, y: 233 },
{ x: 199, y: 231 },
{ x: 198, y: 226 },
{ x: 198, y: 220 },
{ x: 196, y: 207 },
{ x: 195, y: 195 },
{ x: 195, y: 181 },
{ x: 195, y: 173 },
{ x: 195, y: 163 },
{ x: 194, y: 155 },
{ x: 192, y: 145 },
{ x: 192, y: 143 },
{ x: 192, y: 138 },
{ x: 191, y: 135 },
{ x: 191, y: 133 },
{ x: 191, y: 130 },
{ x: 190, y: 128 },
{ x: 188, y: 129 },
{ x: 186, y: 129 },
{ x: 181, y: 132 },
{ x: 173, y: 131 },
{ x: 162, y: 131 },
{ x: 151, y: 132 },
{ x: 149, y: 132 },
{ x: 138, y: 132 },
{ x: 136, y: 132 },
{ x: 122, y: 131 },
{ x: 120, y: 131 },
{ x: 109, y: 130 },
{ x: 107, y: 130 },
{ x: 90, y: 132 },
{ x: 81, y: 133 },
{ x: 76, y: 133 },
],
},
{
name: "circle",
onComplete(editor, gesture?: TLDrawShape) {
const selection = getShapesUnderGesture(editor, gesture!)
editor.setSelectedShapes(selection)
editor.setHintingShapes(selection)
},
points: [
{ x: 127, y: 141 },
{ x: 124, y: 140 },
{ x: 120, y: 139 },
{ x: 118, y: 139 },
{ x: 116, y: 139 },
{ x: 111, y: 140 },
{ x: 109, y: 141 },
{ x: 104, y: 144 },
{ x: 100, y: 147 },
{ x: 96, y: 152 },
{ x: 93, y: 157 },
{ x: 90, y: 163 },
{ x: 87, y: 169 },
{ x: 85, y: 175 },
{ x: 83, y: 181 },
{ x: 82, y: 190 },
{ x: 82, y: 195 },
{ x: 83, y: 200 },
{ x: 84, y: 205 },
{ x: 88, y: 213 },
{ x: 91, y: 216 },
{ x: 96, y: 219 },
{ x: 103, y: 222 },
{ x: 108, y: 224 },
{ x: 111, y: 224 },
{ x: 120, y: 224 },
{ x: 133, y: 223 },
{ x: 142, y: 222 },
{ x: 152, y: 218 },
{ x: 160, y: 214 },
{ x: 167, y: 210 },
{ x: 173, y: 204 },
{ x: 178, y: 198 },
{ x: 179, y: 196 },
{ x: 182, y: 188 },
{ x: 182, y: 177 },
{ x: 178, y: 167 },
{ x: 170, y: 150 },
{ x: 163, y: 138 },
{ x: 152, y: 130 },
{ x: 143, y: 129 },
{ x: 140, y: 131 },
{ x: 129, y: 136 },
{ x: 126, y: 139 },
],
},
{
name: "check",
onComplete(editor, gesture?: TLDrawShape) {
const originPoint = { x: gesture?.x!, y: gesture?.y! }
const shapeAtOrigin = editor.getShapesAtPoint(originPoint, {
hitInside: true,
margin: 10,
})
for (const shape of shapeAtOrigin) {
if (shape.id === gesture?.id) continue
editor.updateShape({
...shape,
props: {
...shape.props,
color: "green",
},
})
}
},
points: [
{ x: 91, y: 185 },
{ x: 93, y: 185 },
{ x: 95, y: 185 },
{ x: 97, y: 185 },
{ x: 100, y: 188 },
{ x: 102, y: 189 },
{ x: 104, y: 190 },
{ x: 106, y: 193 },
{ x: 108, y: 195 },
{ x: 110, y: 198 },
{ x: 112, y: 201 },
{ x: 114, y: 204 },
{ x: 115, y: 207 },
{ x: 117, y: 210 },
{ x: 118, y: 212 },
{ x: 120, y: 214 },
{ x: 121, y: 217 },
{ x: 122, y: 219 },
{ x: 123, y: 222 },
{ x: 124, y: 224 },
{ x: 126, y: 226 },
{ x: 127, y: 229 },
{ x: 129, y: 231 },
{ x: 130, y: 233 },
{ x: 129, y: 231 },
{ x: 129, y: 228 },
{ x: 129, y: 226 },
{ x: 129, y: 224 },
{ x: 129, y: 221 },
{ x: 129, y: 218 },
{ x: 129, y: 212 },
{ x: 129, y: 208 },
{ x: 130, y: 198 },
{ x: 132, y: 189 },
{ x: 134, y: 182 },
{ x: 137, y: 173 },
{ x: 143, y: 164 },
{ x: 147, y: 157 },
{ x: 151, y: 151 },
{ x: 155, y: 144 },
{ x: 161, y: 137 },
{ x: 165, y: 131 },
{ x: 171, y: 122 },
{ x: 174, y: 118 },
{ x: 176, y: 114 },
{ x: 177, y: 112 },
{ x: 177, y: 114 },
{ x: 175, y: 116 },
{ x: 173, y: 118 },
],
},
{
name: "caret",
onComplete(editor) {
editor.alignShapes(editor.getSelectedShapes(), "top")
},
points: [
{ x: 79, y: 245 },
{ x: 79, y: 242 },
{ x: 79, y: 239 },
{ x: 80, y: 237 },
{ x: 80, y: 234 },
{ x: 81, y: 232 },
{ x: 82, y: 230 },
{ x: 84, y: 224 },
{ x: 86, y: 220 },
{ x: 86, y: 218 },
{ x: 87, y: 216 },
{ x: 88, y: 213 },
{ x: 90, y: 207 },
{ x: 91, y: 202 },
{ x: 92, y: 200 },
{ x: 93, y: 194 },
{ x: 94, y: 192 },
{ x: 96, y: 189 },
{ x: 97, y: 186 },
{ x: 100, y: 179 },
{ x: 102, y: 173 },
{ x: 105, y: 165 },
{ x: 107, y: 160 },
{ x: 109, y: 158 },
{ x: 112, y: 151 },
{ x: 115, y: 144 },
{ x: 117, y: 139 },
{ x: 119, y: 136 },
{ x: 119, y: 134 },
{ x: 120, y: 132 },
{ x: 121, y: 129 },
{ x: 122, y: 127 },
{ x: 124, y: 125 },
{ x: 126, y: 124 },
{ x: 129, y: 125 },
{ x: 131, y: 127 },
{ x: 132, y: 130 },
{ x: 136, y: 139 },
{ x: 141, y: 154 },
{ x: 145, y: 166 },
{ x: 151, y: 182 },
{ x: 156, y: 193 },
{ x: 157, y: 196 },
{ x: 161, y: 209 },
{ x: 162, y: 211 },
{ x: 167, y: 223 },
{ x: 169, y: 229 },
{ x: 170, y: 231 },
{ x: 173, y: 237 },
{ x: 176, y: 242 },
{ x: 177, y: 244 },
{ x: 179, y: 250 },
{ x: 181, y: 255 },
{ x: 182, y: 257 },
],
},
// {
// name: "zig-zag",
// points: [
// { x: 307, y: 216 },
// { x: 333, y: 186 },
// { x: 356, y: 215 },
// { x: 375, y: 186 },
// { x: 399, y: 216 },
// { x: 418, y: 186 },
// ],
// },
{
name: "v",
onComplete(editor) {
editor.alignShapes(editor.getSelectedShapes(), "bottom")
},
points: [
{ x: 89, y: 164 },
{ x: 90, y: 162 },
{ x: 92, y: 162 },
{ x: 94, y: 164 },
{ x: 95, y: 166 },
{ x: 96, y: 169 },
{ x: 97, y: 171 },
{ x: 99, y: 175 },
{ x: 101, y: 178 },
{ x: 103, y: 182 },
{ x: 106, y: 189 },
{ x: 108, y: 194 },
{ x: 111, y: 199 },
{ x: 114, y: 204 },
{ x: 117, y: 209 },
{ x: 119, y: 214 },
{ x: 122, y: 218 },
{ x: 124, y: 222 },
{ x: 126, y: 225 },
{ x: 128, y: 228 },
{ x: 130, y: 229 },
{ x: 133, y: 233 },
{ x: 134, y: 236 },
{ x: 136, y: 239 },
{ x: 138, y: 240 },
{ x: 139, y: 242 },
{ x: 140, y: 244 },
{ x: 142, y: 242 },
{ x: 142, y: 240 },
{ x: 142, y: 237 },
{ x: 143, y: 235 },
{ x: 143, y: 233 },
{ x: 145, y: 229 },
{ x: 146, y: 226 },
{ x: 148, y: 217 },
{ x: 149, y: 208 },
{ x: 149, y: 205 },
{ x: 151, y: 196 },
{ x: 151, y: 193 },
{ x: 153, y: 182 },
{ x: 155, y: 172 },
{ x: 157, y: 165 },
{ x: 159, y: 160 },
{ x: 162, y: 155 },
{ x: 164, y: 150 },
{ x: 165, y: 148 },
{ x: 166, y: 146 },
],
},
{
name: "delete",
onComplete(editor) {
editor.deleteShapes(editor.getSelectedShapes())
},
points: [
{ x: 123, y: 129 },
{ x: 123, y: 131 },
{ x: 124, y: 133 },
{ x: 125, y: 136 },
{ x: 127, y: 140 },
{ x: 129, y: 142 },
{ x: 133, y: 148 },
{ x: 137, y: 154 },
{ x: 143, y: 158 },
{ x: 145, y: 161 },
{ x: 148, y: 164 },
{ x: 153, y: 170 },
{ x: 158, y: 176 },
{ x: 160, y: 178 },
{ x: 164, y: 183 },
{ x: 168, y: 188 },
{ x: 171, y: 191 },
{ x: 175, y: 196 },
{ x: 178, y: 200 },
{ x: 180, y: 202 },
{ x: 181, y: 205 },
{ x: 184, y: 208 },
{ x: 186, y: 210 },
{ x: 187, y: 213 },
{ x: 188, y: 215 },
{ x: 186, y: 212 },
{ x: 183, y: 211 },
{ x: 177, y: 208 },
{ x: 169, y: 206 },
{ x: 162, y: 205 },
{ x: 154, y: 207 },
{ x: 145, y: 209 },
{ x: 137, y: 210 },
{ x: 129, y: 214 },
{ x: 122, y: 217 },
{ x: 118, y: 218 },
{ x: 111, y: 221 },
{ x: 109, y: 222 },
{ x: 110, y: 219 },
{ x: 112, y: 217 },
{ x: 118, y: 209 },
{ x: 120, y: 207 },
{ x: 128, y: 196 },
{ x: 135, y: 187 },
{ x: 138, y: 183 },
{ x: 148, y: 167 },
{ x: 157, y: 153 },
{ x: 163, y: 145 },
{ x: 165, y: 142 },
{ x: 172, y: 133 },
{ x: 177, y: 127 },
{ x: 179, y: 127 },
{ x: 180, y: 125 },
],
},
{
name: "pigtail",
onComplete(editor, gesture?: TLDrawShape) {
const shapes = getShapesUnderGesture(editor, gesture!)
editor.setSelectedShapes(shapes)
editor.setHintingShapes(shapes)
editor.animateShapes(shapes.map((shape) => ({
...shape,
rotation: shape.rotation + (Math.PI / -2),
})),
{
animation: {
duration: 600,
easing: (t) => t * t * (3 - 2 * t),
},
},
)
},
points: [
{ x: 81, y: 219 },
{ x: 84, y: 218 },
{ x: 86, y: 220 },
{ x: 88, y: 220 },
{ x: 90, y: 220 },
{ x: 92, y: 219 },
{ x: 95, y: 220 },
{ x: 97, y: 219 },
{ x: 99, y: 220 },
{ x: 102, y: 218 },
{ x: 105, y: 217 },
{ x: 107, y: 216 },
{ x: 110, y: 216 },
{ x: 113, y: 214 },
{ x: 116, y: 212 },
{ x: 118, y: 210 },
{ x: 121, y: 208 },
{ x: 124, y: 205 },
{ x: 126, y: 202 },
{ x: 129, y: 199 },
{ x: 132, y: 196 },
{ x: 136, y: 191 },
{ x: 139, y: 187 },
{ x: 142, y: 182 },
{ x: 144, y: 179 },
{ x: 146, y: 174 },
{ x: 148, y: 170 },
{ x: 149, y: 168 },
{ x: 151, y: 162 },
{ x: 152, y: 160 },
{ x: 152, y: 157 },
{ x: 152, y: 155 },
{ x: 152, y: 151 },
{ x: 152, y: 149 },
{ x: 152, y: 146 },
{ x: 149, y: 142 },
{ x: 148, y: 139 },
{ x: 145, y: 137 },
{ x: 141, y: 135 },
{ x: 139, y: 135 },
{ x: 134, y: 136 },
{ x: 130, y: 140 },
{ x: 128, y: 142 },
{ x: 126, y: 145 },
{ x: 122, y: 150 },
{ x: 119, y: 158 },
{ x: 117, y: 163 },
{ x: 115, y: 170 },
{ x: 114, y: 175 },
{ x: 117, y: 184 },
{ x: 120, y: 190 },
{ x: 125, y: 199 },
{ x: 129, y: 203 },
{ x: 133, y: 208 },
{ x: 138, y: 213 },
{ x: 145, y: 215 },
{ x: 155, y: 218 },
{ x: 164, y: 219 },
{ x: 166, y: 219 },
{ x: 177, y: 219 },
{ x: 182, y: 218 },
{ x: 192, y: 216 },
{ x: 196, y: 213 },
{ x: 199, y: 212 },
{ x: 201, y: 211 },
],
},
]
export const ALT_GESTURES: Gesture[] = [
{
name: "circle layout",
onComplete(editor, gesture?: TLDrawShape) {
const bounds = editor.getShapePageBounds(gesture?.id!)
const center = bounds?.center
const radius = Math.max(bounds?.width || 0, bounds?.height || 0) / 2
const selected = editor.getSelectedShapes()
const radialShapes = circleDistribution(editor, selected, center!, radius)
editor.animateShapes(radialShapes, {
animation: {
duration: 600,
easing: (t) => t * t * (3 - 2 * t),
},
})
},
points: [
{ x: 127, y: 141 },
{ x: 124, y: 140 },
{ x: 120, y: 139 },
{ x: 118, y: 139 },
{ x: 116, y: 139 },
{ x: 111, y: 140 },
{ x: 109, y: 141 },
{ x: 104, y: 144 },
{ x: 100, y: 147 },
{ x: 96, y: 152 },
{ x: 93, y: 157 },
{ x: 90, y: 163 },
{ x: 87, y: 169 },
{ x: 85, y: 175 },
{ x: 83, y: 181 },
{ x: 82, y: 190 },
{ x: 82, y: 195 },
{ x: 83, y: 200 },
{ x: 84, y: 205 },
{ x: 88, y: 213 },
{ x: 91, y: 216 },
{ x: 96, y: 219 },
{ x: 103, y: 222 },
{ x: 108, y: 224 },
{ x: 111, y: 224 },
{ x: 120, y: 224 },
{ x: 133, y: 223 },
{ x: 142, y: 222 },
{ x: 152, y: 218 },
{ x: 160, y: 214 },
{ x: 167, y: 210 },
{ x: 173, y: 204 },
{ x: 178, y: 198 },
{ x: 179, y: 196 },
{ x: 182, y: 188 },
{ x: 182, y: 177 },
{ x: 178, y: 167 },
{ x: 170, y: 150 },
{ x: 163, y: 138 },
{ x: 152, y: 130 },
{ x: 143, y: 129 },
{ x: 140, y: 131 },
{ x: 129, y: 136 },
{ x: 126, y: 139 },
],
},
{
name: "triangle layout",
onComplete(editor, gesture?: TLDrawShape) {
const bounds = editor.getShapePageBounds(gesture?.id!)
const center = bounds?.center
const radius = Math.max(bounds?.width || 0, bounds?.height || 0) / 2
const selected = editor.getSelectedShapes()
const radialShapes = triangleDistribution(editor, selected, center!, radius)
editor.animateShapes(radialShapes, {
animation: {
duration: 600,
easing: (t) => t * t * (3 - 2 * t),
},
})
},
points: [
{ x: 137, y: 139 },
{ x: 135, y: 141 },
{ x: 133, y: 144 },
{ x: 132, y: 146 },
{ x: 130, y: 149 },
{ x: 128, y: 151 },
{ x: 126, y: 155 },
{ x: 123, y: 160 },
{ x: 120, y: 166 },
{ x: 116, y: 171 },
{ x: 112, y: 177 },
{ x: 107, y: 183 },
{ x: 102, y: 188 },
{ x: 100, y: 191 },
{ x: 95, y: 195 },
{ x: 90, y: 199 },
{ x: 86, y: 203 },
{ x: 82, y: 206 },
{ x: 80, y: 209 },
{ x: 75, y: 213 },
{ x: 73, y: 213 },
{ x: 70, y: 216 },
{ x: 67, y: 219 },
{ x: 64, y: 221 },
{ x: 61, y: 223 },
{ x: 60, y: 225 },
{ x: 62, y: 226 },
{ x: 65, y: 225 },
{ x: 67, y: 226 },
{ x: 74, y: 226 },
{ x: 77, y: 227 },
{ x: 85, y: 229 },
{ x: 91, y: 230 },
{ x: 99, y: 231 },
{ x: 108, y: 232 },
{ x: 116, y: 233 },
{ x: 125, y: 233 },
{ x: 134, y: 234 },
{ x: 145, y: 233 },
{ x: 153, y: 232 },
{ x: 160, y: 233 },
{ x: 170, y: 234 },
{ x: 177, y: 235 },
{ x: 179, y: 236 },
{ x: 186, y: 237 },
{ x: 193, y: 238 },
{ x: 198, y: 239 },
{ x: 200, y: 237 },
{ x: 202, y: 239 },
{ x: 204, y: 238 },
{ x: 206, y: 234 },
{ x: 205, y: 230 },
{ x: 202, y: 222 },
{ x: 197, y: 216 },
{ x: 192, y: 207 },
{ x: 186, y: 198 },
{ x: 179, y: 189 },
{ x: 174, y: 183 },
{ x: 170, y: 178 },
{ x: 164, y: 171 },
{ x: 161, y: 168 },
{ x: 154, y: 160 },
{ x: 148, y: 155 },
{ x: 143, y: 150 },
{ x: 138, y: 148 },
{ x: 136, y: 148 },
],
},
]

141
src/default_store.ts Normal file
View File

@ -0,0 +1,141 @@
export const DEFAULT_STORE = {
store: {
"document:document": {
gridSize: 10,
name: "",
meta: {},
id: "document:document",
typeName: "document",
},
"pointer:pointer": {
id: "pointer:pointer",
typeName: "pointer",
x: 0,
y: 0,
lastActivityTimestamp: 0,
meta: {},
},
"page:page": {
meta: {},
id: "page:page",
name: "Page 1",
index: "a1",
typeName: "page",
},
"camera:page:page": {
x: 0,
y: 0,
z: 1,
meta: {},
id: "camera:page:page",
typeName: "camera",
},
"instance_page_state:page:page": {
editingShapeId: null,
croppingShapeId: null,
selectedShapeIds: [],
hoveredShapeId: null,
erasingShapeIds: [],
hintingShapeIds: [],
focusedGroupId: null,
meta: {},
id: "instance_page_state:page:page",
pageId: "page:page",
typeName: "instance_page_state",
},
"instance:instance": {
followingUserId: null,
opacityForNextShape: 1,
stylesForNextShape: {},
brush: null,
scribble: null,
cursor: {
type: "default",
rotation: 0,
},
isFocusMode: false,
exportBackground: true,
isDebugMode: false,
isToolLocked: false,
screenBounds: {
x: 0,
y: 0,
w: 720,
h: 400,
},
zoomBrush: null,
isGridMode: false,
isPenMode: false,
chatMessage: "",
isChatting: false,
highlightedUserIds: [],
canMoveCamera: true,
isFocused: true,
devicePixelRatio: 2,
isCoarsePointer: false,
isHoveringCanvas: false,
openMenus: [],
isChangingStyle: false,
isReadonly: false,
meta: {},
id: "instance:instance",
currentPageId: "page:page",
typeName: "instance",
},
},
schema: {
schemaVersion: 1,
storeVersion: 4,
recordVersions: {
asset: {
version: 1,
subTypeKey: "type",
subTypeVersions: {
image: 2,
video: 2,
bookmark: 0,
},
},
camera: {
version: 1,
},
document: {
version: 2,
},
instance: {
version: 21,
},
instance_page_state: {
version: 5,
},
page: {
version: 1,
},
shape: {
version: 3,
subTypeKey: "type",
subTypeVersions: {
group: 0,
text: 1,
bookmark: 1,
draw: 1,
geo: 7,
note: 4,
line: 1,
frame: 0,
arrow: 1,
highlight: 0,
embed: 4,
image: 2,
video: 1,
},
},
instance_presence: {
version: 5,
},
pointer: {
version: 1,
},
},
},
}

322
src/gestures.ts Normal file
View File

@ -0,0 +1,322 @@
/** Modified $1 for TS & tldraw */
/**
* The $1 Unistroke Recognizer (JavaScript version)
*
* Jacob O. Wobbrock, Ph.D.
* The Information School
* University of Washington
* Seattle, WA 98195-2840
* wobbrock@uw.edu
*
* Andrew D. Wilson, Ph.D.
* Microsoft Research
* One Microsoft Way
* Redmond, WA 98052
* awilson@microsoft.com
*
* Yang Li, Ph.D.
* Department of Computer Science and Engineering
* University of Washington
* Seattle, WA 98195-2840
* yangli@cs.washington.edu
*
* The academic publication for the $1 recognizer, and what should be
* used to cite it, is:
*
* Wobbrock, J.O., Wilson, A.D. and Li, Y. (2007). Gestures without
* libraries, toolkits or training: A $1 recognizer for user interface
* prototypes. Proceedings of the ACM Symposium on User Interface
* Software and Technology (UIST '07). Newport, Rhode Island (October
* 7-10, 2007). New York: ACM Press, pp. 159-168.
* https://dl.acm.org/citation.cfm?id=1294238
*
* The Protractor enhancement was separately published by Yang Li and programmed
* here by Jacob O. Wobbrock:
*
* Li, Y. (2010). Protractor: A fast and accurate gesture
* recognizer. Proceedings of the ACM Conference on Human
* Factors in Computing Systems (CHI '10). Atlanta, Georgia
* (April 10-15, 2010). New York: ACM Press, pp. 2169-2172.
* https://dl.acm.org/citation.cfm?id=1753654
*
* This software is distributed under the "New BSD License" agreement:
*
* Copyright (C) 2007-2012, Jacob O. Wobbrock, Andrew D. Wilson and Yang Li.
* All rights reserved. Last updated July 14, 2018.
*
* Redistribution and use in source and binary forms, with or without
* modification, are permitted provided that the following conditions are met:
* * Redistributions of source code must retain the above copyright
* notice, this list of conditions and the following disclaimer.
* * Redistributions in binary form must reproduce the above copyright
* notice, this list of conditions and the following disclaimer in the
* documentation and/or other materials provided with the distribution.
* * Neither the names of the University of Washington nor Microsoft,
* nor the names of its contributors may be used to endorse or promote
* products derived from this software without specific prior written
* permission.
*
* THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS
* IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO,
* THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
* PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL Jacob O. Wobbrock OR Andrew D. Wilson
* OR Yang Li BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY,
* OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
* SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
* INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT,
* STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY
* OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
**/
import { Editor, TLDrawShape, VecLike, BoxModel } from "tldraw"
const NUM_POINTS = 64
const SQUARE_SIZE = 250.0
const ORIGIN = { x: 0, y: 0 }
interface Result {
name: string
score: number
time: number
onComplete?: (editor: Editor, gesture?: TLDrawShape) => void
}
export interface Gesture {
name: string
points: VecLike[]
onComplete?: (editor: Editor, gesture?: TLDrawShape) => void
}
class Unistroke {
name: string
points: VecLike[]
vector: number[]
private _originalPoints: VecLike[]
onComplete?: (editor: Editor, gesture?: TLDrawShape) => void
constructor(
name: string,
points: VecLike[],
onComplete?: (editor: Editor, gesture?: TLDrawShape) => void,
) {
this.name = name
this.onComplete = onComplete
this._originalPoints = points
this.points = Resample(points, NUM_POINTS)
const radians = IndicativeAngle(this.points)
this.points = RotateBy(this.points, -radians)
this.points = ScaleTo(this.points, SQUARE_SIZE)
this.points = TranslateTo(this.points, ORIGIN)
this.vector = Vectorize(this.points) // for Protractor
}
originalPoints(): VecLike[] {
return this._originalPoints
}
}
export class DollarRecognizer {
unistrokes: Unistroke[] = []
constructor(gestures: Gesture[]) {
for (const gesture of gestures) {
this.unistrokes.push(
new Unistroke(gesture.name, gesture.points, gesture.onComplete),
)
}
}
/**
* Recognize a gesture
* @param points The points of the gesture
* @returns The result
*/
recognize(points: VecLike[]): Result {
const t0 = Date.now()
const candidate = new Unistroke("", points)
let u = -1
let b = +Infinity
for (
let i = 0;
i < this.unistrokes.length;
i++ // for each unistroke template
) {
const d = OptimalCosineDistance(
this.unistrokes[i].vector,
candidate.vector,
) // Protractor
if (d < b) {
b = d // best (least) distance
u = i // unistroke index
}
}
const t1 = Date.now()
return u === -1
? { name: "No match.", score: 0.0, time: t1 - t0 }
: {
name: this.unistrokes[u].name,
score: 1.0 - b,
time: t1 - t0,
onComplete: this.unistrokes[u].onComplete,
}
}
/**
* Add a gesture to the recognizer
* @param name The name of the gesture
* @param points The points of the gesture
* @returns The number of gestures
*/
addGesture(name: string, points: VecLike[]): number {
this.unistrokes[this.unistrokes.length] = new Unistroke(name, points) // append new unistroke
let num = 0
for (let i = 0; i < this.unistrokes.length; i++) {
if (this.unistrokes[i].name === name) num++
}
return num
}
/**
* Remove a gesture from the recognizer
* @param name The name of the gesture
* @returns The number of gestures after removal
*/
removeGesture(name: string): number {
this.unistrokes = this.unistrokes.filter((gesture) => gesture.name !== name)
return this.unistrokes.length
}
}
//
// Private helper functions from here on down
//
function Resample(points: VecLike[], n: number): VecLike[] {
const I = PathLength(points) / (n - 1) // interval length
let D = 0.0
const newpoints = new Array(points[0])
for (let i = 1; i < points.length; i++) {
const d = Distance(points[i - 1], points[i])
if (D + d >= I) {
const qx =
points[i - 1].x + ((I - D) / d) * (points[i].x - points[i - 1].x)
const qy =
points[i - 1].y + ((I - D) / d) * (points[i].y - points[i - 1].y)
const q = { x: qx, y: qy }
newpoints[newpoints.length] = q // append new point 'q'
points.splice(i, 0, q) // insert 'q' at position i in points s.t. 'q' will be the next i
D = 0.0
} else D += d
}
if (newpoints.length === n - 1)
// somtimes we fall a rounding-error short of adding the last point, so add it if so
newpoints[newpoints.length] = {
x: points[points.length - 1].x,
y: points[points.length - 1].y,
}
return newpoints
}
function IndicativeAngle(points: VecLike[]): number {
const c = Centroid(points)
return Math.atan2(c.y - points[0].y, c.x - points[0].x)
}
function RotateBy(points: VecLike[], radians: number): VecLike[] {
// rotates points around centroid
const c = Centroid(points)
const cos = Math.cos(radians)
const sin = Math.sin(radians)
const newpoints = new Array()
for (let i = 0; i < points.length; i++) {
const qx = (points[i].x - c.x) * cos - (points[i].y - c.y) * sin + c.x
const qy = (points[i].x - c.x) * sin + (points[i].y - c.y) * cos + c.y
newpoints[newpoints.length] = { x: qx, y: qy }
}
return newpoints
}
function ScaleTo(points: VecLike[], size: number): VecLike[] {
// non-uniform scale; assumes 2D gestures (i.e., no lines)
const B = BoundingBox(points)
const newpoints = new Array()
for (let i = 0; i < points.length; i++) {
const qx = points[i].x * (size / B.w)
const qy = points[i].y * (size / B.h)
newpoints[newpoints.length] = { x: qx, y: qy }
}
return newpoints
}
function TranslateTo(points: VecLike[], pt: VecLike): VecLike[] {
// translates points' centroid
const c = Centroid(points)
const newpoints = new Array()
for (let i = 0; i < points.length; i++) {
const qx = points[i].x + pt.x - c.x
const qy = points[i].y + pt.y - c.y
newpoints[newpoints.length] = { x: qx, y: qy }
}
return newpoints
}
function Vectorize(points: VecLike[]): number[] {
let sum = 0.0
const vector = new Array()
for (let i = 0; i < points.length; i++) {
vector[vector.length] = points[i].x
vector[vector.length] = points[i].y
sum += points[i].x * points[i].x + points[i].y * points[i].y
}
const magnitude = Math.sqrt(sum)
for (let i = 0; i < vector.length; i++) vector[i] /= magnitude
return vector
}
function OptimalCosineDistance(v1: number[], v2: number[]): number {
let a = 0.0
let b = 0.0
for (let i = 0; i < v1.length; i += 2) {
a += v1[i] * v2[i] + v1[i + 1] * v2[i + 1]
b += v1[i] * v2[i + 1] - v1[i + 1] * v2[i]
}
const angle = Math.atan(b / a)
return Math.acos(a * Math.cos(angle) + b * Math.sin(angle))
}
function Centroid(points: VecLike[]): VecLike {
let x = 0.0
let y = 0.0
for (let i = 0; i < points.length; i++) {
x += points[i].x
y += points[i].y
}
x /= points.length
y /= points.length
return { x: x, y: y }
}
function BoundingBox(points: VecLike[]): BoxModel {
let minX = +Infinity
let maxX = -Infinity
let minY = +Infinity
let maxY = -Infinity
for (let i = 0; i < points.length; i++) {
minX = Math.min(minX, points[i].x)
minY = Math.min(minY, points[i].y)
maxX = Math.max(maxX, points[i].x)
maxY = Math.max(maxY, points[i].y)
}
return { x: minX, y: minY, w: maxX - minX, h: maxY - minY }
}
function PathLength(points: VecLike[]): number {
let d = 0.0
for (let i = 1; i < points.length; i++)
d += Distance(points[i - 1], points[i])
return d
}
function Distance(p1: VecLike, p2: VecLike): number {
const dx = p2.x - p1.x
const dy = p2.y - p1.y
return Math.sqrt(dx * dx + dy * dy)
}

View File

@ -0,0 +1,277 @@
import { Layout } from 'webcola';
import { BaseCollection } from '../collections';
import { Editor, TLArrowShape, TLGeoShape, TLShape, TLShapeId } from '@tldraw/tldraw';
type ColaNode = {
id: TLShapeId;
x: number;
y: number;
width: number;
height: number;
rotation: number;
color?: string;
};
type ColaIdLink = {
source: TLShapeId
target: TLShapeId
};
type ColaNodeLink = {
source: ColaNode
target: ColaNode
};
type AlignmentConstraint = {
type: 'alignment',
axis: 'x' | 'y',
offsets: { node: TLShapeId, offset: number }[]
}
type ColaConstraint = AlignmentConstraint
export class GraphLayoutCollection extends BaseCollection {
override id = 'graph';
graphSim: Layout;
animFrame = -1;
colaNodes: Map<TLShapeId, ColaNode> = new Map();
colaLinks: Map<TLShapeId, ColaIdLink> = new Map();
colaConstraints: ColaConstraint[] = [];
constructor(editor: Editor) {
super(editor)
this.graphSim = new Layout();
const simLoop = () => {
this.step();
this.animFrame = requestAnimationFrame(simLoop);
};
simLoop();
}
override onAdd(shapes: TLShape[]) {
for (const shape of shapes) {
if (shape.type !== "arrow") {
this.addGeo(shape);
}
else {
this.addArrow(shape as TLArrowShape);
}
}
this.refreshGraph();
}
override onRemove(shapes: TLShape[]) {
const removedShapeIds = new Set(shapes.map(shape => shape.id));
for (const shape of shapes) {
this.colaNodes.delete(shape.id);
this.colaLinks.delete(shape.id);
}
// Filter out links where either source or target has been removed
for (const [key, link] of this.colaLinks) {
if (removedShapeIds.has(link.source) || removedShapeIds.has(link.target)) {
this.colaLinks.delete(key);
}
}
this.refreshGraph();
}
override onShapeChange(prev: TLShape, next: TLShape) {
if (prev.type === 'geo' && next.type === 'geo') {
const prevShape = prev as TLGeoShape
const nextShape = next as TLGeoShape
// update color if its changed and refresh constraints which use this
if (prevShape.props.color !== nextShape.props.color) {
const existingNode = this.colaNodes.get(next.id);
if (existingNode) {
this.colaNodes.set(next.id, {
...existingNode,
color: nextShape.props.color,
});
}
this.refreshGraph();
}
}
}
step = () => {
this.graphSim.start(1, 0, 0, 0, true, false);
for (const node of this.graphSim.nodes() as ColaNode[]) {
const shape = this.editor.getShape(node.id);
const { w, h } = this.editor.getShapeGeometry(node.id).bounds
if (!shape) continue;
const { x, y } = getCornerToCenterOffset(w, h, shape.rotation);
// Fix positions if we're dragging them
if (this.editor.getSelectedShapeIds().includes(node.id)) {
node.x = shape.x + x;
node.y = shape.y + y;
}
// Update shape props
node.width = w;
node.height = h;
node.rotation = shape.rotation;
this.editor.updateShape({
id: node.id,
type: "geo",
x: node.x - x,
y: node.y - y,
});
}
};
addArrow = (arrow: TLArrowShape) => {
const bindings = this.editor.getBindingsInvolvingShape(arrow.id);
if (bindings.length !== 2) return;
const startBinding = bindings.find(binding => (binding.props as any).terminal === 'start');
const endBinding = bindings.find(binding => (binding.props as any).terminal === 'end');
if (startBinding && endBinding) {
const source = this.editor.getShape(startBinding.toId);
const target = this.editor.getShape(endBinding.toId);
if (source && target) {
const link: ColaIdLink = {
source: source.id,
target: target.id
};
this.colaLinks.set(arrow.id, link);
}
}
}
addGeo = (shape: TLShape) => {
const { w, h } = this.editor.getShapeGeometry(shape).bounds
const { x, y } = getCornerToCenterOffset(w, h, shape.rotation)
const node: ColaNode = {
id: shape.id,
x: shape.x + x,
y: shape.y + y,
width: w,
height: h,
rotation: shape.rotation,
color: (shape.props as any).color
};
this.colaNodes.set(shape.id, node);
}
refreshGraph() {
// TODO: remove this hardcoded behaviour
this.editor.selectNone()
this.refreshConstraints();
const nodes = [...this.colaNodes.values()];
const nodeIdToIndex = new Map(nodes.map((n, i) => [n.id, i]));
// Convert the Map values to an array for processing
const links = Array.from(this.colaLinks.values()).map(l => ({
source: nodeIdToIndex.get(l.source),
target: nodeIdToIndex.get(l.target)
}));
const constraints = this.colaConstraints.map(constraint => {
if (constraint.type === 'alignment') {
return {
...constraint,
offsets: constraint.offsets.map(offset => ({
node: nodeIdToIndex.get(offset.node),
offset: offset.offset
}))
};
}
return constraint;
});
this.graphSim
.nodes(nodes)
// @ts-ignore
.links(links)
.constraints(constraints)
// you could use .linkDistance(250) too, which is stable but does not handle size/rotation
.linkDistance((edge) => calcEdgeDistance(edge as ColaNodeLink))
.avoidOverlaps(true)
.handleDisconnected(true)
}
refreshConstraints() {
const alignmentConstraintX: AlignmentConstraint = {
type: 'alignment',
axis: 'x',
offsets: [],
};
const alignmentConstraintY: AlignmentConstraint = {
type: 'alignment',
axis: 'y',
offsets: [],
};
// Iterate over shapes and generate constraints based on conditions
for (const node of this.colaNodes.values()) {
if (node.color === "red") {
// Add alignment offset for red shapes
alignmentConstraintX.offsets.push({ node: node.id, offset: 0 });
}
if (node.color === "blue") {
// Add alignment offset for red shapes
alignmentConstraintY.offsets.push({ node: node.id, offset: 0 });
}
}
const constraints = [];
if (alignmentConstraintX.offsets.length > 0) {
constraints.push(alignmentConstraintX);
}
if (alignmentConstraintY.offsets.length > 0) {
constraints.push(alignmentConstraintY);
}
this.colaConstraints = constraints;
}
}
function getCornerToCenterOffset(w: number, h: number, rotation: number) {
// Calculate the center coordinates relative to the top-left corner
const centerX = w / 2;
const centerY = h / 2;
// Apply rotation to the center coordinates
const rotatedCenterX = centerX * Math.cos(rotation) - centerY * Math.sin(rotation);
const rotatedCenterY = centerX * Math.sin(rotation) + centerY * Math.cos(rotation);
return { x: rotatedCenterX, y: rotatedCenterY };
}
function calcEdgeDistance(edge: ColaNodeLink) {
const LINK_DISTANCE = 100;
// horizontal and vertical distances between centers
const dx = edge.target.x - edge.source.x;
const dy = edge.target.y - edge.source.y;
// the angles of the nodes in radians
const sourceAngle = edge.source.rotation;
const targetAngle = edge.target.rotation;
// Calculate the rotated dimensions of the nodes
const sourceWidth = Math.abs(edge.source.width * Math.cos(sourceAngle)) + Math.abs(edge.source.height * Math.sin(sourceAngle));
const sourceHeight = Math.abs(edge.source.width * Math.sin(sourceAngle)) + Math.abs(edge.source.height * Math.cos(sourceAngle));
const targetWidth = Math.abs(edge.target.width * Math.cos(targetAngle)) + Math.abs(edge.target.height * Math.sin(targetAngle));
const targetHeight = Math.abs(edge.target.width * Math.sin(targetAngle)) + Math.abs(edge.target.height * Math.cos(targetAngle));
// Calculate edge-to-edge distances
const horizontalGap = Math.max(0, Math.abs(dx) - (sourceWidth + targetWidth) / 2);
const verticalGap = Math.max(0, Math.abs(dy) - (sourceHeight + targetHeight) / 2);
// Calculate straight-line distance between the centers of the nodes
const centerToCenterDistance = Math.sqrt(dx * dx + dy * dy);
// Adjust the distance by subtracting the edge-to-edge distance and adding the desired travel distance
const adjustedDistance = centerToCenterDistance -
Math.sqrt(horizontalGap * horizontalGap + verticalGap * verticalGap) +
LINK_DISTANCE;
return adjustedDistance;
};

90
src/graph/GraphUi.tsx Normal file
View File

@ -0,0 +1,90 @@
import { useEditor } from "@tldraw/tldraw";
import { useEffect } from "react";
import "../css/dev-ui.css";
import { useCollection } from "@/collections";
export const GraphUi = () => {
const editor = useEditor();
const { collection, size } = useCollection('graph')
const handleAdd = () => {
if (collection) {
collection.add(editor.getSelectedShapes())
editor.selectNone()
}
}
const handleRemove = () => {
if (collection) {
collection.remove(editor.getSelectedShapes())
editor.selectNone()
}
}
const handleShortcut = () => {
if (!collection) return
const empty = collection.getShapes().size === 0
if (empty)
collection.add(editor.getCurrentPageShapes())
else
collection.clear()
};
const handleHighlight = () => {
if (collection) {
editor.setHintingShapes([...collection.getShapes().values()])
}
}
const handleHelp = () => {
alert("Use the 'Add' and 'Remove' buttons to add/remove selected shapes, or hit 'G' to add/remove all shapes. \n\nUse the highlight button (🔦) to visualize shapes in the simulation. \n\nBLUE shapes are constrained horizontally, RED shapes are constrained vertically. This is just to demo basic constraints, I plan to demo more interesting constraints in the future. \n\nFor more details, check the project's README.");
}
useEffect(() => {
window.addEventListener('toggleGraphLayoutEvent', handleShortcut);
return () => {
window.removeEventListener('toggleGraphLayoutEvent', handleShortcut);
};
}, [handleShortcut]);
return (
<div className="custom-layout">
<div className="custom-toolbar">
<div>{size} shapes</div>
<button
type="button"
title="Add Selected"
className="custom-button"
onClick={handleAdd}
>
Add
</button>
<button
type="button"
title="Remove Selected"
className="custom-button"
onClick={handleRemove}
>
Remove
</button>
<button
type="button"
title="Highlight Collection"
className="custom-button"
onClick={handleHighlight}
>
🔦
</button>
<button
type="button"
title="Show Help"
className="custom-button"
onClick={handleHelp}
>
</button>
</div>
</div>
);
};

21
src/graph/uiOverrides.ts Normal file
View File

@ -0,0 +1,21 @@
import {
TLUiEventSource,
TLUiOverrides,
TLUiTranslationKey,
} from "@tldraw/tldraw";
export const uiOverrides: TLUiOverrides = {
actions(_editor, actions) {
actions['toggle-graph-layout'] = {
id: 'toggle-graph-layout',
label: 'Toggle Graph Layout' as TLUiTranslationKey,
readonlyOk: true,
kbd: 'g',
onSelect(_source: TLUiEventSource) {
const event = new CustomEvent('toggleGraphLayoutEvent');
window.dispatchEvent(event);
},
}
return actions
}
}

View File

@ -0,0 +1,87 @@
import { useEffect } from "react"
import { Editor, TLEventMap, TLFrameShape, TLParentId } from "tldraw"
import { cameraHistory } from "@/ui/cameraUtils"
// Define camera state interface
interface CameraState {
x: number
y: number
z: number
}
const MAX_HISTORY = 10
// Track camera changes
const trackCameraChange = (editor: Editor) => {
const currentCamera = editor.getCamera()
const lastPosition = cameraHistory[cameraHistory.length - 1]
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) {
// Track camera changes
useEffect(() => {
if (!editor) return
const handler = () => {
trackCameraChange(editor)
}
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])
// 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)
}

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

@ -0,0 +1,166 @@
import { useSync } from "@tldraw/sync"
import { useMemo, useEffect, useState } from "react"
import { Tldraw, Editor, TLShapeId } 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 {
lockElement,
unlockElement,
setInitialCameraFromUrl,
initLockIndicators,
watchForLockedShapes,
} from "@/ui/cameraUtils"
import { Collection, initializeGlobalCollections } from "@/collections"
import { GraphLayoutCollection } from "@/graph/GraphLayoutCollection"
import { GestureTool } from "@/GestureTool"
import { CmdK } from "@/CmdK"
import "react-cmdk/dist/cmdk.css"
import "@/css/style.css"
const collections: Collection[] = [GraphLayoutCollection]
// 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,
GestureTool,
]
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)
}
}, [])
// Remove the URL-based locking effect and replace with store-based initialization
useEffect(() => {
if (!editor) return
initLockIndicators(editor)
watchForLockedShapes(editor)
}, [editor])
return (
<div style={{ position: "fixed", inset: 0 }}>
<Tldraw
store={store.store}
shapeUtils={customShapeUtils}
tools={customTools}
components={components}
overrides={{
...overrides,
actions: (editor, actions, helpers) => {
const customActions = overrides.actions?.(editor, actions, helpers) ?? {}
return {
...actions,
...customActions,
}
}
}}
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")
setInitialCameraFromUrl(editor)
handleInitialPageLoad(editor)
registerPropagators(editor, [
TickPropagator,
ChangePropagator,
ClickPropagator,
])
// Initialize global collections
initializeGlobalCollections(editor, collections)
}}
>
<CmdK />
</Tldraw>
</div>
)
}

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

@ -0,0 +1,61 @@
export function Contact() {
return (
<main>
<header>
<a href="/">Jeff Emmett</a>
</header>
<h1>Contact</h1>
<div style={{ marginBottom: '2rem' }}>
<h2>Schedule a Meeting</h2>
<p>Book a 30-minute meeting with me:</p>
<iframe
src="https://zcal.co/i/wvI6_DQG?embed=1&embedType=iframe"
loading="lazy"
style={{
border: 'none',
minWidth: '320px',
minHeight: '544px',
height: '731px',
width: '1096px'
}}
id="zcal-invite"
scrolling="no"
title="Schedule a meeting with Jeff Emmett"
/>
</div>
<div style={{ marginBottom: '2rem' }}>
<h2>Blog</h2>
<p>
<a href="https://allthingsdecent.substack.com/" target="_blank" rel="noopener noreferrer">
All Things Decent
</a> - Researching the biggest ideas that could make the world a more decent place
</p>
</div>
<div>
<h2>Connect & Follow</h2>
<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 (at) gmail.com">jeffemmett (at)gmail.com</a>
</p>
<p>
GitHub: <a href="https://github.com/Jeff-Emmett">Jeff-Emmett</a>
</p>
</div>
</main>
)
}

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

@ -0,0 +1,114 @@
export function Default() {
return (
<main>
<header>Jeff Emmett</header>
<nav className="main-nav">
<a href="/presentations" className="nav-link">Presentations</a>
<a href="/contact" className="nav-link">Contact</a>
</nav>
<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>
<p>
You can find my presentations and slides on the{" "}
<a href="/presentations">presentations page</a>.
</p>
<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,292 @@
export function Presentations() {
return (
<main>
<header>Jeff's Presentations</header>
<div className="presentations-info">
<h3>About These Presentations</h3>
<p>
These presentations represent my ongoing research into the intersection of
mycelium networks, emancipatory technologies, and convivial tooling. Each
presentation explores different aspects of how we can design systems that
support collective action and community self-organization.
</p>
<p>
For more of my work, check out my <a href="/">main page</a> or
<a href="/contact">get in touch</a>.
</p>
</div>
<div className="presentations-grid">
<div className="presentation-card">
<h3>Osmotic Governance</h3>
<p>Exploring the intersection of mycelium and emancipatory technologies</p>
<div className="presentation-embed">
<div style={{position: "relative", paddingTop: "max(60%, 324px)", width: "100%", height: 0}}>
<iframe
style={{position: "absolute", border: "none", width: "100%", height: "100%", left: 0, top: 0}}
src="https://online.fliphtml5.com/phqos/xfym/"
seamless={true}
scrolling="no"
frameBorder="0"
allowTransparency={true}
allowFullScreen={true}
title="Osmotic Governance Presentation"
/>
</div>
</div>
<div className="presentation-meta">
<span>Team Human with Douglas Rushkoff</span>
<a href="https://www.teamhuman.fm/episodes/238-jeff-emmett" target="_blank" rel="noopener noreferrer">
Listen to the full episode
</a>
</div>
</div>
<div className="presentation-card">
<h3>Exploring MycoFi</h3>
<p>Mycelial design patterns for Web3 and beyond</p>
<div className="presentation-embed">
<div style={{position: "relative", paddingTop: "max(60%, 324px)", width: "100%", height: 0}}>
<iframe
style={{position: "absolute", border: "none", width: "100%", height: "100%", left: 0, top: 0}}
src="https://online.fliphtml5.com/phqos/bqra/"
seamless={true}
scrolling="no"
frameBorder="0"
allowTransparency={true}
allowFullScreen={true}
title="Exploring MycoFi Presentation"
/>
</div>
</div>
<div className="presentation-meta">
<span>Presentation at DevCon 7 in Bangkok</span>
<a href="https://www.youtube.com/watch?v=0A4jXL5eBaI" target="_blank" rel="noopener noreferrer">
Watch the full talk
</a>
</div>
</div>
<div className="presentation-card">
<h3>MycoFi talk at CoFi gathering</h3>
<p>Mycelial design patterns for Web3 and beyond</p>
<div className="presentation-embed">
<div style={{position: "relative", paddingTop: "max(60%, 324px)", width: "100%", height: 0}}>
<iframe
style={{position: "absolute", border: "none", width: "100%", height: "100%", left: 0, top: 0}}
src="https://online.fliphtml5.com/phqos/vwmt/"
seamless={true}
scrolling="no"
frameBorder="0"
allowTransparency={true}
allowFullScreen={true}
title="MycoFi Presentation at CoFi Gathering"
/>
</div>
</div>
<div className="presentation-meta">
<span>Exploring MycoFi on the Greenpill Network</span>
<a href="https://www.youtube.com/watch?v=AFJFDajuCSg" target="_blank" rel="noopener noreferrer">
Watch the full talk
</a>
</div>
</div>
<div className="presentation-card">
<h3>Myco-Mutualism</h3>
<p>Exploring mutualistic relationships in mycelial networks and their applications to human systems</p>
<div className="presentation-embed">
<div style={{position: "relative", paddingTop: "max(60%, 324px)", width: "100%", height: 0}}>
<iframe
style={{position: "absolute", border: "none", width: "100%", height: "100%", left: 0, top: 0}}
src="https://online.fliphtml5.com/phqos/caal/"
seamless={true}
scrolling="no"
frameBorder="0"
allowTransparency={true}
allowFullScreen={true}
title="Myco-Mutualism Presentation"
/>
</div>
</div>
<div className="presentation-meta">
<span>Presentation at the Mutualist Society</span>
<span>Video coming soon</span>
</div>
</div>
<div className="presentation-card">
<h3>Psilocybernetics: The Emergence of Institutional Neuroplasticity</h3>
<p>Exploring the intersection of mycelium and cybernetic institutional design</p>
<div className="presentation-embed">
<div style={{position: "relative", paddingTop: "max(60%, 324px)", width: "100%", height: 0}}>
<iframe
style={{position: "absolute", border: "none", width: "100%", height: "100%", left: 0, top: 0}}
src="https://online.fliphtml5.com/phqos/pnlz/"
seamless={true}
scrolling="no"
frameBorder="0"
allowTransparency={true}
allowFullScreen={true}
title="Psilocybernetics Presentation"
/>
</div>
</div>
<div className="presentation-meta">
<span>Presentation at the General Forum for Ethereum Localism</span>
<span>Video coming soon</span>
</div>
</div>
<div className="presentation-card">
<h3>Move Slow & Fix Things: The Commons Stack Design Pattern</h3>
<p>Design patterns for sustainable commons infrastructure</p>
<div className="presentation-embed">
<div style={{position: "relative", paddingTop: "max(60%, 324px)", width: "100%", height: 0}}>
<iframe
style={{position: "absolute", border: "none", width: "100%", height: "100%", left: 0, top: 0}}
src="https://online.fliphtml5.com/phqos/bnnf/"
seamless={true}
scrolling="no"
frameBorder="0"
allowTransparency={true}
allowFullScreen={true}
title="Move Slow & Fix Things: Commons Stack Design Pattern"
/>
</div>
</div>
<div className="presentation-meta">
<span>Presentation at the ReFi Unconf @ the Commons Hub Austria</span>
<a href="https://www.youtube.com/live/i8qcg7FfpLM?si=onLcl8q5rz7cMViO&t=1362" target="_blank" rel="noopener noreferrer">
Watch the full talk
</a>
</div>
</div>
<div className="presentation-card">
<h3>Commons Stack Launch & Open Sourcing cadCAD</h3>
<p>The launch of Commons Stack and the open sourcing of cadCAD for token engineering</p>
<div className="presentation-embed">
<div style={{position: "relative", paddingTop: "max(60%, 324px)", width: "100%", height: 0}}>
<iframe
style={{position: "absolute", border: "none", width: "100%", height: "100%", left: 0, top: 0}}
src="https://online.fliphtml5.com/phqos/hxac/"
seamless={true}
scrolling="no"
frameBorder="0"
allowTransparency={true}
allowFullScreen={true}
title="Commons Stack Launch & Open Sourcing cadCAD"
/>
</div>
</div>
<div className="presentation-meta">
<span>Presentation at the Token Engineering Global Gathering (TEGG)</span>
<a href="https://youtu.be/qjdjX2m_p0Q?si=r2AXVnVyzCIxIOSc&t=20" target="_blank" rel="noopener noreferrer">
Watch the full talk
</a>
</div>
</div>
<div className="presentation-card">
<h3>New Tools for Dynamic Collective Intelligence: Conviction Voting & Variations</h3>
<p>Exploring innovative voting mechanisms for collective decision-making in decentralized systems</p>
<div className="presentation-embed">
<div style={{position: "relative", paddingTop: "max(60%, 324px)", width: "100%", height: 0}}>
<iframe
style={{position: "absolute", border: "none", width: "100%", height: "100%", left: 0, top: 0}}
src="https://online.fliphtml5.com/phqos/fhos/"
seamless={true}
scrolling="no"
frameBorder="0"
allowTransparency={true}
allowFullScreen={true}
title="New Tools for Dynamic Collective Intelligence"
/>
</div>
</div>
<div className="presentation-meta">
<span>Presentation on Conviction Voting</span>
<span>Video coming soon</span>
</div>
</div>
<div className="presentation-card">
<h3>Exploring Polycentric Governance in Web3 Ecosystems</h3>
<p>Understanding multi-level governance structures in decentralized networks</p>
<div className="presentation-embed">
<div style={{position: "relative", paddingTop: "max(60%, 324px)", width: "100%", height: 0}}>
<iframe
style={{position: "absolute", border: "none", width: "100%", height: "100%", left: 0, top: 0}}
src="https://online.fliphtml5.com/phqos/zzoy/"
seamless={true}
scrolling="no"
frameBorder="0"
allowTransparency={true}
allowFullScreen={true}
title="Exploring Polycentric Governance in Web3 Ecosystems"
/>
</div>
</div>
<div className="presentation-meta">
<span>Presentation at the OpenWeb Hackathon</span>
<a href="https://youtu.be/ZIWskNogafg?si=DmUbOQJaSRE1rdzq" target="_blank" rel="noopener noreferrer">
Watch the full talk
</a>
</div>
</div>
<div className="presentation-card">
<h3>MycoFi for Myco-munnities</h3>
<p>Exploring mycelial financial systems for community-based organizations</p>
<div className="presentation-embed">
<div style={{position: "relative", paddingTop: "max(60%, 324px)", width: "100%", height: 0}}>
<iframe
style={{position: "absolute", border: "none", width: "100%", height: "100%", left: 0, top: 0}}
src="https://online.fliphtml5.com/phqos/xoea/"
seamless={true}
scrolling="no"
frameBorder="0"
allowTransparency={true}
allowFullScreen={true}
title="MycoFi for Myco-munnities"
/>
</div>
</div>
<div className="presentation-meta">
<span>Presentation at CoFi Gathering in Liege</span>
<span>Video coming soon</span>
</div>
</div>
<div className="presentation-card">
<h3>Building Community Resilience in an Age of Crisis</h3>
<p>Internet outages during crises, such as wars or environmental disasters, can disrupt communication, education, and access to vital information. Preparing for such disruptions is essential for communities and organizations operating in challenging environments.</p>
<div className="presentation-embed">
<div style={{position: "relative", paddingTop: "max(60%, 324px)", width: "100%", height: 0}}>
<iframe
style={{position: "absolute", border: "none", width: "100%", height: "100%", left: 0, top: 0}}
src="https://online.fliphtml5.com/phqos/afbp/"
seamless={true}
scrolling="no"
frameBorder="0"
allowTransparency={true}
allowFullScreen={true}
title="Building Community Resilience in an Age of Crisis"
/>
</div>
</div>
<div className="presentation-meta">
<span>Presentation at re:publica conference in May 2025</span>
<a href="https://www.youtube.com/watch?v=rTOLk7k9Ad8" target="_blank" rel="noopener noreferrer">
Watch the full talk
</a>
</div>
</div>
</div>
</main>
)
}

133
src/routes/Resilience.tsx Normal file
View File

@ -0,0 +1,133 @@
export function Resilience() {
return (
<main>
<header>
<a href="/">Jeff Emmett</a>
</header>
<div className="presentation-info">
<h1>Building Community Resilience in an Age of Crisis</h1>
<p>
Internet outages during crises, such as wars or environmental disasters, can disrupt
communication, education, and access to vital information. Preparing for such disruptions
is essential for communities and organizations operating in challenging environments.
</p>
<p>
This presentation by Jeff Emmett and Fadia Elgharib explores strategies for building resilient communication networks
and maintaining access to critical information when traditional internet infrastructure fails.
</p>
</div>
<div className="presentation-embed">
<h2>Full Presentation</h2>
<div style={{position: "relative", paddingTop: "max(60%, 324px)", width: "100%", height: 0}}>
<iframe
style={{position: "absolute", border: "none", width: "100%", height: "100%", left: 0, top: 0}}
src="https://online.fliphtml5.com/phqos/afbp/"
seamless={true}
scrolling="no"
frameBorder="0"
allowTransparency={true}
allowFullScreen={true}
title="Building Community Resilience in an Age of Crisis"
/>
</div>
</div>
<div className="video-clips">
<h2>Video Clips</h2>
<div className="video-section">
<h3>Full Talk at re:publica Conference</h3>
<div className="video-container">
<iframe
width="560"
height="315"
src="https://www.youtube.com/embed/rTOLk7k9Ad8?si=Ye0gmEg_JhWbxS2a"
title="YouTube video player"
frameBorder="0"
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share"
referrerPolicy="strict-origin-when-cross-origin"
allowFullScreen
/>
</div>
</div>
<div className="video-section">
<h3>Clip 1: How can we mitigate crisis as a global community?</h3>
<div className="video-container">
<iframe
width="560"
height="315"
src="https://www.youtube.com/embed/_ZVzlTzkv8Q?si=zKq2PatVXDDsC-71"
title="YouTube video player"
frameBorder="0"
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share"
referrerPolicy="strict-origin-when-cross-origin"
allowFullScreen
/>
</div>
</div>
<div className="video-section">
<h3>Clip 2: The moral imperative for collective harm reduction</h3>
<div className="video-container">
<iframe
width="560"
height="315"
src="https://www.youtube.com/embed/sriN8AC_EKc?si=7lQS1gEHtEMXWjzD"
title="YouTube video player"
frameBorder="0"
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share"
referrerPolicy="strict-origin-when-cross-origin"
allowFullScreen
/>
</div>
</div>
<div className="video-section">
<h3>Clip 3: The need for protocols of community resilience</h3>
<div className="video-container">
<iframe
width="560"
height="315"
src="https://www.youtube.com/embed/CufzFR_-JOg?si=kmhN39UaZskAyFCh"
title="YouTube video player"
frameBorder="0"
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share"
referrerPolicy="strict-origin-when-cross-origin"
allowFullScreen
/>
</div>
</div>
<div className="video-section">
<h3>Clip 4: Cosmo-localism and protocols of community care</h3>
<div className="video-container">
<iframe
width="560"
height="315"
src="https://www.youtube.com/embed/IjMqssomIww?si=veLdLpGcdsyMRkey"
title="YouTube video player"
frameBorder="0"
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share"
referrerPolicy="strict-origin-when-cross-origin"
allowFullScreen
/>
</div>
</div>
</div>
<div className="presentation-meta">
<p>
<strong>Event:</strong> re:publica conference, May 2025<br />
<strong>Location:</strong> Berlin, Germany<br />
<strong>Topic:</strong> Building Community Resilience in an Age of Crisis
</p>
<p>
<a href="/presentations"> Back to all presentations</a>
</p>
</div>
</main>
)
}

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,626 @@
import { BaseBoxShapeUtil, TLBaseShape } from "tldraw"
import { useCallback, useState } from "react"
//import Embed from "react-embed"
//TODO: FIX PEN AND MOBILE INTERACTION WITH EDITING EMBED URL - DEFAULT TO TEXT SELECTED
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,
cursor: 'text', // Add text cursor to indicate clickable
touchAction: 'none', // Prevent touch scrolling
}}
onClick={(e) => {
e.preventDefault()
e.stopPropagation()
const input = e.currentTarget.querySelector('input')
input?.focus()
}}
>
<form
onSubmit={handleSubmit}
style={{
width: "100%",
height: "100%",
padding: "10px",
display: 'flex',
flexDirection: 'column',
justifyContent: 'center',
}}
onClick={(e) => e.stopPropagation()}
>
<input
type="text"
value={inputUrl}
onChange={(e) => setInputUrl(e.target.value)}
placeholder="Enter URL to embed"
style={{
width: "100%",
padding: "15px", // Increased padding for better touch target
border: "1px solid #ccc",
borderRadius: "4px",
fontSize: "16px", // Increased font size for better visibility
touchAction: 'none',
}}
onKeyDown={(e) => {
if (e.key === "Enter") {
handleSubmit(e)
}
}}
onPointerDown={(e) => {
e.stopPropagation()
e.currentTarget.focus()
}}
/>
{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)
}
}
// Update the pointer down handler
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,178 @@
import React from 'react'
import MDEditor from '@uiw/react-md-editor'
import { BaseBoxShapeUtil, TLBaseShape } from '@tldraw/tldraw'
export type IMarkdownShape = TLBaseShape<
'Markdown',
{
w: number
h: number
text: string
}
>
export class MarkdownShape extends BaseBoxShapeUtil<IMarkdownShape> {
static type = 'Markdown' as const
getDefaultProps(): IMarkdownShape['props'] {
return {
w: 500,
h: 400,
text: '',
}
}
component(shape: IMarkdownShape) {
// Hooks must be at the top level
const isSelected = this.editor.getSelectedShapeIds().includes(shape.id)
const markdownRef = React.useRef<HTMLDivElement>(null)
// Single useEffect hook that handles checkbox interactivity
React.useEffect(() => {
if (!isSelected && markdownRef.current) {
const checkboxes = markdownRef.current.querySelectorAll('input[type="checkbox"]')
checkboxes.forEach((checkbox) => {
checkbox.removeAttribute('disabled')
checkbox.addEventListener('click', handleCheckboxClick)
})
// Cleanup function
return () => {
if (markdownRef.current) {
const checkboxes = markdownRef.current.querySelectorAll('input[type="checkbox"]')
checkboxes.forEach((checkbox) => {
checkbox.removeEventListener('click', handleCheckboxClick)
})
}
}
}
}, [isSelected, shape.props.text])
// Handler function defined outside useEffect
const handleCheckboxClick = (event: Event) => {
event.stopPropagation()
const target = event.target as HTMLInputElement
const checked = target.checked
const text = shape.props.text
const lines = text.split('\n')
const checkboxRegex = /^\s*[-*+]\s+\[([ x])\]/
const newText = lines.map(line => {
if (line.includes(target.parentElement?.textContent || '')) {
return line.replace(checkboxRegex, `- [${checked ? 'x' : ' '}]`)
}
return line
}).join('\n')
this.editor.updateShape<IMarkdownShape>({
id: shape.id,
type: 'Markdown',
props: {
...shape.props,
text: newText,
},
})
}
const wrapperStyle: React.CSSProperties = {
width: '100%',
height: '100%',
backgroundColor: 'white',
border: '1px solid #ddd',
borderRadius: '4px',
overflow: 'hidden',
}
// Simplified contentStyle - removed padding and center alignment
const contentStyle: React.CSSProperties = {
width: '100%',
height: '100%',
backgroundColor: '#FFFFFF',
cursor: isSelected ? 'text' : 'default',
pointerEvents: 'all',
}
// Show MDEditor when selected
if (isSelected) {
return (
<div style={wrapperStyle}>
<div style={contentStyle}>
<MDEditor
value={shape.props.text}
onChange={(value = '') => {
this.editor.updateShape<IMarkdownShape>({
id: shape.id,
type: 'Markdown',
props: {
...shape.props,
text: value,
},
})
}}
preview='live'
visibleDragbar={false}
style={{
height: 'auto',
minHeight: '100%',
border: 'none',
backgroundColor: 'transparent',
}}
previewOptions={{
style: {
padding: '8px',
backgroundColor: 'transparent',
}
}}
textareaProps={{
style: {
padding: '8px',
lineHeight: '1.5',
height: 'auto',
minHeight: '100%',
resize: 'none',
backgroundColor: 'transparent',
}
}}
onPointerDown={(e) => {
e.stopPropagation()
}}
/>
</div>
</div>
)
}
// Show rendered markdown when not selected
return (
<div style={wrapperStyle}>
<div style={contentStyle}>
<div ref={markdownRef} style={{ width: '100%', height: '100%', padding: '12px' }}>
{shape.props.text ? (
<MDEditor.Markdown source={shape.props.text} />
) : (
<span style={{ opacity: 0.5 }}>Click to edit markdown...</span>
)}
</div>
</div>
</div>
)
}
indicator(shape: IMarkdownShape) {
return <rect width={shape.props.w} height={shape.props.h} />
}
// Add handlers for better interaction
override onDoubleClick = (shape: IMarkdownShape) => {
const textarea = document.querySelector(`[data-shape-id="${shape.id}"] textarea`) as HTMLTextAreaElement
textarea?.focus()
}
onPointerDown = (shape: IMarkdownShape) => {
if (!shape.props.text) {
const textarea = document.querySelector(`[data-shape-id="${shape.id}"] textarea`) as HTMLTextAreaElement
textarea?.focus()
}
}
}

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,457 @@
import {
BaseBoxShapeUtil,
HTMLContainer,
TLBaseShape,
TLGeoShape,
TLShape,
} from "tldraw"
import { getEdge } from "@/propagators/tlgraph"
import { llm } from "@/utils/llmUtils"
import { isShapeOfType } from "@/propagators/utils"
import React, { useState } from "react"
type IPrompt = TLBaseShape<
"Prompt",
{
w: number
h: number
prompt: string
value: string
agentBinding: string | null
}
>
// Add this SVG copy icon component at the top level of the file
const CopyIcon = () => (
<svg width="14" height="14" viewBox="0 0 24 24" fill="currentColor">
<path d="M16 1H4C2.9 1 2 1.9 2 3V17H4V3H16V1ZM19 5H8C6.9 5 6 5.9 6 7V21C6 22.1 6.9 23 8 23H19C20.1 23 21 22.1 21 21V7C21 5.9 20.1 5 19 5ZM19 21H8V7H19V21Z"/>
</svg>
)
const CheckIcon = () => (
<svg width="14" height="14" viewBox="0 0 24 24" fill="currentColor">
<path d="M9 16.17L4.83 12l-1.42 1.41L9 19 21 7l-1.41-1.41L9 16.17z"/>
</svg>
)
export class PromptShape extends BaseBoxShapeUtil<IPrompt> {
static override type = "Prompt" as const
FIXED_HEIGHT = 500 as const
MIN_WIDTH = 200 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) => {
const conversationHistory = shape.props.value ? shape.props.value + '\n' : ''
const escapedPrompt = prompt.replace(/[\\"]/g, '\\$&').replace(/\n/g, '\\n')
const userMessage = `{"role": "user", "content": "${escapedPrompt}"}`
// Update with user message and trigger scroll
this.editor.updateShape<IPrompt>({
id: shape.id,
type: "Prompt",
props: {
value: conversationHistory + userMessage,
agentBinding: "someone"
},
})
let fullResponse = ''
await llm(prompt, localStorage.getItem("openai_api_key") || "", (partial: string, done: boolean) => {
if (partial) {
fullResponse = partial
const escapedResponse = partial.replace(/[\\"]/g, '\\$&').replace(/\n/g, '\\n')
const assistantMessage = `{"role": "assistant", "content": "${escapedResponse}"}`
try {
JSON.parse(assistantMessage)
// Use requestAnimationFrame to ensure smooth scrolling during streaming
requestAnimationFrame(() => {
this.editor.updateShape<IPrompt>({
id: shape.id,
type: "Prompt",
props: {
value: conversationHistory + userMessage + '\n' + assistantMessage,
agentBinding: done ? null : "someone"
},
})
})
} catch (error) {
console.error('Invalid JSON message:', error)
}
}
})
// Ensure the final message is saved after streaming is complete
if (fullResponse) {
const escapedResponse = fullResponse.replace(/[\\"]/g, '\\$&').replace(/\n/g, '\\n')
const assistantMessage = `{"role": "assistant", "content": "${escapedResponse}"}`
try {
// Verify the final message is valid JSON before updating
JSON.parse(assistantMessage)
this.editor.updateShape<IPrompt>({
id: shape.id,
type: "Prompt",
props: {
value: conversationHistory + userMessage + '\n' + assistantMessage,
agentBinding: null
},
})
} catch (error) {
console.error('Invalid JSON in final message:', error)
}
}
}
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,
)
}
}
}
generateText(processedPrompt)
this.editor.updateShape<IPrompt>({
id: shape.id,
type: "Prompt",
props: { prompt: "" },
})
}
// Add state for copy button text
const [copyButtonText, setCopyButtonText] = React.useState("Copy Conversation")
// In the component function, add state for tracking copy success
const [isCopied, setIsCopied] = React.useState(false)
// In the component function, update the state to track which message was copied
const [copiedIndex, setCopiedIndex] = React.useState<number | null>(null)
// Add ref for the chat container
const chatContainerRef = React.useRef<HTMLDivElement>(null)
// Add function to scroll to bottom
const scrollToBottom = () => {
if (chatContainerRef.current) {
// Use requestAnimationFrame for smooth scrolling
requestAnimationFrame(() => {
if (chatContainerRef.current) {
chatContainerRef.current.scrollTop = chatContainerRef.current.scrollHeight
}
})
}
}
// Use both value and agentBinding as dependencies to catch all updates
React.useEffect(() => {
scrollToBottom()
}, [shape.props.value, shape.props.agentBinding])
const handleCopy = async () => {
try {
// Parse and format each message
const messages = shape.props.value
.split('\n')
.filter(line => line.trim())
.map(line => {
try {
const parsed = JSON.parse(line);
return `${parsed.role}: ${parsed.content}`;
} catch {
return null;
}
})
.filter(Boolean)
.join('\n\n');
await navigator.clipboard.writeText(messages);
setCopyButtonText("Copied!");
setTimeout(() => {
setCopyButtonText("Copy Conversation");
}, 2000);
} catch (err) {
console.error('Failed to copy text:', err);
setCopyButtonText("Failed to copy");
setTimeout(() => {
setCopyButtonText("Copy Conversation");
}, 2000);
}
};
const isSelected = this.editor.getSelectedShapeIds().includes(shape.id)
const [isHovering, setIsHovering] = useState(false)
return (
<HTMLContainer
style={{
borderRadius: 6,
border: "1px solid lightgrey",
padding: this.PADDING,
height: this.FIXED_HEIGHT,
width: shape.props.w,
pointerEvents: isSelected || isHovering ? "all" : "none",
backgroundColor: "#efefef",
overflow: "visible",
display: "flex",
flexDirection: "column",
justifyContent: "space-between",
alignItems: "stretch",
outline: shape.props.agentBinding ? "2px solid orange" : "none",
}}
//TODO: FIX SCROLL IN PROMPT CHAT WHEN HOVERING OVER ELEMENT
onPointerEnter={() => setIsHovering(true)}
onPointerLeave={() => setIsHovering(false)}
onWheel={(e) => {
if (isSelected || isHovering) {
e.preventDefault()
e.stopPropagation()
if (chatContainerRef.current) {
chatContainerRef.current.scrollTop += e.deltaY
}
}
}}
>
<div
ref={chatContainerRef}
style={{
padding: "4px 8px",
flex: 1,
backgroundColor: "white",
borderRadius: "4px",
marginBottom: "4px",
fontSize: "14px",
overflowY: "auto",
whiteSpace: "pre-wrap",
fontFamily: "monospace",
pointerEvents: isSelected || isHovering ? "all" : "none",
}}
>
{shape.props.value ? (
shape.props.value.split('\n').map((message, index) => {
if (!message.trim()) return null;
try {
const parsed = JSON.parse(message);
const isUser = parsed.role === "user";
return (
<div
key={index}
style={{
display: 'flex',
flexDirection: 'column',
alignItems: isUser ? 'flex-end' : 'flex-start',
margin: '8px 0',
maxWidth: '100%',
position: 'relative',
}}
>
<div
style={{
padding: '12px 16px',
maxWidth: '80%',
backgroundColor: isUser ? '#007AFF' : '#f0f0f0',
color: isUser ? 'white' : 'black',
borderRadius: isUser ? '18px 18px 4px 18px' : '18px 18px 18px 4px',
boxShadow: '0 1px 2px rgba(0,0,0,0.1)',
}}
>
{parsed.content}
<button
style={{
position: 'absolute',
bottom: '-20px',
right: isUser ? '0' : 'auto',
left: isUser ? 'auto' : '0',
backgroundColor: 'transparent',
border: 'none',
cursor: 'pointer',
padding: '4px',
display: 'flex',
alignItems: 'center',
gap: '4px',
fontSize: '12px',
color: '#666',
opacity: 0.7,
transition: 'opacity 0.2s',
}}
onPointerDown={(e) => {
e.stopPropagation()
}}
onClick={async () => {
try {
await navigator.clipboard.writeText(parsed.content)
setCopiedIndex(index)
setTimeout(() => {
setCopiedIndex(null)
}, 2000)
} catch (err) {
console.error('Failed to copy text:', err)
}
}}
onMouseEnter={(e) => {
e.currentTarget.style.opacity = '1'
}}
onMouseLeave={(e) => {
e.currentTarget.style.opacity = '0.7'
}}
>
{copiedIndex === index ? <CheckIcon /> : <CopyIcon />}
</button>
</div>
</div>
);
} catch {
return null; // Skip invalid JSON
}
})
) : (
"Chat history will appear here..."
)}
</div>
<div style={{
display: "flex",
flexDirection: "column",
gap: "5px",
marginTop: "auto",
pointerEvents: isSelected || isHovering ? "all" : "none",
}}>
<div style={{
display: "flex",
gap: "5px"
}}>
<input
style={{
width: "100%",
height: "40px",
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 },
})
}}
onKeyDown={(e) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault()
handlePrompt()
}
}}
/>
<button
style={{
width: 100,
height: "40px",
pointerEvents: "all",
}}
onPointerDown={(e) => {
e.stopPropagation()
}}
type="button"
onClick={handlePrompt}
>
Prompt
</button>
</div>
<button
style={{
width: "100%",
height: "30px",
pointerEvents: "all",
backgroundColor: "#f0f0f0",
border: "1px solid #ddd",
borderRadius: "4px",
cursor: "pointer",
}}
onPointerDown={(e) => {
e.stopPropagation()
}}
onClick={handleCopy}
>
{copyButtonText}
</button>
</div>
</HTMLContainer>
)
}
// Override the default indicator behavior
// TODO: FIX SECOND INDICATOR UX GLITCH
override indicator(shape: IPrompt) {
return (
<rect
width={shape.props.w}
height={this.FIXED_HEIGHT}
rx={6}
/>
)
}
}

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,397 @@
import { BaseBoxShapeUtil, TLBaseShape } from "tldraw"
import { useEffect, useState } from "react"
interface DailyApiResponse {
url: string;
}
interface DailyRecordingResponse {
id: string;
}
export type IVideoChatShape = TLBaseShape<
"VideoChat",
{
w: number
h: number
roomUrl: string | null
allowCamera: boolean
allowMicrophone: boolean
enableRecording: boolean
recordingId: string | null // Track active recording
}
>
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
}
}
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: "active-speaker"
},
format: "mp4",
mode: "audio-only"
},
auto_start_transcription: true,
recordings_template: "{room_name}/audio-{epoch_time}.mp4"
}
})
});
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 response = await fetch(`${workerUrl}/daily/recordings/start`, {
method: 'POST',
headers: {
'Authorization': `Bearer ${apiKey}`,
'Content-Type': 'application/json'
},
body: JSON.stringify({
room_name: shape.id,
layout: {
preset: "active-speaker"
}
})
});
if (!response.ok) throw new Error('Failed to start recording');
const data = await response.json() as DailyRecordingResponse;
await this.editor.updateShape<IVideoChatShape>({
id: shape.id,
type: shape.type,
props: {
...shape.props,
recordingId: data.id
}
});
} catch (error) {
console.error('Error starting recording:', error);
throw error;
}
}
async stopRecording(shape: IVideoChatShape) {
if (!shape.props.recordingId) return;
const workerUrl = import.meta.env.VITE_TLDRAW_WORKER_URL;
const apiKey = import.meta.env.VITE_DAILY_API_KEY;
try {
await fetch(`${workerUrl}/daily/recordings/${shape.props.recordingId}/stop`, {
method: 'POST',
headers: {
'Authorization': `Bearer ${apiKey}`
}
});
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;
}
}
component(shape: IVideoChatShape) {
const [hasPermissions, setHasPermissions] = useState(false)
const [error, setError] = useState<Error | null>(null)
const [isLoading, setIsLoading] = useState(true)
const [roomUrl, setRoomUrl] = useState<string | null>(shape.props.roomUrl)
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])
if (error) {
return <div>Error creating room: {error.message}</div>
}
if (isLoading || !roomUrl || roomUrl === 'undefined') {
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>
)
}
// Construct URL with permission parameters
const roomUrlWithParams = new URL(roomUrl)
roomUrlWithParams.searchParams.set(
"allow_camera",
String(shape.props.allowCamera),
)
roomUrlWithParams.searchParams.set(
"allow_mic",
String(shape.props.allowMicrophone),
)
console.log(roomUrl)
return (
<div
style={{
width: `${shape.props.w}px`,
height: `${shape.props.h}px`,
position: "relative",
pointerEvents: "all",
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>
{shape.props.enableRecording && (
<button
onClick={async () => {
try {
if (shape.props.recordingId) {
await this.stopRecording(shape);
} else {
await this.startRecording(shape);
}
} catch (err) {
console.error('Recording error:', err);
}
}}
style={{
position: "absolute",
top: "8px",
right: "8px",
padding: "4px 8px",
background: shape.props.recordingId ? "#ff4444" : "#ffffff",
border: "1px solid #ccc",
borderRadius: "4px",
cursor: "pointer",
zIndex: 1,
}}
>
{shape.props.recordingId ? "Stop Recording" : "Start Recording"}
</button>
)}
<p
style={{
position: "absolute",
bottom: 0,
left: 0,
margin: "8px",
padding: "4px 8px",
background: "rgba(255, 255, 255, 0.9)",
borderRadius: "4px",
fontSize: "12px",
pointerEvents: "all",
cursor: "text",
userSelect: "text",
zIndex: 1,
}}
>
url: {roomUrl}
</p>
</div>
)
}
}

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"
}

196
src/ui.tsx Normal file
View File

@ -0,0 +1,196 @@
import { CSSProperties, useEffect, useRef, useState } from "react"
import {
TLComponents,
useEditor,
useValue,
stopEventPropagation,
TLUiOverrides,
TLUiActionsContextType,
TLDrawShape,
TLShapePartial,
} from "tldraw"
import { DollarRecognizer } from "@/gestures"
import { DEFAULT_GESTURES } from "@/default_gestures"
export const overrides: TLUiOverrides = {
tools(editor, tools) {
return {
...tools,
gesture: {
id: "gesture",
name: "Gesture",
icon: "👆",
kbd: "g",
label: "Gesture",
onSelect: () => {
editor.setCurrentTool("gesture")
},
},
}
},
actions(editor, actions): TLUiActionsContextType {
const R = new DollarRecognizer(DEFAULT_GESTURES)
return {
...actions,
recognize: {
id: "recognize",
kbd: "c",
onSelect: () => {
const onlySelectedShape = editor.getOnlySelectedShape()
if (!onlySelectedShape || onlySelectedShape.type !== "draw") return
console.log("recognizing")
const verts = editor.getShapeGeometry(onlySelectedShape).vertices
const result = R.recognize(verts)
console.log(result)
},
},
addGesture: {
id: "addGesture",
kbd: "x",
onSelect: () => {
const onlySelectedShape = editor.getOnlySelectedShape()
if (!onlySelectedShape || onlySelectedShape.type !== "draw") return
const name = onlySelectedShape.meta.name
if (!name) return
console.log("adding gesture:", name)
const points = editor.getShapeGeometry(onlySelectedShape).vertices
R.addGesture(name as string, points)
},
},
recognizeAndSnap: {
id: "recognizeAndSnap",
kbd: "z",
onSelect: () => {
const onlySelectedShape = editor.getOnlySelectedShape()
if (!onlySelectedShape || onlySelectedShape.type !== "draw") return
const points = editor.getShapeGeometry(onlySelectedShape).vertices
const result = R.recognize(points)
console.log("morphing to closest:", result.name)
const newShape: TLShapePartial<TLDrawShape> = {
...onlySelectedShape,
type: "draw",
props: {
...onlySelectedShape.props,
segments: [
{
points: R.unistrokes
.find((u) => u.name === result.name)
?.originalPoints() || [],
type: "free",
},
],
},
}
editor.animateShape(newShape)
},
},
}
},
}
export const components: TLComponents = {
OnTheCanvas: () => {
const editor = useEditor()
const inputRef = useRef<HTMLInputElement>(null)
const [shouldFocus, setShouldFocus] = useState(false)
const onlySelectedShape = useValue(
"onlySelectedShape",
() => editor.getOnlySelectedShape(),
[editor],
)
const isInSelectMode = useValue(
"isInSelectMode",
() => editor.isIn("select"),
[editor],
)
useEffect(() => {
if (shouldFocus) {
inputRef.current?.focus()
setShouldFocus(false)
}
}, [shouldFocus])
if (!isInSelectMode) return null
const shapes = editor.getRenderingShapes()
return shapes.map((_shape, i) => {
const shape = editor.getShape(_shape.id)
const isSelected = editor.getOnlySelectedShapeId() === shape?.id
const offset = 35
const x = shape?.x! - offset * Math.sin(-shape?.rotation!)
const y = shape?.y! - offset * Math.cos(shape?.rotation!)
const labelStyle: CSSProperties = {
fontFamily: "Inter, sans-serif",
position: "absolute",
top: 0,
left: 0,
transformOrigin: "top left",
transform: `translate(${x}px, ${y}px) rotate(${shape?.rotation}rad)`,
pointerEvents: "all",
opacity: 0.8,
fontSize: "1.2rem",
}
const inputStyle: CSSProperties = {
fontFamily: "Inter, sans-serif",
fontSize: "1.2rem",
backgroundColor: "transparent",
padding: 0,
margin: 0,
border: "none",
outline: "none",
}
const handleNameChange = (event: React.ChangeEvent<HTMLInputElement>) => {
const newValue = event.target.value
if (/^[a-z0-9]+$/i.test(newValue) || newValue === "") {
editor.updateShape({
...onlySelectedShape!,
meta: {
...onlySelectedShape!.meta,
name: newValue,
},
})
}
}
if (isSelected) {
return (
<div
key={`${shape.id}-${i}`}
style={labelStyle}
onPointerDown={stopEventPropagation}
>
<span style={{ display: "flex", alignItems: "center" }}>
<span>@</span>
<input
ref={inputRef}
style={inputStyle}
type="text"
placeholder="name"
onChange={handleNameChange}
value={(onlySelectedShape?.meta?.name as string) ?? ""}
/>
</span>
</div>
)
}
return (
<div
onClick={() => {
editor.setSelectedShapes([shape?.id!])
setShouldFocus(true)
}}
onKeyDown={() => {}}
style={labelStyle}
key={`${shape?.id}-${i}`}
>
{shape?.meta?.name && `@${shape?.meta.name}`}
</div>
)
})
},
}

View File

@ -0,0 +1,241 @@
import {
Editor,
TldrawUiMenuActionItem,
TldrawUiMenuItem,
TldrawUiMenuSubmenu,
TLGeoShape,
TLShape,
useDefaultHelpers,
} from "tldraw"
import { TldrawUiMenuGroup } from "tldraw"
import { DefaultContextMenu, DefaultContextMenuContent } from "tldraw"
import { TLUiContextMenuProps, useEditor } from "tldraw"
import {
cameraHistory,
} 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"
import { getCustomActions } from './overrides'
import { overrides } from './overrides'
import { useGlobalCollection } from "@/collections"
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 helpers = useDefaultHelpers()
const tools = overrides.tools?.(editor, {}, helpers) ?? {}
const customActions = getCustomActions(editor)
const [selectedShapes, setSelectedShapes] = useState<TLShape[]>([])
const [selectedIds, setSelectedIds] = useState<string[]>([])
// Collection functionality using the global collection manager
const { collection, size } = useGlobalCollection('graph')
// 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
// Collection handlers
const handleAddToCollection = () => {
if (collection) {
collection.add(editor.getSelectedShapes())
editor.selectNone()
}
}
const handleRemoveFromCollection = () => {
if (collection) {
collection.remove(editor.getSelectedShapes())
editor.selectNone()
}
}
const handleHighlightCollection = () => {
if (collection) {
editor.setHintingShapes([...collection.getShapes().values()])
}
}
// Check if selected shapes are already in collection
const selectedShapesInCollection = collection ?
selectedShapes.filter(shape => collection.getShapes().has(shape.id)) : []
const hasSelectedShapesInCollection = selectedShapesInCollection.length > 0
const allSelectedShapesInCollection = selectedShapes.length > 0 && selectedShapesInCollection.length === selectedShapes.length
// Check if collection functionality is available
const hasCollectionContext = collection !== null
// Keyboard shortcut for adding to collection
useEffect(() => {
const handleKeyDown = (event: KeyboardEvent) => {
if (event.key === 'c' && !event.ctrlKey && !event.altKey && !event.metaKey) {
event.preventDefault()
if (hasSelection && collection && !allSelectedShapesInCollection) {
handleAddToCollection()
}
}
}
document.addEventListener('keydown', handleKeyDown)
return () => {
document.removeEventListener('keydown', handleKeyDown)
}
}, [hasSelection, collection, allSelectedShapesInCollection])
//TO DO: Fix camera history for camera revert
return (
<DefaultContextMenu {...props}>
<DefaultContextMenuContent />
{/* Frames List - Moved to top */}
<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>
{/* Camera Controls Group */}
<TldrawUiMenuGroup id="camera-controls">
<TldrawUiMenuItem {...customActions.zoomToSelection} disabled={!hasSelection} />
<TldrawUiMenuItem {...customActions.copyLinkToCurrentView} />
<TldrawUiMenuItem {...customActions.revertCamera} disabled={!hasCameraHistory} />
<TldrawUiMenuItem {...customActions.lockElement} disabled={!hasSelection} />
<TldrawUiMenuItem {...customActions.unlockElement} disabled={!hasSelection} />
<TldrawUiMenuItem {...customActions.saveToPdf} disabled={!hasSelection} />
<TldrawUiMenuItem {...customActions.llm} disabled={!hasSelection} />
</TldrawUiMenuGroup>
{/* Creation Tools Group */}
<TldrawUiMenuGroup id="creation-tools">
<TldrawUiMenuItem {...tools.VideoChat} disabled={hasSelection} />
<TldrawUiMenuItem {...tools.ChatBox} disabled={hasSelection} />
<TldrawUiMenuItem {...tools.Embed} disabled={hasSelection} />
<TldrawUiMenuItem {...tools.SlideShape} disabled={hasSelection} />
<TldrawUiMenuItem {...tools.Markdown} disabled={hasSelection} />
<TldrawUiMenuItem {...tools.MycrozineTemplate} disabled={hasSelection} />
<TldrawUiMenuItem {...tools.Prompt} disabled={hasSelection} />
</TldrawUiMenuGroup>
{/* Collections Group */}
<TldrawUiMenuGroup id="collections">
<TldrawUiMenuSubmenu id="collections-dropdown" label="Collections">
<TldrawUiMenuItem
id="add-to-collection"
label="Add to Collection"
icon="plus"
kbd="c"
disabled={!hasSelection || !collection}
onSelect={handleAddToCollection}
/>
<TldrawUiMenuItem
id="remove-from-collection"
label="Remove from Collection"
icon="minus"
disabled={!hasSelection || !collection}
onSelect={handleRemoveFromCollection}
/>
<TldrawUiMenuItem
id="highlight-collection"
label="Highlight Collection"
icon="lightbulb"
disabled={!collection}
onSelect={handleHighlightCollection}
/>
</TldrawUiMenuSubmenu>
</TldrawUiMenuGroup>
{/* TODO: FIX & IMPLEMENT BROADCASTING*/}
{/* <TldrawUiMenuGroup id="broadcast-controls">
<TldrawUiMenuItem
id="start-broadcast"
label="Start Broadcasting"
icon="broadcast"
kbd="alt+b"
onSelect={() => {
editor.markHistoryStoppingPoint('start-broadcast')
editor.updateInstanceState({ isBroadcasting: true })
const url = new URL(window.location.href)
url.searchParams.set("followId", editor.user.getId())
window.history.replaceState(null, "", url.toString())
}}
/>
<TldrawUiMenuItem
id="stop-broadcast"
label="Stop Broadcasting"
icon="broadcast-off"
kbd="alt+shift+b"
onSelect={() => {
editor.markHistoryStoppingPoint('stop-broadcast')
editor.updateInstanceState({ isBroadcasting: false })
editor.stopFollowingUser()
const url = new URL(window.location.href)
url.searchParams.delete("followId")
window.history.replaceState(null, "", url.toString())
}}
/>
</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>
</>
)
}

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

@ -0,0 +1,357 @@
import { Editor, TLFrameShape, TLParentId, TLShape, TLShapeId } from "tldraw"
export const cameraHistory: { x: number; y: number; z: number }[] = []
const MAX_HISTORY = 10 // Keep last 10 camera positions
const frameObservers = new Map<string, ResizeObserver>()
// 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()
}
}
}
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", Math.round(newCamera.x).toString())
url.searchParams.set("y", Math.round(newCamera.y).toString())
url.searchParams.set("zoom", Math.round(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) => {
if (!editor.store.serialize()) {
//console.warn("Store not ready")
return
}
try {
const baseUrl = `${window.location.origin}${window.location.pathname}`
const url = new URL(baseUrl)
const camera = editor.getCamera()
// Round camera values to integers
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())
const selectedIds = editor.getSelectedShapeIds()
if (selectedIds.length > 0) {
url.searchParams.set("shapeId", selectedIds[0].toString())
}
const finalUrl = url.toString()
if (navigator.clipboard && window.isSecureContext) {
await navigator.clipboard.writeText(finalUrl)
} else {
const textArea = document.createElement("textarea")
textArea.value = finalUrl
document.body.appendChild(textArea)
try {
await navigator.clipboard.writeText(textArea.value)
} catch (err) {
}
document.body.removeChild(textArea)
}
} catch (error) {
alert("Failed to copy link. Please check clipboard permissions.")
}
}
// Add this function to create lock indicators
const createLockIndicator = (editor: Editor, shape: TLShape) => {
const lockIndicator = document.createElement('div')
lockIndicator.id = `lock-indicator-${shape.id}`
lockIndicator.className = 'lock-indicator'
lockIndicator.innerHTML = '🔒'
// Set styles to position at top-right of shape
lockIndicator.style.position = 'absolute'
lockIndicator.style.right = '3px'
lockIndicator.style.top = '3px'
lockIndicator.style.pointerEvents = 'all'
lockIndicator.style.zIndex = '99999'
lockIndicator.style.background = 'white'
lockIndicator.style.border = '1px solid #ddd'
lockIndicator.style.borderRadius = '4px'
lockIndicator.style.padding = '4px'
lockIndicator.style.cursor = 'pointer'
lockIndicator.style.boxShadow = '0 1px 3px rgba(0,0,0,0.12)'
lockIndicator.style.fontSize = '12px'
lockIndicator.style.lineHeight = '1'
lockIndicator.style.display = 'flex'
lockIndicator.style.alignItems = 'center'
lockIndicator.style.justifyContent = 'center'
lockIndicator.style.width = '20px'
lockIndicator.style.height = '20px'
lockIndicator.style.userSelect = 'none'
// Add hover effect
lockIndicator.onmouseenter = () => {
lockIndicator.style.backgroundColor = '#f0f0f0'
}
lockIndicator.onmouseleave = () => {
lockIndicator.style.backgroundColor = 'white'
}
// Add tooltip and click handlers with stopPropagation
lockIndicator.title = 'Unlock shape'
lockIndicator.addEventListener('click', (e) => {
e.stopPropagation()
e.preventDefault()
unlockElement(editor, shape.id)
}, true)
lockIndicator.addEventListener('mousedown', (e) => {
e.stopPropagation()
e.preventDefault()
}, true)
lockIndicator.addEventListener('pointerdown', (e) => {
e.stopPropagation()
e.preventDefault()
}, true)
const shapeElement = document.querySelector(`[data-shape-id="${shape.id}"]`)
if (shapeElement) {
shapeElement.appendChild(lockIndicator)
}
}
// Modify lockElement to use the new function
export const lockElement = async (editor: Editor) => {
const selectedShapes = editor.getSelectedShapes()
if (selectedShapes.length === 0) return
try {
selectedShapes.forEach(shape => {
editor.updateShape({
id: shape.id,
type: shape.type,
isLocked: true,
meta: {
...shape.meta,
isLocked: true,
canInteract: true, // Allow interactions
canMove: false, // Prevent moving
canResize: false, // Prevent resizing
canEdit: true, // Allow text editing
canUpdateProps: true // Allow updating props (for prompt inputs/outputs)
//TO DO: FIX TEXT INPUT ON LOCKED ELEMENTS (e.g. prompt shape) AND ATTACH TO SCREEN EDGE
}
})
createLockIndicator(editor, shape)
})
} catch (error) {
console.error("Failed to lock elements:", error)
}
}
export const unlockElement = (editor: Editor, shapeId: string) => {
const indicator = document.getElementById(`lock-indicator-${shapeId}`)
if (indicator) {
indicator.remove()
}
const shape = editor.getShape(shapeId as TLShapeId)
if (shape) {
editor.updateShape({
id: shapeId as TLShapeId,
type: shape.type,
isLocked: false,
meta: {
...shape.meta,
isLocked: false,
canInteract: true,
canMove: true,
canResize: true,
canEdit: true,
canUpdateProps: true
}
})
}
}
// Initialize lock indicators based on stored state
export const initLockIndicators = (editor: Editor) => {
editor.getCurrentPageShapes().forEach(shape => {
if (shape.isLocked || shape.meta?.isLocked) {
createLockIndicator(editor, shape)
}
})
}
export const setInitialCameraFromUrl = (editor: Editor) => {
const url = new URL(window.location.href)
const x = url.searchParams.get("x")
const y = url.searchParams.get("y")
const zoom = url.searchParams.get("zoom")
const shapeId = url.searchParams.get("shapeId")
const frameId = url.searchParams.get("frameId")
if (x && y && zoom) {
editor.stopCameraAnimation()
editor.setCamera(
{
x: Math.round(parseFloat(x)),
y: Math.round(parseFloat(y)),
z: Math.round(parseFloat(zoom))
},
{ animation: { duration: 0 } }
)
}
// Handle shape/frame selection and zoom
if (shapeId) {
editor.select(shapeId as TLShapeId)
const bounds = editor.getSelectionPageBounds()
if (bounds && !x && !y && !zoom) {
zoomToSelection(editor)
}
} else if (frameId) {
editor.select(frameId as TLShapeId)
const frame = editor.getShape(frameId as TLShapeId)
if (frame && !x && !y && !zoom) {
const bounds = editor.getShapePageBounds(frame as TLShape)
if (bounds) {
editor.zoomToBounds(bounds, {
targetZoom: 1,
animation: { duration: 0 },
})
}
}
}
}
export const zoomToFrame = (editor: Editor, 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 },
})
}
export const copyFrameLink = (_editor: Editor, frameId: string) => {
const url = new URL(window.location.href)
url.searchParams.set("frameId", frameId)
navigator.clipboard.writeText(url.toString())
}
// Initialize lock indicators and watch for changes
export const watchForLockedShapes = (editor: Editor) => {
editor.on('change', () => {
editor.getCurrentPageShapes().forEach(shape => {
const hasIndicator = document.getElementById(`lock-indicator-${shape.id}`)
if (shape.isLocked && !hasIndicator) {
createLockIndicator(editor, shape)
} else if (!shape.isLocked && hasIndicator) {
hasIndicator.remove()
}
})
})
}

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>
)
},
}

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

@ -0,0 +1,434 @@
import { Editor, useDefaultHelpers } from "tldraw"
import {
shapeIdValidator,
TLArrowShape,
TLGeoShape,
TLUiOverrides,
} from "tldraw"
import {
cameraHistory,
copyLinkToCurrentView,
lockElement,
revertCamera,
unlockElement,
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 true
// }
// // 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 } })
// // Prevent default text creation
// info.preventDefault?.()
// info.stopPropagation?.()
// return true
// },
// onDoubleClickCanvas: (info: any) => {
// // 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 } })
// // Prevent default text creation
// info.preventDefault?.()
// info.stopPropagation?.()
// return true
// },
},
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: "alt+z",
readonlyOk: true,
onSelect: () => editor.setCurrentTool("MycrozineTemplate"),
},
Prompt: {
id: "Prompt",
icon: "prompt",
label: "Prompt",
type: "Prompt",
kbd: "alt+l",
readonlyOk: true,
onSelect: () => editor.setCurrentTool("Prompt"),
},
gesture: {
id: "gesture",
icon: "draw",
label: "Gesture",
kbd: "g",
readonlyOk: true,
type: "gesture",
onSelect: () => editor.setCurrentTool("gesture"),
},
hand: {
...tools.hand,
onDoubleClick: (info: any) => {
editor.zoomIn(info.point, { animation: { duration: 200 } })
},
},
}
},
actions(editor, actions) {
const customActions = {
"zoom-in": {
...actions["zoom-in"],
kbd: "ctrl+up",
},
"zoom-out": {
...actions["zoom-out"],
kbd: "ctrl+down",
},
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,
},
lockElement: {
id: "lock-element",
label: "Lock Element",
kbd: "shift+l",
onSelect: () => {
const selectedShapes = editor.getSelectedShapes()
if (selectedShapes.length > 0) {
lockElement(editor)
}
},
readonlyOk: true,
},
unlockElement: {
id: "unlock-element",
label: "Unlock Element",
onSelect: () => {
if (editor.getSelectedShapeIds().length > 0) {
unlockElement(editor, editor.getSelectedShapeIds()[0])
}
},
},
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,
})
})
}
},
},
searchShapes: {
id: "search-shapes",
label: "Search Shapes",
kbd: "s",
readonlyOk: true,
onSelect: () => searchText(editor),
},
llm: {
id: "llm",
label: "Run LLM Prompt",
kbd: "alt+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,
},
})
},
)
}
},
},
//TODO: FIX PREV & NEXT SLIDE KEYBOARD COMMANDS
// "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)
// },
// },
}
return {
...actions,
...customActions,
}
},
}
// Export actions for use in context menu
export const getCustomActions = (editor: Editor) => {
const helpers = useDefaultHelpers()
return overrides.actions?.(editor, {}, helpers) ?? {}
}

183
src/useAutomergeStore.ts Normal file
View File

@ -0,0 +1,183 @@
import {
TLAnyShapeUtilConstructor,
TLRecord,
TLStoreWithStatus,
createTLStore,
defaultShapeUtils,
HistoryEntry,
getUserPreferences,
setUserPreferences,
defaultUserPreferences,
createPresenceStateDerivation,
InstancePresenceRecordType,
computed,
react,
TLStoreSnapshot,
sortById,
} from "@tldraw/tldraw"
import { useEffect, useState } from "react"
import { DocHandle, DocHandleChangePayload } from "@automerge/automerge-repo"
import {
useLocalAwareness,
useRemoteAwareness,
} from "@automerge/automerge-repo-react-hooks"
import { applyAutomergePatchesToTLStore } from "./AutomergeToTLStore.js"
import { applyTLStoreChangesToAutomerge } from "./TLStoreToAutomerge.js"
export function useAutomergeStore({
handle,
shapeUtils = [],
}: {
handle: DocHandle<TLStoreSnapshot>
userId: string
shapeUtils?: TLAnyShapeUtilConstructor[]
}): TLStoreWithStatus {
const [store] = useState(() => {
const store = createTLStore({
shapeUtils: [...defaultShapeUtils, ...shapeUtils],
})
return store
})
const [storeWithStatus, setStoreWithStatus] = useState<TLStoreWithStatus>({
status: "loading",
})
/* -------------------- TLDraw <--> Automerge -------------------- */
useEffect(() => {
const unsubs: (() => void)[] = []
// A hacky workaround to prevent local changes from being applied twice
// once into the automerge doc and then back again.
let preventPatchApplications = false
/* TLDraw to Automerge */
function syncStoreChangesToAutomergeDoc({
changes,
}: HistoryEntry<TLRecord>) {
preventPatchApplications = true
handle.change((doc) => {
applyTLStoreChangesToAutomerge(doc, changes)
})
preventPatchApplications = false
}
unsubs.push(
store.listen(syncStoreChangesToAutomergeDoc, {
source: "user",
scope: "document",
})
)
/* Automerge to TLDraw */
const syncAutomergeDocChangesToStore = ({
patches,
}: DocHandleChangePayload<any>) => {
if (preventPatchApplications) return
applyAutomergePatchesToTLStore(patches, store)
}
handle.on("change", syncAutomergeDocChangesToStore)
unsubs.push(() => handle.off("change", syncAutomergeDocChangesToStore))
/* Defer rendering until the document is ready */
// TODO: need to think through the various status possibilities here and how they map
handle.whenReady().then(() => {
const doc = handle.docSync()
if (!doc) throw new Error("Document not found")
if (!doc.store) throw new Error("Document store not initialized")
store.mergeRemoteChanges(() => {
store.loadSnapshot({
store: JSON.parse(JSON.stringify(doc.store)),
schema: doc.schema,
})
})
setStoreWithStatus({
store,
status: "synced-remote",
connectionStatus: "online",
})
})
return () => {
unsubs.forEach((fn) => fn())
unsubs.length = 0
}
}, [handle, store])
return storeWithStatus
}
export function useAutomergePresence({ handle, store, userMetadata }:
{ handle: DocHandle<TLStoreSnapshot>, store: TLStoreWithStatus, userMetadata: any }) {
const innerStore = store?.store
const { userId, name, color } = userMetadata
const [, updateLocalState] = useLocalAwareness({
handle,
userId,
initialState: {},
})
const [peerStates] = useRemoteAwareness({
handle,
localUserId: userId,
})
/* ----------- Presence stuff ----------- */
useEffect(() => {
if (!innerStore) return
const toPut: TLRecord[] =
Object.values(peerStates)
.filter((record) => record && Object.keys(record).length !== 0)
// put / remove the records in the store
const toRemove = innerStore.query.records('instance_presence').get().sort(sortById)
.map((record) => record.id)
.filter((id) => !toPut.find((record) => record.id === id))
if (toRemove.length) innerStore.remove(toRemove)
if (toPut.length) innerStore.put(toPut)
}, [innerStore, peerStates])
useEffect(() => {
if (!innerStore) return
/* ----------- Presence stuff ----------- */
setUserPreferences({ id: userId, color, name })
const userPreferences = computed<{
id: string
color: string
name: string
}>("userPreferences", () => {
const user = getUserPreferences()
return {
id: user.id,
color: user.color ?? defaultUserPreferences.color,
name: user.name ?? defaultUserPreferences.name,
}
})
const presenceId = InstancePresenceRecordType.createId(userId)
const presenceDerivation = createPresenceStateDerivation(
userPreferences,
presenceId
)(innerStore)
return react("when presence changes", () => {
const presence = presenceDerivation.get()
requestAnimationFrame(() => {
updateLocalState(presence)
})
})
}, [innerStore, userId, updateLocalState])
/* ----------- End presence stuff ----------- */
}

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,43 @@
{ {
"buildCommand": "", "buildCommand": "npm run build",
"framework" : "other", "installCommand": "npm install",
"outputDirectory": "." "framework": "vite",
"outputDirectory": "dist",
"rewrites": [
{
"source": "/board/(.*)",
"destination": "/"
},
{
"source": "/board",
"destination": "/"
},
{
"source": "/inbox",
"destination": "/"
},
{
"source": "/contact",
"destination": "/"
},
{
"source": "/presentations",
"destination": "/"
},
{
"source": "/presentations/resilience",
"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,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": "*",
},
})
}
}

171
worker/assetUploads.ts Normal file
View File

@ -0,0 +1,171 @@
/// <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) {
// Add CORS headers that will be used for both success and error responses
const corsHeaders = {
'access-control-allow-origin': '*',
'access-control-allow-methods': 'GET, POST, HEAD, OPTIONS',
'access-control-allow-headers': '*',
'access-control-max-age': '86400',
}
// Handle preflight
if (request.method === 'OPTIONS') {
return new Response(null, { headers: corsHeaders })
}
try {
const objectName = getAssetObjectName(request.params.uploadId)
const contentType = request.headers.get('content-type') ?? ''
if (!contentType.startsWith('image/') && !contentType.startsWith('video/')) {
return new Response('Invalid content type', {
status: 400,
headers: corsHeaders
})
}
if (await env.TLDRAW_BUCKET.head(objectName)) {
return new Response('Upload already exists', {
status: 409,
headers: corsHeaders
})
}
await env.TLDRAW_BUCKET.put(objectName, request.body, {
httpMetadata: request.headers,
})
return new Response(JSON.stringify({ ok: true }), {
headers: {
...corsHeaders,
'content-type': 'application/json'
}
})
} catch (error) {
console.error('Asset upload failed:', error)
return new Response(JSON.stringify({ error: (error as Error).message }), {
status: 500,
headers: {
...corsHeaders,
'content-type': 'application/json'
}
})
}
}
// 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
) {
// Define CORS headers to be used consistently
const corsHeaders = {
'access-control-allow-origin': '*',
'access-control-allow-methods': 'GET, HEAD, OPTIONS',
'access-control-allow-headers': '*',
'access-control-expose-headers': 'content-length, content-range',
'access-control-max-age': '86400',
}
// Handle preflight
if (request.method === 'OPTIONS') {
return new Response(null, { headers: corsHeaders })
}
try {
const objectName = getAssetObjectName(request.params.uploadId)
// Handle cached response
const cacheKey = new Request(request.url, { headers: request.headers })
// @ts-ignore
const cachedResponse = await caches.default.match(cacheKey)
if (cachedResponse) {
const headers = new Headers(cachedResponse.headers)
Object.entries(corsHeaders).forEach(([key, value]) => headers.set(key, value))
return new Response(cachedResponse.body, {
status: cachedResponse.status,
headers
})
}
// Get from bucket
const object = await env.TLDRAW_BUCKET.get(objectName, {
range: request.headers,
onlyIf: request.headers,
})
if (!object) {
return new Response('Not Found', {
status: 404,
headers: corsHeaders
})
}
// Set up response headers
const headers = new Headers()
object.writeHttpMetadata(headers)
Object.entries(corsHeaders).forEach(([key, value]) => headers.set(key, value))
headers.set('cache-control', 'public, max-age=31536000, immutable')
headers.set('etag', object.httpEtag)
headers.set('cross-origin-resource-policy', 'cross-origin')
headers.set('cross-origin-opener-policy', 'same-origin')
headers.set('cross-origin-embedder-policy', 'require-corp')
// Handle content range
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)
}
const body = 'body' in object && object.body ? object.body : null
const status = body ? (contentRange ? 206 : 200) : 304
// Cache successful 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 })
} catch (error) {
console.error('Asset download failed:', error)
return new Response(
JSON.stringify({ error: (error as Error).message }),
{
status: 500,
headers: {
...corsHeaders,
'content-type': 'application/json'
}
}
)
}
}

11
worker/types.ts Normal file
View File

@ -0,0 +1,11 @@
// 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;
}

385
worker/worker.ts Normal file
View File

@ -0,0 +1,385 @@
import { handleUnfurlRequest } from "cloudflare-workers-unfurl"
import { AutoRouter, cors, error, IRequest } from "itty-router"
import { handleAssetDownload, handleAssetUpload } from "./assetUploads"
import { Environment } from "./types"
// 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 (both http and https)
if (
origin.match(
/^https?:\/\/(localhost|127\.0\.0\.1|192\.168\.|169\.254\.|10\.)/,
)
) {
return origin
}
// If no match found, return * to allow all origins
return "*"
},
allowMethods: ["GET", "POST", "HEAD", "OPTIONS", "UPGRADE"],
allowHeaders: [
"Content-Type",
"Authorization",
"Upgrade",
"Connection",
"Sec-WebSocket-Key",
"Sec-WebSocket-Version",
"Sec-WebSocket-Extensions",
"Sec-WebSocket-Protocol",
"Content-Length",
"Content-Range",
"Range",
"If-None-Match",
"If-Modified-Since",
"*"
],
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' }
})
}
})
// Add new transcription endpoints
.post("/daily/rooms/:roomName/start-transcription", async (req) => {
const apiKey = req.headers.get('Authorization')?.split('Bearer ')[1]
const { roomName } = req.params
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}/transcription/start`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${apiKey}`
}
})
if (!response.ok) {
const error = await response.json()
return new Response(JSON.stringify(error), {
status: response.status,
headers: { 'Content-Type': 'application/json' }
})
}
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' }
})
}
})
.post("/daily/rooms/:roomName/stop-transcription", async (req) => {
const apiKey = req.headers.get('Authorization')?.split('Bearer ')[1]
const { roomName } = req.params
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}/transcription/stop`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${apiKey}`
}
})
if (!response.ok) {
const error = await response.json()
return new Response(JSON.stringify(error), {
status: response.status,
headers: { 'Content-Type': 'application/json' }
})
}
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' }
})
}
})
// Add endpoint to get transcript access link
.get("/daily/transcript/:transcriptId/access-link", async (req) => {
const apiKey = req.headers.get('Authorization')?.split('Bearer ')[1]
const { transcriptId } = req.params
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 error = await response.json()
return new Response(JSON.stringify(error), {
status: response.status,
headers: { 'Content-Type': 'application/json' }
})
}
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' }
})
}
})
// Add endpoint to get transcript text
.get("/daily/transcript/:transcriptId", async (req) => {
const apiKey = req.headers.get('Authorization')?.split('Bearer ')[1]
const { transcriptId } = req.params
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/transcripts/${transcriptId}`, {
method: 'GET',
headers: {
'Authorization': `Bearer ${apiKey}`,
'Content-Type': 'application/json'
}
})
if (!response.ok) {
const error = await response.json()
return new Response(JSON.stringify(error), {
status: response.status,
headers: { 'Content-Type': 'application/json' }
})
}
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' }
})
}
})
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' }
})
})
// export our router for cloudflare
export default router

53
wrangler.toml Normal file
View File

@ -0,0 +1,53 @@
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'
[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.