Compare commits
173 Commits
main
...
revert-boa
| Author | SHA1 | Date |
|---|---|---|
|
|
c33e36cb73 | |
|
|
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,20 @@
|
||||||
|
# Google API Credentials
|
||||||
|
VITE_GOOGLE_CLIENT_ID='your_google_client_id'
|
||||||
|
VITE_GOOGLE_API_KEY='your_google_api_key'
|
||||||
|
VITE_GOOGLE_MAPS_API_KEY='your_google_maps_api_key'
|
||||||
|
|
||||||
|
# Cloudflare Worker
|
||||||
|
CLOUDFLARE_API_TOKEN='your_cloudflare_token'
|
||||||
|
CLOUDFLARE_ACCOUNT_ID='your_account_id'
|
||||||
|
CLOUDFLARE_ZONE_ID='your_zone_id'
|
||||||
|
|
||||||
|
# Worker URL
|
||||||
|
TLDRAW_WORKER_URL='your_worker_url'
|
||||||
|
|
||||||
|
# R2 Bucket Configuration
|
||||||
|
R2_BUCKET_NAME='your_bucket_name'
|
||||||
|
R2_PREVIEW_BUCKET_NAME='your_preview_bucket_name'
|
||||||
|
|
||||||
|
# Daily.co Configuration
|
||||||
|
VITE_DAILY_API_KEY=your_daily_api_key_here
|
||||||
|
VITE_DAILY_DOMAIN='your_daily_domain'
|
||||||
|
|
@ -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,176 @@
|
||||||
|
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
|
||||||
|
|
@ -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,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,59 @@
|
||||||
|
{
|
||||||
|
"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"
|
||||||
|
},
|
||||||
|
"keywords": [],
|
||||||
|
"author": "Jeff Emmett",
|
||||||
|
"license": "ISC",
|
||||||
|
"dependencies": {
|
||||||
|
"@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",
|
||||||
|
"@vercel/analytics": "^1.2.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",
|
||||||
|
"react": "^18.2.0",
|
||||||
|
"react-dom": "^18.2.0",
|
||||||
|
"react-router-dom": "^7.0.2",
|
||||||
|
"recoil": "^0.7.7",
|
||||||
|
"tldraw": "^3.6.0",
|
||||||
|
"vercel": "^39.1.1"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@cloudflare/types": "^6.0.0",
|
||||||
|
"@cloudflare/workers-types": "^4.20240821.1",
|
||||||
|
"@types/lodash.throttle": "^4",
|
||||||
|
"@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.88.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18.0.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,60 @@
|
||||||
|
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 { ChatBoxShape } from "./shapes/ChatBoxShapeUtil"
|
||||||
|
import { VideoChatShape } from "./shapes/VideoChatShapeUtil"
|
||||||
|
import { ChatBoxTool } from "./tools/ChatBoxTool"
|
||||||
|
import { VideoChatTool } from "./tools/VideoChatTool"
|
||||||
|
import { EmbedTool } from "./tools/EmbedTool"
|
||||||
|
import { EmbedShape } from "./shapes/EmbedShapeUtil"
|
||||||
|
import { MycrozineTemplateTool } from './tools/MycrozineTemplateTool'
|
||||||
|
import { MycrozineTemplateShape } from './shapes/MycrozineTemplateShapeUtil'
|
||||||
|
import { MarkdownShape } from "./shapes/MarkdownShapeUtil"
|
||||||
|
import { MarkdownTool } from "./tools/MarkdownTool"
|
||||||
|
import { createRoot } from "react-dom/client"
|
||||||
|
import { handleInitialPageLoad } from "./utils/handleInitialPageLoad"
|
||||||
|
import { DailyProvider } from "@daily-co/daily-react"
|
||||||
|
import Daily from "@daily-co/daily-js"
|
||||||
|
|
||||||
|
|
||||||
|
inject()
|
||||||
|
|
||||||
|
const customShapeUtils = [
|
||||||
|
ChatBoxShape,
|
||||||
|
VideoChatShape,
|
||||||
|
EmbedShape,
|
||||||
|
MycrozineTemplateShape,
|
||||||
|
MarkdownShape,
|
||||||
|
]
|
||||||
|
const customTools = [
|
||||||
|
ChatBoxTool,
|
||||||
|
VideoChatTool,
|
||||||
|
EmbedTool,
|
||||||
|
// MycrozineTemplateTool,
|
||||||
|
// MarkdownTool
|
||||||
|
]
|
||||||
|
|
||||||
|
const callObject = Daily.createCallObject()
|
||||||
|
|
||||||
|
function App() {
|
||||||
|
return (
|
||||||
|
<DailyProvider callObject={callObject}>
|
||||||
|
<BrowserRouter>
|
||||||
|
<Routes>
|
||||||
|
<Route path="/" element={<Default />} />
|
||||||
|
<Route path="/contact" element={<Contact />} />
|
||||||
|
<Route path="/board/:slug" element={<Board />} />
|
||||||
|
<Route path="/inbox" element={<Inbox />} />
|
||||||
|
</Routes>
|
||||||
|
</BrowserRouter>
|
||||||
|
</DailyProvider>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
createRoot(document.getElementById("root")!).render(<App />)
|
||||||
|
|
||||||
|
|
@ -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,422 @@
|
||||||
|
@import url("reset.css");
|
||||||
|
|
||||||
|
html,
|
||||||
|
body {
|
||||||
|
padding: 0;
|
||||||
|
margin: 0;
|
||||||
|
min-height: 100vh;
|
||||||
|
min-height: -webkit-fill-available;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
video {
|
||||||
|
width: 100%;
|
||||||
|
height: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
main {
|
||||||
|
max-width: 60em;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding-left: 4em;
|
||||||
|
padding-right: 4em;
|
||||||
|
padding-top: 3em;
|
||||||
|
padding-bottom: 3em;
|
||||||
|
font-family: "Recursive";
|
||||||
|
font-variation-settings: "MONO" 1;
|
||||||
|
font-variation-settings: "CASL" 1;
|
||||||
|
color: #24292e;
|
||||||
|
}
|
||||||
|
|
||||||
|
h1 {
|
||||||
|
font-size: 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
h2 {
|
||||||
|
font-size: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
h1,
|
||||||
|
h2,
|
||||||
|
h3,
|
||||||
|
h4,
|
||||||
|
h5,
|
||||||
|
h6 {
|
||||||
|
margin-top: 0;
|
||||||
|
margin-bottom: 0.5em;
|
||||||
|
}
|
||||||
|
|
||||||
|
header {
|
||||||
|
margin-bottom: 2em;
|
||||||
|
font-size: 1.5rem;
|
||||||
|
font-variation-settings: "MONO" 1;
|
||||||
|
font-variation-settings: "CASL" 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
i {
|
||||||
|
font-variation-settings: "slnt" -15;
|
||||||
|
}
|
||||||
|
|
||||||
|
pre>code {
|
||||||
|
width: 100%;
|
||||||
|
padding: 1em;
|
||||||
|
display: block;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
word-wrap: break-word;
|
||||||
|
}
|
||||||
|
|
||||||
|
code {
|
||||||
|
background-color: #e4e9ee;
|
||||||
|
width: 100%;
|
||||||
|
color: #38424c;
|
||||||
|
padding: 0.2em 0.4em;
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
b,
|
||||||
|
strong {
|
||||||
|
font-variation-settings: "wght" 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
blockquote {
|
||||||
|
margin: -1em;
|
||||||
|
padding: 1em;
|
||||||
|
background-color: #f1f1f1;
|
||||||
|
margin-top: 1em;
|
||||||
|
margin-bottom: 1em;
|
||||||
|
border-radius: 4px;
|
||||||
|
|
||||||
|
& p {
|
||||||
|
font-variation-settings: "CASL" 1;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
p {
|
||||||
|
font-family: Recursive;
|
||||||
|
margin-top: 0;
|
||||||
|
margin-bottom: 1.5em;
|
||||||
|
font-size: 1.1em;
|
||||||
|
font-variation-settings: "wght" 350;
|
||||||
|
}
|
||||||
|
|
||||||
|
table {
|
||||||
|
width: 100%;
|
||||||
|
border-collapse: collapse;
|
||||||
|
text-align: left;
|
||||||
|
margin-bottom: 1em;
|
||||||
|
font-variation-settings: "mono" 1;
|
||||||
|
font-variation-settings: "casl" 0;
|
||||||
|
|
||||||
|
th,
|
||||||
|
td {
|
||||||
|
padding: 0.5em;
|
||||||
|
border: 1px solid #ddd;
|
||||||
|
}
|
||||||
|
|
||||||
|
th {
|
||||||
|
background-color: #f4f4f4;
|
||||||
|
font-weight: bold;
|
||||||
|
}
|
||||||
|
|
||||||
|
tr:nth-child(even) {
|
||||||
|
background-color: #f9f9f9;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
a {
|
||||||
|
font-variation-settings: "CASL" 0;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
animation: casl-forward 0.2s ease forwards;
|
||||||
|
}
|
||||||
|
|
||||||
|
&:not(:hover) {
|
||||||
|
/* text-decoration: none; */
|
||||||
|
animation: casl-reverse 0.2s ease backwards;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes casl-forward {
|
||||||
|
from {
|
||||||
|
font-variation-settings:
|
||||||
|
"CASL" 0,
|
||||||
|
"wght" 400;
|
||||||
|
}
|
||||||
|
|
||||||
|
to {
|
||||||
|
font-variation-settings:
|
||||||
|
"CASL" 1,
|
||||||
|
"wght" 600;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes casl-reverse {
|
||||||
|
from {
|
||||||
|
font-variation-settings:
|
||||||
|
"CASL" 1,
|
||||||
|
"wght" 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
to {
|
||||||
|
font-variation-settings:
|
||||||
|
"CASL" 0,
|
||||||
|
"wght" 400;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
p a {
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dinkus {
|
||||||
|
display: block;
|
||||||
|
text-align: center;
|
||||||
|
font-size: 1.1rem;
|
||||||
|
margin-top: 2em;
|
||||||
|
margin-bottom: 0em;
|
||||||
|
}
|
||||||
|
|
||||||
|
ol,
|
||||||
|
ul {
|
||||||
|
padding-left: 0;
|
||||||
|
margin-top: 0;
|
||||||
|
font-size: 1rem;
|
||||||
|
|
||||||
|
& li::marker {
|
||||||
|
color: rgba(0, 0, 0, 0.322);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
img {
|
||||||
|
display: block;
|
||||||
|
margin: 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 600px) {
|
||||||
|
main {
|
||||||
|
padding: 2em;
|
||||||
|
}
|
||||||
|
|
||||||
|
header {
|
||||||
|
margin-bottom: 1em;
|
||||||
|
}
|
||||||
|
|
||||||
|
ol {
|
||||||
|
list-style-position: inside;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Some conditional spacing */
|
||||||
|
table:not(:has(+ p)) {
|
||||||
|
margin-bottom: 2em;
|
||||||
|
}
|
||||||
|
|
||||||
|
p:has(+ ul) {
|
||||||
|
margin-bottom: 0.5em;
|
||||||
|
}
|
||||||
|
|
||||||
|
p:has(+ ol) {
|
||||||
|
margin-bottom: 0.5em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.loading {
|
||||||
|
font-family: "Recursive";
|
||||||
|
font-variation-settings: "CASL" 1;
|
||||||
|
font-size: 1rem;
|
||||||
|
text-align: center;
|
||||||
|
position: absolute;
|
||||||
|
top: 40%;
|
||||||
|
left: 50%;
|
||||||
|
transform: translate(-50%, -50%);
|
||||||
|
background-color: #f1f1f1;
|
||||||
|
border: 1px solid #c0c9d1;
|
||||||
|
padding: 0.5em;
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* CANVAS SHENANIGANS */
|
||||||
|
#toggle-physics,
|
||||||
|
#toggle-canvas {
|
||||||
|
position: fixed;
|
||||||
|
z-index: 999;
|
||||||
|
right: 10px;
|
||||||
|
width: 2.5rem;
|
||||||
|
height: 2.5rem;
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
cursor: pointer;
|
||||||
|
opacity: 0.25;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
& img {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#toggle-canvas {
|
||||||
|
top: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
#toggle-physics {
|
||||||
|
top: 60px;
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tl-html-layer {
|
||||||
|
font-family: "Recursive";
|
||||||
|
font-variation-settings: "MONO" 1;
|
||||||
|
font-variation-settings: "CASL" 1;
|
||||||
|
|
||||||
|
& h1,
|
||||||
|
p,
|
||||||
|
span,
|
||||||
|
header,
|
||||||
|
ul,
|
||||||
|
ol {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
& header {
|
||||||
|
font-size: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
& p {
|
||||||
|
font-size: 1.1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Markdown preview styles */
|
||||||
|
& h1 { font-size: 2em; margin: 0.67em 0; }
|
||||||
|
& h2 { font-size: 1.5em; margin: 0.75em 0; }
|
||||||
|
& h3 { font-size: 1.17em; margin: 0.83em 0; }
|
||||||
|
& h4 { margin: 1.12em 0; }
|
||||||
|
& h5 { font-size: 0.83em; margin: 1.5em 0; }
|
||||||
|
& h6 { font-size: 0.75em; margin: 1.67em 0; }
|
||||||
|
|
||||||
|
& ul, & ol {
|
||||||
|
padding-left: 2em;
|
||||||
|
margin: 1em 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
& p {
|
||||||
|
margin: 1em 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
& code {
|
||||||
|
background-color: #f5f5f5;
|
||||||
|
padding: 0.2em 0.4em;
|
||||||
|
border-radius: 3px;
|
||||||
|
font-family: monospace;
|
||||||
|
}
|
||||||
|
|
||||||
|
& pre {
|
||||||
|
background-color: #f5f5f5;
|
||||||
|
padding: 1em;
|
||||||
|
border-radius: 4px;
|
||||||
|
overflow-x: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
& blockquote {
|
||||||
|
margin: 1em 0;
|
||||||
|
padding-left: 1em;
|
||||||
|
border-left: 4px solid #ddd;
|
||||||
|
color: #666;
|
||||||
|
}
|
||||||
|
|
||||||
|
& table {
|
||||||
|
border-collapse: collapse;
|
||||||
|
margin: 1em 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
& th, & td {
|
||||||
|
border: 1px solid #ddd;
|
||||||
|
padding: 6px 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
& tr:nth-child(2n) {
|
||||||
|
background-color: #f8f8f8;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.transparent {
|
||||||
|
opacity: 0 !important;
|
||||||
|
transition: opacity 0.25s ease-in-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
.canvas-mode {
|
||||||
|
overflow: hidden;
|
||||||
|
|
||||||
|
& #toggle-physics {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.tldraw__editor {
|
||||||
|
overscroll-behavior: none;
|
||||||
|
position: fixed;
|
||||||
|
inset: 0px;
|
||||||
|
overflow: hidden;
|
||||||
|
touch-action: none;
|
||||||
|
-webkit-touch-callout: none;
|
||||||
|
-webkit-tap-highlight-color: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tl-background {
|
||||||
|
background-color: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tlui-debug-panel {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.overflowing {
|
||||||
|
box-shadow: 0 0px 16px rgba(0, 0, 0, 0.15);
|
||||||
|
overflow: hidden;
|
||||||
|
background-color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.version-history-menu {
|
||||||
|
padding: 8px;
|
||||||
|
min-width: 300px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.version-list {
|
||||||
|
max-height: 400px;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.version-item {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
padding: 8px;
|
||||||
|
border-bottom: 1px solid var(--color-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.version-date {
|
||||||
|
font-size: 14px;
|
||||||
|
color: var(--color-text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.restore-button {
|
||||||
|
padding: 4px 8px;
|
||||||
|
border-radius: 4px;
|
||||||
|
background: var(--color-primary);
|
||||||
|
color: white;
|
||||||
|
border: none;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.restore-button:disabled {
|
||||||
|
opacity: 0.5;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.no-versions {
|
||||||
|
padding: 16px;
|
||||||
|
text-align: center;
|
||||||
|
color: var(--color-muted);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,166 @@
|
||||||
|
import { useEffect } from "react"
|
||||||
|
import { Editor, TLEventMap, TLFrameShape, TLParentId, TLShapeId } from "tldraw"
|
||||||
|
import { useSearchParams } from "react-router-dom"
|
||||||
|
|
||||||
|
// Define camera state interface
|
||||||
|
interface CameraState {
|
||||||
|
x: number
|
||||||
|
y: number
|
||||||
|
z: number
|
||||||
|
}
|
||||||
|
|
||||||
|
const MAX_HISTORY = 10
|
||||||
|
let cameraHistory: CameraState[] = []
|
||||||
|
|
||||||
|
// TODO: use this
|
||||||
|
|
||||||
|
// Improved camera change tracking with debouncing
|
||||||
|
const trackCameraChange = (editor: Editor) => {
|
||||||
|
const currentCamera = editor.getCamera()
|
||||||
|
const lastPosition = cameraHistory[cameraHistory.length - 1]
|
||||||
|
|
||||||
|
// Store any viewport change that's not from a revert operation
|
||||||
|
if (
|
||||||
|
!lastPosition ||
|
||||||
|
currentCamera.x !== lastPosition.x ||
|
||||||
|
currentCamera.y !== lastPosition.y ||
|
||||||
|
currentCamera.z !== lastPosition.z
|
||||||
|
) {
|
||||||
|
cameraHistory.push({ ...currentCamera })
|
||||||
|
if (cameraHistory.length > MAX_HISTORY) {
|
||||||
|
cameraHistory.shift()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useCameraControls(editor: Editor | null) {
|
||||||
|
const [searchParams] = useSearchParams()
|
||||||
|
|
||||||
|
// Handle URL-based camera positioning
|
||||||
|
useEffect(() => {
|
||||||
|
if (!editor || !editor.store || !editor.getInstanceState().isFocused) {
|
||||||
|
console.log("Editor not ready:", {
|
||||||
|
editor: !!editor,
|
||||||
|
store: !!editor?.store,
|
||||||
|
isFocused: editor?.getInstanceState().isFocused,
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const x = searchParams.get("x")
|
||||||
|
const y = searchParams.get("y")
|
||||||
|
const zoom = searchParams.get("zoom")
|
||||||
|
const frameId = searchParams.get("frameId")
|
||||||
|
const isLocked = searchParams.get("isLocked") === "true"
|
||||||
|
|
||||||
|
console.log("Setting camera:", { x, y, zoom, frameId, isLocked })
|
||||||
|
|
||||||
|
// Set camera position if coordinates exist
|
||||||
|
if (x && y && zoom) {
|
||||||
|
const position = {
|
||||||
|
x: Math.round(parseFloat(x)),
|
||||||
|
y: Math.round(parseFloat(y)),
|
||||||
|
z: Math.round(parseFloat(zoom)),
|
||||||
|
}
|
||||||
|
console.log("Camera position:", position)
|
||||||
|
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
editor.setCamera(position, { animation: { duration: 0 } })
|
||||||
|
|
||||||
|
// Apply camera lock immediately after setting position if needed
|
||||||
|
if (isLocked) {
|
||||||
|
editor.setCameraOptions({ isLocked: true })
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log("Current camera:", editor.getCamera())
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle frame-specific logic
|
||||||
|
if (frameId) {
|
||||||
|
const frame = editor.getShape(frameId as TLShapeId)
|
||||||
|
if (frame) {
|
||||||
|
editor.select(frameId as TLShapeId)
|
||||||
|
|
||||||
|
// If x/y/zoom are not provided in URL, zoom to frame bounds
|
||||||
|
if (!x || !y || !zoom) {
|
||||||
|
const bounds = editor.getShapePageBounds(frame)!
|
||||||
|
const viewportPageBounds = editor.getViewportPageBounds()
|
||||||
|
const targetZoom = Math.min(
|
||||||
|
viewportPageBounds.width / bounds.width,
|
||||||
|
viewportPageBounds.height / bounds.height,
|
||||||
|
1, // Cap at 1x zoom, matching lockCameraToFrame
|
||||||
|
)
|
||||||
|
|
||||||
|
editor.zoomToBounds(bounds, {
|
||||||
|
animation: { duration: 0 },
|
||||||
|
targetZoom,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Apply camera lock after camera is positioned
|
||||||
|
if (isLocked) {
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
editor.setCameraOptions({ isLocked: true })
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [editor, searchParams])
|
||||||
|
|
||||||
|
// Track camera changes
|
||||||
|
useEffect(() => {
|
||||||
|
if (!editor) return
|
||||||
|
|
||||||
|
const handler = () => {
|
||||||
|
trackCameraChange(editor)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Track both viewport changes and user interaction end
|
||||||
|
editor.on("viewportChange" as keyof TLEventMap, handler)
|
||||||
|
editor.on("userChangeEnd" as keyof TLEventMap, handler)
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
editor.off("viewportChange" as keyof TLEventMap, handler)
|
||||||
|
editor.off("userChangeEnd" as keyof TLEventMap, handler)
|
||||||
|
}
|
||||||
|
}, [editor])
|
||||||
|
|
||||||
|
// Enhanced camera control functions
|
||||||
|
return {
|
||||||
|
zoomToFrame: (frameId: string) => {
|
||||||
|
if (!editor) return
|
||||||
|
const frame = editor.getShape(frameId as TLParentId) as TLFrameShape
|
||||||
|
if (!frame) return
|
||||||
|
|
||||||
|
editor.zoomToBounds(editor.getShapePageBounds(frame)!, {
|
||||||
|
inset: 32,
|
||||||
|
animation: { duration: 500 },
|
||||||
|
})
|
||||||
|
},
|
||||||
|
|
||||||
|
copyLocationLink: () => {
|
||||||
|
if (!editor) return
|
||||||
|
const camera = editor.getCamera()
|
||||||
|
const url = new URL(window.location.href)
|
||||||
|
url.searchParams.set("x", Math.round(camera.x).toString())
|
||||||
|
url.searchParams.set("y", Math.round(camera.y).toString())
|
||||||
|
url.searchParams.set("zoom", Math.round(camera.z).toString())
|
||||||
|
navigator.clipboard.writeText(url.toString())
|
||||||
|
},
|
||||||
|
|
||||||
|
copyFrameLink: (frameId: string) => {
|
||||||
|
const url = new URL(window.location.href)
|
||||||
|
url.searchParams.set("frameId", frameId)
|
||||||
|
navigator.clipboard.writeText(url.toString())
|
||||||
|
},
|
||||||
|
|
||||||
|
revertCamera: () => {
|
||||||
|
if (!editor || cameraHistory.length === 0) return
|
||||||
|
const previousCamera = cameraHistory.pop()
|
||||||
|
if (previousCamera) {
|
||||||
|
editor.setCamera(previousCamera, { animation: { duration: 200 } })
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,80 @@
|
||||||
|
import { useSync } from "@tldraw/sync"
|
||||||
|
import { useMemo } from "react"
|
||||||
|
import { Tldraw, Editor } from "tldraw"
|
||||||
|
import { useParams } from "react-router-dom"
|
||||||
|
import { ChatBoxTool } from "@/tools/ChatBoxTool"
|
||||||
|
import { ChatBoxShape } from "@/shapes/ChatBoxShapeUtil"
|
||||||
|
import { VideoChatTool } from "@/tools/VideoChatTool"
|
||||||
|
import { VideoChatShape } from "@/shapes/VideoChatShapeUtil"
|
||||||
|
import { multiplayerAssetStore } from "../utils/multiplayerAssetStore"
|
||||||
|
import { EmbedShape } from "@/shapes/EmbedShapeUtil"
|
||||||
|
import { EmbedTool } from "@/tools/EmbedTool"
|
||||||
|
import { MarkdownShape } from "@/shapes/MarkdownShapeUtil"
|
||||||
|
import { MarkdownTool } from "@/tools/MarkdownTool"
|
||||||
|
import { defaultShapeUtils, defaultBindingUtils } from "tldraw"
|
||||||
|
import { useState } from "react"
|
||||||
|
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"
|
||||||
|
|
||||||
|
// Use development URL when running locally
|
||||||
|
export const WORKER_URL = import.meta.env.DEV
|
||||||
|
? "http://localhost:5172"
|
||||||
|
: "https://jeffemmett-canvas.jeffemmett.workers.dev"
|
||||||
|
|
||||||
|
//console.log('[Debug] WORKER_URL:', WORKER_URL)
|
||||||
|
|
||||||
|
const shapeUtils = [
|
||||||
|
ChatBoxShape,
|
||||||
|
VideoChatShape,
|
||||||
|
EmbedShape,
|
||||||
|
// MycrozineTemplateShape,
|
||||||
|
// MarkdownShape
|
||||||
|
]
|
||||||
|
const tools = [
|
||||||
|
ChatBoxTool,
|
||||||
|
VideoChatTool,
|
||||||
|
EmbedTool,
|
||||||
|
// MycrozineTemplateTool,
|
||||||
|
// MarkdownTool
|
||||||
|
]
|
||||||
|
|
||||||
|
export function Board() {
|
||||||
|
const { slug } = useParams<{ slug: string }>()
|
||||||
|
const roomId = slug || "default-room"
|
||||||
|
|
||||||
|
const storeConfig = useMemo(
|
||||||
|
() => ({
|
||||||
|
uri: `${WORKER_URL}/connect/${roomId}`,
|
||||||
|
assets: multiplayerAssetStore,
|
||||||
|
shapeUtils: [...shapeUtils, ...defaultShapeUtils],
|
||||||
|
bindingUtils: [...defaultBindingUtils],
|
||||||
|
}),
|
||||||
|
[roomId],
|
||||||
|
)
|
||||||
|
|
||||||
|
const store = useSync(storeConfig)
|
||||||
|
const [editor, setEditor] = useState<Editor | null>(null)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{ position: "fixed", inset: 0 }}>
|
||||||
|
<Tldraw
|
||||||
|
store={store.store}
|
||||||
|
shapeUtils={shapeUtils}
|
||||||
|
tools={tools}
|
||||||
|
components={components}
|
||||||
|
overrides={overrides}
|
||||||
|
//maxZoom={20}
|
||||||
|
onMount={(editor) => {
|
||||||
|
setEditor(editor)
|
||||||
|
editor.registerExternalAssetHandler("url", unfurlBookmarkUrl)
|
||||||
|
editor.setCurrentTool("hand")
|
||||||
|
handleInitialPageLoad(editor)
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,29 @@
|
||||||
|
export function Contact() {
|
||||||
|
return (
|
||||||
|
<main>
|
||||||
|
<header>
|
||||||
|
<a href="/">Jeff Emmett</a>
|
||||||
|
</header>
|
||||||
|
<h1>Contact</h1>
|
||||||
|
<p>
|
||||||
|
Twitter: <a href="https://twitter.com/jeffemmett">@jeffemmett</a>
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
BlueSky:{" "}
|
||||||
|
<a href="https://bsky.app/profile/jeffemmett.bsky.social">
|
||||||
|
@jeffemnmett.bsky.social
|
||||||
|
</a>
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
Mastodon:{" "}
|
||||||
|
<a href="https://social.coop/@jeffemmett">@jeffemmett@social.coop</a>
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
Email: <a href="mailto:jeffemmett@gmail.com">jeffemmett@gmail.com</a>
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
GitHub: <a href="https://github.com/Jeff-Emmett">Jeff-Emmett</a>
|
||||||
|
</p>
|
||||||
|
</main>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,106 @@
|
||||||
|
export function Default() {
|
||||||
|
return (
|
||||||
|
<main>
|
||||||
|
<header>Jeff Emmett</header>
|
||||||
|
<h2>Hello! 👋🍄</h2>
|
||||||
|
<p>
|
||||||
|
My research investigates the intersection of mycelium and emancipatory
|
||||||
|
technologies. I am interested in the potential of new convivial tooling
|
||||||
|
as a medium for group consensus building and collective action, in order
|
||||||
|
to empower communities of practice to address their own challenges.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<p>
|
||||||
|
My current focus is basic research into the nature of digital
|
||||||
|
organisation, developing prototype toolkits to improve shared
|
||||||
|
infrastructure, and applying this research to the design of new systems
|
||||||
|
and protocols which support the self-organisation of knowledge and
|
||||||
|
emergent response to local needs.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<h2>My work</h2>
|
||||||
|
<p>
|
||||||
|
Alongside my independent work, I am a researcher and engineering
|
||||||
|
communicator at <a href="https://block.science/">Block Science</a>, an
|
||||||
|
advisor to the Active Inference Lab, Commons Stack, and the Trusted
|
||||||
|
Seed. I am also an occasional collaborator with{" "}
|
||||||
|
<a href="https://economicspace.agency/">ECSA</a>.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<h2>Get in touch</h2>
|
||||||
|
<p>
|
||||||
|
I am on Twitter <a href="https://twitter.com/jeffemmett">@jeffemmett</a>
|
||||||
|
, Mastodon{" "}
|
||||||
|
<a href="https://social.coop/@jeffemmett">@jeffemmett@social.coop</a>{" "}
|
||||||
|
and GitHub <a href="https://github.com/Jeff-Emmett">@Jeff-Emmett</a>.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<span className="dinkus">***</span>
|
||||||
|
|
||||||
|
<h2>Talks</h2>
|
||||||
|
<ol reversed>
|
||||||
|
<li>
|
||||||
|
<a href="https://www.teamhuman.fm/episodes/238-jeff-emmett">
|
||||||
|
MycoPunk Futures on Team Human with Douglas Rushkoff
|
||||||
|
</a>{" "}
|
||||||
|
(<a href="artifact/tft-rocks-integration-domain.pdf">slides</a>)
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<a href="https://www.youtube.com/watch?v=AFJFDajuCSg">
|
||||||
|
Exploring MycoFi on the Greenpill Network with Kevin Owocki
|
||||||
|
</a>{" "}
|
||||||
|
(<a href="artifact/tft-rocks-integration-domain.pdf">slides</a>)
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<a href="https://youtu.be/9ad2EJhMbZ8">
|
||||||
|
Re-imagining Human Value on the Telos Podcast with Rieki &
|
||||||
|
Brandonfrom SEEDS
|
||||||
|
</a>{" "}
|
||||||
|
(<a href="artifact/tft-rocks-integration-domain.pdf">slides</a>)
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<a href="https://www.youtube.com/watch?v=i8qcg7FfpLM&t=1348s">
|
||||||
|
Move Slow & Fix Things: Design Patterns from Nature
|
||||||
|
</a>{" "}
|
||||||
|
(<a href="artifact/tft-rocks-integration-domain.pdf">slides</a>)
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<a href="https://podcasters.spotify.com/pod/show/theownershipeconomy/episodes/Episode-009---Localized-Democracy-and-Public-Goods-with-Token-Engineering--with-Jeff-Emmett-of-The-Commons-Stack--BlockScience-Labs-e1ggkqo">
|
||||||
|
Localized Democracy and Public Goods with Token Engineering on the
|
||||||
|
Ownership Economy
|
||||||
|
</a>{" "}
|
||||||
|
(<a href="artifact/tft-rocks-integration-domain.pdf">slides</a>)
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<a href="https://youtu.be/kxcat-XBWas">
|
||||||
|
A Discussion on Warm Data with Nora Bateson on Systems Innovation
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
</ol>
|
||||||
|
<h2>Writing</h2>
|
||||||
|
<ol reversed>
|
||||||
|
<li>
|
||||||
|
<a href="https://www.mycofi.art">
|
||||||
|
Exploring MycoFi: Mycelial Design Patterns for Web3 & Beyond
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<a href="https://www.frontiersin.org/journals/blockchain/articles/10.3389/fbloc.2021.578721/full">
|
||||||
|
Challenges & Approaches to Scaling the Global Commons
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<a href="https://allthingsdecent.substack.com/p/mycoeconomics-and-permaculture-currencies">
|
||||||
|
From Monoculture to Permaculture Currencies: A Glimpse of the
|
||||||
|
Myco-Economic Future
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<a href="https://medium.com/good-audience/rewriting-the-story-of-human-collaboration-c33a8a4cd5b8">
|
||||||
|
Rewriting the Story of Human Collaboration
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
</ol>
|
||||||
|
</main>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -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,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,435 @@
|
||||||
|
import { BaseBoxShapeUtil, TLBaseShape } from "tldraw"
|
||||||
|
import { useCallback, useState } from "react"
|
||||||
|
//import Embed from "react-embed"
|
||||||
|
|
||||||
|
export type IEmbedShape = TLBaseShape<
|
||||||
|
"Embed",
|
||||||
|
{
|
||||||
|
w: number
|
||||||
|
h: number
|
||||||
|
url: string | null
|
||||||
|
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: 560, h: 315 }
|
||||||
|
}
|
||||||
|
|
||||||
|
// Twitter/X default dimensions
|
||||||
|
if (url.match(/(?:twitter\.com|x\.com)/)) {
|
||||||
|
if (url.match(/\/status\/|\/tweets\//)) {
|
||||||
|
return { w: 500, h: 420 } // For individual tweets
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Google Maps default dimensions
|
||||||
|
if (url.includes("google.com/maps") || url.includes("goo.gl/maps")) {
|
||||||
|
return { w: 600, h: 450 }
|
||||||
|
}
|
||||||
|
|
||||||
|
// Gather.town default dimensions
|
||||||
|
if (url.includes("gather.town")) {
|
||||||
|
return { w: 800, h: 600 }
|
||||||
|
}
|
||||||
|
|
||||||
|
// Default dimensions for other embeds
|
||||||
|
return { w: 640, h: 480 }
|
||||||
|
}
|
||||||
|
|
||||||
|
export class EmbedShape extends BaseBoxShapeUtil<IEmbedShape> {
|
||||||
|
static override type = "Embed"
|
||||||
|
|
||||||
|
getDefaultProps(): IEmbedShape["props"] {
|
||||||
|
return {
|
||||||
|
url: null,
|
||||||
|
w: 640,
|
||||||
|
h: 480,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
indicator(shape: IEmbedShape) {
|
||||||
|
return (
|
||||||
|
<g>
|
||||||
|
<rect x={0} y={0} width={shape.props.w} height={shape.props.h} />
|
||||||
|
</g>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
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 wrapperStyle = {
|
||||||
|
width: `${shape.props.w}px`,
|
||||||
|
height: `${shape.props.h}px`,
|
||||||
|
padding: "15px",
|
||||||
|
boxShadow: "0 2px 4px rgba(0, 0, 0, 0.1)",
|
||||||
|
backgroundColor: "#F0F0F0",
|
||||||
|
borderRadius: "4px",
|
||||||
|
}
|
||||||
|
|
||||||
|
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",
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!shape.props.url) {
|
||||||
|
return (
|
||||||
|
<div style={wrapperStyle}>
|
||||||
|
<div
|
||||||
|
style={contentStyle}
|
||||||
|
onClick={() => document.querySelector("input")?.focus()}
|
||||||
|
>
|
||||||
|
<form
|
||||||
|
onSubmit={handleSubmit}
|
||||||
|
style={{ width: "100%", height: "100%", padding: "10px" }}
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={inputUrl}
|
||||||
|
onChange={(e) => setInputUrl(e.target.value)}
|
||||||
|
placeholder="Enter URL"
|
||||||
|
style={{
|
||||||
|
width: "100%",
|
||||||
|
height: "100%",
|
||||||
|
border: "none",
|
||||||
|
padding: "10px",
|
||||||
|
}}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === "Enter") {
|
||||||
|
handleSubmit(e)
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
{error && (
|
||||||
|
<div style={{ color: "red", marginTop: "10px" }}>{error}</div>
|
||||||
|
)}
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (shape.props.url?.includes("medium.com")) {
|
||||||
|
return (
|
||||||
|
<div style={wrapperStyle}>
|
||||||
|
<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>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
shape.props.url &&
|
||||||
|
shape.props.url.match(/(?:twitter\.com|x\.com)\/[^\/]+$/)
|
||||||
|
) {
|
||||||
|
const username = shape.props.url.split("/").pop()
|
||||||
|
return (
|
||||||
|
<div style={wrapperStyle}>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
...contentStyle,
|
||||||
|
flexDirection: "column",
|
||||||
|
gap: "12px",
|
||||||
|
padding: "20px",
|
||||||
|
textAlign: "center",
|
||||||
|
pointerEvents: "all",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<p>X (Twitter) does not support embedding profile timelines.</p>
|
||||||
|
<a
|
||||||
|
onClick={(e) => {
|
||||||
|
e.preventDefault()
|
||||||
|
e.stopPropagation()
|
||||||
|
window.top?.open(
|
||||||
|
shape.props.url || "",
|
||||||
|
"_blank",
|
||||||
|
"noopener,noreferrer",
|
||||||
|
)
|
||||||
|
}}
|
||||||
|
href={shape.props.url}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
style={{
|
||||||
|
color: "#1976d2",
|
||||||
|
textDecoration: "none",
|
||||||
|
cursor: "pointer",
|
||||||
|
}}
|
||||||
|
onPointerDown={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
View @{username}'s profile in new tab →
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={wrapperStyle}>
|
||||||
|
<div style={contentStyle}>
|
||||||
|
<iframe
|
||||||
|
src={transformUrl(shape.props.url)}
|
||||||
|
width={shape.props.w}
|
||||||
|
height={shape.props.h}
|
||||||
|
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",
|
||||||
|
minHeight: "24px",
|
||||||
|
fontSize: "12px",
|
||||||
|
position: "absolute",
|
||||||
|
bottom: "15px",
|
||||||
|
left: "15px",
|
||||||
|
right: "15px",
|
||||||
|
backgroundColor: "rgba(255, 255, 255, 0.9)",
|
||||||
|
borderRadius: "4px",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
overflow: "hidden",
|
||||||
|
textOverflow: "ellipsis",
|
||||||
|
whiteSpace: "nowrap",
|
||||||
|
flex: 1,
|
||||||
|
marginRight: "8px",
|
||||||
|
color: "#666",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{shape.props.url}
|
||||||
|
</span>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: "flex",
|
||||||
|
gap: "8px",
|
||||||
|
pointerEvents: "all",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
onClick={async (e) => {
|
||||||
|
e.preventDefault()
|
||||||
|
e.stopPropagation()
|
||||||
|
try {
|
||||||
|
await navigator.clipboard.writeText(shape.props.url || "")
|
||||||
|
setCopyStatus(true)
|
||||||
|
setTimeout(() => setCopyStatus(false), 2000)
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Failed to copy:", err)
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
style={{
|
||||||
|
border: "none",
|
||||||
|
background: "transparent",
|
||||||
|
color: "#1976d2",
|
||||||
|
cursor: "pointer",
|
||||||
|
padding: "0 4px",
|
||||||
|
fontSize: "12px",
|
||||||
|
}}
|
||||||
|
onPointerDown={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
{copyStatus ? "Copied" : "Copy link"}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={(e) => {
|
||||||
|
e.preventDefault()
|
||||||
|
e.stopPropagation()
|
||||||
|
window.top?.open(
|
||||||
|
shape.props.url || "",
|
||||||
|
"_blank",
|
||||||
|
"noopener,noreferrer",
|
||||||
|
)
|
||||||
|
}}
|
||||||
|
style={{
|
||||||
|
border: "none",
|
||||||
|
background: "transparent",
|
||||||
|
color: "#1976d2",
|
||||||
|
cursor: "pointer",
|
||||||
|
padding: "0 4px",
|
||||||
|
fontSize: "12px",
|
||||||
|
}}
|
||||||
|
onPointerDown={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
Open in new tab ↗
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,188 @@
|
||||||
|
/** TODO: build this */
|
||||||
|
|
||||||
|
import { BaseBoxShapeUtil, TLBaseBoxShape, TLBaseShape, StyleProp, T, DefaultSizeStyle, DefaultFontStyle, DefaultColorStyle } from "tldraw"
|
||||||
|
import { useEffect, useRef, useState } from "react"
|
||||||
|
import { marked } from "marked"
|
||||||
|
|
||||||
|
// Uncomment and use these style definitions
|
||||||
|
const MarkdownColor = StyleProp.defineEnum('markdown:color', {
|
||||||
|
defaultValue: 'black',
|
||||||
|
values: ['black', 'blue', 'green', 'grey', 'light-blue', 'light-green', 'light-red', 'light-violet', 'orange', 'red', 'violet', 'yellow'],
|
||||||
|
})
|
||||||
|
|
||||||
|
const MarkdownSize = StyleProp.defineEnum('markdown:size', {
|
||||||
|
defaultValue: 'medium',
|
||||||
|
values: ['small', 'medium', 'large'],
|
||||||
|
})
|
||||||
|
|
||||||
|
const MarkdownFont = StyleProp.defineEnum('markdown:font', {
|
||||||
|
defaultValue: 'draw',
|
||||||
|
values: ['draw', 'sans', 'serif', 'mono'],
|
||||||
|
})
|
||||||
|
|
||||||
|
//const MarkdownHorizontalAlign = StyleProp.define('markdown:horizontalalign', { defaultValue: 'start' })
|
||||||
|
//const MarkdownVerticalAlign = StyleProp.define('markdown:verticalalign', { defaultValue: 'start' })
|
||||||
|
|
||||||
|
export type IMarkdownShape = TLBaseShape<
|
||||||
|
"MarkdownTool",
|
||||||
|
{
|
||||||
|
content: string
|
||||||
|
isPreview: boolean
|
||||||
|
w: number
|
||||||
|
h: number
|
||||||
|
color: string
|
||||||
|
size: string
|
||||||
|
font: string
|
||||||
|
}
|
||||||
|
>
|
||||||
|
|
||||||
|
export class MarkdownShape extends BaseBoxShapeUtil<
|
||||||
|
IMarkdownShape & TLBaseBoxShape
|
||||||
|
> {
|
||||||
|
static override type = "MarkdownTool"
|
||||||
|
|
||||||
|
styles = {
|
||||||
|
color: MarkdownColor,
|
||||||
|
size: MarkdownSize,
|
||||||
|
font: MarkdownFont,
|
||||||
|
}
|
||||||
|
|
||||||
|
getDefaultProps(): IMarkdownShape["props"] & { w: number; h: number } {
|
||||||
|
console.log('getDefaultProps called');
|
||||||
|
const props = {
|
||||||
|
content: "",
|
||||||
|
isPreview: false,
|
||||||
|
w: 400,
|
||||||
|
h: 300,
|
||||||
|
color: 'black',
|
||||||
|
size: 'medium',
|
||||||
|
font: 'draw'
|
||||||
|
};
|
||||||
|
console.log('Default props:', props);
|
||||||
|
return props;
|
||||||
|
}
|
||||||
|
|
||||||
|
indicator(shape: IMarkdownShape) {
|
||||||
|
return (
|
||||||
|
<g>
|
||||||
|
<rect x={0} y={0} width={shape.props.w} height={shape.props.h} />
|
||||||
|
</g>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
component(shape: IMarkdownShape) {
|
||||||
|
console.log('Component rendering with shape:', shape);
|
||||||
|
console.log('Available styles:', this.styles);
|
||||||
|
const editor = this.editor
|
||||||
|
return <MarkdownEditor shape={shape} editor={editor} />
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function MarkdownEditor({ shape, editor }: { shape: IMarkdownShape; editor: any }) {
|
||||||
|
console.log('MarkdownEditor mounted with shape:', shape);
|
||||||
|
console.log('Editor instance:', editor);
|
||||||
|
|
||||||
|
const textareaRef = useRef<HTMLTextAreaElement>(null)
|
||||||
|
const [isPreview, setIsPreview] = useState(shape.props.isPreview)
|
||||||
|
const [renderedContent, setRenderedContent] = useState("")
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (textareaRef.current && textareaRef.current.value !== shape.props.content) {
|
||||||
|
textareaRef.current.value = shape.props.content
|
||||||
|
}
|
||||||
|
}, [shape.props.content])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const html = marked.parse(shape.props.content, { breaks: true }) as string
|
||||||
|
setRenderedContent(html)
|
||||||
|
}, [shape.props.content])
|
||||||
|
|
||||||
|
const togglePreview = () => {
|
||||||
|
const newPreviewState = !isPreview
|
||||||
|
setIsPreview(newPreviewState)
|
||||||
|
editor.updateShape(shape.id, {
|
||||||
|
props: {
|
||||||
|
...shape.props,
|
||||||
|
isPreview: newPreviewState
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{
|
||||||
|
width: '100%',
|
||||||
|
height: '100%',
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
backgroundColor: 'white',
|
||||||
|
border: '1px solid #e0e0e0',
|
||||||
|
borderRadius: '4px',
|
||||||
|
overflow: 'hidden'
|
||||||
|
}}>
|
||||||
|
{/* Toolbar */}
|
||||||
|
<div style={{
|
||||||
|
padding: '4px 8px',
|
||||||
|
borderBottom: '1px solid #e0e0e0',
|
||||||
|
backgroundColor: '#f5f5f5',
|
||||||
|
display: 'flex',
|
||||||
|
gap: '8px',
|
||||||
|
alignItems: 'center'
|
||||||
|
}}>
|
||||||
|
<button
|
||||||
|
onClick={togglePreview}
|
||||||
|
style={{
|
||||||
|
padding: '4px 8px',
|
||||||
|
border: '1px solid #ccc',
|
||||||
|
borderRadius: '4px',
|
||||||
|
backgroundColor: 'white',
|
||||||
|
cursor: 'pointer',
|
||||||
|
fontSize: '12px'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{isPreview ? 'Edit' : 'Preview'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Editor/Preview Area */}
|
||||||
|
<div style={{
|
||||||
|
flex: 1,
|
||||||
|
overflow: 'auto',
|
||||||
|
position: 'relative'
|
||||||
|
}}>
|
||||||
|
{isPreview ? (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
padding: '8px',
|
||||||
|
height: '100%',
|
||||||
|
overflow: 'auto'
|
||||||
|
}}
|
||||||
|
dangerouslySetInnerHTML={{
|
||||||
|
__html: marked(shape.props.content, { breaks: true }) as string
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<textarea
|
||||||
|
ref={textareaRef}
|
||||||
|
defaultValue={shape.props.content}
|
||||||
|
style={{
|
||||||
|
width: '100%',
|
||||||
|
height: '100%',
|
||||||
|
resize: 'none',
|
||||||
|
border: 'none',
|
||||||
|
padding: '8px',
|
||||||
|
fontFamily: 'inherit',
|
||||||
|
}}
|
||||||
|
onChange={(e) => {
|
||||||
|
editor.updateShape(shape.id, {
|
||||||
|
props: {
|
||||||
|
...shape.props,
|
||||||
|
content: e.target.value
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -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,194 @@
|
||||||
|
import { BaseBoxShapeUtil, TLBaseShape } from "tldraw"
|
||||||
|
import { useEffect, useState } from "react"
|
||||||
|
|
||||||
|
export type IVideoChatShape = TLBaseShape<
|
||||||
|
"VideoChat",
|
||||||
|
{
|
||||||
|
w: number
|
||||||
|
h: number
|
||||||
|
roomUrl: string | null
|
||||||
|
allowCamera: boolean
|
||||||
|
allowMicrophone: boolean
|
||||||
|
}
|
||||||
|
>
|
||||||
|
|
||||||
|
export class VideoChatShape extends BaseBoxShapeUtil<IVideoChatShape> {
|
||||||
|
static override type = "VideoChat"
|
||||||
|
|
||||||
|
indicator(_shape: IVideoChatShape) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
getDefaultProps(): IVideoChatShape["props"] {
|
||||||
|
return {
|
||||||
|
roomUrl: null,
|
||||||
|
w: 640,
|
||||||
|
h: 480,
|
||||||
|
allowCamera: false,
|
||||||
|
allowMicrophone: false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async ensureRoomExists(shape: IVideoChatShape) {
|
||||||
|
if (shape.props.roomUrl !== null) {
|
||||||
|
console.log("Room already exists:", shape.props.roomUrl)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const apiKey = import.meta.env["VITE_DAILY_API_KEY"]
|
||||||
|
console.log("API Key available:", !!apiKey)
|
||||||
|
if (!apiKey) throw new Error("Daily API key is missing")
|
||||||
|
|
||||||
|
const response = await fetch("https://api.daily.co/v1/rooms", {
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
Authorization: `Bearer ${apiKey.trim()}`,
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
properties: {
|
||||||
|
enable_chat: true,
|
||||||
|
start_audio_off: true,
|
||||||
|
start_video_off: true,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
|
||||||
|
console.log("Response status:", response.status)
|
||||||
|
console.log("Response data:", await response.clone().json())
|
||||||
|
|
||||||
|
if (!response.ok)
|
||||||
|
throw new Error(`Failed to create room (${response.status})`)
|
||||||
|
|
||||||
|
const responseData = (await response.json()) as { url: string }
|
||||||
|
const url = responseData.url
|
||||||
|
|
||||||
|
if (!url) throw new Error("Room URL is missing")
|
||||||
|
|
||||||
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
component(shape: IVideoChatShape) {
|
||||||
|
const [hasPermissions, setHasPermissions] = useState(false)
|
||||||
|
const [error, setError] = useState<Error | null>(null)
|
||||||
|
const [isLoading, setIsLoading] = useState(true)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setIsLoading(true)
|
||||||
|
this.ensureRoomExists(shape)
|
||||||
|
.catch(console.error)
|
||||||
|
.finally(() => setIsLoading(false))
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
// Request permissions when needed
|
||||||
|
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)
|
||||||
|
setHasPermissions(true)
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Permission request failed:", error)
|
||||||
|
setHasPermissions(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
requestPermissions()
|
||||||
|
}, [shape.props.allowCamera, shape.props.allowMicrophone])
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
return <div>Error creating room: {error.message}</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!shape.props.roomUrl) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
width: shape.props.w,
|
||||||
|
height: shape.props.h,
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
backgroundColor: "#f0f0f0",
|
||||||
|
borderRadius: "4px",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{isLoading ? "Creating room... Please wait" : "Creating room..."}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Construct URL with permission parameters
|
||||||
|
const roomUrlWithParams = new URL(shape.props.roomUrl)
|
||||||
|
roomUrlWithParams.searchParams.set(
|
||||||
|
"allow_camera",
|
||||||
|
String(shape.props.allowCamera),
|
||||||
|
)
|
||||||
|
roomUrlWithParams.searchParams.set(
|
||||||
|
"allow_mic",
|
||||||
|
String(shape.props.allowMicrophone),
|
||||||
|
)
|
||||||
|
|
||||||
|
console.log(shape.props.roomUrl)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
width: `${shape.props.w}px`,
|
||||||
|
height: `${shape.props.h}px`,
|
||||||
|
position: "relative",
|
||||||
|
pointerEvents: "all",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<iframe
|
||||||
|
src={roomUrlWithParams.toString()}
|
||||||
|
width="100%"
|
||||||
|
height="100%"
|
||||||
|
style={{ border: "none" }}
|
||||||
|
allow={`camera ${shape.props.allowCamera ? "self" : ""}; microphone ${
|
||||||
|
shape.props.allowMicrophone ? "self" : ""
|
||||||
|
}`}
|
||||||
|
></iframe>
|
||||||
|
<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",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
url: {shape.props.roomUrl}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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,7 @@
|
||||||
|
import { BaseBoxShapeTool } from "tldraw"
|
||||||
|
|
||||||
|
export class VideoChatTool extends BaseBoxShapeTool {
|
||||||
|
static override id = "VideoChat"
|
||||||
|
shapeType = "VideoChat"
|
||||||
|
override initial = "idle"
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,233 @@
|
||||||
|
import {
|
||||||
|
Editor,
|
||||||
|
TldrawUiMenuActionItem,
|
||||||
|
TldrawUiMenuItem,
|
||||||
|
TldrawUiMenuSubmenu,
|
||||||
|
TLShape,
|
||||||
|
} from "tldraw"
|
||||||
|
import { TldrawUiMenuGroup } from "tldraw"
|
||||||
|
import { DefaultContextMenu } from "tldraw"
|
||||||
|
import { TLUiContextMenuProps, useEditor } from "tldraw"
|
||||||
|
import {
|
||||||
|
cameraHistory,
|
||||||
|
copyLinkToCurrentView,
|
||||||
|
lockCameraToFrame,
|
||||||
|
revertCamera,
|
||||||
|
zoomToSelection,
|
||||||
|
} from "./cameraUtils"
|
||||||
|
import { useState, useEffect } from "react"
|
||||||
|
import { saveToPdf } from "../utils/pdfUtils"
|
||||||
|
import { TLFrameShape } from "tldraw"
|
||||||
|
import { searchText } from "../utils/searchUtils"
|
||||||
|
|
||||||
|
const getAllFrames = (editor: Editor) => {
|
||||||
|
return editor
|
||||||
|
.getCurrentPageShapes()
|
||||||
|
.filter((shape): shape is TLFrameShape => shape.type === "frame")
|
||||||
|
.map((frame) => ({
|
||||||
|
id: frame.id,
|
||||||
|
title: frame.props.name || "Untitled Frame",
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
export function CustomContextMenu(props: TLUiContextMenuProps) {
|
||||||
|
const editor = useEditor()
|
||||||
|
const [selectedShapes, setSelectedShapes] = useState<TLShape[]>([])
|
||||||
|
const [selectedIds, setSelectedIds] = useState<string[]>([])
|
||||||
|
|
||||||
|
// Update selection state more frequently
|
||||||
|
useEffect(() => {
|
||||||
|
const updateSelection = () => {
|
||||||
|
setSelectedShapes(editor.getSelectedShapes())
|
||||||
|
setSelectedIds(editor.getSelectedShapeIds())
|
||||||
|
}
|
||||||
|
|
||||||
|
// Initial update
|
||||||
|
updateSelection()
|
||||||
|
|
||||||
|
// Subscribe to selection changes
|
||||||
|
const unsubscribe = editor.addListener("change", updateSelection)
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
if (typeof unsubscribe === "function") {
|
||||||
|
;(unsubscribe as () => void)()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [editor])
|
||||||
|
|
||||||
|
const hasSelection = selectedIds.length > 0
|
||||||
|
const hasCameraHistory = cameraHistory.length > 0
|
||||||
|
|
||||||
|
// Check if exactly one frame is selected
|
||||||
|
const hasFrameSelected =
|
||||||
|
selectedShapes.length === 1 && selectedShapes[0].type === "frame"
|
||||||
|
|
||||||
|
return (
|
||||||
|
<DefaultContextMenu {...props}>
|
||||||
|
{/* Camera Controls Group */}
|
||||||
|
<TldrawUiMenuGroup id="camera-controls">
|
||||||
|
<TldrawUiMenuItem
|
||||||
|
id="zoom-to-selection"
|
||||||
|
label="Zoom to Selection"
|
||||||
|
icon="zoom-in"
|
||||||
|
kbd="z"
|
||||||
|
disabled={!hasSelection}
|
||||||
|
onSelect={() => zoomToSelection(editor)}
|
||||||
|
/>
|
||||||
|
<TldrawUiMenuItem
|
||||||
|
id="copy-link-to-current-view"
|
||||||
|
label="Copy Link to Current View"
|
||||||
|
icon="link"
|
||||||
|
kbd="alt+c"
|
||||||
|
onSelect={() => copyLinkToCurrentView(editor)}
|
||||||
|
/>
|
||||||
|
<TldrawUiMenuItem
|
||||||
|
id="revert-camera"
|
||||||
|
label="Revert Camera"
|
||||||
|
icon="undo"
|
||||||
|
kbd="alt+b"
|
||||||
|
disabled={!hasCameraHistory}
|
||||||
|
onSelect={() => revertCamera(editor)}
|
||||||
|
/>
|
||||||
|
<TldrawUiMenuItem
|
||||||
|
id="save-to-pdf"
|
||||||
|
label="Save Selection as PDF"
|
||||||
|
icon="file"
|
||||||
|
kbd="alt+p"
|
||||||
|
disabled={!hasSelection}
|
||||||
|
onSelect={() => saveToPdf(editor)}
|
||||||
|
/>
|
||||||
|
</TldrawUiMenuGroup>
|
||||||
|
|
||||||
|
{/* Creation Tools Group */}
|
||||||
|
<TldrawUiMenuGroup id="creation-tools">
|
||||||
|
<TldrawUiMenuItem
|
||||||
|
id="video-chat"
|
||||||
|
label="Create Video Chat"
|
||||||
|
icon="video"
|
||||||
|
kbd="alt+v"
|
||||||
|
disabled={hasSelection}
|
||||||
|
onSelect={() => {
|
||||||
|
editor.setCurrentTool("VideoChat")
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<TldrawUiMenuItem
|
||||||
|
id="chat-box"
|
||||||
|
label="Create Chat Box"
|
||||||
|
icon="chat"
|
||||||
|
kbd="alt+c"
|
||||||
|
disabled={hasSelection}
|
||||||
|
onSelect={() => {
|
||||||
|
editor.setCurrentTool("ChatBox")
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<TldrawUiMenuItem
|
||||||
|
id="embed"
|
||||||
|
label="Create Embed"
|
||||||
|
icon="embed"
|
||||||
|
kbd="alt+e"
|
||||||
|
disabled={hasSelection}
|
||||||
|
onSelect={() => {
|
||||||
|
editor.setCurrentTool("Embed")
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
{/*
|
||||||
|
<TldrawUiMenuItem
|
||||||
|
id="mycrozine-template"
|
||||||
|
label="Create Mycrozine Template"
|
||||||
|
icon="rectangle"
|
||||||
|
kbd="m"
|
||||||
|
disabled={hasSelection}
|
||||||
|
onSelect={() => {
|
||||||
|
editor.setCurrentTool("MycrozineTemplate")
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<TldrawUiMenuItem
|
||||||
|
id="markdown"
|
||||||
|
label="Create Markdown"
|
||||||
|
icon="markdown"
|
||||||
|
kbd="alt+m"
|
||||||
|
disabled={hasSelection}
|
||||||
|
onSelect={() => {
|
||||||
|
editor.setCurrentTool("Markdown")
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
*/}
|
||||||
|
</TldrawUiMenuGroup>
|
||||||
|
|
||||||
|
{/* Frame Controls */}
|
||||||
|
<TldrawUiMenuGroup id="frame-controls">
|
||||||
|
<TldrawUiMenuItem
|
||||||
|
id="lock-to-frame"
|
||||||
|
label="Lock to Frame"
|
||||||
|
icon="lock"
|
||||||
|
kbd="shift+l"
|
||||||
|
disabled={!hasFrameSelected}
|
||||||
|
onSelect={() => lockCameraToFrame(editor)}
|
||||||
|
/>
|
||||||
|
</TldrawUiMenuGroup>
|
||||||
|
|
||||||
|
<TldrawUiMenuGroup id="frames-list">
|
||||||
|
<TldrawUiMenuSubmenu id="frames-dropdown" label="Shortcut to Frames">
|
||||||
|
{getAllFrames(editor).map((frame) => (
|
||||||
|
<TldrawUiMenuItem
|
||||||
|
key={frame.id}
|
||||||
|
id={`frame-${frame.id}`}
|
||||||
|
label={frame.title}
|
||||||
|
onSelect={() => {
|
||||||
|
const shape = editor.getShape(frame.id)
|
||||||
|
if (shape) {
|
||||||
|
editor.zoomToBounds(editor.getShapePageBounds(shape)!, {
|
||||||
|
animation: { duration: 400, easing: (t) => t * (2 - t) },
|
||||||
|
})
|
||||||
|
editor.select(frame.id)
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</TldrawUiMenuSubmenu>
|
||||||
|
</TldrawUiMenuGroup>
|
||||||
|
|
||||||
|
<TldrawUiMenuGroup id="broadcast-controls">
|
||||||
|
<TldrawUiMenuItem
|
||||||
|
id="broadcast-view"
|
||||||
|
label="Start Broadcasting View"
|
||||||
|
icon="broadcast"
|
||||||
|
kbd="alt+b"
|
||||||
|
onSelect={() => {
|
||||||
|
const otherUsers = Array.from(editor.store.allRecords()).filter(
|
||||||
|
(record) =>
|
||||||
|
record.typeName === "instance_presence" &&
|
||||||
|
record.id !== editor.user.getId(),
|
||||||
|
)
|
||||||
|
otherUsers.forEach((user) => editor.startFollowingUser(user.id))
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<TldrawUiMenuItem
|
||||||
|
id="stop-broadcast"
|
||||||
|
label="Stop Broadcasting View"
|
||||||
|
icon="broadcast-off"
|
||||||
|
kbd="alt+shift+b"
|
||||||
|
onSelect={() => {
|
||||||
|
const otherUsers = Array.from(editor.store.allRecords()).filter(
|
||||||
|
(record) =>
|
||||||
|
record.typeName === "instance_presence" &&
|
||||||
|
record.id !== editor.user.getId(),
|
||||||
|
)
|
||||||
|
otherUsers.forEach((_user) => editor.stopFollowingUser())
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</TldrawUiMenuGroup>
|
||||||
|
|
||||||
|
<TldrawUiMenuGroup id="search-controls">
|
||||||
|
<TldrawUiMenuItem
|
||||||
|
id="search-text"
|
||||||
|
label="Search Text"
|
||||||
|
icon="search"
|
||||||
|
kbd="s"
|
||||||
|
onSelect={() => searchText(editor)}
|
||||||
|
/>
|
||||||
|
</TldrawUiMenuGroup>
|
||||||
|
</DefaultContextMenu>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,100 @@
|
||||||
|
import {
|
||||||
|
DefaultMainMenu,
|
||||||
|
TldrawUiMenuItem,
|
||||||
|
Editor,
|
||||||
|
TLContent,
|
||||||
|
DefaultMainMenuContent,
|
||||||
|
useEditor,
|
||||||
|
useExportAs,
|
||||||
|
TldrawUiMenuSubmenu,
|
||||||
|
} from "tldraw";
|
||||||
|
import { useState, useEffect } from 'react';
|
||||||
|
|
||||||
|
interface BackupVersion {
|
||||||
|
key: string;
|
||||||
|
timestamp: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function CustomMainMenu() {
|
||||||
|
const editor = useEditor()
|
||||||
|
const exportAs = useExportAs()
|
||||||
|
const [backupVersions, setBackupVersions] = useState<BackupVersion[]>([])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const fetchBackups = async (roomId: string) => {
|
||||||
|
try {
|
||||||
|
const response = await fetch(`/backups/${roomId}`);
|
||||||
|
const versions = await response.json() as BackupVersion[];
|
||||||
|
setBackupVersions(versions);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to fetch backup versions:', error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
fetchBackups([roomId]);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const restoreVersion = async (key: string) => {
|
||||||
|
try {
|
||||||
|
const response = await fetch(`/backups/${key}`);
|
||||||
|
const jsonData = await response.json() as TLContent;
|
||||||
|
editor.putContentOntoCurrentPage(jsonData, { select: true });
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to restore version:', error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
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 />
|
||||||
|
<TldrawUiMenuSubmenu id="restore-version" label="Restore Version">
|
||||||
|
{backupVersions.map((version) => (
|
||||||
|
<TldrawUiMenuItem
|
||||||
|
key={version.key}
|
||||||
|
id={`restore-${version.key}`}
|
||||||
|
label={version.timestamp}
|
||||||
|
onSelect={() => restoreVersion(version.key)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</TldrawUiMenuSubmenu>
|
||||||
|
<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,59 @@
|
||||||
|
import { TldrawUiMenuItem } from "tldraw"
|
||||||
|
import { DefaultToolbar, DefaultToolbarContent } from "tldraw"
|
||||||
|
import { useTools } from "tldraw"
|
||||||
|
import { useEditor } from "tldraw"
|
||||||
|
import { useState, useEffect } from "react"
|
||||||
|
|
||||||
|
export function CustomToolbar() {
|
||||||
|
const editor = useEditor()
|
||||||
|
const tools = useTools()
|
||||||
|
const [isReady, setIsReady] = useState(false)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (editor && tools) {
|
||||||
|
setIsReady(true)
|
||||||
|
}
|
||||||
|
}, [editor, tools])
|
||||||
|
|
||||||
|
if (!isReady) return null
|
||||||
|
|
||||||
|
return (
|
||||||
|
<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["Markdown"] && (
|
||||||
|
<TldrawUiMenuItem
|
||||||
|
{...tools["Markdown"]}
|
||||||
|
icon="markdown"
|
||||||
|
label="Markdown"
|
||||||
|
isSelected={tools["Markdown"].id === editor.getCurrentToolId()}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
*/}
|
||||||
|
</DefaultToolbar>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,89 @@
|
||||||
|
import { useCallback, useEffect, useState } from 'react'
|
||||||
|
import { useEditor } from 'tldraw'
|
||||||
|
import { WORKER_URL } from '../routes/Board'
|
||||||
|
import { useParams } from 'react-router-dom'
|
||||||
|
|
||||||
|
interface Version {
|
||||||
|
timestamp: number
|
||||||
|
version: number
|
||||||
|
dateKey: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export function VersionHistoryMenu() {
|
||||||
|
const editor = useEditor()
|
||||||
|
const { slug } = useParams<{ slug: string }>()
|
||||||
|
const [versions, setVersions] = useState<Version[]>([])
|
||||||
|
const [loading, setLoading] = useState(false)
|
||||||
|
|
||||||
|
const fetchVersions = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
const response = await fetch(`/backups/${slug}`)
|
||||||
|
const data = await response.json()
|
||||||
|
setVersions(data as Version[])
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to fetch versions:', error)
|
||||||
|
}
|
||||||
|
}, [slug])
|
||||||
|
|
||||||
|
const restoreVersion = async (dateKey: string) => {
|
||||||
|
if (!confirm('Are you sure you want to restore this version? Current changes will be lost.')) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
setLoading(true)
|
||||||
|
try {
|
||||||
|
await fetch(`${WORKER_URL}/rooms/${slug}/restore/${dateKey}`, {
|
||||||
|
method: 'POST'
|
||||||
|
})
|
||||||
|
// Reload the page to get the restored version
|
||||||
|
window.location.reload()
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to restore version:', error)
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchVersions()
|
||||||
|
// Refresh versions list every 5 minutes
|
||||||
|
const interval = setInterval(fetchVersions, 5 * 60 * 1000)
|
||||||
|
return () => clearInterval(interval)
|
||||||
|
}, [fetchVersions])
|
||||||
|
|
||||||
|
const formatDate = (timestamp: number) => {
|
||||||
|
const date = new Date(timestamp)
|
||||||
|
return new Intl.DateTimeFormat('en-US', {
|
||||||
|
weekday: 'long',
|
||||||
|
year: 'numeric',
|
||||||
|
month: 'long',
|
||||||
|
day: 'numeric'
|
||||||
|
}).format(date)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="version-history-menu">
|
||||||
|
<h3>Daily Backups</h3>
|
||||||
|
<div className="version-list">
|
||||||
|
{versions.length === 0 ? (
|
||||||
|
<div className="no-versions">No backups available yet</div>
|
||||||
|
) : (
|
||||||
|
versions.map((version) => (
|
||||||
|
<div key={version.dateKey} className="version-item">
|
||||||
|
<span className="version-date">
|
||||||
|
{formatDate(version.timestamp)}
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
onClick={() => restoreVersion(version.dateKey)}
|
||||||
|
disabled={loading}
|
||||||
|
className="restore-button"
|
||||||
|
>
|
||||||
|
{loading ? 'Restoring...' : 'Restore'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,224 @@
|
||||||
|
import { Editor } from "tldraw"
|
||||||
|
|
||||||
|
export const cameraHistory: { x: number; y: number; z: number }[] = []
|
||||||
|
const MAX_HISTORY = 10 // Keep last 10 camera positions
|
||||||
|
|
||||||
|
// Helper function to store camera position
|
||||||
|
const storeCameraPosition = (editor: Editor) => {
|
||||||
|
const currentCamera = editor.getCamera()
|
||||||
|
// Only store if there's a meaningful change from the last position
|
||||||
|
const lastPosition = cameraHistory[cameraHistory.length - 1]
|
||||||
|
if (
|
||||||
|
!lastPosition ||
|
||||||
|
Math.abs(lastPosition.x - currentCamera.x) > 1 ||
|
||||||
|
Math.abs(lastPosition.y - currentCamera.y) > 1 ||
|
||||||
|
Math.abs(lastPosition.z - currentCamera.z) > 0.1
|
||||||
|
) {
|
||||||
|
cameraHistory.push({ ...currentCamera })
|
||||||
|
if (cameraHistory.length > MAX_HISTORY) {
|
||||||
|
cameraHistory.shift()
|
||||||
|
}
|
||||||
|
console.log("Stored camera position:", currentCamera)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const zoomToSelection = (editor: Editor) => {
|
||||||
|
// Store camera position before zooming
|
||||||
|
storeCameraPosition(editor)
|
||||||
|
|
||||||
|
// Get all selected shape IDs
|
||||||
|
const selectedIds = editor.getSelectedShapeIds()
|
||||||
|
if (selectedIds.length === 0) return
|
||||||
|
|
||||||
|
// Get the common bounds that encompass all selected shapes
|
||||||
|
const commonBounds = editor.getSelectionPageBounds()
|
||||||
|
if (!commonBounds) return
|
||||||
|
|
||||||
|
// Calculate viewport dimensions
|
||||||
|
const viewportPageBounds = editor.getViewportPageBounds()
|
||||||
|
|
||||||
|
// Calculate the ratio of selection size to viewport size
|
||||||
|
const widthRatio = commonBounds.width / viewportPageBounds.width
|
||||||
|
const heightRatio = commonBounds.height / viewportPageBounds.height
|
||||||
|
|
||||||
|
// Calculate target zoom based on selection size
|
||||||
|
let targetZoom
|
||||||
|
if (widthRatio < 0.1 || heightRatio < 0.1) {
|
||||||
|
// For very small selections, zoom in up to 20x
|
||||||
|
targetZoom = Math.min(
|
||||||
|
(viewportPageBounds.width * 0.8) / commonBounds.width,
|
||||||
|
(viewportPageBounds.height * 0.8) / commonBounds.height,
|
||||||
|
40, // Max zoom of 20x for small selections
|
||||||
|
)
|
||||||
|
} else if (widthRatio > 1 || heightRatio > 1) {
|
||||||
|
// For selections larger than viewport, zoom out more
|
||||||
|
targetZoom = Math.min(
|
||||||
|
(viewportPageBounds.width * 0.7) / commonBounds.width,
|
||||||
|
(viewportPageBounds.height * 0.7) / commonBounds.height,
|
||||||
|
0.125, // Min zoom of 1/8x for large selections (reciprocal of 8)
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
// For medium-sized selections, allow up to 10x zoom
|
||||||
|
targetZoom = Math.min(
|
||||||
|
(viewportPageBounds.width * 0.8) / commonBounds.width,
|
||||||
|
(viewportPageBounds.height * 0.8) / commonBounds.height,
|
||||||
|
20, // Medium zoom level
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Zoom to the common bounds
|
||||||
|
editor.zoomToBounds(commonBounds, {
|
||||||
|
targetZoom,
|
||||||
|
inset: widthRatio > 1 || heightRatio > 1 ? 20 : 50, // Less padding for large selections
|
||||||
|
animation: {
|
||||||
|
duration: 400,
|
||||||
|
easing: (t) => t * (2 - t),
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
// Update URL with new camera position and first selected shape ID
|
||||||
|
const newCamera = editor.getCamera()
|
||||||
|
const url = new URL(window.location.href)
|
||||||
|
url.searchParams.set("shapeId", selectedIds[0].toString())
|
||||||
|
url.searchParams.set("x", newCamera.x.toString())
|
||||||
|
url.searchParams.set("y", newCamera.y.toString())
|
||||||
|
url.searchParams.set("zoom", newCamera.z.toString())
|
||||||
|
window.history.replaceState(null, "", url.toString())
|
||||||
|
}
|
||||||
|
|
||||||
|
export const revertCamera = (editor: Editor) => {
|
||||||
|
if (cameraHistory.length > 0) {
|
||||||
|
const previousCamera = cameraHistory.pop()
|
||||||
|
if (previousCamera) {
|
||||||
|
// Get current viewport bounds
|
||||||
|
const viewportPageBounds = editor.getViewportPageBounds()
|
||||||
|
|
||||||
|
// Create bounds that center on the previous camera position
|
||||||
|
const targetBounds = {
|
||||||
|
x: previousCamera.x - viewportPageBounds.width / 2 / previousCamera.z,
|
||||||
|
y: previousCamera.y - viewportPageBounds.height / 2 / previousCamera.z,
|
||||||
|
w: viewportPageBounds.width / previousCamera.z,
|
||||||
|
h: viewportPageBounds.height / previousCamera.z,
|
||||||
|
}
|
||||||
|
|
||||||
|
// Use the same zoom animation as zoomToShape
|
||||||
|
editor.zoomToBounds(targetBounds, {
|
||||||
|
targetZoom: previousCamera.z,
|
||||||
|
animation: {
|
||||||
|
duration: 400,
|
||||||
|
easing: (t) => t * (2 - t),
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
console.log("Reverted to camera position:", previousCamera)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
console.log("No camera history available")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const copyLinkToCurrentView = async (editor: Editor) => {
|
||||||
|
console.log("Starting copyLinkToCurrentView")
|
||||||
|
|
||||||
|
if (!editor.store.serialize()) {
|
||||||
|
console.warn("Store not ready")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const baseUrl = `${window.location.origin}${window.location.pathname}`
|
||||||
|
console.log("Base URL:", baseUrl)
|
||||||
|
|
||||||
|
const url = new URL(baseUrl)
|
||||||
|
const camera = editor.getCamera()
|
||||||
|
console.log("Current camera position:", {
|
||||||
|
x: camera.x,
|
||||||
|
y: camera.y,
|
||||||
|
zoom: camera.z,
|
||||||
|
})
|
||||||
|
|
||||||
|
// Set camera parameters first
|
||||||
|
url.searchParams.set("x", camera.x.toString())
|
||||||
|
url.searchParams.set("y", camera.y.toString())
|
||||||
|
url.searchParams.set("zoom", camera.z.toString())
|
||||||
|
|
||||||
|
// Add shape ID last if needed
|
||||||
|
const selectedIds = editor.getSelectedShapeIds()
|
||||||
|
if (selectedIds.length > 0) {
|
||||||
|
url.searchParams.set("shapeId", selectedIds[0].toString())
|
||||||
|
}
|
||||||
|
|
||||||
|
const finalUrl = url.toString()
|
||||||
|
console.log("Final URL to copy:", finalUrl)
|
||||||
|
|
||||||
|
if (navigator.clipboard && window.isSecureContext) {
|
||||||
|
console.log("Using modern clipboard API...")
|
||||||
|
await navigator.clipboard.writeText(finalUrl)
|
||||||
|
console.log("URL copied successfully using clipboard API")
|
||||||
|
} else {
|
||||||
|
console.log("Falling back to legacy clipboard method...")
|
||||||
|
const textArea = document.createElement("textarea")
|
||||||
|
textArea.value = finalUrl
|
||||||
|
document.body.appendChild(textArea)
|
||||||
|
try {
|
||||||
|
await navigator.clipboard.writeText(textArea.value)
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Clipboard API failed:", err)
|
||||||
|
}
|
||||||
|
document.body.removeChild(textArea)
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to copy to clipboard:", error)
|
||||||
|
alert("Failed to copy link. Please check clipboard permissions.")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** TODO: doesnt UNlock */
|
||||||
|
export const lockCameraToFrame = async (editor: Editor) => {
|
||||||
|
const selectedShapes = editor.getSelectedShapes()
|
||||||
|
if (selectedShapes.length === 0) return
|
||||||
|
const selectedShape = selectedShapes[0]
|
||||||
|
const isFrame = selectedShape.type === "frame"
|
||||||
|
const bounds = editor.getShapePageBounds(selectedShape)
|
||||||
|
if (!isFrame || !bounds) return
|
||||||
|
|
||||||
|
try {
|
||||||
|
const baseUrl = `${window.location.origin}${window.location.pathname}`
|
||||||
|
const url = new URL(baseUrl)
|
||||||
|
|
||||||
|
// Calculate zoom level to fit the frame (for URL only)
|
||||||
|
const viewportPageBounds = editor.getViewportPageBounds()
|
||||||
|
const targetZoom = Math.min(
|
||||||
|
viewportPageBounds.width / bounds.width,
|
||||||
|
viewportPageBounds.height / bounds.height,
|
||||||
|
1, // Cap at 1x zoom
|
||||||
|
)
|
||||||
|
|
||||||
|
// Set URL parameters without affecting the current view
|
||||||
|
url.searchParams.set("x", Math.round(bounds.x).toString())
|
||||||
|
url.searchParams.set("y", Math.round(bounds.y).toString())
|
||||||
|
url.searchParams.set(
|
||||||
|
"zoom",
|
||||||
|
(Math.round(targetZoom * 100) / 100).toString(),
|
||||||
|
)
|
||||||
|
url.searchParams.set("frameId", selectedShape.id)
|
||||||
|
url.searchParams.set("isLocked", "true")
|
||||||
|
|
||||||
|
const finalUrl = url.toString()
|
||||||
|
|
||||||
|
// Copy URL to clipboard without modifying the current view
|
||||||
|
if (navigator.clipboard && window.isSecureContext) {
|
||||||
|
await navigator.clipboard.writeText(finalUrl)
|
||||||
|
} else {
|
||||||
|
const textArea = document.createElement("textarea")
|
||||||
|
textArea.value = finalUrl
|
||||||
|
document.body.appendChild(textArea)
|
||||||
|
textArea.select()
|
||||||
|
document.execCommand("copy")
|
||||||
|
document.body.removeChild(textArea)
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to copy frame link:", error)
|
||||||
|
alert("Failed to copy frame link. Please check clipboard permissions.")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,10 @@
|
||||||
|
import { CustomMainMenu } from "./CustomMainMenu"
|
||||||
|
import { CustomToolbar } from "./CustomToolbar"
|
||||||
|
import { CustomContextMenu } from "./CustomContextMenu"
|
||||||
|
import { TLComponents } from "tldraw"
|
||||||
|
|
||||||
|
export const components: TLComponents = {
|
||||||
|
Toolbar: CustomToolbar,
|
||||||
|
MainMenu: CustomMainMenu,
|
||||||
|
ContextMenu: CustomContextMenu,
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,352 @@
|
||||||
|
import { TLUiOverrides } from "tldraw"
|
||||||
|
import {
|
||||||
|
cameraHistory,
|
||||||
|
copyLinkToCurrentView,
|
||||||
|
lockCameraToFrame,
|
||||||
|
revertCamera,
|
||||||
|
zoomToSelection,
|
||||||
|
} from "./cameraUtils"
|
||||||
|
import { saveToPdf } from "../utils/pdfUtils"
|
||||||
|
import { searchText } from "../utils/searchUtils"
|
||||||
|
|
||||||
|
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) => {
|
||||||
|
// Prevent default double-click behavior (which would start text editing)
|
||||||
|
info.preventDefault?.()
|
||||||
|
|
||||||
|
// Handle all pointer types (mouse, touch, pen)
|
||||||
|
const point = info.point || (info.touches && info.touches[0]) || info
|
||||||
|
|
||||||
|
// Zoom in at the clicked/touched point
|
||||||
|
editor.zoomIn(point, { animation: { duration: 200 } })
|
||||||
|
|
||||||
|
// Stop event propagation and prevent default handling
|
||||||
|
info.stopPropagation?.()
|
||||||
|
return false
|
||||||
|
},
|
||||||
|
},
|
||||||
|
VideoChat: {
|
||||||
|
id: "VideoChat",
|
||||||
|
icon: "video",
|
||||||
|
label: "Video Chat",
|
||||||
|
kbd: "alt+v",
|
||||||
|
readonlyOk: true,
|
||||||
|
onSelect: () => editor.setCurrentTool("VideoChat"),
|
||||||
|
},
|
||||||
|
ChatBox: {
|
||||||
|
id: "ChatBox",
|
||||||
|
icon: "chat",
|
||||||
|
label: "Chat",
|
||||||
|
kbd: "alt+c",
|
||||||
|
readonlyOk: true,
|
||||||
|
onSelect: () => editor.setCurrentTool("ChatBox"),
|
||||||
|
},
|
||||||
|
Embed: {
|
||||||
|
id: "Embed",
|
||||||
|
icon: "embed",
|
||||||
|
label: "Embed",
|
||||||
|
kbd: "alt+e",
|
||||||
|
readonlyOk: true,
|
||||||
|
onSelect: () => editor.setCurrentTool("Embed"),
|
||||||
|
},
|
||||||
|
/*
|
||||||
|
Markdown: {
|
||||||
|
id: "Markdown",
|
||||||
|
icon: "markdown",
|
||||||
|
label: "Markdown",
|
||||||
|
kbd: "alt+m",
|
||||||
|
readonlyOk: true,
|
||||||
|
onSelect: () => editor.setCurrentTool("Markdown"),
|
||||||
|
},
|
||||||
|
MycrozineTemplate: {
|
||||||
|
id: "MycrozineTemplate",
|
||||||
|
icon: "rectangle",
|
||||||
|
label: "Mycrozine Template",
|
||||||
|
kbd: "m",
|
||||||
|
readonlyOk: true,
|
||||||
|
onSelect: () => editor.setCurrentTool("MycrozineTemplate"),
|
||||||
|
},
|
||||||
|
*/
|
||||||
|
hand: {
|
||||||
|
...tools.hand,
|
||||||
|
onDoubleClick: (info: any) => {
|
||||||
|
editor.zoomIn(info.point, { animation: { duration: 200 } })
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
},
|
||||||
|
actions(editor, actions) {
|
||||||
|
return {
|
||||||
|
...actions,
|
||||||
|
zoomToSelection: {
|
||||||
|
id: "zoom-to-selection",
|
||||||
|
label: "Zoom to Selection",
|
||||||
|
kbd: "z",
|
||||||
|
onSelect: () => {
|
||||||
|
if (editor.getSelectedShapeIds().length > 0) {
|
||||||
|
zoomToSelection(editor)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
readonlyOk: true,
|
||||||
|
},
|
||||||
|
copyLinkToCurrentView: {
|
||||||
|
id: "copy-link-to-current-view",
|
||||||
|
label: "Copy Link to Current View",
|
||||||
|
kbd: "alt+c",
|
||||||
|
onSelect: () => {
|
||||||
|
copyLinkToCurrentView(editor)
|
||||||
|
},
|
||||||
|
readonlyOk: true,
|
||||||
|
},
|
||||||
|
revertCamera: {
|
||||||
|
id: "revert-camera",
|
||||||
|
label: "Revert Camera",
|
||||||
|
kbd: "alt+b",
|
||||||
|
onSelect: () => {
|
||||||
|
if (cameraHistory.length > 0) {
|
||||||
|
revertCamera(editor)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
readonlyOk: true,
|
||||||
|
},
|
||||||
|
lockToFrame: {
|
||||||
|
id: "lock-to-frame",
|
||||||
|
label: "Lock to Frame",
|
||||||
|
kbd: "shift+l",
|
||||||
|
onSelect: () => lockCameraToFrame(editor),
|
||||||
|
},
|
||||||
|
saveToPdf: {
|
||||||
|
id: "save-to-pdf",
|
||||||
|
label: "Save Selection as PDF",
|
||||||
|
kbd: "alt+p",
|
||||||
|
onSelect: () => {
|
||||||
|
if (editor.getSelectedShapeIds().length > 0) {
|
||||||
|
saveToPdf(editor)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
readonlyOk: true,
|
||||||
|
},
|
||||||
|
moveSelectedLeft: {
|
||||||
|
id: "move-selected-left",
|
||||||
|
label: "Move Left",
|
||||||
|
kbd: "ArrowLeft",
|
||||||
|
onSelect: () => {
|
||||||
|
const selectedShapes = editor.getSelectedShapes()
|
||||||
|
if (selectedShapes.length > 0) {
|
||||||
|
selectedShapes.forEach((shape) => {
|
||||||
|
editor.updateShape({
|
||||||
|
id: shape.id,
|
||||||
|
type: shape.type,
|
||||||
|
x: shape.x - 50,
|
||||||
|
y: shape.y,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
moveSelectedRight: {
|
||||||
|
id: "move-selected-right",
|
||||||
|
label: "Move Right",
|
||||||
|
kbd: "ArrowRight",
|
||||||
|
onSelect: () => {
|
||||||
|
const selectedShapes = editor.getSelectedShapes()
|
||||||
|
if (selectedShapes.length > 0) {
|
||||||
|
selectedShapes.forEach((shape) => {
|
||||||
|
editor.updateShape({
|
||||||
|
id: shape.id,
|
||||||
|
type: shape.type,
|
||||||
|
x: shape.x + 50,
|
||||||
|
y: shape.y,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
moveSelectedUp: {
|
||||||
|
id: "move-selected-up",
|
||||||
|
label: "Move Up",
|
||||||
|
kbd: "ArrowUp",
|
||||||
|
onSelect: () => {
|
||||||
|
const selectedShapes = editor.getSelectedShapes()
|
||||||
|
if (selectedShapes.length > 0) {
|
||||||
|
selectedShapes.forEach((shape) => {
|
||||||
|
editor.updateShape({
|
||||||
|
id: shape.id,
|
||||||
|
type: shape.type,
|
||||||
|
x: shape.x,
|
||||||
|
y: shape.y - 50,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
moveSelectedDown: {
|
||||||
|
id: "move-selected-down",
|
||||||
|
label: "Move Down",
|
||||||
|
kbd: "ArrowDown",
|
||||||
|
onSelect: () => {
|
||||||
|
const selectedShapes = editor.getSelectedShapes()
|
||||||
|
if (selectedShapes.length > 0) {
|
||||||
|
selectedShapes.forEach((shape) => {
|
||||||
|
editor.updateShape({
|
||||||
|
id: shape.id,
|
||||||
|
type: shape.type,
|
||||||
|
x: shape.x,
|
||||||
|
y: shape.y + 50,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
// TODO: FIX THIS
|
||||||
|
resizeSelectedUp: {
|
||||||
|
id: "resize-selected-up",
|
||||||
|
label: "Resize Up",
|
||||||
|
kbd: "ctrl+ArrowUp",
|
||||||
|
onSelect: () => {
|
||||||
|
const selectedShapes = editor.getSelectedShapes()
|
||||||
|
if (selectedShapes.length > 0) {
|
||||||
|
selectedShapes.forEach((shape) => {
|
||||||
|
const bounds = editor.getShapeGeometry(shape).bounds
|
||||||
|
editor.updateShape({
|
||||||
|
id: shape.id,
|
||||||
|
type: shape.type,
|
||||||
|
//y: shape.y - 50,
|
||||||
|
props: {
|
||||||
|
...shape.props,
|
||||||
|
h: bounds.height + 50,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
resizeSelectedDown: {
|
||||||
|
id: "resize-selected-down",
|
||||||
|
label: "Resize Down",
|
||||||
|
kbd: "ctrl+ArrowDown",
|
||||||
|
onSelect: () => {
|
||||||
|
const selectedShapes = editor.getSelectedShapes()
|
||||||
|
if (selectedShapes.length > 0) {
|
||||||
|
selectedShapes.forEach((shape) => {
|
||||||
|
const bounds = editor.getShapeGeometry(shape).bounds
|
||||||
|
editor.updateShape({
|
||||||
|
id: shape.id,
|
||||||
|
type: shape.type,
|
||||||
|
props: {
|
||||||
|
...shape.props,
|
||||||
|
h: bounds.height + 50,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
resizeSelectedLeft: {
|
||||||
|
id: "resize-selected-left",
|
||||||
|
label: "Resize Left",
|
||||||
|
kbd: "ctrl+ArrowLeft",
|
||||||
|
onSelect: () => {
|
||||||
|
const selectedShapes = editor.getSelectedShapes()
|
||||||
|
if (selectedShapes.length > 0) {
|
||||||
|
selectedShapes.forEach((shape) => {
|
||||||
|
const bounds = editor.getShapeGeometry(shape).bounds
|
||||||
|
editor.updateShape({
|
||||||
|
id: shape.id,
|
||||||
|
type: shape.type,
|
||||||
|
props: {
|
||||||
|
...shape.props,
|
||||||
|
w: bounds.width + 50,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
resizeSelectedRight: {
|
||||||
|
id: "resize-selected-right",
|
||||||
|
label: "Resize Right",
|
||||||
|
kbd: "ctrl+ArrowRight",
|
||||||
|
onSelect: () => {
|
||||||
|
const selectedShapes = editor.getSelectedShapes()
|
||||||
|
if (selectedShapes.length > 0) {
|
||||||
|
selectedShapes.forEach((shape) => {
|
||||||
|
const bounds = editor.getShapeGeometry(shape).bounds
|
||||||
|
editor.updateShape({
|
||||||
|
id: shape.id,
|
||||||
|
type: shape.type,
|
||||||
|
props: {
|
||||||
|
...shape.props,
|
||||||
|
w: bounds.width + 50,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
//TODO: MAKE THIS WORK, ADD USER PERMISSIONING TO JOIN BROADCAST?
|
||||||
|
broadcastView: {
|
||||||
|
id: "broadcast-view",
|
||||||
|
label: "Broadcast View",
|
||||||
|
kbd: "alt+b",
|
||||||
|
readonlyOk: true,
|
||||||
|
onSelect: () => {
|
||||||
|
const collaborators = editor.getCollaborators()
|
||||||
|
collaborators
|
||||||
|
.filter((user) => user.id !== editor.user.getId())
|
||||||
|
.forEach((user) => {
|
||||||
|
editor.startFollowingUser(user.id)
|
||||||
|
})
|
||||||
|
},
|
||||||
|
},
|
||||||
|
stopBroadcast: {
|
||||||
|
id: "stop-broadcast",
|
||||||
|
label: "Stop Broadcasting",
|
||||||
|
kbd: "alt+shift+b",
|
||||||
|
readonlyOk: true,
|
||||||
|
onSelect: () => {
|
||||||
|
editor.stopFollowingUser()
|
||||||
|
},
|
||||||
|
},
|
||||||
|
searchShapes: {
|
||||||
|
id: "search-shapes",
|
||||||
|
label: "Search Shapes",
|
||||||
|
kbd: "s",
|
||||||
|
readonlyOk: true,
|
||||||
|
onSelect: () => searchText(editor),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
@ -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,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,59 @@
|
||||||
|
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
|
||||||
|
pdf.addImage(
|
||||||
|
imageData,
|
||||||
|
"PNG",
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
selectionBounds.width,
|
||||||
|
selectionBounds.height,
|
||||||
|
)
|
||||||
|
|
||||||
|
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,19 @@
|
||||||
|
/// <reference types="vite/client" />
|
||||||
|
|
||||||
|
interface ImportMetaEnv {
|
||||||
|
readonly VITE_TLDRAW_WORKER_URL: string
|
||||||
|
readonly VITE_GOOGLE_MAPS_API_KEY: string
|
||||||
|
readonly VITE_DAILY_API_KEY: string
|
||||||
|
readonly VITE_CLOUDFLARE_API_TOKEN: string
|
||||||
|
readonly VITE_CLOUDFLARE_ACCOUNT_ID: string
|
||||||
|
readonly VITE_CLOUDFLARE_ZONE_ID: string
|
||||||
|
readonly VITE_R2_BUCKET_NAME: string
|
||||||
|
readonly VITE_R2_BACKUP_BUCKET_NAME: string
|
||||||
|
readonly VITE_R2_BUCKET: R2Bucket
|
||||||
|
readonly VITE_R2_BACKUP_BUCKET: R2Bucket
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
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"]
|
||||||
|
}
|
||||||
32
vercel.json
32
vercel.json
|
|
@ -1,5 +1,31 @@
|
||||||
{
|
{
|
||||||
"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": "/"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"headers": [
|
||||||
|
{
|
||||||
|
"source": "/assets/(.*)",
|
||||||
|
"headers": [
|
||||||
|
{
|
||||||
|
"key": "Cache-Control",
|
||||||
|
"value": "public, max-age=31536000, immutable"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
}
|
}
|
||||||
|
|
@ -0,0 +1,26 @@
|
||||||
|
import { defineConfig } from "vite"
|
||||||
|
import react from "@vitejs/plugin-react"
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
envPrefix: ["VITE_"],
|
||||||
|
plugins: [react()],
|
||||||
|
server: {
|
||||||
|
host: "0.0.0.0",
|
||||||
|
port: 5173,
|
||||||
|
},
|
||||||
|
build: {
|
||||||
|
sourcemap: true,
|
||||||
|
},
|
||||||
|
base: "/",
|
||||||
|
publicDir: "src/public",
|
||||||
|
resolve: {
|
||||||
|
alias: {
|
||||||
|
"@": "/src",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
define: {
|
||||||
|
"import.meta.env.VITE_WORKER_URL": JSON.stringify(
|
||||||
|
process.env.VITE_WORKER_URL,
|
||||||
|
),
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
@ -0,0 +1,816 @@
|
||||||
|
/// <reference types="@cloudflare/workers-types" />
|
||||||
|
|
||||||
|
import { RoomSnapshot, TLSocketRoom } from "@tldraw/sync-core"
|
||||||
|
import {
|
||||||
|
TLRecord,
|
||||||
|
TLShape,
|
||||||
|
TLStoreSnapshot,
|
||||||
|
createTLSchema,
|
||||||
|
defaultBindingSchemas,
|
||||||
|
defaultShapeSchemas,
|
||||||
|
} 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 { WORKER_URL } from "@/routes/Board"
|
||||||
|
|
||||||
|
// Add after the imports
|
||||||
|
interface BoardVersion {
|
||||||
|
timestamp: number
|
||||||
|
snapshot: RoomSnapshot
|
||||||
|
version: number
|
||||||
|
dateKey: string
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
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
|
||||||
|
private backupR2: R2Bucket
|
||||||
|
private roomId: string | null = null
|
||||||
|
private roomPromise: Promise<TLSocketRoom<TLRecord, void>> | null = null
|
||||||
|
private room: TLSocketRoom<TLRecord, void> | null = null
|
||||||
|
private lastBackupDate: string | null = null
|
||||||
|
private readonly MAX_VERSIONS = 31
|
||||||
|
private readonly env: Environment
|
||||||
|
private readonly BACKUP_INTERVAL: number
|
||||||
|
private readonly schedulePersistToR2: ReturnType<typeof throttle>
|
||||||
|
|
||||||
|
|
||||||
|
constructor(private readonly ctx: DurableObjectState, env: Environment) {
|
||||||
|
if (!ctx) {
|
||||||
|
console.error('[Debug] DurableObjectState is undefined!')
|
||||||
|
throw new Error('DurableObjectState is required')
|
||||||
|
}
|
||||||
|
if (!env) {
|
||||||
|
console.error('[Debug] Environment is undefined!')
|
||||||
|
throw new Error('Environment is required')
|
||||||
|
}
|
||||||
|
|
||||||
|
// Initialize all class properties explicitly
|
||||||
|
this.env = env
|
||||||
|
this.roomId = null
|
||||||
|
this.roomPromise = null
|
||||||
|
this.room = null
|
||||||
|
this.lastBackupDate = null
|
||||||
|
this.BACKUP_INTERVAL = this.env?.DEV === true
|
||||||
|
? 10 * 1000 // 10 seconds in development
|
||||||
|
: 24 * 60 * 60 * 1000 // 24 hours in production
|
||||||
|
|
||||||
|
console.log('[Debug] Initializing TldrawDurableObject:', {
|
||||||
|
hasContext: !!this,
|
||||||
|
hasState: !!ctx,
|
||||||
|
ctxId: ctx.id,
|
||||||
|
hasEnv: !!env,
|
||||||
|
envKeys: Object.keys(env),
|
||||||
|
thisKeys: Object.keys(this)
|
||||||
|
})
|
||||||
|
|
||||||
|
// Verify R2 buckets
|
||||||
|
if (!env.TLDRAW_BUCKET) {
|
||||||
|
console.error('[Debug] TLDRAW_BUCKET is undefined!')
|
||||||
|
throw new Error('TLDRAW_BUCKET is required')
|
||||||
|
}
|
||||||
|
if (!env.TLDRAW_BACKUP_BUCKET) {
|
||||||
|
console.error('[Debug] TLDRAW_BACKUP_BUCKET is undefined!')
|
||||||
|
throw new Error('TLDRAW_BACKUP_BUCKET is required')
|
||||||
|
}
|
||||||
|
|
||||||
|
this.r2 = env.TLDRAW_BUCKET
|
||||||
|
this.backupR2 = env.TLDRAW_BACKUP_BUCKET
|
||||||
|
|
||||||
|
// Verify buckets were assigned
|
||||||
|
console.log('[Debug] Bucket initialization:', {
|
||||||
|
hasMainBucket: !!this.r2,
|
||||||
|
hasBackupBucket: !!this.backupR2,
|
||||||
|
mainBucketMethods: Object.keys(this.r2 || {}),
|
||||||
|
backupBucketMethods: Object.keys(this.backupR2 || {})
|
||||||
|
})
|
||||||
|
|
||||||
|
// Add more detailed logging
|
||||||
|
console.log('[Debug] Environment:', {
|
||||||
|
TLDRAW_BUCKET: !!env.TLDRAW_BUCKET,
|
||||||
|
TLDRAW_BACKUP_BUCKET: !!env.TLDRAW_BACKUP_BUCKET,
|
||||||
|
envKeys: Object.keys(env)
|
||||||
|
})
|
||||||
|
|
||||||
|
console.log('[Debug] Using buckets:', {
|
||||||
|
main: this.r2.get(`rooms/${this.roomId}`) || 'undefined',
|
||||||
|
backup: this.backupR2.get(`rooms/${this.roomId}`) || 'undefined'
|
||||||
|
})
|
||||||
|
|
||||||
|
// Add more detailed logging for storage initialization
|
||||||
|
ctx.blockConcurrencyWhile(async () => {
|
||||||
|
try {
|
||||||
|
console.log('[Debug] Attempting to load roomId from storage...')
|
||||||
|
console.log('[Debug] this.ctx.storage:', this.ctx.storage.get)
|
||||||
|
console.log('[Debug] ctx.storage.get:', ctx.storage.get)
|
||||||
|
console.log('[Debug] this.ctx.storage.get<string>("roomId"):', this.ctx.storage.get<string>("roomId"))
|
||||||
|
const storedRoomId = await ctx.storage.get<string>("roomId")
|
||||||
|
console.log('[Debug] Loaded roomId from storage:', storedRoomId)
|
||||||
|
|
||||||
|
if (storedRoomId) {
|
||||||
|
this.roomId = storedRoomId
|
||||||
|
console.log('[Debug] Successfully set roomId:', this.roomId)
|
||||||
|
} else {
|
||||||
|
console.log('[Debug] No roomId found in storage')
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[Debug] Error loading roomId from storage:', error)
|
||||||
|
throw error // Re-throw to ensure we know if there's a storage issue
|
||||||
|
}
|
||||||
|
}).catch(error => {
|
||||||
|
console.error('[Debug] Failed to initialize storage:', error)
|
||||||
|
})
|
||||||
|
|
||||||
|
// this.BACKUP_INTERVAL = this.env?.DEV === true
|
||||||
|
// ? 10 * 1000 // 10 seconds in development
|
||||||
|
// : 24 * 60 * 60 * 1000 // 24 hours in production
|
||||||
|
|
||||||
|
this.schedulePersistToR2 = throttle(async () => {
|
||||||
|
if (!this.room || !this.roomId) {
|
||||||
|
console.log('[Backup] No room available for backup')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
console.log(`[Backup] Starting backup process for room ${this.roomId}...`)
|
||||||
|
const snapshot = this.room.getCurrentSnapshot()
|
||||||
|
|
||||||
|
// Update current version in main bucket
|
||||||
|
await this.r2.put(
|
||||||
|
`rooms/${this.roomId}`,
|
||||||
|
JSON.stringify(snapshot)
|
||||||
|
).catch(err => {
|
||||||
|
console.error(`[Backup] Failed to update main bucket:`, err)
|
||||||
|
})
|
||||||
|
|
||||||
|
// Check if today's backup already exists
|
||||||
|
const today = new Date().toISOString().split('T')[0]
|
||||||
|
const backupKey = `backups/${this.roomId}/${today}`
|
||||||
|
console.log(`[Backup] Checking for existing backup at key: ${backupKey}`)
|
||||||
|
const existingBackup = await this.backupR2.get(backupKey)
|
||||||
|
|
||||||
|
// Create daily backup if needed
|
||||||
|
if (!existingBackup || this.lastBackupDate !== today) {
|
||||||
|
console.log(`[Backup] Creating new daily backup for ${today}`)
|
||||||
|
|
||||||
|
// Get all assets for this room
|
||||||
|
const assetsPrefix = `uploads/${this.roomId}/`
|
||||||
|
const assets = await this.r2.list({ prefix: assetsPrefix })
|
||||||
|
const assetData: { [key: string]: string } = {}
|
||||||
|
|
||||||
|
// Fetch and store each asset
|
||||||
|
for (const asset of assets.objects) {
|
||||||
|
const assetContent = await this.r2.get(asset.key)
|
||||||
|
if (assetContent) {
|
||||||
|
const assetBuffer = await assetContent.arrayBuffer()
|
||||||
|
const base64Data = Buffer.from(assetBuffer).toString('base64')
|
||||||
|
assetData[asset.key] = base64Data
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const version = {
|
||||||
|
timestamp: Date.now(),
|
||||||
|
snapshot,
|
||||||
|
dateKey: today,
|
||||||
|
version: 0,
|
||||||
|
assets: assetData
|
||||||
|
}
|
||||||
|
|
||||||
|
//TO DO: FIX DAILY BACKUP INTO CLOUDFLARE R2 BACKUPS BUCKET
|
||||||
|
await this.backupR2.put(backupKey, JSON.stringify(version))
|
||||||
|
console.log(`[Backup] ✅ Successfully saved daily backup with ${Object.keys(assetData).length} assets to: ${backupKey}`)
|
||||||
|
|
||||||
|
this.lastBackupDate = today
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[Backup] Error during backup:', error)
|
||||||
|
}
|
||||||
|
}, this.BACKUP_INTERVAL, { leading: false, trailing: true })
|
||||||
|
}
|
||||||
|
|
||||||
|
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) => {
|
||||||
|
try {
|
||||||
|
await this.ensureRoomId(request.params.roomId)
|
||||||
|
return this.handleConnect(request)
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[Debug] Connection error:', error)
|
||||||
|
return new Response((error as Error).message, { status: 400 })
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.get("/room/:roomId", async (request) => {
|
||||||
|
// Directly fetch from jeffemmett-canvas bucket first
|
||||||
|
const currentState = await this.r2.get(`rooms/${request.params.roomId}`)
|
||||||
|
console.log('[Debug] Loading board state from jeffemmett-canvas:', currentState ? 'found' : 'not found')
|
||||||
|
|
||||||
|
if (currentState) {
|
||||||
|
const snapshot = await currentState.json() as RoomSnapshot
|
||||||
|
console.log('[Debug] Loaded snapshot with', snapshot.documents.length, 'documents')
|
||||||
|
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",
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback to empty state
|
||||||
|
console.log('[Debug] No existing board state found, returning empty array')
|
||||||
|
return new Response(JSON.stringify([]), {
|
||||||
|
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",
|
||||||
|
},
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.get("/room/:roomId/versions", async () => {
|
||||||
|
if (!this.roomId) {
|
||||||
|
return new Response("Room not initialized", { status: 400 })
|
||||||
|
}
|
||||||
|
|
||||||
|
const prefix = `backups/${this.roomId}/`
|
||||||
|
const objects = await this.backupR2.list({ prefix })
|
||||||
|
const versions = objects.objects
|
||||||
|
.map(obj => {
|
||||||
|
const dateKey = obj.key.split('/').pop() || ''
|
||||||
|
return {
|
||||||
|
timestamp: obj.uploaded.getTime(),
|
||||||
|
dateKey,
|
||||||
|
version: 0
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.sort((a, b) => b.timestamp - a.timestamp)
|
||||||
|
|
||||||
|
return new Response(JSON.stringify(versions), {
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
"Access-Control-Allow-Origin": "*",
|
||||||
|
"Access-Control-Allow-Methods": "GET, POST, OPTIONS",
|
||||||
|
"Access-Control-Allow-Headers": "Content-Type",
|
||||||
|
},
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.post("/room/:roomId/restore/:dateKey", async (request) => {
|
||||||
|
if (!this.roomId) {
|
||||||
|
return new Response("Room not initialized", { status: 400 })
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const version = await this.restoreVersion(request.params.dateKey)
|
||||||
|
return new Response(JSON.stringify(version), {
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
"Access-Control-Allow-Origin": "*",
|
||||||
|
"Access-Control-Allow-Methods": "GET, POST, OPTIONS",
|
||||||
|
"Access-Control-Allow-Headers": "Content-Type",
|
||||||
|
},
|
||||||
|
})
|
||||||
|
} catch (error) {
|
||||||
|
return new Response(
|
||||||
|
JSON.stringify({ error: (error as Error).message }),
|
||||||
|
{
|
||||||
|
status: 400,
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
"Access-Control-Allow-Origin": "*",
|
||||||
|
"Access-Control-Allow-Methods": "GET, POST, OPTIONS",
|
||||||
|
"Access-Control-Allow-Headers": "Content-Type",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.get("/debug/backup", async (_request) => {
|
||||||
|
console.log('[Debug] Listing all rooms in backup bucket...')
|
||||||
|
const objects = await this.backupR2.list()
|
||||||
|
|
||||||
|
// Group objects by room ID
|
||||||
|
const rooms = objects.objects.reduce((acc, obj) => {
|
||||||
|
const roomId = obj.key.split('/')[0]
|
||||||
|
if (!acc[roomId]) {
|
||||||
|
acc[roomId] = []
|
||||||
|
}
|
||||||
|
acc[roomId].push({
|
||||||
|
key: obj.key,
|
||||||
|
uploaded: obj.uploaded,
|
||||||
|
size: obj.size
|
||||||
|
})
|
||||||
|
return acc
|
||||||
|
}, {} as Record<string, any[]>)
|
||||||
|
|
||||||
|
console.log('[Debug] Found rooms:', rooms)
|
||||||
|
|
||||||
|
return new Response(JSON.stringify(rooms, null, 2), {
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
"Access-Control-Allow-Origin": "*"
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.get("/debug/bucket", async () => {
|
||||||
|
console.log('[Debug] Listing all objects in bucket:', this.env.TLDRAW_BUCKET_NAME)
|
||||||
|
const objects = await this.r2.list()
|
||||||
|
|
||||||
|
console.log('[Debug] Found', objects.objects.length, 'objects')
|
||||||
|
objects.objects.forEach(obj => {
|
||||||
|
console.log('[Debug] Object:', {
|
||||||
|
key: obj.key,
|
||||||
|
size: `${(obj.size / 1024).toFixed(2)} KB`,
|
||||||
|
uploaded: obj.uploaded.toISOString()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
return new Response(JSON.stringify({
|
||||||
|
bucket: this.env.TLDRAW_BUCKET_NAME,
|
||||||
|
objects: objects.objects.map(obj => ({
|
||||||
|
key: obj.key,
|
||||||
|
size: obj.size,
|
||||||
|
uploaded: obj.uploaded
|
||||||
|
}))
|
||||||
|
}), {
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
"Access-Control-Allow-Origin": "*"
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.get("/debug/sync-from-prod", async () => {
|
||||||
|
console.log('[Debug] Starting production sync...')
|
||||||
|
|
||||||
|
try {
|
||||||
|
// List objects directly from production bucket
|
||||||
|
const objects = await this.r2.list()
|
||||||
|
console.log('[Debug] Path:', WORKER_URL + '/rooms/' + this.roomId)
|
||||||
|
console.log('[Debug] Found', objects.objects.length, 'rooms in production')
|
||||||
|
|
||||||
|
// Copy each room to local bucket
|
||||||
|
let syncedCount = 0
|
||||||
|
for (const obj of objects.objects) {
|
||||||
|
// Get the room data directly from production bucket
|
||||||
|
const roomData = await this.r2.get(obj.key)
|
||||||
|
if (!roomData) {
|
||||||
|
console.error(`Failed to fetch room data for ${obj.key}`)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// Store in local bucket
|
||||||
|
await this.r2.put(obj.key, roomData.body)
|
||||||
|
syncedCount++
|
||||||
|
console.log(`[Debug] Synced room: ${obj.key}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
return new Response(JSON.stringify({
|
||||||
|
message: 'Sync complete',
|
||||||
|
totalRooms: objects.objects.length,
|
||||||
|
syncedRooms: syncedCount
|
||||||
|
}), {
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
"Access-Control-Allow-Origin": "*"
|
||||||
|
}
|
||||||
|
})
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[Debug] Sync error:', error)
|
||||||
|
return new Response(JSON.stringify({
|
||||||
|
error: 'Sync failed',
|
||||||
|
message: (error as Error).message
|
||||||
|
}), {
|
||||||
|
status: 500,
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
"Access-Control-Allow-Origin": "*"
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// `fetch` is the entry point for all requests to the Durable Object
|
||||||
|
fetch(request: Request): Response | Promise<Response> {
|
||||||
|
console.log('[Debug] Incoming request:', request.url, request.method)
|
||||||
|
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> {
|
||||||
|
console.log('[Worker] handleConnect called')
|
||||||
|
|
||||||
|
const { 0: clientWebSocket, 1: serverWebSocket } = new WebSocketPair()
|
||||||
|
|
||||||
|
try {
|
||||||
|
console.log('[Worker] Accepting WebSocket connection')
|
||||||
|
serverWebSocket.accept()
|
||||||
|
|
||||||
|
const room = await this.getRoom()
|
||||||
|
console.log('[Debug] Room obtained, connecting socket')
|
||||||
|
|
||||||
|
// Handle socket connection with proper error boundaries
|
||||||
|
room.handleSocketConnect({
|
||||||
|
sessionId: request.query.sessionId as string,
|
||||||
|
socket: {
|
||||||
|
send: (data: string) => {
|
||||||
|
// console.log('[WebSocket] Sending:', data.slice(0, 100) + '...')
|
||||||
|
try {
|
||||||
|
serverWebSocket.send(data)
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[WebSocket] Send error:', err)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
close: () => {
|
||||||
|
try {
|
||||||
|
serverWebSocket.close()
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[WebSocket] Close error:', err)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
addEventListener: serverWebSocket.addEventListener.bind(serverWebSocket),
|
||||||
|
removeEventListener: serverWebSocket.removeEventListener.bind(serverWebSocket),
|
||||||
|
readyState: serverWebSocket.readyState,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
console.log('[Debug] WebSocket connection established successfully')
|
||||||
|
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("[Debug] WebSocket connection error:", error)
|
||||||
|
serverWebSocket.close(1011, "Failed to initialize connection")
|
||||||
|
return new Response("Failed to establish WebSocket connection", {
|
||||||
|
status: 500,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async getRoom() {
|
||||||
|
const roomId = this.roomId
|
||||||
|
console.log('[Debug] Getting room:', roomId)
|
||||||
|
console.log('[Debug] R2 bucket instance:', {
|
||||||
|
exists: !!this.r2
|
||||||
|
})
|
||||||
|
if (!roomId) throw new Error("Missing roomId")
|
||||||
|
|
||||||
|
if (!this.roomPromise) {
|
||||||
|
console.log('[Debug] Creating new room promise')
|
||||||
|
this.roomPromise = (async () => {
|
||||||
|
// First, list all objects to see what's actually in the bucket
|
||||||
|
const allObjects = await this.r2.list({ prefix: 'rooms/' })
|
||||||
|
console.log('[Debug] Current bucket contents:',
|
||||||
|
allObjects.objects.map(obj => ({
|
||||||
|
key: obj.key,
|
||||||
|
size: obj.size
|
||||||
|
}))
|
||||||
|
)
|
||||||
|
|
||||||
|
const path = `rooms/${this.roomId}`
|
||||||
|
console.log('[Debug] Attempting to fetch from path:', path)
|
||||||
|
|
||||||
|
const roomFromBucket = await this.r2.get(path)
|
||||||
|
console.log('[Debug] Room fetch result:', {
|
||||||
|
exists: !!roomFromBucket,
|
||||||
|
size: roomFromBucket?.size,
|
||||||
|
etag: roomFromBucket?.etag,
|
||||||
|
path: path,
|
||||||
|
bucket: this.r2 ? 'initialized' : 'undefined'
|
||||||
|
})
|
||||||
|
|
||||||
|
// Add this to see the actual content if it exists
|
||||||
|
if (roomFromBucket) {
|
||||||
|
const content = await roomFromBucket.text()
|
||||||
|
console.log('[Debug] Room content preview:', content.slice(0, 100))
|
||||||
|
}
|
||||||
|
|
||||||
|
// if it doesn't exist, we'll just create a new empty room
|
||||||
|
const initialSnapshot = roomFromBucket
|
||||||
|
? ((await roomFromBucket.json()) as RoomSnapshot)
|
||||||
|
: undefined
|
||||||
|
|
||||||
|
// Create the room and store it in this.room for direct access
|
||||||
|
const room = new TLSocketRoom<TLRecord, void>({
|
||||||
|
schema: customSchema,
|
||||||
|
initialSnapshot,
|
||||||
|
onDataChange: async () => {
|
||||||
|
console.log('[Backup] Data change detected in room:', this.roomId)
|
||||||
|
if (!this.lastBackupDate) {
|
||||||
|
console.log('[Backup] First change detected, forcing immediate backup')
|
||||||
|
await this.schedulePersistToR2.flush()
|
||||||
|
}
|
||||||
|
this.schedulePersistToR2()
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
console.log('[Debug] Room created with snapshot:', initialSnapshot ? 'yes' : 'no')
|
||||||
|
this.room = room
|
||||||
|
return room
|
||||||
|
})()
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.roomPromise
|
||||||
|
}
|
||||||
|
|
||||||
|
// Comment out the duplicate function
|
||||||
|
/*
|
||||||
|
schedulePersistToR2 = throttle(async () => {
|
||||||
|
if (!this.room || !this.roomId) {
|
||||||
|
console.log('[Backup] No room available for backup')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
console.log(`[Backup] Starting backup process for room ${this.roomId}...`)
|
||||||
|
const snapshot = this.room.getCurrentSnapshot()
|
||||||
|
|
||||||
|
// Update current version in main bucket
|
||||||
|
await this.r2.put(
|
||||||
|
`rooms/${this.roomId}`,
|
||||||
|
JSON.stringify(snapshot)
|
||||||
|
).catch(err => {
|
||||||
|
console.error(`[Backup] Failed to update main bucket:`, err)
|
||||||
|
})
|
||||||
|
|
||||||
|
// Check if today's backup already exists
|
||||||
|
const today = new Date().toISOString().split('T')[0]
|
||||||
|
const backupKey = `backups/${this.roomId}/${today}`
|
||||||
|
console.log(`[Backup] Checking for existing backup at key: ${backupKey}`)
|
||||||
|
const existingBackup = await this.backupR2.get(backupKey)
|
||||||
|
|
||||||
|
// Create daily backup if needed
|
||||||
|
if (!existingBackup || this.lastBackupDate !== today) {
|
||||||
|
console.log(`[Backup] Creating new daily backup for ${today}`)
|
||||||
|
|
||||||
|
// Get all assets for this room
|
||||||
|
const assetsPrefix = `uploads/${this.roomId}/`
|
||||||
|
const assets = await this.r2.list({ prefix: assetsPrefix })
|
||||||
|
const assetData: { [key: string]: string } = {}
|
||||||
|
|
||||||
|
// Fetch and store each asset
|
||||||
|
for (const asset of assets.objects) {
|
||||||
|
const assetContent = await this.r2.get(asset.key)
|
||||||
|
if (assetContent) {
|
||||||
|
const assetBuffer = await assetContent.arrayBuffer()
|
||||||
|
const base64Data = Buffer.from(assetBuffer).toString('base64')
|
||||||
|
assetData[asset.key] = base64Data
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const version = {
|
||||||
|
timestamp: Date.now(),
|
||||||
|
snapshot,
|
||||||
|
dateKey: today,
|
||||||
|
version: 0,
|
||||||
|
assets: assetData
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.backupR2.put(backupKey, JSON.stringify(version))
|
||||||
|
console.log(`[Backup] ✅ Successfully saved daily backup with ${Object.keys(assetData).length} assets to: ${backupKey}`)
|
||||||
|
|
||||||
|
this.lastBackupDate = today
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[Backup] Error during backup:', error)
|
||||||
|
}
|
||||||
|
}, this.BACKUP_INTERVAL)
|
||||||
|
*/
|
||||||
|
|
||||||
|
// Modified scheduleBackupToR2 method
|
||||||
|
scheduleBackupToR2 = throttle(async () => {
|
||||||
|
if (!this.room || !this.roomId) return
|
||||||
|
|
||||||
|
// Get current snapshot using TLSocketRoom's method
|
||||||
|
const snapshot = this.room.getCurrentSnapshot()
|
||||||
|
|
||||||
|
// Always update current version
|
||||||
|
await this.r2.put(
|
||||||
|
`rooms/${this.roomId}`,
|
||||||
|
JSON.stringify(snapshot)
|
||||||
|
)
|
||||||
|
|
||||||
|
// Check if we should create a daily backup
|
||||||
|
const today = new Date().toISOString().split('T')[0] // YYYY-MM-DD format
|
||||||
|
if (this.lastBackupDate !== today) {
|
||||||
|
// Create version object with date info
|
||||||
|
const version: BoardVersion = {
|
||||||
|
timestamp: Date.now(),
|
||||||
|
snapshot,
|
||||||
|
version: 0,
|
||||||
|
dateKey: today
|
||||||
|
}
|
||||||
|
|
||||||
|
// Store versioned backup with date in key
|
||||||
|
await this.backupR2.put(
|
||||||
|
`backups/${this.roomId}/${today}`,
|
||||||
|
JSON.stringify(version)
|
||||||
|
)
|
||||||
|
|
||||||
|
this.lastBackupDate = today
|
||||||
|
|
||||||
|
// Clean up old versions
|
||||||
|
//await this.cleanupOldVersions()
|
||||||
|
}
|
||||||
|
}, this.BACKUP_INTERVAL )
|
||||||
|
|
||||||
|
// Modified method to restore specific version
|
||||||
|
async restoreVersion(dateKey: string) {
|
||||||
|
const versionKey = `backups/${this.roomId}/${dateKey}`
|
||||||
|
console.log(`[Restore] Attempting to restore version from ${this.backupR2} at key: ${versionKey}`)
|
||||||
|
const versionObj = await this.backupR2.get(versionKey)
|
||||||
|
|
||||||
|
if (!versionObj) {
|
||||||
|
console.error(`[Restore] Version not found in ${this.backupR2}`)
|
||||||
|
throw new Error('Version not found')
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`[Restore] Found version in ${this.backupR2}, restoring...`)
|
||||||
|
const version = JSON.parse(await versionObj.text()) as BoardVersion & { assets?: { [key: string]: string } }
|
||||||
|
|
||||||
|
// Restore assets if they exist
|
||||||
|
if (version.assets) {
|
||||||
|
console.log(`[Restore] Restoring ${Object.keys(version.assets).length} assets to ${this.r2}...`)
|
||||||
|
for (const [key, base64Data] of Object.entries(version.assets)) {
|
||||||
|
const binaryData = Buffer.from(base64Data, 'base64')
|
||||||
|
await this.r2.put(key, binaryData)
|
||||||
|
console.log(`[Restore] Asset restored: ${key}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!this.room) {
|
||||||
|
this.room = new TLSocketRoom({
|
||||||
|
schema: customSchema,
|
||||||
|
initialSnapshot: version.snapshot,
|
||||||
|
onDataChange: () => {
|
||||||
|
console.log('[Backup] Data change detected, triggering backup...')
|
||||||
|
this.schedulePersistToR2()
|
||||||
|
},
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
this.room.loadSnapshot(version.snapshot)
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.r2.put(
|
||||||
|
`rooms/${this.roomId}`,
|
||||||
|
JSON.stringify(version.snapshot)
|
||||||
|
)
|
||||||
|
|
||||||
|
return version
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add method to initialize room from snapshot
|
||||||
|
private async initializeRoom(snapshot?: TLStoreSnapshot) {
|
||||||
|
this.room = new TLSocketRoom({
|
||||||
|
schema: customSchema,
|
||||||
|
initialSnapshot: snapshot,
|
||||||
|
onDataChange: () => {
|
||||||
|
this.schedulePersistToR2()
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Modified method to handle WebSocket connections
|
||||||
|
async handleWebSocket(webSocket: WebSocket) {
|
||||||
|
if (!this.room) {
|
||||||
|
const current = await this.r2.get(`rooms/${this.roomId}`)
|
||||||
|
if (current) {
|
||||||
|
const snapshot = JSON.parse(await current.text()) as TLStoreSnapshot
|
||||||
|
await this.initializeRoom(snapshot)
|
||||||
|
} else {
|
||||||
|
await this.initializeRoom()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
this.room?.handleSocketConnect({
|
||||||
|
sessionId: crypto.randomUUID(),
|
||||||
|
socket: {
|
||||||
|
send: webSocket.send.bind(webSocket),
|
||||||
|
close: webSocket.close.bind(webSocket),
|
||||||
|
addEventListener: webSocket.addEventListener.bind(webSocket),
|
||||||
|
removeEventListener: webSocket.removeEventListener.bind(webSocket),
|
||||||
|
readyState: webSocket.readyState,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
//TODO: TURN ON OLD VERSION CLEANUP AT SOME POINT
|
||||||
|
|
||||||
|
// private async cleanupOldVersions() {
|
||||||
|
// if (!this.roomId) return
|
||||||
|
|
||||||
|
// const prefix = `${this.roomId}/`
|
||||||
|
// const objects = await this.backupR2.list({ prefix })
|
||||||
|
// const versions = objects.objects
|
||||||
|
// .sort((a, b) => b.uploaded.getTime() - a.uploaded.getTime())
|
||||||
|
|
||||||
|
// // Delete versions beyond MAX_VERSIONS
|
||||||
|
// for (let i = this.MAX_VERSIONS; i < versions.length; i++) {
|
||||||
|
// await this.backupR2.delete(versions[i].key)
|
||||||
|
// }
|
||||||
|
// }
|
||||||
|
|
||||||
|
// Modify the connect handler to ensure roomId is set
|
||||||
|
private async ensureRoomId(requestRoomId: string): Promise<void> {
|
||||||
|
if (!this.roomId) {
|
||||||
|
await this.ctx.blockConcurrencyWhile(async () => {
|
||||||
|
// Double-check inside the critical section
|
||||||
|
if (!this.roomId) {
|
||||||
|
await this.ctx.storage.put("roomId", requestRoomId)
|
||||||
|
this.roomId = requestRoomId
|
||||||
|
console.log('[Debug] Set new roomId:', this.roomId)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
} else if (this.roomId !== requestRoomId) {
|
||||||
|
throw new Error(`Room ID mismatch: expected ${this.roomId}, got ${requestRoomId}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,354 @@
|
||||||
|
/// <reference types="@cloudflare/workers-types" />
|
||||||
|
|
||||||
|
import { RoomSnapshot, TLSocketRoom } from "@tldraw/sync-core"
|
||||||
|
import {
|
||||||
|
TLRecord,
|
||||||
|
TLShape,
|
||||||
|
createTLSchema,
|
||||||
|
defaultBindingSchemas,
|
||||||
|
defaultShapeSchemas,
|
||||||
|
} 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 { TLContent } from '@tldraw/tldraw'
|
||||||
|
|
||||||
|
// 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,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
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
|
||||||
|
private backupsR2: R2Bucket
|
||||||
|
private roomId: string | null = null
|
||||||
|
private roomPromise: Promise<TLSocketRoom<TLRecord, void>> | null = null
|
||||||
|
|
||||||
|
constructor(private readonly ctx: DurableObjectState, env: Environment) {
|
||||||
|
console.log('[Debug] Constructor - env:', {
|
||||||
|
isDev: env.DEV,
|
||||||
|
bucketName: env.TLDRAW_BUCKET_NAME,
|
||||||
|
})
|
||||||
|
this.r2 = env.TLDRAW_BUCKET
|
||||||
|
this.backupsR2 = env.TLDRAW_BACKUP_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) {
|
||||||
|
console.error('[Error] Missing roomId')
|
||||||
|
throw new Error("Missing roomId")
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!this.roomPromise) {
|
||||||
|
this.roomPromise = (async () => {
|
||||||
|
try {
|
||||||
|
// Add debug logging
|
||||||
|
console.log('[Debug] Room ID:', roomId)
|
||||||
|
console.log('[Debug] R2 Bucket:', this.r2)
|
||||||
|
|
||||||
|
const path = `rooms/${roomId}`
|
||||||
|
console.log('[Debug] Fetching path:', path)
|
||||||
|
|
||||||
|
if (!this.r2) {
|
||||||
|
throw new Error('R2 bucket not initialized')
|
||||||
|
}
|
||||||
|
|
||||||
|
// fetch the room from R2
|
||||||
|
const roomFromBucket = await this.r2.get(path)
|
||||||
|
if (!roomFromBucket) {
|
||||||
|
console.warn(`[Warn] No data found for room: ${roomId}`)
|
||||||
|
return new TLSocketRoom<TLRecord, void>({
|
||||||
|
schema: customSchema,
|
||||||
|
onDataChange: () => this.schedulePersistToR2(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const text = await roomFromBucket.text()
|
||||||
|
if (!text) {
|
||||||
|
throw new Error('Empty room data')
|
||||||
|
}
|
||||||
|
|
||||||
|
const initialSnapshot = JSON.parse(text) as RoomSnapshot
|
||||||
|
return new TLSocketRoom<TLRecord, void>({
|
||||||
|
schema: customSchema,
|
||||||
|
initialSnapshot,
|
||||||
|
onDataChange: () => this.schedulePersistToR2(),
|
||||||
|
})
|
||||||
|
|
||||||
|
} catch (e) {
|
||||||
|
console.error('[Error] Failed to initialize room:', e)
|
||||||
|
throw e
|
||||||
|
}
|
||||||
|
})()
|
||||||
|
}
|
||||||
|
|
||||||
|
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()
|
||||||
|
|
||||||
|
// Save to main storage
|
||||||
|
const snapshot = JSON.stringify(room.getCurrentSnapshot())
|
||||||
|
await this.r2.put(`rooms/${this.roomId}`, snapshot)
|
||||||
|
|
||||||
|
// Check if we need to create a daily backup
|
||||||
|
const today = new Date().toISOString().split('T')[0]
|
||||||
|
const lastBackupKey = `backups/${this.roomId}/${today}`
|
||||||
|
|
||||||
|
const existingBackup = await this.backupsR2.head(lastBackupKey)
|
||||||
|
if (!existingBackup) {
|
||||||
|
await this.createDailyBackup()
|
||||||
|
}
|
||||||
|
}, 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": "*",
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
private async listVersions(): Promise<Array<{ timestamp: number; version: number; dateKey: string }>> {
|
||||||
|
const prefix = `backups/${this.roomId}/`
|
||||||
|
const objects = await this.backupsR2.list({ prefix })
|
||||||
|
|
||||||
|
return objects.objects
|
||||||
|
.map(obj => {
|
||||||
|
const dateKey = obj.key.split('/').pop()!
|
||||||
|
return {
|
||||||
|
timestamp: obj.uploaded.getTime(),
|
||||||
|
version: 1,
|
||||||
|
dateKey,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.sort((a, b) => b.timestamp - a.timestamp)
|
||||||
|
}
|
||||||
|
|
||||||
|
private async restoreVersion(dateKey: string): Promise<boolean> {
|
||||||
|
const backupKey = `backups/${this.roomId}/${dateKey}`
|
||||||
|
const backup = await this.backupsR2.get(backupKey)
|
||||||
|
|
||||||
|
if (!backup) return false
|
||||||
|
|
||||||
|
const backupData = await backup.json() as RoomSnapshot
|
||||||
|
|
||||||
|
// Update the current room state
|
||||||
|
const room = await this.getRoom()
|
||||||
|
room.updateStore((store) => {
|
||||||
|
// Delete all existing records
|
||||||
|
store.getAll().forEach(record => store.delete(record.id))
|
||||||
|
// Apply the backup snapshot
|
||||||
|
backupData.documents.forEach(record => store.put(record as unknown as TLRecord))
|
||||||
|
})
|
||||||
|
|
||||||
|
// Also update the main storage
|
||||||
|
await this.r2.put(`rooms/${this.roomId}`, JSON.stringify(backupData))
|
||||||
|
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
private async createDailyBackup() {
|
||||||
|
if (!this.roomId) return
|
||||||
|
|
||||||
|
const room = await this.getRoom()
|
||||||
|
const snapshot = room.getCurrentSnapshot()
|
||||||
|
const dateKey = new Date().toISOString().split('T')[0]
|
||||||
|
|
||||||
|
await this.backupsR2.put(
|
||||||
|
`backups/${this.roomId}/${dateKey}`,
|
||||||
|
JSON.stringify(snapshot)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,109 @@
|
||||||
|
/// <reference types="@cloudflare/workers-types" />
|
||||||
|
|
||||||
|
import { IRequest, error } from 'itty-router'
|
||||||
|
import { Environment } from './types'
|
||||||
|
|
||||||
|
// assets are stored in the bucket under the /uploads path
|
||||||
|
function getAssetObjectName(uploadId: string) {
|
||||||
|
return `uploads/${uploadId.replace(/[^a-zA-Z0-9\_\-]+/g, '_')}`
|
||||||
|
}
|
||||||
|
|
||||||
|
// when a user uploads an asset, we store it in the bucket. we only allow image and video assets.
|
||||||
|
export async function handleAssetUpload(request: IRequest, env: Environment) {
|
||||||
|
try {
|
||||||
|
const objectName = getAssetObjectName(request.params.uploadId)
|
||||||
|
|
||||||
|
const contentType = request.headers.get('content-type') ?? ''
|
||||||
|
if (!contentType.startsWith('image/') && !contentType.startsWith('video/')) {
|
||||||
|
return error(400, 'Invalid content type')
|
||||||
|
}
|
||||||
|
|
||||||
|
if (await env.TLDRAW_BUCKET.head(objectName)) {
|
||||||
|
return error(409, 'Upload already exists')
|
||||||
|
}
|
||||||
|
|
||||||
|
await env.TLDRAW_BUCKET.put(objectName, request.body, {
|
||||||
|
httpMetadata: request.headers,
|
||||||
|
})
|
||||||
|
|
||||||
|
return { ok: true }
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Asset upload failed:', error);
|
||||||
|
return new Response(`Upload failed: ${(error as Error).message}`, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// when a user downloads an asset, we retrieve it from the bucket. we also cache the response for performance.
|
||||||
|
export async function handleAssetDownload(
|
||||||
|
request: IRequest,
|
||||||
|
env: Environment,
|
||||||
|
ctx: ExecutionContext
|
||||||
|
) {
|
||||||
|
const objectName = getAssetObjectName(request.params.uploadId)
|
||||||
|
|
||||||
|
// if we have a cached response for this request (automatically handling ranges etc.), return it
|
||||||
|
const cacheKey = new Request(request.url, { headers: request.headers })
|
||||||
|
// @ts-ignore
|
||||||
|
const cachedResponse = await caches.default.match(cacheKey)
|
||||||
|
if (cachedResponse) {
|
||||||
|
return cachedResponse
|
||||||
|
}
|
||||||
|
|
||||||
|
// if not, we try to fetch the asset from the bucket
|
||||||
|
const object = await env.TLDRAW_BUCKET.get(objectName, {
|
||||||
|
range: request.headers,
|
||||||
|
onlyIf: request.headers,
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!object) {
|
||||||
|
return error(404)
|
||||||
|
}
|
||||||
|
|
||||||
|
// write the relevant metadata to the response headers
|
||||||
|
const headers = new Headers()
|
||||||
|
object.writeHttpMetadata(headers)
|
||||||
|
|
||||||
|
// assets are immutable, so we can cache them basically forever:
|
||||||
|
headers.set('cache-control', 'public, max-age=31536000, immutable')
|
||||||
|
headers.set('etag', object.httpEtag)
|
||||||
|
|
||||||
|
// we set CORS headers so all clients can access assets. we do this here so our `cors` helper in
|
||||||
|
// worker.ts doesn't try to set extra cors headers on responses that have been read from the
|
||||||
|
// cache, which isn't allowed by cloudflare.
|
||||||
|
headers.set('access-control-allow-origin', '*')
|
||||||
|
|
||||||
|
// cloudflare doesn't set the content-range header automatically in writeHttpMetadata, so we
|
||||||
|
// need to do it ourselves.
|
||||||
|
let contentRange
|
||||||
|
if (object.range) {
|
||||||
|
if ('suffix' in object.range) {
|
||||||
|
const start = object.size - object.range.suffix
|
||||||
|
const end = object.size - 1
|
||||||
|
contentRange = `bytes ${start}-${end}/${object.size}`
|
||||||
|
} else {
|
||||||
|
const start = object.range.offset ?? 0
|
||||||
|
const end = object.range.length ? start + object.range.length - 1 : object.size - 1
|
||||||
|
if (start !== 0 || end !== object.size - 1) {
|
||||||
|
contentRange = `bytes ${start}-${end}/${object.size}`
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (contentRange) {
|
||||||
|
headers.set('content-range', contentRange)
|
||||||
|
}
|
||||||
|
|
||||||
|
// make sure we get the correct body/status for the response
|
||||||
|
const body = 'body' in object && object.body ? object.body : null
|
||||||
|
const status = body ? (contentRange ? 206 : 200) : 304
|
||||||
|
|
||||||
|
// we only cache complete (200) responses
|
||||||
|
if (status === 200) {
|
||||||
|
const [cacheBody, responseBody] = body!.tee()
|
||||||
|
// @ts-ignore
|
||||||
|
ctx.waitUntil(caches.default.put(cacheKey, new Response(cacheBody, { headers, status })))
|
||||||
|
return new Response(responseBody, { headers, status })
|
||||||
|
}
|
||||||
|
|
||||||
|
return new Response(body, { headers, status })
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,21 @@
|
||||||
|
// 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
|
||||||
|
TLDRAW_BUCKET_NAME: 'jeffemmett-canvas'
|
||||||
|
TLDRAW_BACKUP_BUCKET: R2Bucket
|
||||||
|
TLDRAW_BACKUP_BUCKET_NAME: 'board-backups'
|
||||||
|
TLDRAW_DURABLE_OBJECT: DurableObjectNamespace
|
||||||
|
DAILY_API_KEY: string;
|
||||||
|
DAILY_DOMAIN: string;
|
||||||
|
DEV: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
// export interface BoardVersion {
|
||||||
|
// timestamp: number
|
||||||
|
// snapshot: RoomSnapshot
|
||||||
|
// version: number
|
||||||
|
// dateKey: string // YYYY-MM-DD format
|
||||||
|
// }
|
||||||
|
|
@ -0,0 +1,230 @@
|
||||||
|
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
|
||||||
|
if (
|
||||||
|
origin.match(
|
||||||
|
/^http:\/\/(localhost|127\.0\.0\.1|192\.168\.|169\.254\.|10\.)/,
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
return origin
|
||||||
|
}
|
||||||
|
|
||||||
|
return undefined
|
||||||
|
},
|
||||||
|
allowMethods: ["GET", "POST", "OPTIONS", "UPGRADE"],
|
||||||
|
allowHeaders: [
|
||||||
|
"Content-Type",
|
||||||
|
"Authorization",
|
||||||
|
"Upgrade",
|
||||||
|
"Connection",
|
||||||
|
"Sec-WebSocket-Key",
|
||||||
|
"Sec-WebSocket-Version",
|
||||||
|
"Sec-WebSocket-Extensions",
|
||||||
|
"Sec-WebSocket-Protocol",
|
||||||
|
],
|
||||||
|
maxAge: 86400,
|
||||||
|
credentials: true,
|
||||||
|
})
|
||||||
|
const router = AutoRouter<IRequest, [env: Environment, ctx: ExecutionContext]>({
|
||||||
|
before: [preflight],
|
||||||
|
finally: [
|
||||||
|
(response) => {
|
||||||
|
// Skip header modification for responses that already have CORS headers
|
||||||
|
if (!response.headers.has('Access-Control-Allow-Origin')) {
|
||||||
|
// 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)
|
||||||
|
}
|
||||||
|
return 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)
|
||||||
|
},
|
||||||
|
})
|
||||||
|
// Add debug routes that forward to the Durable Object
|
||||||
|
.get("/debug/:command", async (request, env) => {
|
||||||
|
try {
|
||||||
|
console.log('[Debug] Handling debug command:', request.params.command)
|
||||||
|
const id = env.TLDRAW_DURABLE_OBJECT.idFromName('debug')
|
||||||
|
const room = env.TLDRAW_DURABLE_OBJECT.get(id)
|
||||||
|
return room.fetch(request.url)
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[Debug] Error in debug endpoint:', error)
|
||||||
|
return new Response(JSON.stringify({
|
||||||
|
error: 'Internal Server Error',
|
||||||
|
message: (error as Error).message
|
||||||
|
}), {
|
||||||
|
status: 500,
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'Access-Control-Allow-Origin': '*'
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
// 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 (request, env) => {
|
||||||
|
try {
|
||||||
|
const { name, properties } = (await request.json()) as {
|
||||||
|
name: string
|
||||||
|
properties: Record<string, unknown>
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create a room using Daily.co API
|
||||||
|
const dailyResponse = await fetch("https://api.daily.co/v1/rooms", {
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
Authorization: `Bearer ${env.DAILY_API_KEY}`,
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
name,
|
||||||
|
properties,
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
|
||||||
|
const dailyData = await dailyResponse.json()
|
||||||
|
|
||||||
|
if (!dailyResponse.ok) {
|
||||||
|
return new Response(
|
||||||
|
JSON.stringify({
|
||||||
|
message:
|
||||||
|
(dailyData as any).info || "Failed to create Daily.co room",
|
||||||
|
}),
|
||||||
|
{
|
||||||
|
status: 400,
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return new Response(
|
||||||
|
JSON.stringify({
|
||||||
|
url: `https://${env.DAILY_DOMAIN}/${(dailyData as any).name}`,
|
||||||
|
}),
|
||||||
|
{
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
},
|
||||||
|
)
|
||||||
|
} catch (error) {
|
||||||
|
return new Response(
|
||||||
|
JSON.stringify({
|
||||||
|
message: error instanceof Error ? error.message : "Unknown error",
|
||||||
|
}),
|
||||||
|
{
|
||||||
|
status: 500,
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
//DOES THIS NEED TO LOOK AT BOARD_BACKUPS OR JEFFEMMETT_CANVAS?
|
||||||
|
// Get all versions for a room
|
||||||
|
.get("/room/:roomId/:dateKey", 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, {
|
||||||
|
headers: request.headers,
|
||||||
|
method: request.method,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
// Restore a specific version
|
||||||
|
.post("/room/:roomId/restore/:dateKey", 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, {
|
||||||
|
headers: request.headers,
|
||||||
|
method: request.method,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
// export our router for cloudflare
|
||||||
|
export default router
|
||||||
|
|
@ -0,0 +1,44 @@
|
||||||
|
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
|
||||||
|
DEV = false
|
||||||
|
TLDRAW_BUCKET_NAME = "jeffemmett-canvas"
|
||||||
|
TLDRAW_BACKUP_BUCKET_NAME = "board-backups"
|
||||||
|
|
||||||
|
[env.development]
|
||||||
|
vars = { DEV = true }
|
||||||
|
binding = 'TLDRAW_BUCKET'
|
||||||
|
bucket_name = 'jeffemmett-canvas-preview'
|
||||||
|
|
||||||
|
|
||||||
|
[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'
|
||||||
|
|
||||||
|
[[r2_buckets]]
|
||||||
|
binding = 'TLDRAW_BACKUP_BUCKET'
|
||||||
|
bucket_name = 'board-backups'
|
||||||
|
|
||||||
|
[observability]
|
||||||
|
enabled = true
|
||||||
|
head_sampling_rate = 1
|
||||||
Loading…
Reference in New Issue