cleanup
This commit is contained in:
parent
299f3eff87
commit
8817af2962
|
|
@ -172,7 +172,6 @@ dist
|
|||
.pnp.\*
|
||||
|
||||
.wrangler/
|
||||
.*.md
|
||||
|
||||
# Vercel
|
||||
.vercel/
|
||||
|
|
|
|||
|
|
@ -7,10 +7,10 @@
|
|||
"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",
|
||||
"build": "tsc && vite build",
|
||||
"lint": "eslint src --ext ts,tsx --report-unused-disable-directives --max-warnings 0",
|
||||
"preview": "vite preview",
|
||||
"deploy": "yarn build && vercel deploy --prod"
|
||||
"deploy": "yarn build && vercel deploy --prod && wrangler deploy"
|
||||
},
|
||||
"keywords": [],
|
||||
"author": "Jeff Emmett",
|
||||
|
|
|
|||
12
src/App.tsx
12
src/App.tsx
|
|
@ -2,12 +2,11 @@ import { inject } from '@vercel/analytics';
|
|||
import "tldraw/tldraw.css";
|
||||
import "@/css/style.css"
|
||||
import ReactDOM from "react-dom/client";
|
||||
import { Default } from "@/components/Default";
|
||||
import { Default } from "@/routes/Default";
|
||||
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 { Contact } from "@/routes/Contact";
|
||||
import { Board } from './routes/Board';
|
||||
import { Inbox } from './routes/Inbox';
|
||||
import {
|
||||
Editor,
|
||||
Tldraw,
|
||||
|
|
@ -86,8 +85,7 @@ function App() {
|
|||
<BrowserRouter>
|
||||
<Routes>
|
||||
<Route path="/" element={<Default />} />
|
||||
<Route path="/card/contact" element={<Contact />} />
|
||||
<Route path="/posts/:slug" element={<Post />} />
|
||||
<Route path="/contact" element={<Contact />} />
|
||||
<Route path="/board/:slug" element={<Board />} />
|
||||
<Route path="/inbox" element={<Inbox />} />
|
||||
</Routes>
|
||||
|
|
|
|||
|
|
@ -1,45 +0,0 @@
|
|||
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>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,12 +0,0 @@
|
|||
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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,66 +0,0 @@
|
|||
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
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
|
|
@ -1,36 +0,0 @@
|
|||
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,
|
||||
};
|
||||
|
|
@ -1,95 +0,0 @@
|
|||
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;
|
||||
}
|
||||
|
|
@ -1,398 +0,0 @@
|
|||
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()
|
||||
}
|
||||
};
|
||||
}
|
||||
|
|
@ -1,120 +0,0 @@
|
|||
---
|
||||
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.
|
||||
|
|
@ -1,112 +0,0 @@
|
|||
---
|
||||
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.
|
||||
|
|
@ -1,3 +0,0 @@
|
|||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:c8a5fea015bcf937fbce5b0a233067e31059fb2e4d0f32f6471395fb82c6407c
|
||||
size 1708542
|
||||
|
|
@ -1,3 +0,0 @@
|
|||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:8763ad95ef6e31e4c66f9298d0ad22296edd83ab2fd6d7aa1549c8845521c932
|
||||
size 61302
|
||||
|
|
@ -1,3 +0,0 @@
|
|||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:fbc1493d124f92abe9c35534b4b6dca4a8081c570f4e3f07c4d9559d60dea3eb
|
||||
size 87493
|
||||
|
|
@ -1,3 +0,0 @@
|
|||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:55e5c2ce64c027a808951ad11f8757f3a8d7d739639a72517a4b39c35aad815d
|
||||
size 21393177
|
||||
|
|
@ -1,3 +0,0 @@
|
|||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:0c27fbc53fb819ffc3f83a52a939c52bc5f92788e5212a602c31f94ff0f108d8
|
||||
size 386809
|
||||
|
|
@ -1,3 +0,0 @@
|
|||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:2f50fe4800e2a5957e0b96aa18121ea3dc40f514162914e9f771497e4cc54d1f
|
||||
size 224988
|
||||
|
|
@ -1,3 +0,0 @@
|
|||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:29771060ae2ddb4c8056b4a68e24da75066e96b0a9264ab5577efba66de34379
|
||||
size 131411
|
||||
|
|
@ -1,3 +0,0 @@
|
|||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:3725438bab72654d31e56303f4694cb481da03ed52668a3235c0510769e43ef2
|
||||
size 213429
|
||||
|
|
@ -1,3 +0,0 @@
|
|||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:6c4030b83ec1ae36a9475a1165dc791f8d651da70cae91c1ab90c50a54d5bbd3
|
||||
size 127229
|
||||
|
|
@ -12,7 +12,7 @@ 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 { multiplayerAssetStore } from '../utils/multiplayerAssetStore'
|
||||
import { EmbedShape } from '@/shapes/EmbedShapeUtil'
|
||||
import { EmbedTool } from '@/tools/EmbedTool'
|
||||
import { defaultShapeUtils, defaultBindingUtils } from 'tldraw'
|
||||
|
|
@ -0,0 +1,153 @@
|
|||
|
||||
Yes, it is possible to allow users of your website to render their own Google Docs securely, but it requires additional steps to ensure privacy, user authentication, and proper permissions. Here's how you can set it up:
|
||||
|
||||
---
|
||||
|
||||
### Steps to Enable Users to Render Their Own Google Docs
|
||||
|
||||
#### 1. Enable Google Sign-In for Your Website
|
||||
- Users need to authenticate with their Google account to grant your app access to their documents.
|
||||
- Use the [Google Sign-In library](https://developers.google.com/identity/sign-in/web) to implement OAuth authentication.
|
||||
|
||||
Steps:
|
||||
- Include the Google Sign-In button on your site:
|
||||
<script src="https://apis.google.com/js/platform.js" async defer></script>
|
||||
<meta name="google-signin-client_id" content="YOUR_CLIENT_ID.apps.googleusercontent.com">
|
||||
<div class="g-signin2" data-onsuccess="onSignIn"></div>
|
||||
|
||||
|
||||
- Handle the user's authentication token on sign-in:
|
||||
function onSignIn(googleUser) {
|
||||
var profile = googleUser.getBasicProfile();
|
||||
var idToken = googleUser.getAuthResponse().id_token;
|
||||
|
||||
// Send the token to your backend to authenticate and fetch user-specific documents
|
||||
fetch('/api/authenticate', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ token: idToken }),
|
||||
}).then(response => response.json())
|
||||
.then(data => console.log(data));
|
||||
}
|
||||
|
||||
|
||||
---
|
||||
|
||||
#### 2. Request Google Docs API Permissions
|
||||
- Once the user is authenticated, request permissions for the Google Docs API.
|
||||
- Scopes needed:
|
||||
|
||||
https://www.googleapis.com/auth/documents.readonly
|
||||
|
||||
|
||||
- Example request for API access:
|
||||
function requestDocsAccess() {
|
||||
gapi.auth2.getAuthInstance().signIn({
|
||||
scope: 'https://www.googleapis.com/auth/documents.readonly',
|
||||
}).then(() => {
|
||||
console.log('API access granted');
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
---
|
||||
|
||||
#### 3. Fetch User's Document Content
|
||||
- After receiving user authorization, fetch their document content using the Google Docs API.
|
||||
- Example using JavaScript:
|
||||
gapi.client.load('docs', 'v1', function () {
|
||||
var request = gapi.client.docs.documents.get({
|
||||
documentId: 'USER_DOCUMENT_ID',
|
||||
});
|
||||
|
||||
request.execute(function (response) {
|
||||
console.log(response);
|
||||
// Render document content on your website
|
||||
document.getElementById('doc-container').innerHTML = response.body.content.map(
|
||||
item => item.paragraph.elements.map(
|
||||
el => el.textRun.content
|
||||
).join('')
|
||||
).join('<br>');
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
- Ensure that USER_DOCUMENT_ID is input by the user (e.g., through a form field).
|
||||
|
||||
---
|
||||
|
||||
#### 4. Secure Your Backend
|
||||
- Create an API endpoint to handle requests for fetching document content.
|
||||
- Validate the user's Google token on your server using Google's token verification endpoint.
|
||||
- Use their authenticated token to call the Google Docs API and fetch the requested document.
|
||||
|
||||
Example in Python (using Flask):
|
||||
from google.oauth2 import id_token
|
||||
from google.auth.transport import requests
|
||||
from googleapiclient.discovery import build
|
||||
|
||||
@app.route('/api/fetch-doc', methods=['POST'])
|
||||
def fetch_doc():
|
||||
token = request.json.get('token')
|
||||
document_id = request.json.get('document_id')
|
||||
|
||||
# Verify token
|
||||
idinfo = id_token.verify_oauth2_token(token, requests.Request(), CLIENT_ID)
|
||||
if idinfo['iss'] not in ['accounts.google.com', 'https://accounts.google.com']:
|
||||
return 'Invalid token', 401
|
||||
|
||||
# Fetch the document
|
||||
creds = id_token.Credentials(token=token)
|
||||
service = build('docs', 'v1', credentials=creds)
|
||||
doc = service.documents().get(documentId=document_id).execute()
|
||||
|
||||
return jsonify(doc)
|
||||
|
||||
|
||||
|
||||
---
|
||||
|
||||
rohan mehta, [2024-11-21 4:42 PM]
|
||||
#### 5. Provide a Frontend UI
|
||||
- Allow users to input their Google Doc ID through a form.
|
||||
- Example:
|
||||
<input type="text" id="doc-id" placeholder="Enter your Google Doc ID">
|
||||
<button onclick="fetchDoc()">Render Doc</button>
|
||||
<div id="doc-container"></div>
|
||||
|
||||
|
||||
- JavaScript to send the document ID to your backend:
|
||||
function fetchDoc() {
|
||||
const docId = document.getElementById('doc-id').value;
|
||||
|
||||
fetch('/api/fetch-doc', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ token: userToken, document_id: docId }),
|
||||
})
|
||||
.then(response => response.json())
|
||||
.then(data => {
|
||||
document.getElementById('doc-container').innerHTML = JSON.stringify(data);
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
---
|
||||
|
||||
### Security and Privacy Considerations
|
||||
1. Authentication:
|
||||
- Verify each user's Google token before processing their request.
|
||||
- Only fetch documents they own or have shared with them.
|
||||
|
||||
2. Rate Limiting:
|
||||
- Implement rate limiting on your backend API to prevent abuse.
|
||||
|
||||
3. Permission Scope:
|
||||
- Use the minimal scope (documents.readonly) to ensure you can only read documents, not modify them.
|
||||
|
||||
4. Data Handling:
|
||||
- Never store user document content unless explicitly required and with user consent.
|
||||
|
||||
---
|
||||
|
||||
With this approach, each user will be able to render their own Google Docs securely while maintaining privacy. Let me know if you’d like a more detailed implementation in any specific programming language!
|
||||
|
|
@ -1,159 +0,0 @@
|
|||
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>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -1,6 +1,6 @@
|
|||
import { BaseBoxShapeUtil, TLBaseShape } from "tldraw";
|
||||
import { useEffect, useState } from "react";
|
||||
import { WORKER_URL } from '../components/Board';
|
||||
import { WORKER_URL } from '../routes/Board';
|
||||
|
||||
export type IVideoChatShape = TLBaseShape<
|
||||
'VideoChat',
|
||||
|
|
|
|||
|
|
@ -1,6 +0,0 @@
|
|||
declare module 'crdts/src/G-Set' {
|
||||
export default class GSet<T = any> {
|
||||
add(value: T): void;
|
||||
values(): Set<T>;
|
||||
}
|
||||
}
|
||||
|
|
@ -1,5 +1,5 @@
|
|||
import { TLAssetStore, uniqueId } from 'tldraw'
|
||||
import { WORKER_URL } from '../components/Board'
|
||||
import { WORKER_URL } from '../routes/Board'
|
||||
|
||||
export const multiplayerAssetStore: TLAssetStore = {
|
||||
async upload(_asset, file) {
|
||||
|
|
@ -1,16 +0,0 @@
|
|||
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;
|
||||
}
|
||||
|
|
@ -14,7 +14,6 @@ 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({
|
||||
|
|
@ -91,9 +90,8 @@ export class TldrawDurableObject {
|
|||
})
|
||||
.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)), {
|
||||
return new Response(JSON.stringify(Array.from(records)), {
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Access-Control-Allow-Origin': request.headers.get('Origin') || '*',
|
||||
|
|
@ -221,22 +219,6 @@ export class TldrawDurableObject {
|
|||
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) {
|
||||
|
|
|
|||
Loading…
Reference in New Issue