oriomimicry

This commit is contained in:
Jeff Emmett 2024-08-09 23:14:58 -04:00
parent d47b8b9be9
commit ea9f47e48c
44 changed files with 1967 additions and 4 deletions

5
.gitattributes vendored Normal file
View File

@ -0,0 +1,5 @@
*.pdf filter=lfs diff=lfs merge=lfs -text
*.mp4 filter=lfs diff=lfs merge=lfs -text
*.mov filter=lfs diff=lfs merge=lfs -text
*.png filter=lfs diff=lfs merge=lfs -text
*.gif filter=lfs diff=lfs merge=lfs -text

172
.gitignore vendored Normal file
View File

@ -0,0 +1,172 @@
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.\*

3
README.md Normal file
View File

@ -0,0 +1,3 @@
A website.
Do `yarn dev`

17
build/markdownPlugin.js Normal file
View File

@ -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)};`;
}
},
};

42
build/markdownToHtml.js Normal file
View File

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

View File

@ -1 +1,42 @@
Content is here
<!DOCTYPE html>
<html>
<head>
<title>Orion Reed</title>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<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://orionreed.com">
<meta property="og:type" content="website">
<meta property="og:title" content="Orion Reed">
<meta property="og: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:image" content="/website-embed.png">
<meta name="twitter:card" content="summary_large_image">
<meta property="twitter:domain" content="orionreed.com">
<meta property="twitter:url" content="https://orionreed.com">
<meta name="twitter:title" content="Orion Reed">
<meta name="twitter: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 name="twitter:image" content="/website-embed.png">
<!-- Analytics -->
<script data-goatcounter="https://orion.goatcounter.com/count"
async src="//gc.zgo.at/count.js"></script>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/App.tsx"></script>
</body>
</html>

37
package.json Normal file
View File

@ -0,0 +1,37 @@
{
"name": "orionreed",
"version": "1.0.0",
"description": "Orion Reed's personal website",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc && vite build",
"preview": "tsc && vite build && vite preview"
},
"keywords": [],
"author": "Orion Reed",
"license": "ISC",
"dependencies": {
"@dimforge/rapier2d": "^0.11.2",
"@tldraw/tldraw": "2.0.2",
"@types/markdown-it": "^14.1.1",
"@vercel/analytics": "^1.2.2",
"gray-matter": "^4.0.3",
"markdown-it": "^14.1.0",
"markdown-it-latex2img": "^0.0.6",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"react-router-dom": "^6.22.3"
},
"devDependencies": {
"@biomejs/biome": "1.4.1",
"@types/react": "^18.2.15",
"@types/react-dom": "^18.2.7",
"@vitejs/plugin-react": "^4.0.3",
"typescript": "^5.0.2",
"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"
}
}

56
src/App.tsx Normal file
View File

@ -0,0 +1,56 @@
import { inject } from '@vercel/analytics';
import "@tldraw/tldraw/tldraw.css";
import "@/css/style.css"
import React, { 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";
import { BrowserRouter, Route, Routes } from 'react-router-dom';
import { Contact } from "@/components/Contact";
import { Post } from '@/components/Post';
inject();
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 />} />
</Routes>
</BrowserRouter>
</React.StrictMode>
);
};
function Home() {
const { isCanvasEnabled, elementsInfo } = useCanvas();
const shapes = createShapes(elementsInfo)
const [isEditorMounted, setIsEditorMounted] = useState(false);
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}</>)
}

34
src/components/Canvas.tsx Normal file
View File

@ -0,0 +1,34 @@
import { Editor, Tldraw, TLShape, TLUiComponents } from "@tldraw/tldraw";
import { SimController } from "@/physics/PhysicsControls";
import { HTMLShapeUtil } from "@/shapes/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: Editor) => {
window.dispatchEvent(new CustomEvent('editorDidMountEvent'));
editor.user.updateUserPreferences({ isDarkMode: false })
}}
>
<SimController shapes={shapes} />
</Tldraw>
</div>
);
}

View File

@ -0,0 +1,17 @@
export function Contact() {
return (
<main>
<header>
<a href="/">
Orion Reed
</a>
</header>
<h1>Contact</h1>
<p>Twitter: <a href="https://twitter.com/OrionReedOne">@OrionReedOne</a></p>
<p>Mastodon: <a href="https://hci.social/@orion">orion@hci.social</a></p>
<p>Email: <a href="mailto:me@orionreed.com">me@orionreed.com</a></p>
<p>GitHub: <a href="https://github.com/orionreed">OrionReed</a></p>
</main>
);
}

View File

@ -0,0 +1,73 @@
export function Default() {
return (
<main>
<header>
Orion Reed
</header>
<h2>Hello! 👋</h2>
<p>
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.
</p>
<p>
My current focus is basic research into the nature of digital
organisation, developing theoretical 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 computational artifacts.
</p>
<h2>My work</h2>
<p>
Alongside my independent work I am a researcher at <a href="https://block.science/">Block Science</a> building{' '}
<i>knowledge organisation infrastructure</i> and an engineer-in-residence at <a href="https://tldraw.com">tldraw</a>. I am also part of the nascent <a href="https://libcomp.org/">Liberatory Computing</a>{' '}
collective, occasional collaborator with <a href="https://economicspace.agency/">ECSA</a> and a co-organiser of the <a href="https://canvasprotocol.org/">OCWG</a>.
</p>
<h2>Get in touch</h2>
<p>
I am on Twitter <a href="https://twitter.com/OrionReedOne">@OrionReedOne</a>,
Mastodon <a href="https://hci.social/@orion">@orion@hci.social</a> and GitHub <a href="https://github.com/orionreed">@orionreed</a>. You can also shoot me an email <a href="mailto:me@orionreed.com">me@orionreed.com</a>
</p>
<span className="dinkus">***</span>
<h2>Talks</h2>
<ol reversed>
<li><a
href="https://www.youtube.com/watch?v=csGNVaB83Rk">Spatial
Canvases: Towards an Integration Domain for HCI @ TfT Rocks 2024</a> (<a href="artifact/tft-rocks-integration-domain.pdf">slides</a>)
</li>
<li><a
href="https://www.youtube.com/watch?v=-q-kk-NMFbA">Knowledge Organisation Infrastructure Demo @ NPC
Denver</a></li>
</ol>
<h2>Writing</h2>
<ol reversed>
<li><a
href="/posts/scoped-propagators">Scoped Propagators: A programming model for spatial canvases</a></li>
<li><a
href="https://blog.block.science/objects-as-reference-toward-robust-first-principles-of-digital-organization/">Objects
as Reference: Toward Robust First Principles of Digital Organization</a></li>
</ol>
<h2>Things I made recently</h2>
<ol reversed>
<li><a
href="https://twitter.com/OrionReedOne/status/1772934478421188620">Tiny 3D HTML/DOM viewer (you can paste it into your console)</a>
</li>
<li><a
href="https://twitter.com/OrionReedOne/status/1772271156373905465">DOM to canvas translation (it's that button in the top right of this page)</a>
</li>
<li><a
href="https://github.com/OrionReed/tldraw-graph-layout">Constraint-based graph layout in tldraw</a>
</li>
<li><a
href="https://github.com/OrionReed/tldraw-physics">Physics in tldraw</a>
</li>
</ol>
</main>
);
}

45
src/components/Post.tsx Normal file
View File

@ -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 :&#40;</div>;
}
document.title = post.data.title;
return (
<main>
<header>
<a href="/" style={{ textDecoration: 'none' }}>Orion Reed</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>
);
}

12
src/components/Toggle.tsx Normal file
View File

@ -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>
</>
);
}

89
src/css/reset.css Normal file
View File

@ -0,0 +1,89 @@
/* Box sizing rules */
*,
*::before,
*::after {
box-sizing: border-box;
}
/* Prevent font size inflation */
html {
-moz-text-size-adjust: none;
-webkit-text-size-adjust: none;
text-size-adjust: none;
}
/* Remove default margin in favour of better control in authored CSS */
body,
h1,
h2,
h3,
h4,
p,
figure,
blockquote,
dl,
dd {
margin-block-end: 0;
}
/* Remove list styles on ul, ol elements with a list role, which suggests default styling will be removed */
ul[role="list"],
ol[role="list"] {
list-style: none;
}
/* Set core body defaults */
body {
min-height: 100vh;
line-height: 1.5;
}
/* Set shorter line heights on headings and interactive elements */
h1,
h2,
h3,
h4,
button,
input,
label {
line-height: 1.1;
}
/* Balance text wrapping on headings */
h1,
h2,
h3,
h4 {
text-wrap: balance;
}
/* A elements that don't have a class get default styles */
a:not([class]) {
text-decoration-skip-ink: auto;
color: currentColor;
}
/* Make images easier to work with */
img,
picture {
max-width: 100%;
display: block;
}
/* Inherit fonts for inputs and buttons */
input,
button,
textarea,
select {
font: inherit;
}
/* Make sure textareas without a rows attribute are not tiny */
textarea:not([rows]) {
min-height: 10em;
}
/* Anything that has been anchored to should have extra scroll margin */
:target {
scroll-margin-block: 5ex;
}

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

@ -0,0 +1,304 @@
@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;
}
.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;
}

98
src/hooks/useCanvas.ts Normal file
View File

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

View File

@ -0,0 +1,66 @@
import { Editor, TLUnknownShape, createShapeId, useEditor } from "@tldraw/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
}
});
}

36
src/physics/config.ts Normal file
View File

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

95
src/physics/math.ts Normal file
View File

@ -0,0 +1,95 @@
import { Geometry2d, Vec, VecLike } from "@tldraw/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;
}

395
src/physics/simulation.ts Normal file
View File

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

View File

@ -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.
![examples](examples.mp4)
## 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:
![intro](intro.mp4)
## 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.
![buttons](buttons.mp4)
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.
![constraints](constraints.mp4)
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.
![inspection](inspection.mp4)
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.
![lerp](lerp.mp4)
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.
![tools](tools.mp4)
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.
![bridging systems](bridging.mov)
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.
![game](game.mp4)
## 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.

View File

@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:c8a5fea015bcf937fbce5b0a233067e31059fb2e4d0f32f6471395fb82c6407c
size 1708542

View File

@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:8763ad95ef6e31e4c66f9298d0ad22296edd83ab2fd6d7aa1549c8845521c932
size 61302

View File

@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:fbc1493d124f92abe9c35534b4b6dca4a8081c570f4e3f07c4d9559d60dea3eb
size 87493

View File

@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:55e5c2ce64c027a808951ad11f8757f3a8d7d739639a72517a4b39c35aad815d
size 21393177

View File

@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:0c27fbc53fb819ffc3f83a52a939c52bc5f92788e5212a602c31f94ff0f108d8
size 386809

View File

@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:2f50fe4800e2a5957e0b96aa18121ea3dc40f514162914e9f771497e4cc54d1f
size 224988

View File

@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:29771060ae2ddb4c8056b4a68e24da75066e96b0a9264ab5577efba66de34379
size 131411

View File

@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:3725438bab72654d31e56303f4694cb481da03ed52668a3235c0510769e43ef2
size 213429

View File

@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:6c4030b83ec1ae36a9475a1165dc791f8d651da70cae91c1ab90c50a54d5bbd3
size 127229

View File

@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:c8140e8b76c29fa3c828553e8a6a87651af6e7ee18d1ccb82e79125eb532194b
size 19738445

View File

@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:d63dd9f37a74b85680a7c1df823f69936619ecab5e8d4d06eeeedea4d908f1de
size 18268955

View File

@ -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

BIN
src/public/favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 84 KiB

View File

@ -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

View File

@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:4b55a408375712bc169bc5c987d85c71c353028e611f216817f13ca0fb284604
size 28423

View File

@ -0,0 +1,58 @@
import { Rectangle2d, resizeBox, TLBaseShape, TLOnBeforeUpdateHandler, TLOnResizeHandler } from '@tldraw/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} />
}
}

16
src/utils.tsx Normal file
View File

@ -0,0 +1,16 @@
import { createShapeId } from "@tldraw/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;
}

9
src/utils/readingTime.ts Normal file
View File

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

1
src/vite-env.d.ts vendored Normal file
View File

@ -0,0 +1 @@
/// <reference types="vite/client" />

31
tsconfig.json Normal file
View File

@ -0,0 +1,31 @@
{
"compilerOptions": {
"baseUrl": "./src",
"paths": {
"@/*": ["*"],
"src/*": ["./src/*"],
},
"target": "ES2020",
"useDefineForClassFields": true,
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"module": "ESNext",
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"jsx": "react-jsx",
/* Linting */
"strict": true,
"noUnusedLocals": false,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true
},
"include": ["src"],
"references": [{ "path": "./tsconfig.node.json" }]
}

10
tsconfig.node.json Normal file
View File

@ -0,0 +1,10 @@
{
"compilerOptions": {
"composite": true,
"skipLibCheck": true,
"module": "ESNext",
"moduleResolution": "bundler",
"allowSyntheticDefaultImports": true
},
"include": ["vite.config.ts"]
}

View File

@ -1,5 +1,11 @@
{
"buildCommand": "",
"framework" : null,
"outputDirectory": "."
"buildCommand": "yarn build",
"framework": "vite",
"outputDirectory": "dist",
"rewrites": [
{
"source": "/posts/(.*)",
"destination": "/"
}
]
}

34
vite.config.ts Normal file
View File

@ -0,0 +1,34 @@
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({
plugins: [
react(),
wasm(),
topLevelAwait(),
markdownPlugin,
viteStaticCopy({
targets: [
{
src: 'src/posts/',
dest: '.'
}
]
})
],
build: {
sourcemap: true,
},
base: '/',
publicDir: 'src/public',
resolve: {
alias: {
'@': '/src',
},
},
})