Compare commits
221 Commits
| Author | SHA1 | Date |
|---|---|---|
|
|
59ab53a8f7 | |
|
|
de7de98ee8 | |
|
|
5eb5789c23 | |
|
|
15fa9b8d19 | |
|
|
7e3cca656e | |
|
|
6e373e57f1 | |
|
|
545372dcba | |
|
|
d7fcf121f8 | |
|
|
bb144428d0 | |
|
|
33f1aa4e90 | |
|
|
411fc99201 | |
|
|
4364743555 | |
|
|
6dd387613b | |
|
|
04705665f5 | |
|
|
c13d8720d2 | |
|
|
df72890577 | |
|
|
4e88428706 | |
|
|
52736e9812 | |
|
|
7b84d34c98 | |
|
|
e936d1c597 | |
|
|
b0beefe516 | |
|
|
49f11dc6e5 | |
|
|
30c0dfc3ba | |
|
|
d7b1e348e9 | |
|
|
2a3b79df15 | |
|
|
b11aecffa4 | |
|
|
4b5ba9eab3 | |
|
|
0add9bd514 | |
|
|
a770d516df | |
|
|
47db716af3 | |
|
|
e7e911c5bb | |
|
|
1126fc4a1c | |
|
|
59e9025336 | |
|
|
7d6afb6c6b | |
|
|
3a99af257d | |
|
|
12256c5b9c | |
|
|
87854883c6 | |
|
|
ebe2d4c0a2 | |
|
|
d733b61a66 | |
|
|
61143d2c20 | |
|
|
f47c3e0007 | |
|
|
536e1e7a87 | |
|
|
ab2a9f6a79 | |
|
|
9b33efdcb3 | |
|
|
86b37b9cc8 | |
|
|
7805a1e961 | |
|
|
fdb96b6ae1 | |
|
|
1783d1b6eb | |
|
|
bfbe7b8325 | |
|
|
e3e2c474ac | |
|
|
7b1fe2b803 | |
|
|
02f816e613 | |
|
|
198109a919 | |
|
|
c6370c0fde | |
|
|
c75acca85b | |
|
|
d7f4d61b55 | |
|
|
221a453411 | |
|
|
ce3063e9ba | |
|
|
7987c3a8e4 | |
|
|
8f94ee3a6f | |
|
|
201e489cef | |
|
|
d23dca3ba8 | |
|
|
42e5afbb21 | |
|
|
997f690d22 | |
|
|
7978772d7b | |
|
|
9f54400f18 | |
|
|
34681a3f4f | |
|
|
3bb7eda655 | |
|
|
72a7a54866 | |
|
|
e714233f67 | |
|
|
cca1a06b9f | |
|
|
84e737216d | |
|
|
bf5b3239dd | |
|
|
5858775483 | |
|
|
b74ae75fa8 | |
|
|
6e1e03d05b | |
|
|
ce50366985 | |
|
|
d9fb9637bd | |
|
|
5d39baaea8 | |
|
|
9def6c52b5 | |
|
|
1f6b693ec1 | |
|
|
b2e06ad76b | |
|
|
ac69e09aca | |
|
|
08f31a0bbd | |
|
|
2bdd6a8dba | |
|
|
9ff366c80b | |
|
|
cc216eb07f | |
|
|
d2ff445ddf | |
|
|
a8ca366bb6 | |
|
|
4901a56d61 | |
|
|
2d562b3e4c | |
|
|
a9a23e27e3 | |
|
|
cee2bfa336 | |
|
|
5924b0cc97 | |
|
|
4ec6b73fb3 | |
|
|
ce50026cc3 | |
|
|
0ff9c64908 | |
|
|
cf722c2490 | |
|
|
64d7581e6b | |
|
|
1190848222 | |
|
|
11c88ec0de | |
|
|
95307ed453 | |
|
|
bfe6b238e9 | |
|
|
fe4b40a3fe | |
|
|
4fda800e8b | |
|
|
7c28758204 | |
|
|
75c769a774 | |
|
|
5d8781462d | |
|
|
b2d6b1599b | |
|
|
c81238c45a | |
|
|
f012632cde | |
|
|
78e396d11e | |
|
|
cba62a453b | |
|
|
923f61ac9e | |
|
|
94bec533c4 | |
|
|
e286a120f1 | |
|
|
2e0a05ab32 | |
|
|
110fc19b94 | |
|
|
111be03907 | |
|
|
39e6cccc3f | |
|
|
08175d3a7c | |
|
|
3006e85375 | |
|
|
632e7979a2 | |
|
|
71fc07133a | |
|
|
97b00c1569 | |
|
|
c4198e1faf | |
|
|
6f6c924f66 | |
|
|
0eb4407219 | |
|
|
3a2a38c0b6 | |
|
|
02124ce920 | |
|
|
b700846a9c | |
|
|
f7310919f8 | |
|
|
949062941f | |
|
|
7f497ae8d8 | |
|
|
1d817c8e0f | |
|
|
7dd045bb33 | |
|
|
11d13a03d3 | |
|
|
3bcfa83168 | |
|
|
b0a3cd7328 | |
|
|
c71b67e24c | |
|
|
d582be49b2 | |
|
|
46ee4e7906 | |
|
|
c34418e964 | |
|
|
1c8909ce69 | |
|
|
5f2c90219d | |
|
|
fef2ca0eb3 | |
|
|
eab574e130 | |
|
|
b2656c911b | |
|
|
6ba124b038 | |
|
|
1cd7208ddf | |
|
|
d555910c77 | |
|
|
d1a8407a9b | |
|
|
db3205f97a | |
|
|
100b88268b | |
|
|
202971f343 | |
|
|
b26b9e6384 | |
|
|
4d69340a6b | |
|
|
14e0126995 | |
|
|
04782854d2 | |
|
|
4eff918bd3 | |
|
|
4e2103aab2 | |
|
|
895d02a19c | |
|
|
375f69b365 | |
|
|
09a729c787 | |
|
|
bb8a76026e | |
|
|
4319a6b1ee | |
|
|
2ca6705599 | |
|
|
07556dd53a | |
|
|
c93b3066bd | |
|
|
d282f6b650 | |
|
|
c34cae40b6 | |
|
|
46b54394ad | |
|
|
b05aa413e3 | |
|
|
2435f3f495 | |
|
|
49bca38b5f | |
|
|
0d7ee5889c | |
|
|
a0bba93055 | |
|
|
a2d7ab4af0 | |
|
|
99f7f131ed | |
|
|
c369762001 | |
|
|
d81ae56de0 | |
|
|
f384673cf9 | |
|
|
670c9ff0b0 | |
|
|
2ac4ec8de3 | |
|
|
7e16f6e6b0 | |
|
|
63cd76e919 | |
|
|
91df5214c6 | |
|
|
900833c06c | |
|
|
700875434f | |
|
|
9d5d0d6655 | |
|
|
8ce8dec8f7 | |
|
|
836d37df76 | |
|
|
2c35a0c53c | |
|
|
a8c8d62e63 | |
|
|
807637eae0 | |
|
|
572608f878 | |
|
|
6747c5df02 | |
|
|
2c4b2f6c91 | |
|
|
80cda32cba | |
|
|
032e4e1199 | |
|
|
04676b3788 | |
|
|
d6f3830884 | |
|
|
50c7c52c3d | |
|
|
a6eb2abed0 | |
|
|
1c38cb1bdb | |
|
|
932c9935d5 | |
|
|
249031619d | |
|
|
408df0d11e | |
|
|
fc602ff943 | |
|
|
d34e586215 | |
|
|
ee2484f1d0 | |
|
|
0ac03dec60 | |
|
|
5f3cf2800c | |
|
|
206d2a57ec | |
|
|
87118b86d5 | |
|
|
58cb4da348 | |
|
|
d087b61ce5 | |
|
|
9d73295702 | |
|
|
3e6db31c69 | |
|
|
b8038a6a97 | |
|
|
ee49689416 |
|
|
@ -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
|
||||||
|
|
@ -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
|
||||||
|
|
@ -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 }}
|
||||||
|
|
@ -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
|
||||||
|
|
@ -0,0 +1,3 @@
|
||||||
|
legacy-peer-deps=true
|
||||||
|
strict-peer-dependencies=false
|
||||||
|
auto-install-peers=true
|
||||||
|
|
@ -0,0 +1,4 @@
|
||||||
|
{
|
||||||
|
"semi": false,
|
||||||
|
"trailingComma": "all"
|
||||||
|
}
|
||||||
|
|
@ -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);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
@ -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;
|
||||||
|
|
@ -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
|
|
@ -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
|
||||||
|
|
@ -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>
|
||||||
File diff suppressed because it is too large
Load Diff
|
|
@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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 />)
|
||||||
|
|
||||||
|
|
@ -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
|
||||||
|
}
|
||||||
|
|
@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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>
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
@ -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]
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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 };
|
||||||
|
};
|
||||||
|
|
@ -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 };
|
||||||
|
|
@ -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);
|
||||||
|
};
|
||||||
|
|
@ -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.
|
||||||
|
|
@ -0,0 +1,5 @@
|
||||||
|
export * from './BaseCollection';
|
||||||
|
export * from './CollectionProvider';
|
||||||
|
export * from './CollectionContextWrapper';
|
||||||
|
export * from './GlobalCollectionManager';
|
||||||
|
export * from './useCollection';
|
||||||
|
|
@ -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 };
|
||||||
|
};
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
|
@ -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 },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
@ -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,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
@ -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)
|
||||||
|
}
|
||||||
|
|
@ -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;
|
||||||
|
};
|
||||||
|
|
@ -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>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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 } })
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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
|
||||||
|
}
|
||||||
|
|
@ -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."
|
||||||
|
|
@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
|
@ -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)
|
||||||
|
}
|
||||||
|
|
@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -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 you’d like a more detailed implementation in any specific programming language!
|
||||||
|
|
@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
@ -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}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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} />
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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>
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
|
@ -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[]
|
||||||
|
}
|
||||||
|
|
@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,7 @@
|
||||||
|
import { BaseBoxShapeTool } from "tldraw"
|
||||||
|
|
||||||
|
export class ChatBoxTool extends BaseBoxShapeTool {
|
||||||
|
static override id = "ChatBox"
|
||||||
|
shapeType = "ChatBox"
|
||||||
|
override initial = "idle"
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,7 @@
|
||||||
|
import { BaseBoxShapeTool } from "tldraw"
|
||||||
|
|
||||||
|
export class EmbedTool extends BaseBoxShapeTool {
|
||||||
|
static override id = "Embed"
|
||||||
|
shapeType = "Embed"
|
||||||
|
override initial = "idle"
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,7 @@
|
||||||
|
import { BaseBoxShapeTool } from "tldraw"
|
||||||
|
|
||||||
|
export class MarkdownTool extends BaseBoxShapeTool {
|
||||||
|
static override id = "Markdown"
|
||||||
|
shapeType = "Markdown"
|
||||||
|
override initial = "idle"
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,7 @@
|
||||||
|
import { BaseBoxShapeTool } from "tldraw"
|
||||||
|
|
||||||
|
export class MycrozineTemplateTool extends BaseBoxShapeTool {
|
||||||
|
static override id = "MycrozineTemplate"
|
||||||
|
shapeType = "MycrozineTemplate"
|
||||||
|
override initial = "idle"
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,8 @@
|
||||||
|
import { BaseBoxShapeTool } from 'tldraw'
|
||||||
|
|
||||||
|
export class PromptShapeTool extends BaseBoxShapeTool {
|
||||||
|
static override id = 'Prompt'
|
||||||
|
static override initial = 'idle'
|
||||||
|
override shapeType = 'Prompt'
|
||||||
|
|
||||||
|
}
|
||||||
|
|
@ -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 })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,7 @@
|
||||||
|
import { BaseBoxShapeTool } from "tldraw"
|
||||||
|
|
||||||
|
export class VideoChatTool extends BaseBoxShapeTool {
|
||||||
|
static override id = "VideoChat"
|
||||||
|
shapeType = "VideoChat"
|
||||||
|
override initial = "idle"
|
||||||
|
}
|
||||||
|
|
@ -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>
|
||||||
|
)
|
||||||
|
})
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -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>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -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()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
@ -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>
|
||||||
|
)
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
@ -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) ?? {}
|
||||||
|
}
|
||||||
|
|
@ -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 ----------- */
|
||||||
|
|
||||||
|
}
|
||||||
|
|
@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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);
|
||||||
|
}
|
||||||
|
|
@ -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
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
@ -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.")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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
|
||||||
|
}
|
||||||
|
|
@ -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
|
||||||
|
}
|
||||||
|
|
@ -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" }]
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,10 @@
|
||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"composite": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"module": "ESNext",
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"allowSyntheticDefaultImports": true
|
||||||
|
},
|
||||||
|
"include": ["vite.config.ts"]
|
||||||
|
}
|
||||||
|
|
@ -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"]
|
||||||
|
}
|
||||||
44
vercel.json
44
vercel.json
|
|
@ -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"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
}
|
}
|
||||||
|
|
@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
@ -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": "*",
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
|
@ -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
|
||||||
|
|
@ -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.
|
||||||
Loading…
Reference in New Issue