Compare commits
79 Commits
main
...
add-camera
| Author | SHA1 | Date |
|---|---|---|
|
|
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,19 @@
|
||||||
|
# Google API Credentials
|
||||||
|
VITE_GOOGLE_CLIENT_ID='your_google_client_id'
|
||||||
|
VITE_GOOGLE_API_KEY='your_google_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
|
||||||
|
DAILY_API_KEY='your_daily_api_key'
|
||||||
|
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,185 @@
|
||||||
|
dist/
|
||||||
|
.DS_Store
|
||||||
|
bun.lockb
|
||||||
|
yarn.lock
|
||||||
|
|
||||||
|
# Logs
|
||||||
|
|
||||||
|
logs
|
||||||
|
_.log
|
||||||
|
npm-debug.log_
|
||||||
|
yarn-debug.log*
|
||||||
|
yarn-error.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
|
||||||
|
|
||||||
|
# Yarn Integrity file
|
||||||
|
|
||||||
|
.yarn-integrity
|
||||||
|
|
||||||
|
# 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
|
||||||
|
|
||||||
|
# yarn v2
|
||||||
|
|
||||||
|
.yarn/cache
|
||||||
|
.yarn/unplugged
|
||||||
|
.yarn/build-state.yml
|
||||||
|
.yarn/install-state.gz
|
||||||
|
.pnp.\*
|
||||||
|
|
||||||
|
.wrangler/
|
||||||
|
.*.md
|
||||||
|
.vercel
|
||||||
|
|
||||||
|
# Environment files
|
||||||
|
.env
|
||||||
|
.env.local
|
||||||
|
.env.*.local
|
||||||
|
.dev.vars
|
||||||
|
|
||||||
|
# Keep example file
|
||||||
|
!.env.example
|
||||||
|
|
@ -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,17 @@
|
||||||
|
import matter from "gray-matter";
|
||||||
|
import { markdownToHtml } from "./markdownToHtml";
|
||||||
|
import path from "path";
|
||||||
|
|
||||||
|
export const markdownPlugin = {
|
||||||
|
name: "markdown-plugin",
|
||||||
|
enforce: "pre",
|
||||||
|
transform(code, id) {
|
||||||
|
if (id.endsWith(".md")) {
|
||||||
|
const { data, content } = matter(code);
|
||||||
|
const filename = path.basename(id, ".md");
|
||||||
|
const html = markdownToHtml(filename, content);
|
||||||
|
return `export const html = ${JSON.stringify(html)};
|
||||||
|
export const data = ${JSON.stringify(data)};`;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
@ -0,0 +1,42 @@
|
||||||
|
import MarkdownIt from "markdown-it";
|
||||||
|
// import markdownItLatex from "markdown-it-latex";
|
||||||
|
import markdownLatex from "markdown-it-latex2img";
|
||||||
|
|
||||||
|
const md = new MarkdownIt({
|
||||||
|
html: true,
|
||||||
|
breaks: true,
|
||||||
|
linkify: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
md.use(
|
||||||
|
markdownLatex,
|
||||||
|
// {style: "width: 200%; height: 200%;",}
|
||||||
|
);
|
||||||
|
|
||||||
|
// const mediaSrc = (folderName, fileName) => {
|
||||||
|
// return `/posts/${folderName}/${fileName}`;
|
||||||
|
// };
|
||||||
|
|
||||||
|
md.renderer.rules.code_block = (tokens, idx, options, env, self) => {
|
||||||
|
console.log("tokens", tokens);
|
||||||
|
return `<code>${tokens[idx].content}</code>`;
|
||||||
|
};
|
||||||
|
md.renderer.rules.image = (tokens, idx, options, env, self) => {
|
||||||
|
const token = tokens[idx];
|
||||||
|
const src = token.attrGet("src");
|
||||||
|
const alt = token.content;
|
||||||
|
const postName = env.postName;
|
||||||
|
const formattedSrc = `/posts/${postName}/${src}`;
|
||||||
|
|
||||||
|
if (src.endsWith(".mp4") || src.endsWith(".mov")) {
|
||||||
|
return `<video controls loop>
|
||||||
|
<source src="${formattedSrc}" type="video/mp4">
|
||||||
|
</video>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return `<img src="${formattedSrc}" alt="${alt}" />`;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function markdownToHtml(postName, content) {
|
||||||
|
return md.render(content, { postName: postName });
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,12 @@
|
||||||
|
{
|
||||||
|
"folders": [
|
||||||
|
{
|
||||||
|
"path": "."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "jeffemmett.com",
|
||||||
|
"path": "../jeffemmett.com"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"settings": {}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,45 @@
|
||||||
|
<!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="icon" type="image/x-icon" href="/favicon.ico?v=4" />
|
||||||
|
<link rel="shortcut icon" type="image/x-icon" href="/favicon.ico?v=4" />
|
||||||
|
<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>
|
||||||
|
|
@ -0,0 +1,69 @@
|
||||||
|
{
|
||||||
|
"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 \"yarn dev:client\" \"yarn dev:worker\"",
|
||||||
|
"dev:client": "vite --host --port 5173",
|
||||||
|
"dev:worker": "wrangler dev --local --port 5172 --ip 0.0.0.0",
|
||||||
|
"build": "tsc && vite build && wrangler deploy",
|
||||||
|
"lint": "eslint src --ext ts,tsx --report-unused-disable-directives --max-warnings 0",
|
||||||
|
"preview": "vite preview",
|
||||||
|
"deploy": "yarn build && vercel deploy --prod"
|
||||||
|
},
|
||||||
|
"keywords": [],
|
||||||
|
"author": "Jeff Emmett",
|
||||||
|
"license": "ISC",
|
||||||
|
"dependencies": {
|
||||||
|
"@dimforge/rapier2d": "^0.11.2",
|
||||||
|
"@tldraw/assets": "^2.0.0",
|
||||||
|
"@tldraw/tldraw": "^3.4.1",
|
||||||
|
"@tldraw/sync": "^2.4.6",
|
||||||
|
"@tldraw/sync-core": "^2.4.6",
|
||||||
|
"@tldraw/tlschema": "^2.4.6",
|
||||||
|
"@types/markdown-it": "^14.1.1",
|
||||||
|
"@vercel/analytics": "^1.2.2",
|
||||||
|
"@whereby.com/browser-sdk": "^3.9.2",
|
||||||
|
"cloudflare-workers-unfurl": "^0.0.7",
|
||||||
|
"crdts": "^0.2.0",
|
||||||
|
"gray-matter": "^4.0.3",
|
||||||
|
"itty-router": "^5.0.17",
|
||||||
|
"lodash.throttle": "^4.1.1",
|
||||||
|
"markdown-it": "^14.1.0",
|
||||||
|
"markdown-it-latex2img": "^0.0.6",
|
||||||
|
"react": "^18.2.0",
|
||||||
|
"react-dom": "^18.2.0",
|
||||||
|
"react-error-boundary": "^4.1.2",
|
||||||
|
"react-router-dom": "^6.22.3",
|
||||||
|
"tldraw": "^2.4.6",
|
||||||
|
"use-local-storage-state": "^19.5.0",
|
||||||
|
"vercel": "^39.1.1"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@biomejs/biome": "1.4.1",
|
||||||
|
"@cloudflare/types": "^6.29.1",
|
||||||
|
"@cloudflare/workers-types": "^4.20240821.1",
|
||||||
|
"@types/lodash.throttle": "^4",
|
||||||
|
"@types/react": "^18.2.0",
|
||||||
|
"@types/react-dom": "^18.2.7",
|
||||||
|
"@typescript-eslint/eslint-plugin": "^5.59.0",
|
||||||
|
"@typescript-eslint/parser": "^5.59.0",
|
||||||
|
"@vitejs/plugin-react": "^4.0.3",
|
||||||
|
"@vitejs/plugin-react-swc": "^3.6.0",
|
||||||
|
"concurrently": "^8.2.2",
|
||||||
|
"eslint": "^8.38.0",
|
||||||
|
"eslint-plugin-react-hooks": "^4.6.0",
|
||||||
|
"eslint-plugin-react-refresh": "^0.3.4",
|
||||||
|
"typescript": "^5.6.3",
|
||||||
|
"vite": "^5.3.3",
|
||||||
|
"vite-plugin-static-copy": "^1.0.6",
|
||||||
|
"vite-plugin-top-level-await": "^1.3.1",
|
||||||
|
"vite-plugin-wasm": "^3.2.2",
|
||||||
|
"wrangler": "^3.88.0"
|
||||||
|
},
|
||||||
|
"resolutions": {
|
||||||
|
"react": "^18.2.0",
|
||||||
|
"@types/react": "^18.2.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,63 @@
|
||||||
|
{
|
||||||
|
"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 \"yarn dev:client\" \"yarn dev:worker\"",
|
||||||
|
"dev:client": "vite --host --port 5173",
|
||||||
|
"dev:worker": "wrangler dev --local --port 5172 --ip 0.0.0.0",
|
||||||
|
"build": "tsc && vite build && wrangler deploy",
|
||||||
|
"lint": "eslint src --ext ts,tsx --report-unused-disable-directives --max-warnings 0",
|
||||||
|
"preview": "vite preview",
|
||||||
|
"deploy": "yarn build && vercel deploy --prod"
|
||||||
|
},
|
||||||
|
"keywords": [],
|
||||||
|
"author": "Jeff Emmett",
|
||||||
|
"license": "ISC",
|
||||||
|
"dependencies": {
|
||||||
|
"@dimforge/rapier2d": "^0.11.2",
|
||||||
|
"@tldraw/sync": "^3.4.1",
|
||||||
|
"@tldraw/sync-core": "^3.4.1",
|
||||||
|
"@tldraw/tldraw": "^3.4.1",
|
||||||
|
"@tldraw/tlschema": "^3.4.1",
|
||||||
|
"@types/markdown-it": "^14.1.1",
|
||||||
|
"@vercel/analytics": "^1.2.2",
|
||||||
|
"cloudflare-workers-unfurl": "^0.0.7",
|
||||||
|
"crdts": "^0.2.0",
|
||||||
|
"gray-matter": "^4.0.3",
|
||||||
|
"itty-router": "^5.0.17",
|
||||||
|
"lodash.throttle": "^4.1.1",
|
||||||
|
"markdown-it": "^14.1.0",
|
||||||
|
"markdown-it-latex2img": "^0.0.6",
|
||||||
|
"react": "^18.2.0",
|
||||||
|
"react-dom": "^18.2.0",
|
||||||
|
"react-error-boundary": "^4.1.2",
|
||||||
|
"react-router-dom": "^6.22.3",
|
||||||
|
"tldraw": "^3.4.1",
|
||||||
|
"use-local-storage-state": "^19.5.0",
|
||||||
|
"vercel": "^39.1.1"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@biomejs/biome": "1.4.1",
|
||||||
|
"@cloudflare/types": "^6.0.0",
|
||||||
|
"@cloudflare/workers-types": "^4.20240821.1",
|
||||||
|
"@types/lodash.throttle": "^4",
|
||||||
|
"@types/react": "^18.2.15",
|
||||||
|
"@types/react-dom": "^18.2.7",
|
||||||
|
"@typescript-eslint/eslint-plugin": "^5.59.0",
|
||||||
|
"@typescript-eslint/parser": "^5.59.0",
|
||||||
|
"@vitejs/plugin-react": "^4.0.3",
|
||||||
|
"@vitejs/plugin-react-swc": "^3.6.0",
|
||||||
|
"concurrently": "^8.2.2",
|
||||||
|
"eslint": "^8.38.0",
|
||||||
|
"eslint-plugin-react-hooks": "^4.6.0",
|
||||||
|
"eslint-plugin-react-refresh": "^0.3.4",
|
||||||
|
"typescript": "^5.6.3",
|
||||||
|
"vite": "^5.3.3",
|
||||||
|
"vite-plugin-static-copy": "^1.0.6",
|
||||||
|
"vite-plugin-top-level-await": "^1.3.1",
|
||||||
|
"vite-plugin-wasm": "^3.2.2",
|
||||||
|
"wrangler": "^3.88.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,158 @@
|
||||||
|
import { inject } from '@vercel/analytics';
|
||||||
|
import "tldraw/tldraw.css";
|
||||||
|
import "@/css/style.css"
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import ReactDOM from "react-dom/client";
|
||||||
|
import { Default } from "@/components/Default";
|
||||||
|
import { Canvas } from "@/components/Canvas";
|
||||||
|
import { Toggle } from "@/components/Toggle";
|
||||||
|
import { useCanvas } from "@/hooks/useCanvas"
|
||||||
|
import { createShapes } from "@/utils/utils";
|
||||||
|
import { BrowserRouter, Route, Routes } from 'react-router-dom';
|
||||||
|
import { Contact } from "@/components/Contact";
|
||||||
|
import { Post } from '@/components/Post';
|
||||||
|
import { Board } from './components/Board';
|
||||||
|
import { Inbox } from './components/Inbox';
|
||||||
|
import { Books } from './components/Books';
|
||||||
|
import {
|
||||||
|
BindingUtil,
|
||||||
|
Editor,
|
||||||
|
IndexKey,
|
||||||
|
TLBaseBinding,
|
||||||
|
TLBaseShape,
|
||||||
|
Tldraw,
|
||||||
|
TLShapeId,
|
||||||
|
} from 'tldraw';
|
||||||
|
import { components, uiOverrides } from './ui-overrides';
|
||||||
|
import { ChatBoxShape } from './shapes/ChatBoxShapeUtil';
|
||||||
|
import { VideoChatShape } from './shapes/VideoChatShapeUtil';
|
||||||
|
import { ChatBoxTool } from './tools/ChatBoxTool';
|
||||||
|
import { VideoChatTool } from './tools/VideoChatTool';
|
||||||
|
|
||||||
|
inject();
|
||||||
|
|
||||||
|
// The container shapes that can contain element shapes
|
||||||
|
const CONTAINER_PADDING = 24;
|
||||||
|
|
||||||
|
type ContainerShape = TLBaseShape<'element', { height: number; width: number }>;
|
||||||
|
|
||||||
|
// ... existing code for ContainerShapeUtil ...
|
||||||
|
|
||||||
|
// The element shapes that can be placed inside the container shapes
|
||||||
|
type ElementShape = TLBaseShape<'element', { color: string }>;
|
||||||
|
|
||||||
|
// ... existing code for ElementShapeUtil ...
|
||||||
|
|
||||||
|
// The binding between the element shapes and the container shapes
|
||||||
|
type LayoutBinding = TLBaseBinding<
|
||||||
|
'layout',
|
||||||
|
{
|
||||||
|
index: IndexKey;
|
||||||
|
placeholder: boolean;
|
||||||
|
}
|
||||||
|
>;
|
||||||
|
|
||||||
|
const customShapeUtils = [ChatBoxShape, VideoChatShape];
|
||||||
|
const customTools = [ChatBoxTool, VideoChatTool];
|
||||||
|
|
||||||
|
// [2]
|
||||||
|
export default function InteractiveShapeExample() {
|
||||||
|
return (
|
||||||
|
<div className="tldraw__editor">
|
||||||
|
<Tldraw
|
||||||
|
shapeUtils={customShapeUtils}
|
||||||
|
tools={customTools}
|
||||||
|
overrides={uiOverrides}
|
||||||
|
components={components}
|
||||||
|
onMount={(editor) => {
|
||||||
|
handleInitialShapeLoad(editor);
|
||||||
|
editor.createShape({ type: 'my-interactive-shape', x: 100, y: 100 });
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add this function before or after InteractiveShapeExample
|
||||||
|
const handleInitialShapeLoad = (editor: Editor) => {
|
||||||
|
const url = new URL(window.location.href);
|
||||||
|
const shapeId = url.searchParams.get('shapeId') || url.searchParams.get('frameId');
|
||||||
|
const x = url.searchParams.get('x');
|
||||||
|
const y = url.searchParams.get('y');
|
||||||
|
const zoom = url.searchParams.get('zoom');
|
||||||
|
|
||||||
|
if (shapeId) {
|
||||||
|
console.log('Found shapeId in URL:', shapeId);
|
||||||
|
const shape = editor.getShape(shapeId as TLShapeId);
|
||||||
|
|
||||||
|
if (shape) {
|
||||||
|
console.log('Found shape:', shape);
|
||||||
|
if (x && y && zoom) {
|
||||||
|
console.log('Setting camera to:', { x, y, zoom });
|
||||||
|
editor.setCamera({
|
||||||
|
x: parseFloat(x),
|
||||||
|
y: parseFloat(y),
|
||||||
|
z: parseFloat(zoom)
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
console.log('Zooming to shape bounds');
|
||||||
|
editor.zoomToBounds(editor.getShapeGeometry(shape).bounds, {
|
||||||
|
targetZoom: 1,
|
||||||
|
//padding: 32
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
console.warn('Shape not found in the editor');
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
console.warn('No shapeId found in the URL');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
ReactDOM.createRoot(document.getElementById("root")!).render(<App />);
|
||||||
|
|
||||||
|
function App() {
|
||||||
|
|
||||||
|
return (
|
||||||
|
// <React.StrictMode>
|
||||||
|
<BrowserRouter>
|
||||||
|
<Routes>
|
||||||
|
<Route path="/" element={<Home />} />
|
||||||
|
<Route path="/card/contact" element={<Contact />} />
|
||||||
|
<Route path="/posts/:slug" element={<Post />} />
|
||||||
|
<Route path="/board/:slug" element={<Board />} />
|
||||||
|
<Route path="/inbox" element={<Inbox />} />
|
||||||
|
<Route path="/books" element={<Books />} />
|
||||||
|
</Routes>
|
||||||
|
</BrowserRouter>
|
||||||
|
// </React.StrictMode>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
function Home() {
|
||||||
|
const { isCanvasEnabled, elementsInfo } = useCanvas();
|
||||||
|
const shapes = createShapes(elementsInfo)
|
||||||
|
const [isEditorMounted, setIsEditorMounted] = useState(false);
|
||||||
|
|
||||||
|
//console.log("THIS WORKS SO FAR")
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const handleEditorDidMount = () => {
|
||||||
|
setIsEditorMounted(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
window.addEventListener('editorDidMountEvent', handleEditorDidMount);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener('editorDidMountEvent', handleEditorDidMount);
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<><Toggle />
|
||||||
|
<div style={{ zIndex: 999999 }} className={`${isCanvasEnabled && isEditorMounted ? 'transparent' : ''}`}>
|
||||||
|
{<Default />}
|
||||||
|
</div>
|
||||||
|
{isCanvasEnabled && elementsInfo.length > 0 ? <Canvas shapes={shapes} /> : null}</>
|
||||||
|
)
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
|
|
@ -0,0 +1,37 @@
|
||||||
|
import { AssetRecordType, TLAsset, TLBookmarkAsset, getHashForString } from 'tldraw'
|
||||||
|
|
||||||
|
// How does our server handle bookmark unfurling?
|
||||||
|
export async function getBookmarkPreview({ url }: { url: string }): Promise<TLAsset> {
|
||||||
|
// we start with an empty asset record
|
||||||
|
const asset: TLBookmarkAsset = {
|
||||||
|
id: AssetRecordType.createId(getHashForString(url)),
|
||||||
|
typeName: 'asset',
|
||||||
|
type: 'bookmark',
|
||||||
|
meta: {},
|
||||||
|
props: {
|
||||||
|
src: url,
|
||||||
|
description: '',
|
||||||
|
image: '',
|
||||||
|
favicon: '',
|
||||||
|
title: '',
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// try to fetch the preview data from the server
|
||||||
|
const response = await fetch(
|
||||||
|
`${process.env.TLDRAW_WORKER_URL}/unfurl?url=${encodeURIComponent(url)}`
|
||||||
|
)
|
||||||
|
const data = await response.json() as {description: string, image: string, favicon: string, title: string}
|
||||||
|
|
||||||
|
// fill in our asset with whatever info we found
|
||||||
|
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,25 @@
|
||||||
|
import { TLAssetStore, uniqueId } from 'tldraw'
|
||||||
|
import { WORKER_URL } from '../components/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,123 @@
|
||||||
|
import { useSync } from '@tldraw/sync'
|
||||||
|
import {
|
||||||
|
AssetRecordType,
|
||||||
|
getHashForString,
|
||||||
|
TLBookmarkAsset,
|
||||||
|
Tldraw,
|
||||||
|
// useLocalStorageState,
|
||||||
|
} from 'tldraw'
|
||||||
|
import { useParams } from 'react-router-dom'
|
||||||
|
import useLocalStorageState from 'use-local-storage-state'
|
||||||
|
import { ChatBoxTool } from '@/tools/ChatBoxTool'
|
||||||
|
import { ChatBoxShape } from '@/shapes/ChatBoxShapeUtil'
|
||||||
|
import { VideoChatTool } from '@/tools/VideoChatTool'
|
||||||
|
import { VideoChatShape } from '@/shapes/VideoChatShapeUtil'
|
||||||
|
import { multiplayerAssetStore } from '../client/multiplayerAssetStore'
|
||||||
|
import { customSchema } from '../../worker/TldrawDurableObject'
|
||||||
|
import { EmbedShape } from '@/shapes/EmbedShapeUtil'
|
||||||
|
import { EmbedTool } from '@/tools/EmbedTool'
|
||||||
|
|
||||||
|
import React, { useEffect, useState } from 'react';
|
||||||
|
import { ChatBox } from '@/shapes/ChatBoxShapeUtil';
|
||||||
|
import { components, uiOverrides } from '@/ui-overrides'
|
||||||
|
|
||||||
|
const WORKER_URL = `https://jeffemmett-canvas.jeffemmett.workers.dev`
|
||||||
|
|
||||||
|
const shapeUtils = [ChatBoxShape, VideoChatShape, EmbedShape]
|
||||||
|
const tools = [ChatBoxTool, VideoChatTool, EmbedTool]; // Array of tools
|
||||||
|
|
||||||
|
export function Board() {
|
||||||
|
const { slug } = useParams<{ slug: string }>(); // Ensure this is inside the Board component
|
||||||
|
const roomId = slug || 'default-room'; // Declare roomId here
|
||||||
|
|
||||||
|
const store = useSync({
|
||||||
|
uri: `${WORKER_URL}/connect/${roomId}`,
|
||||||
|
assets: multiplayerAssetStore,
|
||||||
|
shapeUtils: shapeUtils,
|
||||||
|
schema: customSchema,
|
||||||
|
});
|
||||||
|
|
||||||
|
const [isChatBoxVisible, setChatBoxVisible] = useState(false);
|
||||||
|
const [userName, setUserName] = useState('');
|
||||||
|
const [isVideoChatVisible, setVideoChatVisible] = useState(false); // Added state for video chat visibility
|
||||||
|
|
||||||
|
const handleNameChange = (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
setUserName(event.target.value);
|
||||||
|
};
|
||||||
|
|
||||||
|
const [persistedStore, setPersistedStore] = useLocalStorageState('board-store', { defaultValue: store }
|
||||||
|
)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setPersistedStore(store);
|
||||||
|
}, [store]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{ position: 'fixed', inset: 0 }}>
|
||||||
|
<Tldraw
|
||||||
|
//store={persistedStore}
|
||||||
|
store={store}
|
||||||
|
shapeUtils={shapeUtils}
|
||||||
|
overrides={uiOverrides}
|
||||||
|
components={components}
|
||||||
|
tools={tools}
|
||||||
|
onMount={(editor) => {
|
||||||
|
editor.registerExternalAssetHandler('url', unfurlBookmarkUrl)
|
||||||
|
editor.setCurrentTool('hand')
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
{isChatBoxVisible && (
|
||||||
|
<div>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={userName}
|
||||||
|
onChange={handleNameChange}
|
||||||
|
placeholder="Enter your name"
|
||||||
|
/>
|
||||||
|
<ChatBox
|
||||||
|
userName={userName}
|
||||||
|
roomId={roomId} // Added roomId
|
||||||
|
w={200} // Set appropriate width
|
||||||
|
h={200} // Set appropriate height
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{isVideoChatVisible && ( // Render the button to join video chat
|
||||||
|
<button onClick={() => setVideoChatVisible(false)} className="bg-green-500 text-white px-4 py-2 rounded">
|
||||||
|
Join Video Call
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// How does our server handle bookmark unfurling?
|
||||||
|
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,119 @@
|
||||||
|
import { useSync } from '@tldraw/sync'
|
||||||
|
import {
|
||||||
|
AssetRecordType,
|
||||||
|
getHashForString,
|
||||||
|
TLBookmarkAsset,
|
||||||
|
TLRecord,
|
||||||
|
Tldraw,
|
||||||
|
Editor,
|
||||||
|
TLFrameShape,
|
||||||
|
TLUiEventSource,
|
||||||
|
} from 'tldraw'
|
||||||
|
import { useParams } from 'react-router-dom'
|
||||||
|
import useLocalStorageState from 'use-local-storage-state'
|
||||||
|
import { ChatBoxTool } from '@/tools/ChatBoxTool'
|
||||||
|
import { ChatBoxShape } from '@/shapes/ChatBoxShapeUtil'
|
||||||
|
import { VideoChatTool } from '@/tools/VideoChatTool'
|
||||||
|
import { VideoChatShape } from '@/shapes/VideoChatShapeUtil'
|
||||||
|
import { multiplayerAssetStore } from '../client/multiplayerAssetStore'
|
||||||
|
import { customSchema } from '../../worker/TldrawDurableObject'
|
||||||
|
import { EmbedShape } from '@/shapes/EmbedShapeUtil'
|
||||||
|
import { EmbedTool } from '@/tools/EmbedTool'
|
||||||
|
|
||||||
|
import React, { useState, useEffect, useCallback } from 'react';
|
||||||
|
import { ChatBox } from '@/shapes/ChatBoxShapeUtil';
|
||||||
|
import { components, uiOverrides } from '@/ui-overrides'
|
||||||
|
import { useCameraControls } from '@/hooks/useCameraControls'
|
||||||
|
|
||||||
|
//const WORKER_URL = `https://jeffemmett-canvas.jeffemmett.workers.dev`
|
||||||
|
export const WORKER_URL = 'https://jeffemmett-canvas.jeffemmett.workers.dev';
|
||||||
|
|
||||||
|
const shapeUtils = [ChatBoxShape, VideoChatShape, EmbedShape]
|
||||||
|
const tools = [ChatBoxTool, VideoChatTool, EmbedTool]; // Array of tools
|
||||||
|
|
||||||
|
// Add these imports
|
||||||
|
import { useGSetState } from '@/hooks/useGSetState';
|
||||||
|
import { useLocalStorageRoom } from '@/hooks/useLocalStorageRoom';
|
||||||
|
import { usePersistentBoard } from '@/hooks/usePersistentBoard';
|
||||||
|
|
||||||
|
|
||||||
|
export function Board() {
|
||||||
|
const { slug } = useParams<{ slug: string }>();
|
||||||
|
const roomId = slug || 'default-room';
|
||||||
|
const { store } = usePersistentBoard(roomId);
|
||||||
|
const [editor, setEditor] = useState<Editor | null>(null)
|
||||||
|
const { zoomToFrame, copyFrameLink, copyLocationLink } = useCameraControls(editor)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{ position: 'fixed', inset: 0 }}>
|
||||||
|
<Tldraw
|
||||||
|
store={store}
|
||||||
|
shapeUtils={shapeUtils}
|
||||||
|
overrides={{
|
||||||
|
...uiOverrides,
|
||||||
|
tools: (_editor, baseTools) => ({
|
||||||
|
...baseTools,
|
||||||
|
frame: {
|
||||||
|
...baseTools.frame,
|
||||||
|
contextMenu: (shape: TLFrameShape) => [
|
||||||
|
{
|
||||||
|
id: 'copy-frame-link',
|
||||||
|
label: 'Copy Frame Link',
|
||||||
|
onSelect: () => copyFrameLink(shape.id),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'zoom-to-frame',
|
||||||
|
label: 'Zoom to Frame',
|
||||||
|
onSelect: () => zoomToFrame(shape.id),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'copy-location-link',
|
||||||
|
label: 'Copy Location Link',
|
||||||
|
onSelect: () => copyLocationLink(),
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}}
|
||||||
|
components={components}
|
||||||
|
tools={tools}
|
||||||
|
onMount={(editor) => {
|
||||||
|
setEditor(editor)
|
||||||
|
editor.registerExternalAssetHandler('url', unfurlBookmarkUrl)
|
||||||
|
editor.setCurrentTool('hand')
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// How does our server handle bookmark unfurling?
|
||||||
|
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,15 @@
|
||||||
|
|
||||||
|
import { Editor, Tldraw } from "tldraw";
|
||||||
|
import { canvas } from "@/canvas01";
|
||||||
|
|
||||||
|
export function Books() {
|
||||||
|
return (
|
||||||
|
<div className="tldraw__editor">
|
||||||
|
<Tldraw
|
||||||
|
onMount={(editor: Editor) => {
|
||||||
|
editor.putContentOntoCurrentPage(canvas as any)
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,33 @@
|
||||||
|
import { Editor, Tldraw, TLShape, TLUiComponents } from "tldraw";
|
||||||
|
import { SimController } from "@/physics/PhysicsControls";
|
||||||
|
import { HTMLShapeUtil } from "@/utils/HTMLShapeUtil";
|
||||||
|
|
||||||
|
const components: TLUiComponents = {
|
||||||
|
HelpMenu: null,
|
||||||
|
StylePanel: null,
|
||||||
|
PageMenu: null,
|
||||||
|
NavigationPanel: null,
|
||||||
|
DebugMenu: null,
|
||||||
|
//ContextMenu: null,
|
||||||
|
ActionsMenu: null,
|
||||||
|
QuickActions: null,
|
||||||
|
MainMenu: null,
|
||||||
|
MenuPanel: null,
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Canvas({ shapes }: { shapes: TLShape[]; }) {
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="tldraw__editor">
|
||||||
|
<Tldraw
|
||||||
|
components={components}
|
||||||
|
shapeUtils={[HTMLShapeUtil]}
|
||||||
|
onMount={(_: Editor) => {
|
||||||
|
window.dispatchEvent(new CustomEvent('editorDidMountEvent'));
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<SimController shapes={shapes} />
|
||||||
|
</Tldraw>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,17 @@
|
||||||
|
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,59 @@
|
||||||
|
import {
|
||||||
|
DefaultMainMenu,
|
||||||
|
TldrawUiMenuItem,
|
||||||
|
Editor,
|
||||||
|
TLContent,
|
||||||
|
DefaultMainMenuContent,
|
||||||
|
useEditor,
|
||||||
|
useExportAs,
|
||||||
|
} from "tldraw";
|
||||||
|
|
||||||
|
export function CustomMainMenu() {
|
||||||
|
const editor = useEditor()
|
||||||
|
const exportAs = useExportAs()
|
||||||
|
|
||||||
|
const importJSON = (editor: Editor) => {
|
||||||
|
const input = document.createElement("input");
|
||||||
|
input.type = "file";
|
||||||
|
input.accept = ".json";
|
||||||
|
input.onchange = (event) => {
|
||||||
|
const file = (event.target as HTMLInputElement).files?.[0];
|
||||||
|
const reader = new FileReader();
|
||||||
|
reader.onload = (event) => {
|
||||||
|
if (typeof event.target?.result !== 'string') {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const jsonData = JSON.parse(event.target.result) as TLContent
|
||||||
|
editor.putContentOntoCurrentPage(jsonData, { select: true })
|
||||||
|
};
|
||||||
|
if (file) {
|
||||||
|
reader.readAsText(file);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
input.click();
|
||||||
|
};
|
||||||
|
const exportJSON = (editor: Editor) => {
|
||||||
|
const exportName = `props-${Math.round(+new Date() / 1000).toString().slice(5)}`
|
||||||
|
exportAs(Array.from(editor.getCurrentPageShapeIds()), 'json', exportName)
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<DefaultMainMenu>
|
||||||
|
<DefaultMainMenuContent />
|
||||||
|
<TldrawUiMenuItem
|
||||||
|
id="export"
|
||||||
|
label="Export JSON"
|
||||||
|
icon="external-link"
|
||||||
|
readonlyOk
|
||||||
|
onSelect={() => exportJSON(editor)}
|
||||||
|
/>
|
||||||
|
<TldrawUiMenuItem
|
||||||
|
id="import"
|
||||||
|
label="Import JSON"
|
||||||
|
icon="external-link"
|
||||||
|
readonlyOk
|
||||||
|
onSelect={() => importJSON(editor)}
|
||||||
|
/>
|
||||||
|
</DefaultMainMenu>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,68 @@
|
||||||
|
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,74 @@
|
||||||
|
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,45 @@
|
||||||
|
import { calcReadingTime } from '@/utils/readingTime';
|
||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import { useParams } from 'react-router-dom';
|
||||||
|
|
||||||
|
export function Post() {
|
||||||
|
const { slug } = useParams<{ slug: string }>();
|
||||||
|
const [post, setPost] = useState<{ html: string, data: Record<string, any> } | null>(null);
|
||||||
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
import(`../posts/${slug}.md`)
|
||||||
|
.then((module) => {
|
||||||
|
setPost({ html: module.html, data: module.data });
|
||||||
|
setIsLoading(false);
|
||||||
|
})
|
||||||
|
.catch((error) => {
|
||||||
|
console.error('Failed to load post:', error);
|
||||||
|
setIsLoading(false);
|
||||||
|
});
|
||||||
|
}, [slug]);
|
||||||
|
|
||||||
|
|
||||||
|
if (isLoading) {
|
||||||
|
return <div className='loading'>hold on...</div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!post) {
|
||||||
|
return <div className='loading'>post not found :(</div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
document.title = post.data.title;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<main>
|
||||||
|
<header>
|
||||||
|
<a href="/" style={{ textDecoration: 'none' }}>Jeff Emmett</a>
|
||||||
|
</header>
|
||||||
|
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'baseline' }}>
|
||||||
|
<h1>{post.data.title}</h1>
|
||||||
|
<span style={{ opacity: '0.5' }}>{calcReadingTime(post.html)}</span>
|
||||||
|
</div>
|
||||||
|
<div dangerouslySetInnerHTML={{ __html: post.html }} />
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,12 @@
|
||||||
|
export function Toggle() {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<button id="toggle-canvas" onClick={() => window.dispatchEvent(new CustomEvent('toggleCanvasEvent'))}>
|
||||||
|
<img src="/canvas-button.svg" alt="Toggle Canvas" />
|
||||||
|
</button>
|
||||||
|
<button id="toggle-physics" className="hidden" onClick={() => window.dispatchEvent(new CustomEvent('togglePhysicsEvent'))}>
|
||||||
|
<img src="/gravity-button.svg" alt="Toggle Physics" />
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,59 @@
|
||||||
|
/* General chatbox styles */
|
||||||
|
.chatbox {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
padding: 10px;
|
||||||
|
border: 1px solid #ccc;
|
||||||
|
border-radius: 8px;
|
||||||
|
max-width: 400px; /* Adjust as needed */
|
||||||
|
margin: auto; /* Center on the page */
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Message styles */
|
||||||
|
.message {
|
||||||
|
padding: 8px;
|
||||||
|
margin: 5px 0;
|
||||||
|
border-radius: 5px;
|
||||||
|
word-wrap: break-word; /* Ensure long words break */
|
||||||
|
}
|
||||||
|
|
||||||
|
/* User message styles */
|
||||||
|
.user-message {
|
||||||
|
background-color: #d1e7dd; /* Light green */
|
||||||
|
color: #0c5460; /* Darker text color for contrast */
|
||||||
|
align-self: flex-end; /* Align to the right */
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Other message styles */
|
||||||
|
.other-message {
|
||||||
|
background-color: #f8d7da; /* Light red */
|
||||||
|
color: #721c24; /* Darker text color for contrast */
|
||||||
|
align-self: flex-start; /* Align to the left */
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Input area styles */
|
||||||
|
.input-area {
|
||||||
|
display: flex;
|
||||||
|
margin-top: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.input-area input {
|
||||||
|
flex: 1;
|
||||||
|
padding: 10px;
|
||||||
|
border: 1px solid #ccc;
|
||||||
|
border-radius: 5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.input-area button {
|
||||||
|
padding: 10px;
|
||||||
|
margin-left: 5px;
|
||||||
|
border: none;
|
||||||
|
background-color: #007bff; /* Bootstrap primary color */
|
||||||
|
color: white;
|
||||||
|
border-radius: 5px;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.input-area button:hover {
|
||||||
|
background-color: #0056b3; /* Darker shade on hover */
|
||||||
|
}
|
||||||
|
|
@ -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,327 @@
|
||||||
|
@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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.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;
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,90 @@
|
||||||
|
import { useEffect } from 'react';
|
||||||
|
import { Editor, TLFrameShape, TLParentId } from 'tldraw';
|
||||||
|
import { useSearchParams } from 'react-router-dom';
|
||||||
|
|
||||||
|
export function useCameraControls(editor: Editor | null) {
|
||||||
|
const [searchParams] = useSearchParams();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!editor) return;
|
||||||
|
|
||||||
|
const frameId = searchParams.get('frameId');
|
||||||
|
const x = searchParams.get('x');
|
||||||
|
const y = searchParams.get('y');
|
||||||
|
const zoom = searchParams.get('zoom');
|
||||||
|
|
||||||
|
console.log('Loading camera position:', { frameId, x, y, zoom });
|
||||||
|
|
||||||
|
if (x && y && zoom) {
|
||||||
|
editor.setCamera({
|
||||||
|
x: parseFloat(x),
|
||||||
|
y: parseFloat(y),
|
||||||
|
z: parseFloat(zoom)
|
||||||
|
});
|
||||||
|
console.log('Camera position set from URL params');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!frameId) return;
|
||||||
|
|
||||||
|
const frame = editor.getShape(frameId as TLParentId) as TLFrameShape;
|
||||||
|
if (!frame) {
|
||||||
|
console.warn('Frame not found:', frameId);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
editor.zoomToBounds(
|
||||||
|
editor.getShapePageBounds(frame)!,
|
||||||
|
{
|
||||||
|
inset: 32,
|
||||||
|
targetZoom: editor.getCamera().z,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
const newUrl = new URL(window.location.href);
|
||||||
|
newUrl.searchParams.set('frameId', frameId);
|
||||||
|
window.history.replaceState(null, '', newUrl.toString());
|
||||||
|
}, [editor, searchParams]);
|
||||||
|
|
||||||
|
const copyLocationLink = () => {
|
||||||
|
if (!editor) return;
|
||||||
|
const camera = editor.getCamera();
|
||||||
|
const url = new URL(window.location.href);
|
||||||
|
url.searchParams.set('x', camera.x.toString());
|
||||||
|
url.searchParams.set('y', camera.y.toString());
|
||||||
|
url.searchParams.set('zoom', camera.z.toString());
|
||||||
|
console.log('Copying location link:', url.toString());
|
||||||
|
navigator.clipboard.writeText(url.toString());
|
||||||
|
};
|
||||||
|
|
||||||
|
const zoomToFrame = (frameId: string) => {
|
||||||
|
if (!editor) return;
|
||||||
|
|
||||||
|
const frame = editor.getShape(frameId as TLParentId) as TLFrameShape;
|
||||||
|
if (!frame) {
|
||||||
|
console.warn('Frame not found:', frameId);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
editor.zoomToBounds(
|
||||||
|
editor.getShapePageBounds(frame)!,
|
||||||
|
{
|
||||||
|
inset: 32,
|
||||||
|
targetZoom: editor.getCamera().z,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const copyFrameLink = (frameId: string) => {
|
||||||
|
const url = new URL(window.location.href);
|
||||||
|
url.searchParams.set('frameId', frameId);
|
||||||
|
console.log('Copying frame link:', url.toString());
|
||||||
|
navigator.clipboard.writeText(url.toString());
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
zoomToFrame,
|
||||||
|
copyFrameLink,
|
||||||
|
copyLocationLink
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,98 @@
|
||||||
|
import { useState, useEffect } from 'react';
|
||||||
|
|
||||||
|
interface ElementInfo {
|
||||||
|
tagName: string;
|
||||||
|
x: number;
|
||||||
|
y: number;
|
||||||
|
w: number;
|
||||||
|
h: number;
|
||||||
|
html: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useCanvas() {
|
||||||
|
const [isCanvasEnabled, setIsCanvasEnabled] = useState(false);
|
||||||
|
const [elementsInfo, setElementsInfo] = useState<ElementInfo[]>([]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const toggleCanvas = async () => {
|
||||||
|
if (!isCanvasEnabled) {
|
||||||
|
const info = await gatherElementsInfo();
|
||||||
|
setElementsInfo(info);
|
||||||
|
setIsCanvasEnabled(true);
|
||||||
|
document.body.classList.add('canvas-mode');
|
||||||
|
} else {
|
||||||
|
setElementsInfo([]);
|
||||||
|
setIsCanvasEnabled(false);
|
||||||
|
document.body.classList.remove('canvas-mode');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
window.addEventListener('toggleCanvasEvent', toggleCanvas);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener('toggleCanvasEvent', toggleCanvas);
|
||||||
|
};
|
||||||
|
}, [isCanvasEnabled]);
|
||||||
|
|
||||||
|
return { isCanvasEnabled, elementsInfo };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function gatherElementsInfo() {
|
||||||
|
const rootElement = document.getElementsByTagName('main')[0];
|
||||||
|
const info: any[] = [];
|
||||||
|
if (rootElement) {
|
||||||
|
for (const child of rootElement.children) {
|
||||||
|
if (['BUTTON'].includes(child.tagName)) continue;
|
||||||
|
const rect = child.getBoundingClientRect();
|
||||||
|
let w = rect.width;
|
||||||
|
if (!['P', 'UL', 'OL'].includes(child.tagName)) {
|
||||||
|
w = measureElementTextWidth(child as HTMLElement);
|
||||||
|
}
|
||||||
|
// Check if the element is centered
|
||||||
|
const computedStyle = window.getComputedStyle(child);
|
||||||
|
let x = rect.left; // Default x position
|
||||||
|
if (computedStyle.display === 'block' && computedStyle.textAlign === 'center') {
|
||||||
|
// Adjust x position for centered elements
|
||||||
|
const parentWidth = child.parentElement ? child.parentElement.getBoundingClientRect().width : 0;
|
||||||
|
x = (parentWidth - w) / 2 + window.scrollX + (child.parentElement ? child.parentElement.getBoundingClientRect().left : 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
info.push({
|
||||||
|
tagName: child.tagName,
|
||||||
|
x: x,
|
||||||
|
y: rect.top,
|
||||||
|
w: w,
|
||||||
|
h: rect.height,
|
||||||
|
html: child.outerHTML
|
||||||
|
});
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return info;
|
||||||
|
}
|
||||||
|
|
||||||
|
function measureElementTextWidth(element: HTMLElement) {
|
||||||
|
// Create a temporary span element
|
||||||
|
const tempElement = document.createElement('span');
|
||||||
|
// Get the text content from the passed element
|
||||||
|
tempElement.textContent = element.textContent || element.innerText;
|
||||||
|
// Get the computed style of the passed element
|
||||||
|
const computedStyle = window.getComputedStyle(element);
|
||||||
|
// Apply relevant styles to the temporary element
|
||||||
|
tempElement.style.font = computedStyle.font;
|
||||||
|
tempElement.style.fontWeight = computedStyle.fontWeight;
|
||||||
|
tempElement.style.fontSize = computedStyle.fontSize;
|
||||||
|
tempElement.style.fontFamily = computedStyle.fontFamily;
|
||||||
|
tempElement.style.letterSpacing = computedStyle.letterSpacing;
|
||||||
|
// Ensure the temporary element is not visible in the viewport
|
||||||
|
tempElement.style.position = 'absolute';
|
||||||
|
tempElement.style.visibility = 'hidden';
|
||||||
|
tempElement.style.whiteSpace = 'nowrap'; // Prevent text from wrapping
|
||||||
|
// Append to the body to make measurements possible
|
||||||
|
document.body.appendChild(tempElement);
|
||||||
|
// Measure the width
|
||||||
|
const width = tempElement.getBoundingClientRect().width;
|
||||||
|
// Remove the temporary element from the document
|
||||||
|
document.body.removeChild(tempElement);
|
||||||
|
// Return the measured width
|
||||||
|
return width === 0 ? 10 : width;
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,40 @@
|
||||||
|
import useLocalStorageState from 'use-local-storage-state';
|
||||||
|
import GSet from 'crdts/src/G-Set';
|
||||||
|
import { TLRecord } from 'tldraw';
|
||||||
|
import { useRef, useCallback } from 'react';
|
||||||
|
|
||||||
|
export function useGSetState(roomId: string) {
|
||||||
|
const [localSet, setLocalSet] = useLocalStorageState<TLRecord[]>(`gset-${roomId}`, {
|
||||||
|
defaultValue: []
|
||||||
|
});
|
||||||
|
|
||||||
|
// Keep GSet instance in a ref to persist between renders
|
||||||
|
const gsetRef = useRef<GSet<TLRecord>>();
|
||||||
|
if (!gsetRef.current) {
|
||||||
|
gsetRef.current = new GSet<TLRecord>();
|
||||||
|
// Initialize G-Set with local data
|
||||||
|
if (localSet && Array.isArray(localSet)) {
|
||||||
|
localSet.forEach(record => gsetRef.current?.add(record));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const addRecord = useCallback((record: TLRecord) => {
|
||||||
|
if (!gsetRef.current) return;
|
||||||
|
gsetRef.current.add(record);
|
||||||
|
setLocalSet(Array.from(gsetRef.current.values()));
|
||||||
|
}, [setLocalSet]);
|
||||||
|
|
||||||
|
const merge = useCallback((remoteSet: Set<TLRecord>) => {
|
||||||
|
if (!gsetRef.current) return new Set<TLRecord>();
|
||||||
|
remoteSet.forEach(record => gsetRef.current?.add(record));
|
||||||
|
setLocalSet(Array.from(gsetRef.current.values()));
|
||||||
|
return gsetRef.current.values();
|
||||||
|
}, [setLocalSet]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
values: gsetRef.current.values(),
|
||||||
|
add: addRecord,
|
||||||
|
merge,
|
||||||
|
localSet
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,103 @@
|
||||||
|
import useLocalStorageState from 'use-local-storage-state';
|
||||||
|
import { TLRecord, createTLStore, SerializedStore, Editor, StoreSchema, TLStoreProps } from '@tldraw/tldraw';
|
||||||
|
import { customSchema } from '../../worker/TldrawDurableObject';
|
||||||
|
import { useMemo, useCallback, useEffect, useState } from 'react';
|
||||||
|
import { useSync } from '@tldraw/sync';
|
||||||
|
import { WORKER_URL } from '../components/Board';
|
||||||
|
import { TLRecord as TLSchemaRecord } from '@tldraw/tlschema'
|
||||||
|
import { defaultAssetUrls } from '@tldraw/assets'
|
||||||
|
|
||||||
|
const CACHE_VERSION = '1.0';
|
||||||
|
|
||||||
|
export function useLocalStorageRoom(roomId: string) {
|
||||||
|
const [isOnline, setIsOnline] = useState(navigator.onLine);
|
||||||
|
const storageKey = `tldraw_board_${roomId}_v${CACHE_VERSION}`;
|
||||||
|
|
||||||
|
const [records, setRecords] = useLocalStorageState<SerializedStore<TLRecord>>(storageKey, {
|
||||||
|
defaultValue: createTLStore({
|
||||||
|
schema: customSchema as unknown as StoreSchema<TLRecord, unknown>
|
||||||
|
}).serialize()
|
||||||
|
});
|
||||||
|
|
||||||
|
// Create a persistent store
|
||||||
|
const baseStore = useMemo(() => {
|
||||||
|
return createTLStore({
|
||||||
|
schema: customSchema as unknown as StoreSchema<TLRecord, unknown>,
|
||||||
|
initialData: records,
|
||||||
|
})
|
||||||
|
}, [records]);
|
||||||
|
|
||||||
|
// Use sync with the base store
|
||||||
|
const syncedStore = useSync({
|
||||||
|
uri: `${WORKER_URL.replace('https://', 'wss://')}/connect/${roomId}`,
|
||||||
|
schema: customSchema,
|
||||||
|
store: baseStore,
|
||||||
|
assets: defaultAssetUrls
|
||||||
|
});
|
||||||
|
|
||||||
|
// Handle online/offline transitions
|
||||||
|
useEffect(() => {
|
||||||
|
const handleOnline = () => {
|
||||||
|
setIsOnline(true);
|
||||||
|
if (syncedStore?.store) {
|
||||||
|
const filteredRecords = filterNonCameraRecords(records);
|
||||||
|
syncedStore.store.mergeRemoteChanges(() => {
|
||||||
|
Object.values(filteredRecords).forEach(record => {
|
||||||
|
syncedStore.store.put([record as unknown as TLSchemaRecord]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleOffline = () => setIsOnline(false);
|
||||||
|
|
||||||
|
window.addEventListener('online', handleOnline);
|
||||||
|
window.addEventListener('offline', handleOffline);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener('online', handleOnline);
|
||||||
|
window.removeEventListener('offline', handleOffline);
|
||||||
|
};
|
||||||
|
}, [records, syncedStore?.store]);
|
||||||
|
|
||||||
|
const filterNonCameraRecords = (data: SerializedStore<TLRecord>) => {
|
||||||
|
return Object.fromEntries(
|
||||||
|
Object.entries(data).filter(([_, record]) => {
|
||||||
|
return (record as TLRecord).typeName !== 'camera' &&
|
||||||
|
(record as TLRecord).typeName !== 'instance_page_state' &&
|
||||||
|
(record as TLRecord).typeName !== 'instance_presence';
|
||||||
|
})
|
||||||
|
) as SerializedStore<TLRecord>;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Sync with server store when online
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isOnline || !syncedStore?.store) return;
|
||||||
|
|
||||||
|
const syncInterval = setInterval(() => {
|
||||||
|
const serverRecords = syncedStore.store.allRecords();
|
||||||
|
if (Object.keys(serverRecords).length > 0) {
|
||||||
|
setRecords(syncedStore.store.serialize() as typeof records);
|
||||||
|
}
|
||||||
|
}, 5000);
|
||||||
|
|
||||||
|
return () => clearInterval(syncInterval);
|
||||||
|
}, [isOnline, syncedStore?.store, setRecords]);
|
||||||
|
|
||||||
|
const store = useMemo(() => {
|
||||||
|
if (isOnline && syncedStore?.store) {
|
||||||
|
return syncedStore.store;
|
||||||
|
}
|
||||||
|
return createTLStore({
|
||||||
|
schema: customSchema as unknown as StoreSchema<TLRecord, unknown>,
|
||||||
|
initialData: records,
|
||||||
|
});
|
||||||
|
}, [isOnline, syncedStore?.store, records]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
store,
|
||||||
|
records,
|
||||||
|
setRecords,
|
||||||
|
isOnline
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,20 @@
|
||||||
|
import useLocalStorageState from 'use-local-storage-state';
|
||||||
|
import { TLRecord, createTLStore, SerializedStore } from 'tldraw';
|
||||||
|
import { customSchema } from '../../worker/TldrawDurableObject';
|
||||||
|
|
||||||
|
export function useLocalStorageRoom(roomId: string) {
|
||||||
|
const [records, setRecords] = useLocalStorageState<SerializedStore<TLRecord>>(`tldraw-room-${roomId}`, {
|
||||||
|
defaultValue: createTLStore({ schema: customSchema }).serialize()
|
||||||
|
});
|
||||||
|
|
||||||
|
const store = createTLStore({
|
||||||
|
schema: customSchema,
|
||||||
|
initialData: records,
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
store,
|
||||||
|
records,
|
||||||
|
setRecords
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,92 @@
|
||||||
|
import { useSync } from '@tldraw/sync'
|
||||||
|
import { useState, useEffect, useCallback, useRef } from 'react'
|
||||||
|
import { customSchema } from '../../worker/TldrawDurableObject'
|
||||||
|
import { multiplayerAssetStore } from '../client/multiplayerAssetStore'
|
||||||
|
import { useGSetState } from './useGSetState'
|
||||||
|
import { useLocalStorageRoom } from './useLocalStorageRoom'
|
||||||
|
import { TLRecord } from 'tldraw'
|
||||||
|
import { WORKER_URL } from '../components/Board'
|
||||||
|
|
||||||
|
export function usePersistentBoard(roomId: string) {
|
||||||
|
const [isOnline, setIsOnline] = useState(navigator.onLine)
|
||||||
|
const { store: localStore, records, setRecords } = useLocalStorageRoom(roomId)
|
||||||
|
const { values, add, merge } = useGSetState(roomId)
|
||||||
|
const initialSyncRef = useRef(false)
|
||||||
|
const mergeInProgressRef = useRef(false)
|
||||||
|
|
||||||
|
const syncedStore = useSync({
|
||||||
|
uri: `${WORKER_URL.replace('https://', 'wss://')}/connect/${roomId}`,
|
||||||
|
schema: customSchema,
|
||||||
|
assets: multiplayerAssetStore,
|
||||||
|
})
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const handleOnline = () => setIsOnline(true)
|
||||||
|
const handleOffline = () => setIsOnline(false)
|
||||||
|
|
||||||
|
window.addEventListener('online', handleOnline)
|
||||||
|
window.addEventListener('offline', handleOffline)
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener('online', handleOnline)
|
||||||
|
window.removeEventListener('offline', handleOffline)
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const mergeRecords = useCallback((records: Set<TLRecord>) => {
|
||||||
|
if (mergeInProgressRef.current || records.size === 0) return
|
||||||
|
|
||||||
|
try {
|
||||||
|
mergeInProgressRef.current = true
|
||||||
|
merge(records)
|
||||||
|
if (!isOnline && localStore) {
|
||||||
|
setRecords(localStore.serialize())
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
mergeInProgressRef.current = false
|
||||||
|
}
|
||||||
|
}, [isOnline, localStore, merge, setRecords])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!syncedStore?.store || !localStore) return
|
||||||
|
|
||||||
|
if (isOnline && !initialSyncRef.current) {
|
||||||
|
initialSyncRef.current = true
|
||||||
|
const serverRecords = Object.values(syncedStore.store.allRecords())
|
||||||
|
if (serverRecords.length > 0) {
|
||||||
|
mergeRecords(new Set(serverRecords))
|
||||||
|
}
|
||||||
|
|
||||||
|
const unsubscribe = syncedStore.store.listen((event) => {
|
||||||
|
if ('changes' in event) {
|
||||||
|
const changedRecords = Object.values(event.changes)
|
||||||
|
if (changedRecords.length > 0) {
|
||||||
|
mergeRecords(new Set(changedRecords))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
return () => unsubscribe()
|
||||||
|
} else if (!isOnline) {
|
||||||
|
const currentRecords = Object.values(localStore.allRecords())
|
||||||
|
if (currentRecords.length > 0) {
|
||||||
|
mergeRecords(new Set(currentRecords))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [isOnline, syncedStore?.store, localStore, mergeRecords])
|
||||||
|
|
||||||
|
const addRecord = useCallback((record: TLRecord) => {
|
||||||
|
if (!record) return
|
||||||
|
add(record)
|
||||||
|
if (!isOnline && localStore) {
|
||||||
|
setRecords(localStore.serialize())
|
||||||
|
}
|
||||||
|
}, [add, isOnline, localStore, setRecords])
|
||||||
|
|
||||||
|
return {
|
||||||
|
store: isOnline ? syncedStore?.store : localStore,
|
||||||
|
isOnline,
|
||||||
|
addRecord,
|
||||||
|
mergeRecords
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,66 @@
|
||||||
|
import { Editor, TLUnknownShape, createShapeId, useEditor } from "tldraw";
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { usePhysicsSimulation } from "./simulation";
|
||||||
|
|
||||||
|
export const SimController = ({ shapes }: { shapes: TLUnknownShape[] }) => {
|
||||||
|
const editor = useEditor();
|
||||||
|
const [isPhysicsActive, setIsPhysicsActive] = useState(false);
|
||||||
|
const { addShapes, destroy } = usePhysicsSimulation(editor);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
editor.createShapes(shapes)
|
||||||
|
return () => { editor.deleteShapes(editor.getCurrentPageShapes()) }
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const togglePhysics = () => {
|
||||||
|
setIsPhysicsActive((currentIsPhysicsActive) => {
|
||||||
|
if (currentIsPhysicsActive) {
|
||||||
|
destroy();
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
createFloor(editor);
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
// Listen for the togglePhysicsEvent to enable/disable physics simulation
|
||||||
|
window.addEventListener('togglePhysicsEvent', togglePhysics);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener('togglePhysicsEvent', togglePhysics);
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (isPhysicsActive) {
|
||||||
|
addShapes(editor.getCurrentPageShapes()); // Activate physics simulation
|
||||||
|
} else {
|
||||||
|
destroy(); // Deactivate physics simulation
|
||||||
|
}
|
||||||
|
}, [isPhysicsActive, addShapes, shapes]);
|
||||||
|
|
||||||
|
return (<></>);
|
||||||
|
};
|
||||||
|
|
||||||
|
function createFloor(editor: Editor) {
|
||||||
|
|
||||||
|
const viewBounds = editor.getViewportPageBounds();
|
||||||
|
|
||||||
|
editor.createShape({
|
||||||
|
id: createShapeId(),
|
||||||
|
type: 'geo',
|
||||||
|
x: viewBounds.minX,
|
||||||
|
y: viewBounds.maxY,
|
||||||
|
props: {
|
||||||
|
w: viewBounds.width,
|
||||||
|
h: 50,
|
||||||
|
color: 'grey',
|
||||||
|
fill: 'solid'
|
||||||
|
},
|
||||||
|
meta: {
|
||||||
|
fixed: true
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
|
@ -0,0 +1,36 @@
|
||||||
|
export const GRAVITY = { x: 0.0, y: 98 };
|
||||||
|
export const DEFAULT_RESTITUTION = 0;
|
||||||
|
export const DEFAULT_FRICTION = 0.1;
|
||||||
|
|
||||||
|
export function isRigidbody(color: string) {
|
||||||
|
return !color || color === "black" ? false : true;
|
||||||
|
}
|
||||||
|
export function getGravityFromColor(color: string) {
|
||||||
|
return color === 'grey' ? 0 : 1
|
||||||
|
}
|
||||||
|
export function getRestitutionFromColor(color: string) {
|
||||||
|
return color === "orange" ? 0.9 : 0;
|
||||||
|
}
|
||||||
|
export function getFrictionFromColor(color: string) {
|
||||||
|
return color === "blue" ? 0.1 : 0.8;
|
||||||
|
}
|
||||||
|
export const MATERIAL = {
|
||||||
|
defaultRestitution: 0,
|
||||||
|
defaultFriction: 0.1,
|
||||||
|
};
|
||||||
|
export const CHARACTER = {
|
||||||
|
up: { x: 0.0, y: -1.0 },
|
||||||
|
additionalMass: 20,
|
||||||
|
maxSlopeClimbAngle: 1,
|
||||||
|
slideEnabled: true,
|
||||||
|
minSlopeSlideAngle: 0.9,
|
||||||
|
applyImpulsesToDynamicBodies: true,
|
||||||
|
autostepHeight: 5,
|
||||||
|
autostepMaxClimbAngle: 1,
|
||||||
|
snapToGroundDistance: 3,
|
||||||
|
maxMoveSpeedX: 100,
|
||||||
|
moveAcceleration: 600,
|
||||||
|
moveDeceleration: 500,
|
||||||
|
jumpVelocity: 300,
|
||||||
|
gravityMultiplier: 10,
|
||||||
|
};
|
||||||
|
|
@ -0,0 +1,95 @@
|
||||||
|
import { Geometry2d, Vec, VecLike } from "tldraw";
|
||||||
|
|
||||||
|
type ShapeTransform = {
|
||||||
|
x: number;
|
||||||
|
y: number;
|
||||||
|
width: number;
|
||||||
|
height: number;
|
||||||
|
rotation: number;
|
||||||
|
parent?: Geometry2d;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Define rotatePoint as a standalone function
|
||||||
|
const rotatePoint = (cx: number, cy: number, x: number, y: number, angle: number) => {
|
||||||
|
const cos = Math.cos(angle);
|
||||||
|
const sin = Math.sin(angle);
|
||||||
|
return {
|
||||||
|
x: cos * (x - cx) - sin * (y - cy) + cx,
|
||||||
|
y: sin * (x - cx) + cos * (y - cy) + cy,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export const cornerToCenter = ({
|
||||||
|
x,
|
||||||
|
y,
|
||||||
|
width,
|
||||||
|
height,
|
||||||
|
rotation,
|
||||||
|
parent
|
||||||
|
}: ShapeTransform): { x: number; y: number } => {
|
||||||
|
const centerX = x + width / 2;
|
||||||
|
const centerY = y + height / 2;
|
||||||
|
const rotatedCenter = rotatePoint(x, y, centerX, centerY, rotation);
|
||||||
|
|
||||||
|
if (parent) {
|
||||||
|
rotatedCenter.x -= parent.center.x;
|
||||||
|
rotatedCenter.y -= parent.center.y;
|
||||||
|
}
|
||||||
|
|
||||||
|
return rotatedCenter;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const centerToCorner = ({
|
||||||
|
x,
|
||||||
|
y,
|
||||||
|
width,
|
||||||
|
height,
|
||||||
|
rotation,
|
||||||
|
}: ShapeTransform): { x: number; y: number } => {
|
||||||
|
|
||||||
|
const cornerX = x - width / 2;
|
||||||
|
const cornerY = y - height / 2;
|
||||||
|
|
||||||
|
return rotatePoint(x, y, cornerX, cornerY, rotation);
|
||||||
|
}
|
||||||
|
|
||||||
|
export const getDisplacement = (
|
||||||
|
velocity: VecLike,
|
||||||
|
acceleration: VecLike,
|
||||||
|
timeStep: number,
|
||||||
|
speedLimitX: number,
|
||||||
|
decelerationX: number,
|
||||||
|
): VecLike => {
|
||||||
|
let newVelocityX =
|
||||||
|
acceleration.x === 0 && velocity.x !== 0
|
||||||
|
? Math.max(Math.abs(velocity.x) - decelerationX * timeStep, 0) *
|
||||||
|
Math.sign(velocity.x)
|
||||||
|
: velocity.x + acceleration.x * timeStep;
|
||||||
|
|
||||||
|
newVelocityX =
|
||||||
|
Math.min(Math.abs(newVelocityX), speedLimitX) * Math.sign(newVelocityX);
|
||||||
|
|
||||||
|
const averageVelocityX = (velocity.x + newVelocityX) / 2;
|
||||||
|
const x = averageVelocityX * timeStep;
|
||||||
|
const y =
|
||||||
|
velocity.y * timeStep + 0.5 * acceleration.y * timeStep ** 2;
|
||||||
|
|
||||||
|
return { x, y }
|
||||||
|
}
|
||||||
|
|
||||||
|
export const convertVerticesToFloat32Array = (
|
||||||
|
vertices: Vec[],
|
||||||
|
width: number,
|
||||||
|
height: number,
|
||||||
|
) => {
|
||||||
|
const vec2Array = new Float32Array(vertices.length * 2);
|
||||||
|
const hX = width / 2;
|
||||||
|
const hY = height / 2;
|
||||||
|
|
||||||
|
for (let i = 0; i < vertices.length; i++) {
|
||||||
|
vec2Array[i * 2] = vertices[i].x - hX;
|
||||||
|
vec2Array[i * 2 + 1] = vertices[i].y - hY;
|
||||||
|
}
|
||||||
|
|
||||||
|
return vec2Array;
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,398 @@
|
||||||
|
import RAPIER from "@dimforge/rapier2d";
|
||||||
|
import { CHARACTER, GRAVITY, MATERIAL, getFrictionFromColor, getGravityFromColor, getRestitutionFromColor, isRigidbody } from "./config";
|
||||||
|
import { Editor, Geometry2d, TLDrawShape, TLGeoShape, TLGroupShape, TLShape, TLShapeId, VecLike } from "tldraw";
|
||||||
|
import { useEffect, useRef } from "react";
|
||||||
|
import { centerToCorner, convertVerticesToFloat32Array, cornerToCenter, getDisplacement } from "./math";
|
||||||
|
|
||||||
|
type BodyWithShapeData = RAPIER.RigidBody & {
|
||||||
|
userData: { id: TLShapeId; type: TLShape["type"]; w: number; h: number };
|
||||||
|
};
|
||||||
|
type RigidbodyLookup = { [key: TLShapeId]: RAPIER.RigidBody };
|
||||||
|
|
||||||
|
export class PhysicsWorld {
|
||||||
|
private editor: Editor;
|
||||||
|
private world: RAPIER.World;
|
||||||
|
private rigidbodyLookup: RigidbodyLookup;
|
||||||
|
private animFrame = -1; // Store the animation frame id
|
||||||
|
private character: {
|
||||||
|
rigidbody: RAPIER.RigidBody | null;
|
||||||
|
collider: RAPIER.Collider | null;
|
||||||
|
};
|
||||||
|
constructor(editor: Editor) {
|
||||||
|
this.editor = editor
|
||||||
|
this.world = new RAPIER.World(GRAVITY)
|
||||||
|
this.rigidbodyLookup = {}
|
||||||
|
this.character = { rigidbody: null, collider: null }
|
||||||
|
}
|
||||||
|
|
||||||
|
public start() {
|
||||||
|
this.world = new RAPIER.World(GRAVITY);
|
||||||
|
|
||||||
|
const simLoop = () => {
|
||||||
|
this.world.step();
|
||||||
|
this.updateCharacterControllers();
|
||||||
|
this.updateRigidbodies();
|
||||||
|
this.animFrame = requestAnimationFrame(simLoop);
|
||||||
|
};
|
||||||
|
simLoop();
|
||||||
|
return () => cancelAnimationFrame(this.animFrame);
|
||||||
|
};
|
||||||
|
|
||||||
|
public stop() {
|
||||||
|
if (this.animFrame !== -1) {
|
||||||
|
cancelAnimationFrame(this.animFrame);
|
||||||
|
this.animFrame = -1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public addShapes(shapes: TLShape[]) {
|
||||||
|
for (const shape of shapes) {
|
||||||
|
if ('color' in shape.props && shape.props.color === "violet") {
|
||||||
|
this.createCharacter(shape as TLGeoShape);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
switch (shape.type) {
|
||||||
|
case "html":
|
||||||
|
case "geo":
|
||||||
|
case "image":
|
||||||
|
case "video":
|
||||||
|
this.createShape(shape as TLGeoShape);
|
||||||
|
break;
|
||||||
|
case "draw":
|
||||||
|
this.createCompoundLine(shape as TLDrawShape);
|
||||||
|
break;
|
||||||
|
case "group":
|
||||||
|
this.createGroup(shape as TLGroupShape);
|
||||||
|
break;
|
||||||
|
// Add cases for any new shape types here
|
||||||
|
case "VideoChat":
|
||||||
|
this.createShape (shape as TLGeoShape);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
createShape(shape: TLGeoShape | TLDrawShape) {
|
||||||
|
if (!shape.meta.fixed) {
|
||||||
|
const rb = this.createRigidbody(shape, 1);
|
||||||
|
this.createCollider(shape, rb);
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
this.createCollider(shape);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
createCharacter(characterShape: TLGeoShape) {
|
||||||
|
const initialPosition = cornerToCenter({
|
||||||
|
x: characterShape.x,
|
||||||
|
y: characterShape.y,
|
||||||
|
width: characterShape.props.w,
|
||||||
|
height: characterShape.props.h,
|
||||||
|
rotation: characterShape.rotation,
|
||||||
|
});
|
||||||
|
const vertices = this.editor.getShapeGeometry(characterShape).vertices;
|
||||||
|
const vec2Array = convertVerticesToFloat32Array(
|
||||||
|
vertices,
|
||||||
|
characterShape.props.w,
|
||||||
|
characterShape.props.h,
|
||||||
|
);
|
||||||
|
const colliderDesc = RAPIER.ColliderDesc.convexHull(vec2Array);
|
||||||
|
if (!colliderDesc) {
|
||||||
|
console.error("Failed to create collider description.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const rigidBodyDesc = RAPIER.RigidBodyDesc.kinematicPositionBased()
|
||||||
|
.setTranslation(initialPosition.x, initialPosition.y)
|
||||||
|
.setAdditionalMass(CHARACTER.additionalMass);
|
||||||
|
const charRigidbody = this.world.createRigidBody(rigidBodyDesc);
|
||||||
|
const charCollider = this.world.createCollider(colliderDesc, charRigidbody);
|
||||||
|
const char = this.world.createCharacterController(0.1);
|
||||||
|
char.setUp(CHARACTER.up);
|
||||||
|
char.setMaxSlopeClimbAngle(CHARACTER.maxSlopeClimbAngle);
|
||||||
|
char.setSlideEnabled(CHARACTER.slideEnabled);
|
||||||
|
char.setMinSlopeSlideAngle(CHARACTER.minSlopeSlideAngle);
|
||||||
|
char.setApplyImpulsesToDynamicBodies(CHARACTER.applyImpulsesToDynamicBodies);
|
||||||
|
char.enableAutostep(
|
||||||
|
CHARACTER.autostepHeight,
|
||||||
|
CHARACTER.autostepMaxClimbAngle,
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
char.enableSnapToGround(CHARACTER.snapToGroundDistance);
|
||||||
|
// Setup references so we can update character position in sim loop
|
||||||
|
this.character.rigidbody = charRigidbody;
|
||||||
|
this.character.collider = charCollider;
|
||||||
|
charRigidbody.userData = {
|
||||||
|
id: characterShape.id,
|
||||||
|
type: characterShape.type,
|
||||||
|
w: characterShape.props.w,
|
||||||
|
h: characterShape.props.h,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
createGroup(group: TLGroupShape) {
|
||||||
|
// create rigidbody for group
|
||||||
|
const rigidbody = this.createRigidbody(group);
|
||||||
|
const rigidbodyGeometry = this.editor.getShapeGeometry(group);
|
||||||
|
|
||||||
|
this.editor.getSortedChildIdsForParent(group.id).forEach((childId) => {
|
||||||
|
// create collider for each
|
||||||
|
const child = this.editor.getShape(childId);
|
||||||
|
if (!child) return;
|
||||||
|
const isRb = "color" in child.props && isRigidbody(child?.props.color);
|
||||||
|
if (isRb) {
|
||||||
|
this.createCollider(child, rigidbody, rigidbodyGeometry);
|
||||||
|
} else {
|
||||||
|
this.createCollider(child);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
createCompoundLine(drawShape: TLDrawShape) {
|
||||||
|
const rigidbody = this.createRigidbody(drawShape);
|
||||||
|
const drawnGeo = this.editor.getShapeGeometry(drawShape);
|
||||||
|
const verts = drawnGeo.vertices;
|
||||||
|
// const isRb =
|
||||||
|
// "color" in drawShape.props && isRigidbody(drawShape.props.color);
|
||||||
|
const isRb = true;
|
||||||
|
verts.forEach((point) => {
|
||||||
|
if (isRb) this.createColliderAtPoint(point, drawShape, rigidbody);
|
||||||
|
else this.createColliderAtPoint(point, drawShape);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
updateRigidbodies() {
|
||||||
|
this.world.bodies.forEach((rb) => {
|
||||||
|
if (rb === this.character?.rigidbody) return;
|
||||||
|
if (!rb.userData) return;
|
||||||
|
const body = rb as BodyWithShapeData;
|
||||||
|
const position = body.translation();
|
||||||
|
const rotation = body.rotation();
|
||||||
|
|
||||||
|
const cornerPos = centerToCorner({
|
||||||
|
x: position.x,
|
||||||
|
y: position.y,
|
||||||
|
width: body.userData?.w,
|
||||||
|
height: body.userData?.h,
|
||||||
|
rotation: rotation,
|
||||||
|
});
|
||||||
|
|
||||||
|
this.editor.updateShape({
|
||||||
|
id: body.userData?.id,
|
||||||
|
type: body.userData?.type,
|
||||||
|
rotation: rotation,
|
||||||
|
x: cornerPos.x,
|
||||||
|
y: cornerPos.y,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
updateCharacterControllers() {
|
||||||
|
const right = this.editor.inputs.keys.has("ArrowRight") ? 1 : 0;
|
||||||
|
const left = this.editor.inputs.keys.has("ArrowLeft") ? -1 : 0;
|
||||||
|
const acceleration: VecLike = {
|
||||||
|
x: (right + left) * CHARACTER.moveAcceleration,
|
||||||
|
y: CHARACTER.gravityMultiplier * GRAVITY.y,
|
||||||
|
}
|
||||||
|
|
||||||
|
this.world.characterControllers.forEach((char) => {
|
||||||
|
if (!this.character.rigidbody || !this.character.collider) return;
|
||||||
|
const charRigidbody = this.character.rigidbody as BodyWithShapeData;
|
||||||
|
const charCollider = this.character.collider;
|
||||||
|
const grounded = char.computedGrounded();
|
||||||
|
const isJumping = this.editor.inputs.keys.has("ArrowUp") && grounded;
|
||||||
|
const velocity: VecLike = {
|
||||||
|
x: charRigidbody.linvel().x,
|
||||||
|
y: isJumping ? -CHARACTER.jumpVelocity : charRigidbody.linvel().y,
|
||||||
|
}
|
||||||
|
const displacement = getDisplacement(
|
||||||
|
velocity,
|
||||||
|
acceleration,
|
||||||
|
1 / 60,
|
||||||
|
CHARACTER.maxMoveSpeedX,
|
||||||
|
CHARACTER.moveDeceleration,
|
||||||
|
);
|
||||||
|
|
||||||
|
char.computeColliderMovement(
|
||||||
|
charCollider as RAPIER.Collider, // The collider we would like to move.
|
||||||
|
new RAPIER.Vector2(displacement.x, displacement.y),
|
||||||
|
);
|
||||||
|
const correctedDisplacement = char.computedMovement();
|
||||||
|
const currentPos = charRigidbody.translation();
|
||||||
|
const nextX = currentPos.x + correctedDisplacement.x;
|
||||||
|
const nextY = currentPos.y + correctedDisplacement.y;
|
||||||
|
charRigidbody?.setNextKinematicTranslation({ x: nextX, y: nextY });
|
||||||
|
|
||||||
|
const w = charRigidbody.userData.w;
|
||||||
|
const h = charRigidbody.userData.h;
|
||||||
|
this.editor.updateShape({
|
||||||
|
id: charRigidbody.userData.id,
|
||||||
|
type: charRigidbody.userData.type,
|
||||||
|
x: nextX - w / 2,
|
||||||
|
y: nextY - h / 2,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
private getShapeDimensions(
|
||||||
|
shape: TLShape,
|
||||||
|
): { width: number; height: number } {
|
||||||
|
const geo = this.editor.getShapeGeometry(shape);
|
||||||
|
const width = geo.center.x * 2;
|
||||||
|
const height = geo.center.y * 2;
|
||||||
|
return { width, height };
|
||||||
|
}
|
||||||
|
private shouldConvexify(shape: TLShape): boolean {
|
||||||
|
return !(
|
||||||
|
shape.type === "geo" && (shape as TLGeoShape).props.geo === "rectangle"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
private createRigidbody(
|
||||||
|
shape: TLShape,
|
||||||
|
gravity = 1,
|
||||||
|
): RAPIER.RigidBody {
|
||||||
|
const dimensions = this.getShapeDimensions(shape);
|
||||||
|
const centerPosition = cornerToCenter({
|
||||||
|
x: shape.x,
|
||||||
|
y: shape.y,
|
||||||
|
width: dimensions.width,
|
||||||
|
height: dimensions.height,
|
||||||
|
rotation: shape.rotation,
|
||||||
|
});
|
||||||
|
const rigidBodyDesc = RAPIER.RigidBodyDesc.dynamic()
|
||||||
|
.setTranslation(centerPosition.x, centerPosition.y)
|
||||||
|
.setRotation(shape.rotation)
|
||||||
|
.setGravityScale(gravity);
|
||||||
|
const rigidbody = this.world.createRigidBody(rigidBodyDesc);
|
||||||
|
this.rigidbodyLookup[shape.id] = rigidbody;
|
||||||
|
rigidbody.userData = {
|
||||||
|
id: shape.id,
|
||||||
|
type: shape.type,
|
||||||
|
w: dimensions.width,
|
||||||
|
h: dimensions.height,
|
||||||
|
};
|
||||||
|
return rigidbody;
|
||||||
|
}
|
||||||
|
private createColliderAtPoint(
|
||||||
|
point: VecLike,
|
||||||
|
relativeToParent: TLDrawShape,
|
||||||
|
parentRigidBody: RAPIER.RigidBody | null = null,
|
||||||
|
) {
|
||||||
|
const radius = 5;
|
||||||
|
const parentGeo = this.editor.getShapeGeometry(relativeToParent);
|
||||||
|
const center = cornerToCenter({
|
||||||
|
x: point.x,
|
||||||
|
y: point.y,
|
||||||
|
width: radius,
|
||||||
|
height: radius,
|
||||||
|
rotation: 0,
|
||||||
|
parent: parentGeo,
|
||||||
|
});
|
||||||
|
let colliderDesc: RAPIER.ColliderDesc | null = null;
|
||||||
|
colliderDesc = RAPIER.ColliderDesc.ball(radius);
|
||||||
|
|
||||||
|
if (!colliderDesc) {
|
||||||
|
console.error("Failed to create collider description.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (parentRigidBody) {
|
||||||
|
colliderDesc.setTranslation(center.x, center.y);
|
||||||
|
this.world.createCollider(colliderDesc, parentRigidBody);
|
||||||
|
} else {
|
||||||
|
colliderDesc.setTranslation(
|
||||||
|
relativeToParent.x + center.x,
|
||||||
|
relativeToParent.y + center.y,
|
||||||
|
);
|
||||||
|
this.world.createCollider(colliderDesc);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
private createCollider(
|
||||||
|
shape: TLShape,
|
||||||
|
parentRigidBody: RAPIER.RigidBody | null = null,
|
||||||
|
parentGeo: Geometry2d | null = null,
|
||||||
|
) {
|
||||||
|
const dimensions = this.getShapeDimensions(shape);
|
||||||
|
const centerPosition = cornerToCenter({
|
||||||
|
x: shape.x,
|
||||||
|
y: shape.y,
|
||||||
|
width: dimensions.width,
|
||||||
|
height: dimensions.height,
|
||||||
|
rotation: shape.rotation,
|
||||||
|
parent: parentGeo || undefined,
|
||||||
|
});
|
||||||
|
|
||||||
|
const restitution =
|
||||||
|
"color" in shape.props
|
||||||
|
? getRestitutionFromColor(shape.props.color)
|
||||||
|
: MATERIAL.defaultRestitution;
|
||||||
|
const friction =
|
||||||
|
"color" in shape.props
|
||||||
|
? getFrictionFromColor(shape.props.color)
|
||||||
|
: MATERIAL.defaultFriction;
|
||||||
|
|
||||||
|
let colliderDesc: RAPIER.ColliderDesc | null = null;
|
||||||
|
|
||||||
|
if (this.shouldConvexify(shape)) {
|
||||||
|
// Convert vertices for convex shapes
|
||||||
|
const vertices = this.editor.getShapeGeometry(shape).vertices;
|
||||||
|
const vec2Array = convertVerticesToFloat32Array(
|
||||||
|
vertices,
|
||||||
|
dimensions.width,
|
||||||
|
dimensions.height,
|
||||||
|
);
|
||||||
|
colliderDesc = RAPIER.ColliderDesc.convexHull(vec2Array);
|
||||||
|
} else {
|
||||||
|
// Cuboid for rectangle shapes
|
||||||
|
colliderDesc = RAPIER.ColliderDesc.cuboid(
|
||||||
|
dimensions.width / 2,
|
||||||
|
dimensions.height / 2,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (!colliderDesc) {
|
||||||
|
console.error("Failed to create collider description.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
colliderDesc
|
||||||
|
.setRestitution(restitution)
|
||||||
|
.setRestitutionCombineRule(RAPIER.CoefficientCombineRule.Max)
|
||||||
|
.setFriction(friction)
|
||||||
|
.setFrictionCombineRule(RAPIER.CoefficientCombineRule.Min);
|
||||||
|
if (parentRigidBody) {
|
||||||
|
if (parentGeo) {
|
||||||
|
colliderDesc.setTranslation(centerPosition.x, centerPosition.y);
|
||||||
|
colliderDesc.setRotation(shape.rotation);
|
||||||
|
}
|
||||||
|
this.world.createCollider(colliderDesc, parentRigidBody);
|
||||||
|
} else {
|
||||||
|
colliderDesc
|
||||||
|
.setTranslation(centerPosition.x, centerPosition.y)
|
||||||
|
.setRotation(shape.rotation);
|
||||||
|
this.world.createCollider(colliderDesc);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
public setEditor(editor: Editor) {
|
||||||
|
this.editor = editor;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function usePhysicsSimulation(editor: Editor) {
|
||||||
|
const sim = useRef<PhysicsWorld>(new PhysicsWorld(editor));
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
sim.current.start()
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
sim.current.setEditor(editor);
|
||||||
|
}, [editor, sim]);
|
||||||
|
|
||||||
|
// Return any values or functions that the UI components might need
|
||||||
|
return {
|
||||||
|
addShapes: (shapes: TLShape[]) => sim.current.addShapes(shapes),
|
||||||
|
destroy: () => {
|
||||||
|
sim.current.stop()
|
||||||
|
sim.current = new PhysicsWorld(editor); // Replace with a new instance
|
||||||
|
sim.current.start()
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,120 @@
|
||||||
|
---
|
||||||
|
title: Conviction Voting
|
||||||
|
---
|
||||||
|
|
||||||
|
> this research is a work-in-progress (play with the [live demo](https://orionreed.github.io/scoped-propagators/))
|
||||||
|
|
||||||
|
## Abstract
|
||||||
|
|
||||||
|
Watch Conviction Voting in action in a bipartite graph, with the left half representing voters and the right half representing proposals supported by those voters.
|
||||||
|
<iframe width="560" height="315" src="https://www.youtube.com/embed/WDkk3ZXoTn0" frameborder="0" allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture" allowfullscreen></iframe>
|
||||||
|
|
||||||
|
|
||||||
|
https://blog.giveth.io/conviction-voting-a-novel-continuous-decision-making-alternative-to-governance-aa746cfb9475
|
||||||
|
|
||||||
|
https://medium.com/commonsstack/announcing-the-conviction-voting-cadcad-model-release-8e907ce67e4e
|
||||||
|
|
||||||
|
https://github.com/1Hive/conviction-voting-cadcad
|
||||||
|
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
## Introduction
|
||||||
|
A scoped propagator is formed of a function which takes a *source* and *target* node and returns an partial update to the *target* node, and a scope which defines some subset of events which trigger propagation.
|
||||||
|
|
||||||
|
the Scoped Propagator model is based on two key insights:
|
||||||
|
1. by representing computation as mappings between nodes along edges, you do not need to know at design-time what node types exist.
|
||||||
|
2. by scoping the propagation to events, you can augment nodes with interactive behaviour suitable for the environment in which SPs have been embedded.
|
||||||
|
|
||||||
|
Below are the four event scopes which are currently implemented, which I have found to be appropriate and useful for an infinite canvas environment.
|
||||||
|
|
||||||
|
| Scope | Firing Condition |
|
||||||
|
|----------|----------|
|
||||||
|
| change (default) | Properties of the source node change |
|
||||||
|
| click | A source node is clicked |
|
||||||
|
| tick | A tick (frame render) event fires |
|
||||||
|
| geo | A node changes whose bounds overlap the target |
|
||||||
|
|
||||||
|
The syntax for SPs in this implementation is a *scope* followed by a *JS object literal*:
|
||||||
|
```
|
||||||
|
scope { property1: value1, property2: value2 }
|
||||||
|
```
|
||||||
|
Each propagator is passed the *source* and *target* nodes (named "from" and "to" for brevity) which can be accessed like so:
|
||||||
|
```
|
||||||
|
click {x: from.x + 10, rotation: to.rotation + 1 }
|
||||||
|
```
|
||||||
|
The propagator above will, when the source is clicked, set the targets `x` value to be 10 units greater than the source, and increment the targets rotation. Here is an example of this basic idea:
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
## Demonstration
|
||||||
|
|
||||||
|
By passing the target as well as the source node, it makes it trivial to create toggles and counters. We can do this by creating an arrow from a node *to itself* and getting a value from either the source or target nodes (which are now the same).
|
||||||
|
|
||||||
|
Note that by allowing nodes from `self -> self` we do not have to worry about the layout of nodes, as the arrow will move wherever the node moves. This is in contrast to, for example, needing to move a button node alongside the node of interest, or have some suitable grouping primitive available.
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
This is already sufficient for many primitive constraint-based layouts, with the caveat that constraints do not, without the addition of a backwards propagator, work in both directions.
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
Being able to take a property from one node, transform it, and set the property of another node to that value, is useful not just for adding behaviour but also for debugging. Here we are formatting the full properties of one node and setting the text property of the target whenever the source updates.
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
If we wish to create dynamic behaviours as a function of time, we can use an appropriate scope such as `tick` and pass a readonly `deltaTime` value to these propagators. Which here we are using to implement a classic linear interpolation equation.
|
||||||
|
|
||||||
|
Note that, as with all of the examples, 100% of the behaviour is encoded in the text of the arrows. This creates a kind of diagrammatic specification of behaviour, where all behaviours could be re-created from a static screenshot.
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
While pure functions make reasoning about a system of SPs easier, we may in practice want to allow side effects. Here we have extended the syntax to support arbitrary Javascript:
|
||||||
|
|
||||||
|
```
|
||||||
|
scope () {
|
||||||
|
/* arbitrary JS can be executed in this function body */
|
||||||
|
|
||||||
|
// optional return:
|
||||||
|
return { /* update */ }
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
This is useful if we want to, for example, create utilities or DIY tools out of existing nodes, such as this "paintbrush" which creates a new shape at the top-left corner whenever the brush is not overlapping with another shape.
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
Scoped Propagators are interesting in part because of their ability to cross the boundaries of otherwise siloed systems and to do so without the use of an escape hatch — all additional behaviour happens in-situ, in the same environment as the interface elements, not from editing source code.
|
||||||
|
|
||||||
|
Here is an example of a Petri Net (left box) which is being mapped to a chart primitive (right box). By merit of knowing some specifics of both systems, an author can create a mapping from one to the other without any explicit relationship existing prior to the creation of the propagator (here mapping the number of tokens in a box to the height of a rectangle in a chart)
|
||||||
|
|
||||||
|
>NOTE: the syntax here is slightly older and not consistent with the other examples.
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
Let's now combine some of these examples to create something less trivial. In this example, we have:
|
||||||
|
- a joystick (constrained to a box)
|
||||||
|
- fish movement controlled by the joystick, based on the red circles position relative to the center of the joystick box
|
||||||
|
- a shark with a fish follow behaviour
|
||||||
|
- an on/off toggle
|
||||||
|
- a dead state, which resets the score, and swaps the fish image source to a dead fish
|
||||||
|
- a score counter which increments over time for as long as the fish is alive
|
||||||
|
|
||||||
|
This small game consists of nine relatively terse arrows, propagating between nodes of different types. Propagators were also used to build the game, as it was unclear if or how I could change an image source URL until I used a propagator to inspect the internal state of the image and discovered the property to change.
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
## Prior Work
|
||||||
|
Scoped Propagators are related to [Propagator Networks](https://dspace.mit.edu/handle/1721.1/54635) but differ in three key ways:
|
||||||
|
- propagation happens along *edges* instead of *nodes*
|
||||||
|
- propagation is only fired when to a scope condition is met.
|
||||||
|
- instead of stateful *cell nodes* and *propagator nodes*, all nodes can be stateful and can be of an arbitrary type
|
||||||
|
|
||||||
|
This is also not the first application of propagators to infinite canvas environments, [Dennis Hansen](https://x.com/dennizor/status/1793389346881417323) built [Holograph](https://www.holograph.so), an implementation of propagator networks in [tldraw](https://tldraw.com), and motivated the use of the term "propagator" in this model.
|
||||||
|
|
||||||
|
## Open Questions
|
||||||
|
Many questions about this model have yet to be answered including questions of *function reuse*, modeling of *side-effects*, handling of *multi-input-multi-output* propagation (which is trivial in traditional propagator networks), and applications to other domains such as graph-databases.
|
||||||
|
|
||||||
|
This model has not yet been formalised, and while the propagators themselves can be simply expressed as a function $f(a,b) \mapsto b'$, I have not yet found an appropriate way to express *scopes* and the relationship between the two.
|
||||||
|
|
||||||
|
These questions, along with formalisation of the model and an examination of real-world usage is left to future work.
|
||||||
|
|
@ -0,0 +1,112 @@
|
||||||
|
---
|
||||||
|
title: Scoped Propagators
|
||||||
|
---
|
||||||
|
|
||||||
|
> this research is a work-in-progress (play with the [live demo](https://orionreed.github.io/scoped-propagators/))
|
||||||
|
|
||||||
|
## Abstract
|
||||||
|
Graphs, as a model of computation and as a means of interaction and authorship, have found success in specific domains such as shader programming and signal processing. In these systems, computation is often expressed on nodes of specific types, with edges representing the flow of information. This is a powerful and general-purpose model, but is typically a closed-world environment where both node and edge types are decided at design-time. By choosing an alternate topology where computation is represented by edges, the incentive for a closed environment is reduced.
|
||||||
|
|
||||||
|
I present *Scoped Propagators (SPs)*, a programming model designed to be embedded within existing environments and user interfaces. By representing computation as mappings between nodes along edges, SPs make it possible to add behaviour and interactivity to environments which were not designed with liveness in mind. I demonstrate an implementation of the SP model in an infinite canvas environment, where users can create arrows between arbitrary shapes and define SPs as Javascript object literals on these arrows.
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
## Introduction
|
||||||
|
A scoped propagator is formed of a function which takes a *source* and *target* node and returns an partial update to the *target* node, and a scope which defines some subset of events which trigger propagation.
|
||||||
|
|
||||||
|
the Scoped Propagator model is based on two key insights:
|
||||||
|
1. by representing computation as mappings between nodes along edges, you do not need to know at design-time what node types exist.
|
||||||
|
2. by scoping the propagation to events, you can augment nodes with interactive behaviour suitable for the environment in which SPs have been embedded.
|
||||||
|
|
||||||
|
Below are the four event scopes which are currently implemented, which I have found to be appropriate and useful for an infinite canvas environment.
|
||||||
|
|
||||||
|
| Scope | Firing Condition |
|
||||||
|
|----------|----------|
|
||||||
|
| change (default) | Properties of the source node change |
|
||||||
|
| click | A source node is clicked |
|
||||||
|
| tick | A tick (frame render) event fires |
|
||||||
|
| geo | A node changes whose bounds overlap the target |
|
||||||
|
|
||||||
|
The syntax for SPs in this implementation is a *scope* followed by a *JS object literal*:
|
||||||
|
```
|
||||||
|
scope { property1: value1, property2: value2 }
|
||||||
|
```
|
||||||
|
Each propagator is passed the *source* and *target* nodes (named "from" and "to" for brevity) which can be accessed like so:
|
||||||
|
```
|
||||||
|
click {x: from.x + 10, rotation: to.rotation + 1 }
|
||||||
|
```
|
||||||
|
The propagator above will, when the source is clicked, set the targets `x` value to be 10 units greater than the source, and increment the targets rotation. Here is an example of this basic idea:
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
## Demonstration
|
||||||
|
|
||||||
|
By passing the target as well as the source node, it makes it trivial to create toggles and counters. We can do this by creating an arrow from a node *to itself* and getting a value from either the source or target nodes (which are now the same).
|
||||||
|
|
||||||
|
Note that by allowing nodes from `self -> self` we do not have to worry about the layout of nodes, as the arrow will move wherever the node moves. This is in contrast to, for example, needing to move a button node alongside the node of interest, or have some suitable grouping primitive available.
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
This is already sufficient for many primitive constraint-based layouts, with the caveat that constraints do not, without the addition of a backwards propagator, work in both directions.
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
Being able to take a property from one node, transform it, and set the property of another node to that value, is useful not just for adding behaviour but also for debugging. Here we are formatting the full properties of one node and setting the text property of the target whenever the source updates.
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
If we wish to create dynamic behaviours as a function of time, we can use an appropriate scope such as `tick` and pass a readonly `deltaTime` value to these propagators. Which here we are using to implement a classic linear interpolation equation.
|
||||||
|
|
||||||
|
Note that, as with all of the examples, 100% of the behaviour is encoded in the text of the arrows. This creates a kind of diagrammatic specification of behaviour, where all behaviours could be re-created from a static screenshot.
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
While pure functions make reasoning about a system of SPs easier, we may in practice want to allow side effects. Here we have extended the syntax to support arbitrary Javascript:
|
||||||
|
|
||||||
|
```
|
||||||
|
scope () {
|
||||||
|
/* arbitrary JS can be executed in this function body */
|
||||||
|
|
||||||
|
// optional return:
|
||||||
|
return { /* update */ }
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
This is useful if we want to, for example, create utilities or DIY tools out of existing nodes, such as this "paintbrush" which creates a new shape at the top-left corner whenever the brush is not overlapping with another shape.
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
Scoped Propagators are interesting in part because of their ability to cross the boundaries of otherwise siloed systems and to do so without the use of an escape hatch — all additional behaviour happens in-situ, in the same environment as the interface elements, not from editing source code.
|
||||||
|
|
||||||
|
Here is an example of a Petri Net (left box) which is being mapped to a chart primitive (right box). By merit of knowing some specifics of both systems, an author can create a mapping from one to the other without any explicit relationship existing prior to the creation of the propagator (here mapping the number of tokens in a box to the height of a rectangle in a chart)
|
||||||
|
|
||||||
|
>NOTE: the syntax here is slightly older and not consistent with the other examples.
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
Let's now combine some of these examples to create something less trivial. In this example, we have:
|
||||||
|
- a joystick (constrained to a box)
|
||||||
|
- fish movement controlled by the joystick, based on the red circles position relative to the center of the joystick box
|
||||||
|
- a shark with a fish follow behaviour
|
||||||
|
- an on/off toggle
|
||||||
|
- a dead state, which resets the score, and swaps the fish image source to a dead fish
|
||||||
|
- a score counter which increments over time for as long as the fish is alive
|
||||||
|
|
||||||
|
This small game consists of nine relatively terse arrows, propagating between nodes of different types. Propagators were also used to build the game, as it was unclear if or how I could change an image source URL until I used a propagator to inspect the internal state of the image and discovered the property to change.
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
## Prior Work
|
||||||
|
Scoped Propagators are related to [Propagator Networks](https://dspace.mit.edu/handle/1721.1/54635) but differ in three key ways:
|
||||||
|
- propagation happens along *edges* instead of *nodes*
|
||||||
|
- propagation is only fired when to a scope condition is met.
|
||||||
|
- instead of stateful *cell nodes* and *propagator nodes*, all nodes can be stateful and can be of an arbitrary type
|
||||||
|
|
||||||
|
This is also not the first application of propagators to infinite canvas environments, [Dennis Hansen](https://x.com/dennizor/status/1793389346881417323) built [Holograph](https://www.holograph.so), an implementation of propagator networks in [tldraw](https://tldraw.com), and motivated the use of the term "propagator" in this model.
|
||||||
|
|
||||||
|
## Open Questions
|
||||||
|
Many questions about this model have yet to be answered including questions of *function reuse*, modeling of *side-effects*, handling of *multi-input-multi-output* propagation (which is trivial in traditional propagator networks), and applications to other domains such as graph-databases.
|
||||||
|
|
||||||
|
This model has not yet been formalised, and while the propagators themselves can be simply expressed as a function $f(a,b) \mapsto b'$, I have not yet found an appropriate way to express *scopes* and the relationship between the two.
|
||||||
|
|
||||||
|
These questions, along with formalisation of the model and an examination of real-world usage is left to future work.
|
||||||
|
|
@ -0,0 +1,3 @@
|
||||||
|
version https://git-lfs.github.com/spec/v1
|
||||||
|
oid sha256:c8a5fea015bcf937fbce5b0a233067e31059fb2e4d0f32f6471395fb82c6407c
|
||||||
|
size 1708542
|
||||||
|
|
@ -0,0 +1,3 @@
|
||||||
|
version https://git-lfs.github.com/spec/v1
|
||||||
|
oid sha256:8763ad95ef6e31e4c66f9298d0ad22296edd83ab2fd6d7aa1549c8845521c932
|
||||||
|
size 61302
|
||||||
|
|
@ -0,0 +1,3 @@
|
||||||
|
version https://git-lfs.github.com/spec/v1
|
||||||
|
oid sha256:fbc1493d124f92abe9c35534b4b6dca4a8081c570f4e3f07c4d9559d60dea3eb
|
||||||
|
size 87493
|
||||||
|
|
@ -0,0 +1,3 @@
|
||||||
|
version https://git-lfs.github.com/spec/v1
|
||||||
|
oid sha256:55e5c2ce64c027a808951ad11f8757f3a8d7d739639a72517a4b39c35aad815d
|
||||||
|
size 21393177
|
||||||
|
|
@ -0,0 +1,3 @@
|
||||||
|
version https://git-lfs.github.com/spec/v1
|
||||||
|
oid sha256:0c27fbc53fb819ffc3f83a52a939c52bc5f92788e5212a602c31f94ff0f108d8
|
||||||
|
size 386809
|
||||||
|
|
@ -0,0 +1,3 @@
|
||||||
|
version https://git-lfs.github.com/spec/v1
|
||||||
|
oid sha256:2f50fe4800e2a5957e0b96aa18121ea3dc40f514162914e9f771497e4cc54d1f
|
||||||
|
size 224988
|
||||||
|
|
@ -0,0 +1,3 @@
|
||||||
|
version https://git-lfs.github.com/spec/v1
|
||||||
|
oid sha256:29771060ae2ddb4c8056b4a68e24da75066e96b0a9264ab5577efba66de34379
|
||||||
|
size 131411
|
||||||
|
|
@ -0,0 +1,3 @@
|
||||||
|
version https://git-lfs.github.com/spec/v1
|
||||||
|
oid sha256:3725438bab72654d31e56303f4694cb481da03ed52668a3235c0510769e43ef2
|
||||||
|
size 213429
|
||||||
|
|
@ -0,0 +1,3 @@
|
||||||
|
version https://git-lfs.github.com/spec/v1
|
||||||
|
oid sha256:6c4030b83ec1ae36a9475a1165dc791f8d651da70cae91c1ab90c50a54d5bbd3
|
||||||
|
size 127229
|
||||||
|
|
@ -0,0 +1,3 @@
|
||||||
|
version https://git-lfs.github.com/spec/v1
|
||||||
|
oid sha256:c8140e8b76c29fa3c828553e8a6a87651af6e7ee18d1ccb82e79125eb532194b
|
||||||
|
size 19738445
|
||||||
|
|
@ -0,0 +1,3 @@
|
||||||
|
version https://git-lfs.github.com/spec/v1
|
||||||
|
oid sha256:d63dd9f37a74b85680a7c1df823f69936619ecab5e8d4d06eeeedea4d908f1de
|
||||||
|
size 18268955
|
||||||
|
|
@ -0,0 +1,5 @@
|
||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<!-- Generated by Pixelmator Pro 3.5.7 -->
|
||||||
|
<svg width="293" height="293" viewBox="0 0 293 293" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<path id="canvas-button" fill="none" stroke="#000000" stroke-width="22" stroke-linecap="round" stroke-linejoin="round" d="M 72.367233 280.871094 C 50.196026 280.871094 39.109756 280.870453 30.79781 276.215546 C 24.923391 272.92572 20.074295 268.07663 16.784464 262.202179 C 12.129553 253.890228 12.128906 242.80397 12.128906 220.632767 L 12.128906 195.152313 L 24.139235 195.152313 L 24.139235 229.431793 C 24.139235 243.943832 24.139042 251.199875 27.185894 256.640411 C 29.339237 260.485474 32.514515 263.660767 36.359585 265.814117 C 41.800129 268.860962 49.056156 268.860779 63.568214 268.860779 L 97.847694 268.860779 L 97.847694 280.871094 L 72.367233 280.871094 Z M 195.276031 280.871094 L 195.276031 268.860779 L 229.560684 268.860779 C 244.072723 268.860779 251.328766 268.860962 256.769318 265.814117 C 260.61441 263.660767 263.789673 260.485474 265.943024 256.640411 C 268.989868 251.199875 268.989685 243.943832 268.989685 229.431793 L 268.989685 195.152313 L 281 195.152313 L 281 220.632767 C 281 242.80397 280.999359 253.890228 276.344452 262.202179 C 273.054626 268.07663 268.205536 272.92572 262.331085 276.215546 C 254.019119 280.870453 242.932877 280.871094 220.761673 280.871094 L 195.276031 280.871094 Z M 12.128906 97.723969 L 12.128906 72.238327 C 12.128906 50.067123 12.129553 38.980881 16.784464 30.668915 C 20.074295 24.794464 24.923391 19.945404 30.79781 16.655548 C 39.109756 12.000641 50.196026 12 72.367233 12 L 97.847694 12 L 97.847694 24.010315 L 63.568214 24.010315 C 49.056156 24.010315 41.800129 24.010132 36.359585 27.056976 C 32.514519 29.210327 29.339237 32.38562 27.185894 36.230682 C 24.139044 41.671234 24.139235 48.927277 24.139235 63.439316 L 24.139235 97.723969 L 12.128906 97.723969 Z M 268.989685 97.723969 L 268.989685 63.439316 C 268.989685 48.927277 268.989868 41.671234 265.943024 36.230682 C 263.789673 32.38559 260.61438 29.210327 256.769318 27.056976 C 251.328766 24.010132 244.072723 24.010315 229.560684 24.010315 L 195.276031 24.010315 L 195.276031 12 L 220.761673 12 C 242.932877 12 254.019119 12.000641 262.331085 16.655548 C 268.205536 19.945374 273.054596 24.794464 276.344452 30.668915 C 280.999359 38.980881 281 50.067123 281 72.238327 L 281 97.723969 L 268.989685 97.723969 Z"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 2.4 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 84 KiB |
|
|
@ -0,0 +1,8 @@
|
||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<!-- Generated by Pixelmator Pro 3.5.7 -->
|
||||||
|
<svg width="247" height="450" viewBox="0 0 247 450" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<g id="gravity-button">
|
||||||
|
<path id="secondary" fill="none" stroke="#000000" stroke-width="30" stroke-linecap="round" stroke-linejoin="round" d="M 123.166664 32.750031 L 123.166664 169.500031 M 214.333313 65.541687 L 214.333313 202.291687 M 32 65.541687 L 32 202.291687"/>
|
||||||
|
<path id="primary" fill="none" stroke="#000000" stroke-width="30" stroke-linecap="round" stroke-linejoin="round" d="M 214.333313 341.833344 C 214.333313 392.183289 173.516632 433 123.166664 433 C 72.816711 433 32 392.183289 32 341.833344 C 32 291.483398 72.816711 250.666687 123.166664 250.666687 C 173.516632 250.666687 214.333313 291.483398 214.333313 341.833344 Z"/>
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 846 B |
|
|
@ -0,0 +1,3 @@
|
||||||
|
version https://git-lfs.github.com/spec/v1
|
||||||
|
oid sha256:4b55a408375712bc169bc5c987d85c71c353028e611f216817f13ca0fb284604
|
||||||
|
size 28423
|
||||||
|
|
@ -0,0 +1,155 @@
|
||||||
|
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,159 @@
|
||||||
|
import { BaseBoxShapeUtil, TLBaseShape } from "tldraw";
|
||||||
|
import { useCallback, useState } from "react";
|
||||||
|
|
||||||
|
export type IEmbedShape = TLBaseShape<
|
||||||
|
'Embed',
|
||||||
|
{
|
||||||
|
w: number;
|
||||||
|
h: number;
|
||||||
|
url: string | null;
|
||||||
|
}
|
||||||
|
>;
|
||||||
|
|
||||||
|
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 [inputUrl, setInputUrl] = useState(shape.props.url || '');
|
||||||
|
const [error, setError] = useState('');
|
||||||
|
|
||||||
|
const handleSubmit = useCallback((e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
let completedUrl = inputUrl.startsWith('http://') || inputUrl.startsWith('https://') ? inputUrl : `https://${inputUrl}`;
|
||||||
|
|
||||||
|
// Handle YouTube links
|
||||||
|
if (completedUrl.includes('youtube.com') || completedUrl.includes('youtu.be')) {
|
||||||
|
const videoId = extractYouTubeVideoId(completedUrl);
|
||||||
|
if (videoId) {
|
||||||
|
completedUrl = `https://www.youtube.com/embed/${videoId}`;
|
||||||
|
} else {
|
||||||
|
setError('Invalid YouTube URL');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Handle Google Docs links
|
||||||
|
if (completedUrl.includes('docs.google.com')) {
|
||||||
|
// Handle different types of Google Docs URLs
|
||||||
|
if (completedUrl.includes('/document/d/')) {
|
||||||
|
const docId = completedUrl.match(/\/document\/d\/([a-zA-Z0-9-_]+)/)?.[1];
|
||||||
|
if (docId) {
|
||||||
|
completedUrl = `https://docs.google.com/document/d/${docId}/edit`;
|
||||||
|
} else {
|
||||||
|
setError('Invalid Google Docs URL');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
} else if (completedUrl.includes('/spreadsheets/d/')) {
|
||||||
|
const docId = completedUrl.match(/\/spreadsheets\/d\/([a-zA-Z0-9-_]+)/)?.[1];
|
||||||
|
if (docId) {
|
||||||
|
completedUrl = `https://docs.google.com/spreadsheets/d/${docId}/edit`;
|
||||||
|
} else {
|
||||||
|
setError('Invalid Google Sheets URL');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
} else if (completedUrl.includes('/presentation/d/')) {
|
||||||
|
const docId = completedUrl.match(/\/presentation\/d\/([a-zA-Z0-9-_]+)/)?.[1];
|
||||||
|
if (docId) {
|
||||||
|
completedUrl = `https://docs.google.com/presentation/d/${docId}/embed`;
|
||||||
|
} else {
|
||||||
|
setError('Invalid Google Slides URL');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add parameters for access
|
||||||
|
completedUrl += '?authuser=0'; // Allow Google authentication
|
||||||
|
}
|
||||||
|
|
||||||
|
this.editor.updateShape<IEmbedShape>({ id: shape.id, type: 'Embed', props: { ...shape.props, url: completedUrl } });
|
||||||
|
|
||||||
|
// Check if the URL is valid
|
||||||
|
const isValidUrl = completedUrl.match(/(^\w+:|^)\/\//);
|
||||||
|
if (!isValidUrl) {
|
||||||
|
setError('Invalid website URL');
|
||||||
|
} else {
|
||||||
|
setError('');
|
||||||
|
}
|
||||||
|
}, [inputUrl]);
|
||||||
|
|
||||||
|
const extractYouTubeVideoId = (url: string): string | null => {
|
||||||
|
const regExp = /^.*(youtu.be\/|v\/|u\/\w\/|embed\/|watch\?v=|\&v=)([^#\&\?]*).*/;
|
||||||
|
const match = url.match(regExp);
|
||||||
|
return (match && match[2].length === 11) ? match[2] : null;
|
||||||
|
};
|
||||||
|
|
||||||
|
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: '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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={wrapperStyle}>
|
||||||
|
<div style={contentStyle}>
|
||||||
|
<iframe
|
||||||
|
src={shape.props.url}
|
||||||
|
width="100%"
|
||||||
|
height="100%"
|
||||||
|
style={{ border: 'none' }}
|
||||||
|
allowFullScreen
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,138 @@
|
||||||
|
import { BaseBoxShapeUtil, TLBaseShape } from "tldraw";
|
||||||
|
import { useCallback, useState } from "react";
|
||||||
|
|
||||||
|
export type IEmbedShape = TLBaseShape<
|
||||||
|
'Embed',
|
||||||
|
{
|
||||||
|
w: number;
|
||||||
|
h: number;
|
||||||
|
url: string | null;
|
||||||
|
}
|
||||||
|
>;
|
||||||
|
|
||||||
|
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 [inputUrl, setInputUrl] = useState(shape.props.url || '');
|
||||||
|
const [error, setError] = useState('');
|
||||||
|
|
||||||
|
const handleSubmit = useCallback((e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
let completedUrl = inputUrl.startsWith('http://') || inputUrl.startsWith('https://') ? inputUrl : `https://${inputUrl}`;
|
||||||
|
|
||||||
|
// Handle YouTube links
|
||||||
|
if (completedUrl.includes('youtube.com') || completedUrl.includes('youtu.be')) {
|
||||||
|
const videoId = extractYouTubeVideoId(completedUrl);
|
||||||
|
if (videoId) {
|
||||||
|
completedUrl = `https://www.youtube.com/embed/${videoId}`;
|
||||||
|
} else {
|
||||||
|
setError('Invalid YouTube URL');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle Google Docs links
|
||||||
|
if (completedUrl.includes('docs.google.com')) {
|
||||||
|
const docId = completedUrl.match(/\/d\/([a-zA-Z0-9-_]+)/)?.[1];
|
||||||
|
if (docId) {
|
||||||
|
completedUrl = `https://docs.google.com/document/d/${docId}/preview`;
|
||||||
|
} else {
|
||||||
|
setError('Invalid Google Docs URL');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
this.editor.updateShape<IEmbedShape>({ id: shape.id, type: 'Embed', props: { ...shape.props, url: completedUrl } });
|
||||||
|
|
||||||
|
// Check if the URL is valid
|
||||||
|
const isValidUrl = completedUrl.match(/(^\w+:|^)\/\//);
|
||||||
|
if (!isValidUrl) {
|
||||||
|
setError('Invalid website URL');
|
||||||
|
} else {
|
||||||
|
setError('');
|
||||||
|
}
|
||||||
|
}, [inputUrl]);
|
||||||
|
|
||||||
|
const extractYouTubeVideoId = (url: string): string | null => {
|
||||||
|
const regExp = /^.*(youtu.be\/|v\/|u\/\w\/|embed\/|watch\?v=|\&v=)([^#\&\?]*).*/;
|
||||||
|
const match = url.match(regExp);
|
||||||
|
return (match && match[2].length === 11) ? match[2] : null;
|
||||||
|
};
|
||||||
|
|
||||||
|
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: '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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={wrapperStyle}>
|
||||||
|
<div style={contentStyle}>
|
||||||
|
<iframe
|
||||||
|
src={shape.props.url}
|
||||||
|
width="100%"
|
||||||
|
height="100%"
|
||||||
|
style={{ border: 'none' }}
|
||||||
|
allowFullScreen
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,119 @@
|
||||||
|
import { BaseBoxShapeUtil, TLBaseShape } from "tldraw";
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { WORKER_URL } from '../components/Board';
|
||||||
|
|
||||||
|
export type IVideoChatShape = TLBaseShape<
|
||||||
|
'VideoChat',
|
||||||
|
{
|
||||||
|
w: number;
|
||||||
|
h: number;
|
||||||
|
roomUrl: string | null;
|
||||||
|
userName: string;
|
||||||
|
}
|
||||||
|
>;
|
||||||
|
|
||||||
|
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,
|
||||||
|
userName: ''
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async ensureRoomExists(shape: IVideoChatShape) {
|
||||||
|
if (shape.props.roomUrl !== null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await fetch(`${WORKER_URL}/daily/rooms`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json'
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
properties: {
|
||||||
|
enable_recording: true,
|
||||||
|
max_participants: 8
|
||||||
|
}
|
||||||
|
})
|
||||||
|
});
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
this.editor.updateShape<IVideoChatShape>({
|
||||||
|
id: shape.id,
|
||||||
|
type: 'VideoChat',
|
||||||
|
props: {
|
||||||
|
...shape.props,
|
||||||
|
roomUrl: (data as any).url
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
component(shape: IVideoChatShape) {
|
||||||
|
const [isInRoom, setIsInRoom] = useState(false);
|
||||||
|
const [error, setError] = useState("");
|
||||||
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (isInRoom && shape.props.roomUrl) {
|
||||||
|
const script = document.createElement('script');
|
||||||
|
script.src = 'https://www.daily.co/static/call-machine.js';
|
||||||
|
document.body.appendChild(script);
|
||||||
|
|
||||||
|
script.onload = () => {
|
||||||
|
// @ts-ignore
|
||||||
|
window.DailyIframe.createFrame({
|
||||||
|
iframeStyle: {
|
||||||
|
width: '100%',
|
||||||
|
height: '100%',
|
||||||
|
border: '0',
|
||||||
|
borderRadius: '4px'
|
||||||
|
},
|
||||||
|
showLeaveButton: true,
|
||||||
|
showFullscreenButton: true
|
||||||
|
}).join({ url: shape.props.roomUrl });
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}, [isInRoom, shape.props.roomUrl]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{
|
||||||
|
pointerEvents: 'all',
|
||||||
|
width: `${shape.props.w}px`,
|
||||||
|
height: `${shape.props.h}px`,
|
||||||
|
position: 'absolute',
|
||||||
|
top: '10px',
|
||||||
|
left: '10px',
|
||||||
|
zIndex: 9999,
|
||||||
|
padding: '15px',
|
||||||
|
backgroundColor: '#F0F0F0',
|
||||||
|
boxShadow: '0 2px 4px rgba(0, 0, 0, 0.1)',
|
||||||
|
borderRadius: '4px',
|
||||||
|
}}>
|
||||||
|
{!isInRoom ? (
|
||||||
|
<button
|
||||||
|
onClick={() => setIsInRoom(true)}
|
||||||
|
className="bg-blue-500 text-white px-4 py-2 rounded"
|
||||||
|
>
|
||||||
|
Join Room
|
||||||
|
</button>
|
||||||
|
) : (
|
||||||
|
<div id="daily-call-iframe-container" style={{
|
||||||
|
width: '100%',
|
||||||
|
height: '100%'
|
||||||
|
}} />
|
||||||
|
)}
|
||||||
|
{error && <p className="text-red-500 mt-2">{error}</p>}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
File diff suppressed because one or more lines are too long
|
|
@ -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,9 @@
|
||||||
|
import { BaseBoxShapeTool } from "tldraw";
|
||||||
|
|
||||||
|
export class EmbedTool extends BaseBoxShapeTool {
|
||||||
|
static override id = 'Embed'
|
||||||
|
shapeType = 'Embed';
|
||||||
|
override initial = 'idle';
|
||||||
|
|
||||||
|
// Additional methods for handling video chat functionality can be added here
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,9 @@
|
||||||
|
import { BaseBoxShapeTool } from "tldraw";
|
||||||
|
|
||||||
|
export class VideoChatTool extends BaseBoxShapeTool {
|
||||||
|
static override id = 'VideoChat'
|
||||||
|
shapeType = 'VideoChat';
|
||||||
|
override initial = 'idle';
|
||||||
|
|
||||||
|
// Additional methods for handling video chat functionality can be added here
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,6 @@
|
||||||
|
declare module 'crdts/src/G-Set' {
|
||||||
|
export default class GSet<T = any> {
|
||||||
|
add(value: T): void;
|
||||||
|
values(): Set<T>;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,442 @@
|
||||||
|
import {
|
||||||
|
DefaultToolbar,
|
||||||
|
DefaultToolbarContent,
|
||||||
|
TLComponents,
|
||||||
|
TLUiOverrides,
|
||||||
|
TldrawUiMenuItem,
|
||||||
|
useEditor,
|
||||||
|
useTools,
|
||||||
|
TLShapeId,
|
||||||
|
DefaultContextMenu,
|
||||||
|
DefaultContextMenuContent,
|
||||||
|
TLUiContextMenuProps,
|
||||||
|
TldrawUiMenuGroup,
|
||||||
|
TLShape,
|
||||||
|
} from 'tldraw'
|
||||||
|
import { CustomMainMenu } from './components/CustomMainMenu'
|
||||||
|
import { Editor } from 'tldraw'
|
||||||
|
|
||||||
|
let 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);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const copyFrameLink = async (editor: Editor, frameId: string) => {
|
||||||
|
console.log('Starting copyFrameLink with frameId:', frameId);
|
||||||
|
|
||||||
|
if (!editor.store.getSnapshot()) {
|
||||||
|
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);
|
||||||
|
url.searchParams.set('frameId', frameId);
|
||||||
|
|
||||||
|
const frame = editor.getShape(frameId as TLShapeId);
|
||||||
|
console.log('Found frame:', frame);
|
||||||
|
|
||||||
|
if (frame) {
|
||||||
|
const camera = editor.getCamera();
|
||||||
|
console.log('Camera position:', { x: camera.x, y: camera.y, zoom: camera.z });
|
||||||
|
|
||||||
|
url.searchParams.set('x', camera.x.toString());
|
||||||
|
url.searchParams.set('y', camera.y.toString());
|
||||||
|
url.searchParams.set('zoom', camera.z.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);
|
||||||
|
textArea.select();
|
||||||
|
document.execCommand('copy');
|
||||||
|
document.body.removeChild(textArea);
|
||||||
|
console.log('URL copied successfully using fallback method');
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to copy to clipboard:', error);
|
||||||
|
alert('Failed to copy link. Please check clipboard permissions.');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const zoomToShape = (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 8x
|
||||||
|
targetZoom = Math.min(
|
||||||
|
(viewportPageBounds.width * 0.8) / commonBounds.width,
|
||||||
|
(viewportPageBounds.height * 0.8) / commonBounds.height,
|
||||||
|
8 // Max zoom of 8x 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 4x zoom
|
||||||
|
targetZoom = Math.min(
|
||||||
|
(viewportPageBounds.width * 0.8) / commonBounds.width,
|
||||||
|
(viewportPageBounds.height * 0.8) / commonBounds.height,
|
||||||
|
4 // 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());
|
||||||
|
};
|
||||||
|
|
||||||
|
const copyLinkToCurrentView = async (editor: Editor) => {
|
||||||
|
console.log('Starting copyLinkToCurrentView');
|
||||||
|
|
||||||
|
if (!editor.store.getSnapshot()) {
|
||||||
|
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
|
||||||
|
url.searchParams.set('x', camera.x.toString());
|
||||||
|
url.searchParams.set('y', camera.y.toString());
|
||||||
|
url.searchParams.set('zoom', camera.z.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);
|
||||||
|
textArea.select();
|
||||||
|
document.execCommand('copy');
|
||||||
|
document.body.removeChild(textArea);
|
||||||
|
console.log('URL copied successfully using fallback method');
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to copy to clipboard:', error);
|
||||||
|
alert('Failed to copy link. Please check clipboard permissions.');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
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');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
function CustomContextMenu(props: TLUiContextMenuProps) {
|
||||||
|
const editor = useEditor()
|
||||||
|
const hasSelection = editor.getSelectedShapeIds().length > 0
|
||||||
|
const selectedShape = editor.getSelectedShapes()[0]
|
||||||
|
const hasCameraHistory = cameraHistory.length > 0
|
||||||
|
|
||||||
|
return (
|
||||||
|
<DefaultContextMenu {...props}>
|
||||||
|
<TldrawUiMenuGroup id="camera-actions">
|
||||||
|
<TldrawUiMenuItem
|
||||||
|
id="revert-camera"
|
||||||
|
label="Revert Camera"
|
||||||
|
icon="undo"
|
||||||
|
kbd="b"
|
||||||
|
readonlyOk
|
||||||
|
disabled={!hasCameraHistory}
|
||||||
|
onSelect={() => {
|
||||||
|
console.log('Reverting camera');
|
||||||
|
revertCamera(editor);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<TldrawUiMenuItem
|
||||||
|
id="zoom-to-shape"
|
||||||
|
label="Zoom to Selection"
|
||||||
|
icon="zoom-in"
|
||||||
|
kbd="z"
|
||||||
|
readonlyOk
|
||||||
|
disabled={!hasSelection}
|
||||||
|
onSelect={() => {
|
||||||
|
console.log('Zoom to Selection clicked');
|
||||||
|
zoomToShape(editor);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<TldrawUiMenuItem
|
||||||
|
id="copy-link-to-current-view"
|
||||||
|
label="Copy Link to Current View"
|
||||||
|
icon="link"
|
||||||
|
kbd="c"
|
||||||
|
readonlyOk
|
||||||
|
onSelect={() => {
|
||||||
|
console.log('Copy Link to Current View clicked');
|
||||||
|
copyLinkToCurrentView(editor);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</TldrawUiMenuGroup>
|
||||||
|
<DefaultContextMenuContent />
|
||||||
|
</DefaultContextMenu>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export const uiOverrides: TLUiOverrides = {
|
||||||
|
tools(editor, tools) {
|
||||||
|
tools.VideoChat = {
|
||||||
|
id: 'VideoChat',
|
||||||
|
icon: 'color',
|
||||||
|
label: 'Video',
|
||||||
|
kbd: 'x',
|
||||||
|
meta: {},
|
||||||
|
onSelect: () => {
|
||||||
|
editor.setCurrentTool('VideoChat')
|
||||||
|
},
|
||||||
|
}
|
||||||
|
tools.ChatBox = {
|
||||||
|
id: 'ChatBox',
|
||||||
|
icon: 'color',
|
||||||
|
label: 'Chat',
|
||||||
|
kbd: 'x',
|
||||||
|
meta: {},
|
||||||
|
onSelect: () => {
|
||||||
|
editor.setCurrentTool('ChatBox')
|
||||||
|
},
|
||||||
|
}
|
||||||
|
tools.Embed = {
|
||||||
|
id: 'Embed',
|
||||||
|
icon: 'embed',
|
||||||
|
label: 'Embed',
|
||||||
|
kbd: 'e',
|
||||||
|
meta: {},
|
||||||
|
onSelect: () => {
|
||||||
|
editor.setCurrentTool('Embed')
|
||||||
|
},
|
||||||
|
}
|
||||||
|
return tools
|
||||||
|
},
|
||||||
|
actions(editor, actions) {
|
||||||
|
actions['copyFrameLink'] = {
|
||||||
|
id: 'copy-frame-link',
|
||||||
|
label: 'Copy Frame Link',
|
||||||
|
onSelect: () => {
|
||||||
|
const shape = editor.getSelectedShapes()[0]
|
||||||
|
if (shape && shape.type === 'frame') {
|
||||||
|
copyFrameLink(editor, shape.id)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
readonlyOk: true,
|
||||||
|
}
|
||||||
|
|
||||||
|
actions['zoomToFrame'] = {
|
||||||
|
id: 'zoom-to-frame',
|
||||||
|
label: 'Zoom to Frame',
|
||||||
|
onSelect: () => {
|
||||||
|
const shape = editor.getSelectedShapes()[0]
|
||||||
|
if (shape && shape.type === 'frame') {
|
||||||
|
zoomToShape(editor)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
readonlyOk: true,
|
||||||
|
}
|
||||||
|
|
||||||
|
actions['copyLinkToCurrentView'] = {
|
||||||
|
id: 'copy-link-to-current-view',
|
||||||
|
label: 'Copy Link to Current View',
|
||||||
|
kbd: 'c',
|
||||||
|
onSelect: () => {
|
||||||
|
console.log('Creating link to current view');
|
||||||
|
copyLinkToCurrentView(editor);
|
||||||
|
},
|
||||||
|
readonlyOk: true,
|
||||||
|
}
|
||||||
|
|
||||||
|
actions['zoomToShape'] = {
|
||||||
|
id: 'zoom-to-shape',
|
||||||
|
label: 'Zoom to Selection',
|
||||||
|
kbd: 'z',
|
||||||
|
onSelect: () => {
|
||||||
|
if (editor.getSelectedShapeIds().length > 0) {
|
||||||
|
console.log('Zooming to selection');
|
||||||
|
zoomToShape(editor);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
readonlyOk: true,
|
||||||
|
}
|
||||||
|
|
||||||
|
actions['revertCamera'] = {
|
||||||
|
id: 'revert-camera',
|
||||||
|
label: 'Revert Camera',
|
||||||
|
kbd: 'b',
|
||||||
|
onSelect: () => {
|
||||||
|
console.log('Reverting camera position');
|
||||||
|
revertCamera(editor);
|
||||||
|
},
|
||||||
|
readonlyOk: true,
|
||||||
|
}
|
||||||
|
|
||||||
|
return actions
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
export const components: TLComponents = {
|
||||||
|
Toolbar: function Toolbar() {
|
||||||
|
const editor = useEditor()
|
||||||
|
const tools = useTools()
|
||||||
|
return (
|
||||||
|
<DefaultToolbar>
|
||||||
|
{tools['VideoChat'] && (
|
||||||
|
<TldrawUiMenuItem
|
||||||
|
{...tools['VideoChat']}
|
||||||
|
isSelected={tools['VideoChat'].id === editor.getCurrentToolId()}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{tools['ChatBox'] && (
|
||||||
|
<TldrawUiMenuItem
|
||||||
|
{...tools['ChatBox']}
|
||||||
|
isSelected={tools['ChatBox'].id === editor.getCurrentToolId()}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{tools['Embed'] && (
|
||||||
|
<TldrawUiMenuItem
|
||||||
|
{...tools['Embed']}
|
||||||
|
isSelected={tools['Embed'].id === editor.getCurrentToolId()}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<DefaultToolbarContent />
|
||||||
|
</DefaultToolbar>
|
||||||
|
)
|
||||||
|
},
|
||||||
|
MainMenu: CustomMainMenu,
|
||||||
|
ContextMenu: CustomContextMenu,
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleInitialShapeLoad = (editor: Editor) => {
|
||||||
|
const url = new URL(window.location.href);
|
||||||
|
|
||||||
|
// Check for both shapeId and legacy frameId (for backwards compatibility)
|
||||||
|
const shapeId = url.searchParams.get('shapeId') || url.searchParams.get('frameId');
|
||||||
|
const x = url.searchParams.get('x');
|
||||||
|
const y = url.searchParams.get('y');
|
||||||
|
const zoom = url.searchParams.get('zoom');
|
||||||
|
|
||||||
|
if (shapeId) {
|
||||||
|
console.log('Found shapeId in URL:', shapeId);
|
||||||
|
const shape = editor.getShape(shapeId as TLShapeId);
|
||||||
|
|
||||||
|
if (shape) {
|
||||||
|
console.log('Found shape:', shape);
|
||||||
|
if (x && y && zoom) {
|
||||||
|
console.log('Setting camera to:', { x, y, zoom });
|
||||||
|
editor.setCamera({
|
||||||
|
x: parseFloat(x),
|
||||||
|
y: parseFloat(y),
|
||||||
|
z: parseFloat(zoom)
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
console.log('Zooming to shape bounds');
|
||||||
|
editor.zoomToBounds(editor.getShapeGeometry(shape).bounds, {
|
||||||
|
targetZoom: 1,
|
||||||
|
//padding: 32
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
console.warn('Shape not found:', shapeId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
@ -0,0 +1,58 @@
|
||||||
|
import { Rectangle2d, resizeBox, TLBaseShape, TLOnBeforeUpdateHandler, TLOnResizeHandler } from 'tldraw';
|
||||||
|
import { ShapeUtil } from 'tldraw'
|
||||||
|
|
||||||
|
export type HTMLShape = TLBaseShape<'html', { w: number; h: number, html: string }>
|
||||||
|
|
||||||
|
export class HTMLShapeUtil extends ShapeUtil<HTMLShape> {
|
||||||
|
static override type = 'html' as const
|
||||||
|
override canBind = () => true
|
||||||
|
override canEdit = () => false
|
||||||
|
override canResize = () => true
|
||||||
|
override isAspectRatioLocked = () => false
|
||||||
|
|
||||||
|
getDefaultProps(): HTMLShape['props'] {
|
||||||
|
return {
|
||||||
|
w: 100,
|
||||||
|
h: 100,
|
||||||
|
html: "<div></div>"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override onTranslate: TLOnBeforeUpdateHandler<HTMLShape> = (prev, next) => {
|
||||||
|
if (prev.x !== next.x || prev.y !== next.y) {
|
||||||
|
this.editor.bringToFront([next.id]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override onResize: TLOnResizeHandler<HTMLShape> = (shape: HTMLShape, info) => {
|
||||||
|
const element = document.getElementById(shape.id);
|
||||||
|
if (!element || !element.parentElement) return resizeBox(shape, info);
|
||||||
|
const { width, height } = element.parentElement.getBoundingClientRect();
|
||||||
|
if (element) {
|
||||||
|
const isOverflowing = element.scrollWidth > width || element.scrollHeight > height;
|
||||||
|
if (isOverflowing) {
|
||||||
|
element.parentElement?.classList.add('overflowing');
|
||||||
|
} else {
|
||||||
|
element.parentElement?.classList.remove('overflowing');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return resizeBox(shape, info)
|
||||||
|
}
|
||||||
|
|
||||||
|
getGeometry(shape: HTMLShape) {
|
||||||
|
return new Rectangle2d({
|
||||||
|
width: shape.props.w,
|
||||||
|
height: shape.props.h,
|
||||||
|
isFilled: true,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
component(shape: HTMLShape) {
|
||||||
|
return <div id={shape.id} dangerouslySetInnerHTML={{ __html: shape.props.html }} />
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
indicator(shape: HTMLShape) {
|
||||||
|
return <rect width={shape.props.w} height={shape.props.h} />
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,25 @@
|
||||||
|
import { createShapePropsMigrationIds, createShapePropsMigrationSequence } from 'tldraw'
|
||||||
|
|
||||||
|
const versions = createShapePropsMigrationIds(
|
||||||
|
// this must match the shape type in the shape definition
|
||||||
|
'card',
|
||||||
|
{
|
||||||
|
AddSomeProperty: 1,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
// Migrations for the custom card shape (optional but very helpful)
|
||||||
|
export const cardShapeMigrations = createShapePropsMigrationSequence({
|
||||||
|
sequence: [
|
||||||
|
{
|
||||||
|
id: versions.AddSomeProperty,
|
||||||
|
up(props) {
|
||||||
|
// it is safe to mutate the props object here
|
||||||
|
props.someProperty = 'some value'
|
||||||
|
},
|
||||||
|
down(props) {
|
||||||
|
delete props.someProperty
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
})
|
||||||
|
|
@ -0,0 +1,11 @@
|
||||||
|
import { DefaultColorStyle, RecordProps, T } from 'tldraw'
|
||||||
|
import { ICardShape } from './card-shape-types'
|
||||||
|
|
||||||
|
// Validation for our custom card shape's props, using one of tldraw's default styles
|
||||||
|
export const cardShapeProps: RecordProps<ICardShape> = {
|
||||||
|
w: T.number,
|
||||||
|
h: T.number,
|
||||||
|
color: DefaultColorStyle,
|
||||||
|
}
|
||||||
|
|
||||||
|
// To generate your own custom styles, check out the custom styles example.
|
||||||
|
|
@ -0,0 +1,11 @@
|
||||||
|
import { TLBaseShape, TLDefaultColorStyle } from 'tldraw'
|
||||||
|
|
||||||
|
// A type for our custom card shape
|
||||||
|
export type ICardShape = TLBaseShape<
|
||||||
|
'card',
|
||||||
|
{
|
||||||
|
w: number
|
||||||
|
h: number
|
||||||
|
color: TLDefaultColorStyle
|
||||||
|
}
|
||||||
|
>
|
||||||
|
|
@ -0,0 +1,120 @@
|
||||||
|
import { BaseBoxShapeUtil, HTMLContainer, RecordProps, T, TLBaseShape } from 'tldraw'
|
||||||
|
|
||||||
|
// There's a guide at the bottom of this file!
|
||||||
|
|
||||||
|
type IMyInteractiveShape = TLBaseShape<
|
||||||
|
'my-interactive-shape',
|
||||||
|
{
|
||||||
|
w: number
|
||||||
|
h: number
|
||||||
|
checked: boolean
|
||||||
|
text: string
|
||||||
|
}
|
||||||
|
>
|
||||||
|
|
||||||
|
export class myInteractiveShape extends BaseBoxShapeUtil<IMyInteractiveShape> {
|
||||||
|
static override type = 'my-interactive-shape' as const
|
||||||
|
static override props: RecordProps<IMyInteractiveShape> = {
|
||||||
|
w: T.number,
|
||||||
|
h: T.number,
|
||||||
|
checked: T.boolean,
|
||||||
|
text: T.string,
|
||||||
|
}
|
||||||
|
|
||||||
|
getDefaultProps(): IMyInteractiveShape['props'] {
|
||||||
|
return {
|
||||||
|
w: 230,
|
||||||
|
h: 230,
|
||||||
|
checked: false,
|
||||||
|
text: '',
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// [1]
|
||||||
|
component(shape: IMyInteractiveShape) {
|
||||||
|
return (
|
||||||
|
<HTMLContainer
|
||||||
|
style={{
|
||||||
|
padding: 16,
|
||||||
|
height: shape.props.h,
|
||||||
|
width: shape.props.w,
|
||||||
|
// [a] This is where we allow pointer events on our shape
|
||||||
|
pointerEvents: 'all',
|
||||||
|
backgroundColor: '#efefef',
|
||||||
|
overflow: 'hidden',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={shape.props.checked}
|
||||||
|
onChange={() =>
|
||||||
|
this.editor.updateShape<IMyInteractiveShape>({
|
||||||
|
id: shape.id,
|
||||||
|
type: 'my-interactive-shape',
|
||||||
|
props: { checked: !shape.props.checked },
|
||||||
|
})
|
||||||
|
}
|
||||||
|
// [b] This is where we stop event propagation
|
||||||
|
onPointerDown={(e) => e.stopPropagation()}
|
||||||
|
onTouchStart={(e) => e.stopPropagation()}
|
||||||
|
onTouchEnd={(e) => e.stopPropagation()}
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder="Enter a todo..."
|
||||||
|
readOnly={shape.props.checked}
|
||||||
|
value={shape.props.text}
|
||||||
|
onChange={(e) =>
|
||||||
|
this.editor.updateShape<IMyInteractiveShape>({
|
||||||
|
id: shape.id,
|
||||||
|
type: 'my-interactive-shape',
|
||||||
|
props: { text: e.currentTarget.value },
|
||||||
|
})
|
||||||
|
}
|
||||||
|
// [c]
|
||||||
|
onPointerDown={(e) => {
|
||||||
|
if (!shape.props.checked) {
|
||||||
|
e.stopPropagation()
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
onTouchStart={(e) => {
|
||||||
|
if (!shape.props.checked) {
|
||||||
|
e.stopPropagation()
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
onTouchEnd={(e) => {
|
||||||
|
if (!shape.props.checked) {
|
||||||
|
e.stopPropagation()
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</HTMLContainer>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// [5]
|
||||||
|
indicator(shape: IMyInteractiveShape) {
|
||||||
|
return <rect width={shape.props.w} height={shape.props.h} />
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
This is a custom shape, for a more in-depth look at how to create a custom shape,
|
||||||
|
see our custom shape example.
|
||||||
|
|
||||||
|
[1]
|
||||||
|
This is where we describe how our shape will render
|
||||||
|
|
||||||
|
[a] We need to set pointer-events to all so that we can interact with our shape. This CSS property is
|
||||||
|
set to "none" off by default. We need to manually opt-in to accepting pointer events by setting it to
|
||||||
|
'all' or 'auto'.
|
||||||
|
|
||||||
|
[b] We need to stop event propagation so that the editor doesn't select the shape
|
||||||
|
when we click on the checkbox. The 'canvas container' forwards events that it receives
|
||||||
|
on to the editor, so stopping propagation here prevents the event from reaching the canvas.
|
||||||
|
|
||||||
|
[c] If the shape is not checked, we stop event propagation so that the editor doesn't
|
||||||
|
select the shape when we click on the input. If the shape is checked then we allow that event to
|
||||||
|
propagate to the canvas and then get sent to the editor, triggering clicks or drags as usual.
|
||||||
|
|
||||||
|
*/
|
||||||
|
|
@ -0,0 +1,9 @@
|
||||||
|
export const calcReadingTime = (text: string): string => {
|
||||||
|
if (!text) return "∞ min read";
|
||||||
|
|
||||||
|
const wordsPerMinute = 300;
|
||||||
|
const wordCount = text.split(/\s+/).length;
|
||||||
|
const minutes = Math.ceil(wordCount / wordsPerMinute);
|
||||||
|
|
||||||
|
return `${minutes} min read`;
|
||||||
|
};
|
||||||
|
|
@ -0,0 +1,16 @@
|
||||||
|
import { createShapeId } from "tldraw";
|
||||||
|
|
||||||
|
export function createShapes(elementsInfo: any) {
|
||||||
|
const shapes = elementsInfo.map((element: any) => ({
|
||||||
|
id: createShapeId(),
|
||||||
|
type: 'html',
|
||||||
|
x: element.x,
|
||||||
|
y: element.y,
|
||||||
|
props: {
|
||||||
|
w: element.w,
|
||||||
|
h: element.h,
|
||||||
|
html: element.html,
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
return shapes;
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1 @@
|
||||||
|
/// <reference types="vite/client" />
|
||||||
|
|
@ -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"]
|
||||||
|
}
|
||||||
40
vercel.json
40
vercel.json
|
|
@ -1,5 +1,39 @@
|
||||||
{
|
{
|
||||||
"buildCommand": "",
|
"buildCommand": "yarn build",
|
||||||
"framework" : "other",
|
"installCommand": "yarn install",
|
||||||
"outputDirectory": "."
|
"framework": "vite",
|
||||||
|
"outputDirectory": "dist",
|
||||||
|
"rewrites": [
|
||||||
|
{
|
||||||
|
"source": "/posts/(.*)",
|
||||||
|
"destination": "/"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"source": "/board/(.*)",
|
||||||
|
"destination": "/"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"source": "/board",
|
||||||
|
"destination": "/"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"source": "/inbox",
|
||||||
|
"destination": "/"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"source": "/books",
|
||||||
|
"destination": "/"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"headers": [
|
||||||
|
{
|
||||||
|
"source": "/assets/(.*)",
|
||||||
|
"headers": [
|
||||||
|
{
|
||||||
|
"key": "Cache-Control",
|
||||||
|
"value": "public, max-age=31536000, immutable"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
}
|
}
|
||||||
|
|
@ -0,0 +1,38 @@
|
||||||
|
import { markdownPlugin } from './build/markdownPlugin';
|
||||||
|
import { defineConfig } from 'vite'
|
||||||
|
import react from '@vitejs/plugin-react'
|
||||||
|
import wasm from "vite-plugin-wasm";
|
||||||
|
import topLevelAwait from "vite-plugin-top-level-await";
|
||||||
|
import { viteStaticCopy } from 'vite-plugin-static-copy';
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
envPrefix: ['VITE_'],
|
||||||
|
plugins: [
|
||||||
|
react(),
|
||||||
|
wasm(),
|
||||||
|
topLevelAwait(),
|
||||||
|
markdownPlugin,
|
||||||
|
viteStaticCopy({
|
||||||
|
targets: [
|
||||||
|
{
|
||||||
|
src: 'src/posts/',
|
||||||
|
dest: '.'
|
||||||
|
}
|
||||||
|
]
|
||||||
|
})
|
||||||
|
],
|
||||||
|
server: {
|
||||||
|
host: '0.0.0.0',
|
||||||
|
port: 5173,
|
||||||
|
},
|
||||||
|
build: {
|
||||||
|
sourcemap: true,
|
||||||
|
},
|
||||||
|
base: '/',
|
||||||
|
publicDir: 'src/public',
|
||||||
|
resolve: {
|
||||||
|
alias: {
|
||||||
|
'@': '/src',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
@ -0,0 +1,194 @@
|
||||||
|
/// <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 GSet from 'crdts/src/G-Set'
|
||||||
|
|
||||||
|
// add custom shapes and bindings here if needed:
|
||||||
|
export const customSchema = createTLSchema({
|
||||||
|
shapes: {
|
||||||
|
...defaultShapeSchemas,
|
||||||
|
ChatBox: ChatBoxShape,
|
||||||
|
VideoChat: VideoChatShape,
|
||||||
|
Embed: EmbedShape
|
||||||
|
},
|
||||||
|
// bindings: { ...defaultBindingSchemas },
|
||||||
|
})
|
||||||
|
|
||||||
|
// each whiteboard room is hosted in a DurableObject:
|
||||||
|
// https://developers.cloudflare.com/durable-objects/
|
||||||
|
|
||||||
|
// there's only ever one durable object instance per room. it keeps all the room state in memory and
|
||||||
|
// handles websocket connections. periodically, it persists the room state to the R2 bucket.
|
||||||
|
export class TldrawDurableObject {
|
||||||
|
private r2: R2Bucket
|
||||||
|
// the room ID will be missing whilst the room is being initialized
|
||||||
|
private roomId: string | null = null
|
||||||
|
// when we load the room from the R2 bucket, we keep it here. it's a promise so we only ever
|
||||||
|
// load it once.
|
||||||
|
private roomPromise: Promise<TLSocketRoom<TLRecord, void>> | null = null
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
private readonly ctx: DurableObjectState,
|
||||||
|
env: Environment
|
||||||
|
) {
|
||||||
|
this.r2 = env.TLDRAW_BUCKET
|
||||||
|
|
||||||
|
ctx.blockConcurrencyWhile(async () => {
|
||||||
|
this.roomId = ((await this.ctx.storage.get('roomId')) ?? null) as string | null
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
private readonly router = AutoRouter({
|
||||||
|
catch: (e) => {
|
||||||
|
console.log(e)
|
||||||
|
return error(e)
|
||||||
|
},
|
||||||
|
})
|
||||||
|
// when we get a connection request, we stash the room id if needed and handle the connection
|
||||||
|
.get('/connect/:roomId', async (request) => {
|
||||||
|
if (!this.roomId) {
|
||||||
|
await this.ctx.blockConcurrencyWhile(async () => {
|
||||||
|
await this.ctx.storage.put('roomId', request.params.roomId)
|
||||||
|
this.roomId = request.params.roomId
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return this.handleConnect(request)
|
||||||
|
})
|
||||||
|
.get('/room/:roomId', async () => {
|
||||||
|
const room = await this.getRoom()
|
||||||
|
const snapshot = room.getCurrentSnapshot()
|
||||||
|
return new Response(JSON.stringify(snapshot.documents))
|
||||||
|
})
|
||||||
|
.post('/room/:roomId', async (request) => {
|
||||||
|
const records = await request.json() as TLRecord[]
|
||||||
|
const mergedRecords = await this.mergeCrdtState(records)
|
||||||
|
return new Response(JSON.stringify(Array.from(mergedRecords)))
|
||||||
|
})
|
||||||
|
|
||||||
|
// `fetch` is the entry point for all requests to the Durable Object
|
||||||
|
fetch(request: Request): Response | Promise<Response> {
|
||||||
|
return this.router.fetch(request)
|
||||||
|
}
|
||||||
|
|
||||||
|
// what happens when someone tries to connect to this room?
|
||||||
|
async handleConnect(request: IRequest): Promise<Response> {
|
||||||
|
// extract query params from request
|
||||||
|
const sessionId = request.query.sessionId as string
|
||||||
|
if (!sessionId) return error(400, 'Missing sessionId')
|
||||||
|
|
||||||
|
// Create the websocket pair for the client
|
||||||
|
const { 0: clientWebSocket, 1: serverWebSocket } = new WebSocketPair()
|
||||||
|
// @ts-ignore
|
||||||
|
serverWebSocket.accept()
|
||||||
|
|
||||||
|
// load the room, or retrieve it if it's already loaded
|
||||||
|
const room = await this.getRoom()
|
||||||
|
|
||||||
|
// connect the client to the room
|
||||||
|
room.handleSocketConnect({ sessionId, socket: serverWebSocket })
|
||||||
|
|
||||||
|
// return the websocket connection to the client
|
||||||
|
return new Response(null, { status: 101, webSocket: clientWebSocket })
|
||||||
|
}
|
||||||
|
|
||||||
|
getRoom() {
|
||||||
|
const roomId = this.roomId
|
||||||
|
if (!roomId) throw new Error('Missing roomId')
|
||||||
|
|
||||||
|
if (!this.roomPromise) {
|
||||||
|
this.roomPromise = (async () => {
|
||||||
|
// fetch the room from R2
|
||||||
|
const roomFromBucket = await this.r2.get(`rooms/${roomId}`)
|
||||||
|
// if it doesn't exist, we'll just create a new empty room
|
||||||
|
const initialSnapshot = roomFromBucket
|
||||||
|
? ((await roomFromBucket.json()) as RoomSnapshot)
|
||||||
|
: undefined
|
||||||
|
if (initialSnapshot) {
|
||||||
|
initialSnapshot.documents = initialSnapshot.documents.filter(record => {
|
||||||
|
const shape = record.state as TLShape
|
||||||
|
return shape.type !== "chatBox"
|
||||||
|
})
|
||||||
|
}
|
||||||
|
// create a new TLSocketRoom. This handles all the sync protocol & websocket connections.
|
||||||
|
// it's up to us to persist the room state to R2 when needed though.
|
||||||
|
return new TLSocketRoom<TLRecord, void>({
|
||||||
|
schema: customSchema,
|
||||||
|
initialSnapshot,
|
||||||
|
onDataChange: () => {
|
||||||
|
// and persist whenever the data in the room changes
|
||||||
|
this.schedulePersistToR2()
|
||||||
|
},
|
||||||
|
})
|
||||||
|
})()
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.roomPromise
|
||||||
|
}
|
||||||
|
|
||||||
|
// we throttle persistance so it only happens every 10 seconds
|
||||||
|
schedulePersistToR2 = throttle(async () => {
|
||||||
|
if (!this.roomPromise || !this.roomId) return
|
||||||
|
const room = await this.getRoom()
|
||||||
|
|
||||||
|
// convert the room to JSON and upload it to R2
|
||||||
|
const snapshot = JSON.stringify(room.getCurrentSnapshot())
|
||||||
|
await this.r2.put(`rooms/${this.roomId}`, snapshot)
|
||||||
|
}, 10_000)
|
||||||
|
|
||||||
|
async mergeCrdtState(records: TLRecord[]) {
|
||||||
|
const room = await this.getRoom();
|
||||||
|
const gset = new GSet<TLRecord>();
|
||||||
|
|
||||||
|
const store = room.getCurrentSnapshot();
|
||||||
|
if (!store) {
|
||||||
|
throw new Error('Room store not initialized');
|
||||||
|
}
|
||||||
|
|
||||||
|
// First cast to unknown, then to TLRecord
|
||||||
|
store.documents.forEach((record) => gset.add(record as unknown as TLRecord));
|
||||||
|
|
||||||
|
// Merge new records
|
||||||
|
records.forEach((record: TLRecord) => gset.add(record));
|
||||||
|
return gset.values();
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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
|
||||||
|
server.addEventListener('error', (err) => {
|
||||||
|
console.error('WebSocket error:', err)
|
||||||
|
})
|
||||||
|
|
||||||
|
return new Response(null, {
|
||||||
|
status: 101,
|
||||||
|
webSocket: client,
|
||||||
|
headers: {
|
||||||
|
'Access-Control-Allow-Origin': '*',
|
||||||
|
'Access-Control-Allow-Headers': '*',
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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,10 @@
|
||||||
|
// 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_DURABLE_OBJECT: DurableObjectNamespace
|
||||||
|
DAILY_API_KEY: string;
|
||||||
|
DAILY_DOMAIN: string;
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,132 @@
|
||||||
|
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) => {
|
||||||
|
if (!origin) return undefined
|
||||||
|
|
||||||
|
const allowedPatterns = [
|
||||||
|
// Localhost with any port
|
||||||
|
/^http:\/\/localhost:\d+$/,
|
||||||
|
// 127.0.0.1 with any port
|
||||||
|
/^http:\/\/127\.0\.0\.1:\d+$/,
|
||||||
|
// 192.168.*.* with any port
|
||||||
|
/^http:\/\/192\.168\.\d+\.\d+:\d+$/,
|
||||||
|
// 169.254.*.* with any port
|
||||||
|
/^http:\/\/169\.254\.\d+\.\d+:\d+$/,
|
||||||
|
// 10.*.*.* with any port
|
||||||
|
/^http:\/\/10\.\d+\.\d+\.\d+:\d+$/,
|
||||||
|
// Production domain
|
||||||
|
/^https:\/\/jeffemmett\.com$/
|
||||||
|
]
|
||||||
|
|
||||||
|
// Check if origin matches any of our patterns
|
||||||
|
const isAllowed = allowedPatterns.some(pattern =>
|
||||||
|
pattern instanceof RegExp
|
||||||
|
? pattern.test(origin)
|
||||||
|
: pattern === origin
|
||||||
|
)
|
||||||
|
|
||||||
|
return isAllowed ? origin : undefined
|
||||||
|
},
|
||||||
|
allowMethods: ['GET', 'POST', 'OPTIONS', 'UPGRADE'],
|
||||||
|
allowHeaders: [
|
||||||
|
'Content-Type',
|
||||||
|
'Authorization',
|
||||||
|
'Upgrade',
|
||||||
|
'Connection',
|
||||||
|
'Sec-WebSocket-Key',
|
||||||
|
'Sec-WebSocket-Version',
|
||||||
|
'Sec-WebSocket-Extensions',
|
||||||
|
'Sec-WebSocket-Protocol',
|
||||||
|
...Object.keys(securityHeaders)
|
||||||
|
],
|
||||||
|
maxAge: 86400,
|
||||||
|
})
|
||||||
|
const router = AutoRouter<IRequest, [env: Environment, ctx: ExecutionContext]>({
|
||||||
|
before: [preflight],
|
||||||
|
finally: [(response) => {
|
||||||
|
// Add security headers to all responses except WebSocket upgrades
|
||||||
|
if (response.status !== 101) {
|
||||||
|
Object.entries(securityHeaders).forEach(([key, value]) => {
|
||||||
|
response.headers.set(key, value)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return corsify(response)
|
||||||
|
}],
|
||||||
|
catch: (e) => {
|
||||||
|
console.error(e)
|
||||||
|
return error(e)
|
||||||
|
},
|
||||||
|
})
|
||||||
|
// requests to /connect are routed to the Durable Object, and handle realtime websocket syncing
|
||||||
|
.get('/connect/:roomId', (request, env) => {
|
||||||
|
const id = env.TLDRAW_DURABLE_OBJECT.idFromName(request.params.roomId)
|
||||||
|
const room = env.TLDRAW_DURABLE_OBJECT.get(id)
|
||||||
|
return room.fetch(request.url, { headers: request.headers, body: request.body })
|
||||||
|
})
|
||||||
|
|
||||||
|
// 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', async (request, env) => {
|
||||||
|
const id = env.TLDRAW_DURABLE_OBJECT.idFromName(request.params.roomId)
|
||||||
|
const room = env.TLDRAW_DURABLE_OBJECT.get(id)
|
||||||
|
const response = await room.fetch(request.url)
|
||||||
|
return response
|
||||||
|
})
|
||||||
|
|
||||||
|
.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) => {
|
||||||
|
const response = await fetch('https://api.daily.co/v1/rooms', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Authorization': `Bearer ${env.DAILY_API_KEY}`,
|
||||||
|
'Content-Type': 'application/json'
|
||||||
|
},
|
||||||
|
body: await request.text()
|
||||||
|
});
|
||||||
|
|
||||||
|
const data = await response.json() as Record<string, unknown>;
|
||||||
|
return new Response(JSON.stringify({
|
||||||
|
...data,
|
||||||
|
url: `https://${env.DAILY_DOMAIN}/${data.name}`
|
||||||
|
}), {
|
||||||
|
headers: { 'Content-Type': 'application/json' }
|
||||||
|
});
|
||||||
|
})
|
||||||
|
|
||||||
|
// export our router for cloudflare
|
||||||
|
export default router
|
||||||
|
|
@ -0,0 +1,33 @@
|
||||||
|
main = "worker/worker.ts"
|
||||||
|
compatibility_date = "2024-07-01"
|
||||||
|
name = "jeffemmett-canvas"
|
||||||
|
account_id = "0e7b3338d5278ed1b148e6456b940913"
|
||||||
|
zone_id = "45c200f8dc2a01852e41b9bb09eb7359"
|
||||||
|
|
||||||
|
[vars]
|
||||||
|
# Environment variables are managed in Cloudflare Dashboard
|
||||||
|
# Workers & Pages → jeffemmett-canvas → Settings → Variables
|
||||||
|
|
||||||
|
[dev]
|
||||||
|
port = 5172
|
||||||
|
ip = "0.0.0.0"
|
||||||
|
local_protocol = "http"
|
||||||
|
upstream_protocol = "https"
|
||||||
|
|
||||||
|
[durable_objects]
|
||||||
|
bindings = [
|
||||||
|
{ name = "TLDRAW_DURABLE_OBJECT", class_name = "TldrawDurableObject" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[migrations]]
|
||||||
|
tag = "v1"
|
||||||
|
new_classes = ["TldrawDurableObject"]
|
||||||
|
|
||||||
|
[[r2_buckets]]
|
||||||
|
binding = 'TLDRAW_BUCKET'
|
||||||
|
bucket_name = 'jeffemmett-canvas'
|
||||||
|
preview_bucket_name = 'jeffemmett-canvas-preview'
|
||||||
|
|
||||||
|
[observability]
|
||||||
|
enabled = true
|
||||||
|
head_sampling_rate = 1
|
||||||
Loading…
Reference in New Issue