From 1ec463f1935c0258c27f3b3b48e1f6297a185cfe Mon Sep 17 00:00:00 2001
From: Jeff Emmett
Date: Thu, 1 Jan 2026 16:27:07 +0100
Subject: [PATCH] Initial rspace-online: FolkJS collaborative canvas with
subdomain routing
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
- Pure FolkJS implementation with folk-shape, folk-markdown components
- Bun server with WebSocket sync and Host header subdomain detection
- Community creation API at /api/communities
- Docker setup with Traefik labels for wildcard *.rspace.online routing
- Landing page with community creation form
- Canvas page with basic markdown note creation
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude Opus 4.5
---
.gitignore | 27 ++
Dockerfile | 40 +++
bun.lock | 162 ++++++++++++
docker-compose.yml | 35 +++
lib/DOMRectTransform.ts | 256 ++++++++++++++++++
lib/Matrix.ts | 138 ++++++++++
lib/TransformEvent.ts | 66 +++++
lib/Vector.ts | 89 +++++++
lib/cursors.ts | 35 +++
lib/folk-element.ts | 15 ++
lib/folk-markdown.ts | 269 +++++++++++++++++++
lib/folk-shape.ts | 544 ++++++++++++++++++++++++++++++++++++++
lib/index.ts | 23 ++
lib/resize-manager.ts | 43 +++
lib/tags.ts | 8 +
lib/types.ts | 1 +
lib/utils.ts | 1 +
package.json | 24 ++
server/community-store.ts | 114 ++++++++
server/index.ts | 260 ++++++++++++++++++
tsconfig.json | 25 ++
vite.config.ts | 28 ++
website/canvas.html | 302 +++++++++++++++++++++
website/index.html | 261 ++++++++++++++++++
24 files changed, 2766 insertions(+)
create mode 100644 .gitignore
create mode 100644 Dockerfile
create mode 100644 bun.lock
create mode 100644 docker-compose.yml
create mode 100644 lib/DOMRectTransform.ts
create mode 100644 lib/Matrix.ts
create mode 100644 lib/TransformEvent.ts
create mode 100644 lib/Vector.ts
create mode 100644 lib/cursors.ts
create mode 100644 lib/folk-element.ts
create mode 100644 lib/folk-markdown.ts
create mode 100644 lib/folk-shape.ts
create mode 100644 lib/index.ts
create mode 100644 lib/resize-manager.ts
create mode 100644 lib/tags.ts
create mode 100644 lib/types.ts
create mode 100644 lib/utils.ts
create mode 100644 package.json
create mode 100644 server/community-store.ts
create mode 100644 server/index.ts
create mode 100644 tsconfig.json
create mode 100644 vite.config.ts
create mode 100644 website/canvas.html
create mode 100644 website/index.html
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..32d71f4
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,27 @@
+# Dependencies
+node_modules/
+
+# Build output
+dist/
+
+# Data storage
+data/
+
+# IDE
+.vscode/
+.idea/
+
+# OS
+.DS_Store
+Thumbs.db
+
+# Logs
+*.log
+
+# Environment
+.env
+.env.local
+.env.*.local
+
+# Bun
+bun.lockb
diff --git a/Dockerfile b/Dockerfile
new file mode 100644
index 0000000..06a29ae
--- /dev/null
+++ b/Dockerfile
@@ -0,0 +1,40 @@
+# Build stage
+FROM oven/bun:1 AS build
+WORKDIR /app
+
+# Copy package files
+COPY package.json bun.lockb* ./
+RUN bun install --frozen-lockfile
+
+# Copy source
+COPY . .
+
+# Build frontend
+RUN bun run build
+
+# Production stage
+FROM oven/bun:1-slim AS production
+WORKDIR /app
+
+# Copy built assets and server
+COPY --from=build /app/dist ./dist
+COPY --from=build /app/server ./server
+COPY --from=build /app/package.json .
+
+# Install production dependencies only
+RUN bun install --production --frozen-lockfile
+
+# Create data directory
+RUN mkdir -p /data/communities
+
+# Set environment
+ENV NODE_ENV=production
+ENV STORAGE_DIR=/data/communities
+ENV PORT=3000
+
+# Data volume for persistence
+VOLUME /data/communities
+
+EXPOSE 3000
+
+CMD ["bun", "run", "server/index.ts"]
diff --git a/bun.lock b/bun.lock
new file mode 100644
index 0000000..6fa75ab
--- /dev/null
+++ b/bun.lock
@@ -0,0 +1,162 @@
+{
+ "lockfileVersion": 1,
+ "configVersion": 1,
+ "workspaces": {
+ "": {
+ "name": "rspace-online",
+ "dependencies": {
+ "@automerge/automerge": "^2.2.8",
+ "@lit/reactive-element": "^2.0.4",
+ "perfect-arrows": "^0.3.7",
+ "perfect-freehand": "^1.2.2",
+ },
+ "devDependencies": {
+ "@types/node": "^22.10.1",
+ "bun-types": "^1.1.38",
+ "typescript": "^5.7.2",
+ "vite": "^6.0.3",
+ },
+ },
+ },
+ "packages": {
+ "@automerge/automerge": ["@automerge/automerge@2.2.9", "", { "dependencies": { "uuid": "^9.0.0" } }, "sha512-6HM52Ops79hAQBWMg/t0MNfGOdEiXyenQjO9F1hKZq0RWDsMLpPa1SzRy/C4/4UyX67sTHuA5CwBpH34SpfZlA=="],
+
+ "@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.25.12", "", { "os": "aix", "cpu": "ppc64" }, "sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA=="],
+
+ "@esbuild/android-arm": ["@esbuild/android-arm@0.25.12", "", { "os": "android", "cpu": "arm" }, "sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg=="],
+
+ "@esbuild/android-arm64": ["@esbuild/android-arm64@0.25.12", "", { "os": "android", "cpu": "arm64" }, "sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg=="],
+
+ "@esbuild/android-x64": ["@esbuild/android-x64@0.25.12", "", { "os": "android", "cpu": "x64" }, "sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg=="],
+
+ "@esbuild/darwin-arm64": ["@esbuild/darwin-arm64@0.25.12", "", { "os": "darwin", "cpu": "arm64" }, "sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg=="],
+
+ "@esbuild/darwin-x64": ["@esbuild/darwin-x64@0.25.12", "", { "os": "darwin", "cpu": "x64" }, "sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA=="],
+
+ "@esbuild/freebsd-arm64": ["@esbuild/freebsd-arm64@0.25.12", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg=="],
+
+ "@esbuild/freebsd-x64": ["@esbuild/freebsd-x64@0.25.12", "", { "os": "freebsd", "cpu": "x64" }, "sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ=="],
+
+ "@esbuild/linux-arm": ["@esbuild/linux-arm@0.25.12", "", { "os": "linux", "cpu": "arm" }, "sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw=="],
+
+ "@esbuild/linux-arm64": ["@esbuild/linux-arm64@0.25.12", "", { "os": "linux", "cpu": "arm64" }, "sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ=="],
+
+ "@esbuild/linux-ia32": ["@esbuild/linux-ia32@0.25.12", "", { "os": "linux", "cpu": "ia32" }, "sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA=="],
+
+ "@esbuild/linux-loong64": ["@esbuild/linux-loong64@0.25.12", "", { "os": "linux", "cpu": "none" }, "sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng=="],
+
+ "@esbuild/linux-mips64el": ["@esbuild/linux-mips64el@0.25.12", "", { "os": "linux", "cpu": "none" }, "sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw=="],
+
+ "@esbuild/linux-ppc64": ["@esbuild/linux-ppc64@0.25.12", "", { "os": "linux", "cpu": "ppc64" }, "sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA=="],
+
+ "@esbuild/linux-riscv64": ["@esbuild/linux-riscv64@0.25.12", "", { "os": "linux", "cpu": "none" }, "sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w=="],
+
+ "@esbuild/linux-s390x": ["@esbuild/linux-s390x@0.25.12", "", { "os": "linux", "cpu": "s390x" }, "sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg=="],
+
+ "@esbuild/linux-x64": ["@esbuild/linux-x64@0.25.12", "", { "os": "linux", "cpu": "x64" }, "sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw=="],
+
+ "@esbuild/netbsd-arm64": ["@esbuild/netbsd-arm64@0.25.12", "", { "os": "none", "cpu": "arm64" }, "sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg=="],
+
+ "@esbuild/netbsd-x64": ["@esbuild/netbsd-x64@0.25.12", "", { "os": "none", "cpu": "x64" }, "sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ=="],
+
+ "@esbuild/openbsd-arm64": ["@esbuild/openbsd-arm64@0.25.12", "", { "os": "openbsd", "cpu": "arm64" }, "sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A=="],
+
+ "@esbuild/openbsd-x64": ["@esbuild/openbsd-x64@0.25.12", "", { "os": "openbsd", "cpu": "x64" }, "sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw=="],
+
+ "@esbuild/openharmony-arm64": ["@esbuild/openharmony-arm64@0.25.12", "", { "os": "none", "cpu": "arm64" }, "sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg=="],
+
+ "@esbuild/sunos-x64": ["@esbuild/sunos-x64@0.25.12", "", { "os": "sunos", "cpu": "x64" }, "sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w=="],
+
+ "@esbuild/win32-arm64": ["@esbuild/win32-arm64@0.25.12", "", { "os": "win32", "cpu": "arm64" }, "sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg=="],
+
+ "@esbuild/win32-ia32": ["@esbuild/win32-ia32@0.25.12", "", { "os": "win32", "cpu": "ia32" }, "sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ=="],
+
+ "@esbuild/win32-x64": ["@esbuild/win32-x64@0.25.12", "", { "os": "win32", "cpu": "x64" }, "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA=="],
+
+ "@lit-labs/ssr-dom-shim": ["@lit-labs/ssr-dom-shim@1.5.0", "", {}, "sha512-HLomZXMmrCFHSRKESF5vklAKsDY7/fsT/ZhqCu3V0UoW/Qbv8wxmO4W9bx4KnCCF2Zak4yuk+AGraK/bPmI4kA=="],
+
+ "@lit/reactive-element": ["@lit/reactive-element@2.1.2", "", { "dependencies": { "@lit-labs/ssr-dom-shim": "^1.5.0" } }, "sha512-pbCDiVMnne1lYUIaYNN5wrwQXDtHaYtg7YEFPeW+hws6U47WeFvISGUWekPGKWOP1ygrs0ef0o1VJMk1exos5A=="],
+
+ "@rollup/rollup-android-arm-eabi": ["@rollup/rollup-android-arm-eabi@4.54.0", "", { "os": "android", "cpu": "arm" }, "sha512-OywsdRHrFvCdvsewAInDKCNyR3laPA2mc9bRYJ6LBp5IyvF3fvXbbNR0bSzHlZVFtn6E0xw2oZlyjg4rKCVcng=="],
+
+ "@rollup/rollup-android-arm64": ["@rollup/rollup-android-arm64@4.54.0", "", { "os": "android", "cpu": "arm64" }, "sha512-Skx39Uv+u7H224Af+bDgNinitlmHyQX1K/atIA32JP3JQw6hVODX5tkbi2zof/E69M1qH2UoN3Xdxgs90mmNYw=="],
+
+ "@rollup/rollup-darwin-arm64": ["@rollup/rollup-darwin-arm64@4.54.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-k43D4qta/+6Fq+nCDhhv9yP2HdeKeP56QrUUTW7E6PhZP1US6NDqpJj4MY0jBHlJivVJD5P8NxrjuobZBJTCRw=="],
+
+ "@rollup/rollup-darwin-x64": ["@rollup/rollup-darwin-x64@4.54.0", "", { "os": "darwin", "cpu": "x64" }, "sha512-cOo7biqwkpawslEfox5Vs8/qj83M/aZCSSNIWpVzfU2CYHa2G3P1UN5WF01RdTHSgCkri7XOlTdtk17BezlV3A=="],
+
+ "@rollup/rollup-freebsd-arm64": ["@rollup/rollup-freebsd-arm64@4.54.0", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-miSvuFkmvFbgJ1BevMa4CPCFt5MPGw094knM64W9I0giUIMMmRYcGW/JWZDriaw/k1kOBtsWh1z6nIFV1vPNtA=="],
+
+ "@rollup/rollup-freebsd-x64": ["@rollup/rollup-freebsd-x64@4.54.0", "", { "os": "freebsd", "cpu": "x64" }, "sha512-KGXIs55+b/ZfZsq9aR026tmr/+7tq6VG6MsnrvF4H8VhwflTIuYh+LFUlIsRdQSgrgmtM3fVATzEAj4hBQlaqQ=="],
+
+ "@rollup/rollup-linux-arm-gnueabihf": ["@rollup/rollup-linux-arm-gnueabihf@4.54.0", "", { "os": "linux", "cpu": "arm" }, "sha512-EHMUcDwhtdRGlXZsGSIuXSYwD5kOT9NVnx9sqzYiwAc91wfYOE1g1djOEDseZJKKqtHAHGwnGPQu3kytmfaXLQ=="],
+
+ "@rollup/rollup-linux-arm-musleabihf": ["@rollup/rollup-linux-arm-musleabihf@4.54.0", "", { "os": "linux", "cpu": "arm" }, "sha512-+pBrqEjaakN2ySv5RVrj/qLytYhPKEUwk+e3SFU5jTLHIcAtqh2rLrd/OkbNuHJpsBgxsD8ccJt5ga/SeG0JmA=="],
+
+ "@rollup/rollup-linux-arm64-gnu": ["@rollup/rollup-linux-arm64-gnu@4.54.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-NSqc7rE9wuUaRBsBp5ckQ5CVz5aIRKCwsoa6WMF7G01sX3/qHUw/z4pv+D+ahL1EIKy6Enpcnz1RY8pf7bjwng=="],
+
+ "@rollup/rollup-linux-arm64-musl": ["@rollup/rollup-linux-arm64-musl@4.54.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-gr5vDbg3Bakga5kbdpqx81m2n9IX8M6gIMlQQIXiLTNeQW6CucvuInJ91EuCJ/JYvc+rcLLsDFcfAD1K7fMofg=="],
+
+ "@rollup/rollup-linux-loong64-gnu": ["@rollup/rollup-linux-loong64-gnu@4.54.0", "", { "os": "linux", "cpu": "none" }, "sha512-gsrtB1NA3ZYj2vq0Rzkylo9ylCtW/PhpLEivlgWe0bpgtX5+9j9EZa0wtZiCjgu6zmSeZWyI/e2YRX1URozpIw=="],
+
+ "@rollup/rollup-linux-ppc64-gnu": ["@rollup/rollup-linux-ppc64-gnu@4.54.0", "", { "os": "linux", "cpu": "ppc64" }, "sha512-y3qNOfTBStmFNq+t4s7Tmc9hW2ENtPg8FeUD/VShI7rKxNW7O4fFeaYbMsd3tpFlIg1Q8IapFgy7Q9i2BqeBvA=="],
+
+ "@rollup/rollup-linux-riscv64-gnu": ["@rollup/rollup-linux-riscv64-gnu@4.54.0", "", { "os": "linux", "cpu": "none" }, "sha512-89sepv7h2lIVPsFma8iwmccN7Yjjtgz0Rj/Ou6fEqg3HDhpCa+Et+YSufy27i6b0Wav69Qv4WBNl3Rs6pwhebQ=="],
+
+ "@rollup/rollup-linux-riscv64-musl": ["@rollup/rollup-linux-riscv64-musl@4.54.0", "", { "os": "linux", "cpu": "none" }, "sha512-ZcU77ieh0M2Q8Ur7D5X7KvK+UxbXeDHwiOt/CPSBTI1fBmeDMivW0dPkdqkT4rOgDjrDDBUed9x4EgraIKoR2A=="],
+
+ "@rollup/rollup-linux-s390x-gnu": ["@rollup/rollup-linux-s390x-gnu@4.54.0", "", { "os": "linux", "cpu": "s390x" }, "sha512-2AdWy5RdDF5+4YfG/YesGDDtbyJlC9LHmL6rZw6FurBJ5n4vFGupsOBGfwMRjBYH7qRQowT8D/U4LoSvVwOhSQ=="],
+
+ "@rollup/rollup-linux-x64-gnu": ["@rollup/rollup-linux-x64-gnu@4.54.0", "", { "os": "linux", "cpu": "x64" }, "sha512-WGt5J8Ij/rvyqpFexxk3ffKqqbLf9AqrTBbWDk7ApGUzaIs6V+s2s84kAxklFwmMF/vBNGrVdYgbblCOFFezMQ=="],
+
+ "@rollup/rollup-linux-x64-musl": ["@rollup/rollup-linux-x64-musl@4.54.0", "", { "os": "linux", "cpu": "x64" }, "sha512-JzQmb38ATzHjxlPHuTH6tE7ojnMKM2kYNzt44LO/jJi8BpceEC8QuXYA908n8r3CNuG/B3BV8VR3Hi1rYtmPiw=="],
+
+ "@rollup/rollup-openharmony-arm64": ["@rollup/rollup-openharmony-arm64@4.54.0", "", { "os": "none", "cpu": "arm64" }, "sha512-huT3fd0iC7jigGh7n3q/+lfPcXxBi+om/Rs3yiFxjvSxbSB6aohDFXbWvlspaqjeOh+hx7DDHS+5Es5qRkWkZg=="],
+
+ "@rollup/rollup-win32-arm64-msvc": ["@rollup/rollup-win32-arm64-msvc@4.54.0", "", { "os": "win32", "cpu": "arm64" }, "sha512-c2V0W1bsKIKfbLMBu/WGBz6Yci8nJ/ZJdheE0EwB73N3MvHYKiKGs3mVilX4Gs70eGeDaMqEob25Tw2Gb9Nqyw=="],
+
+ "@rollup/rollup-win32-ia32-msvc": ["@rollup/rollup-win32-ia32-msvc@4.54.0", "", { "os": "win32", "cpu": "ia32" }, "sha512-woEHgqQqDCkAzrDhvDipnSirm5vxUXtSKDYTVpZG3nUdW/VVB5VdCYA2iReSj/u3yCZzXID4kuKG7OynPnB3WQ=="],
+
+ "@rollup/rollup-win32-x64-gnu": ["@rollup/rollup-win32-x64-gnu@4.54.0", "", { "os": "win32", "cpu": "x64" }, "sha512-dzAc53LOuFvHwbCEOS0rPbXp6SIhAf2txMP5p6mGyOXXw5mWY8NGGbPMPrs4P1WItkfApDathBj/NzMLUZ9rtQ=="],
+
+ "@rollup/rollup-win32-x64-msvc": ["@rollup/rollup-win32-x64-msvc@4.54.0", "", { "os": "win32", "cpu": "x64" }, "sha512-hYT5d3YNdSh3mbCU1gwQyPgQd3T2ne0A3KG8KSBdav5TiBg6eInVmV+TeR5uHufiIgSFg0XsOWGW5/RhNcSvPg=="],
+
+ "@types/estree": ["@types/estree@1.0.8", "", {}, "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w=="],
+
+ "@types/node": ["@types/node@22.19.3", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-1N9SBnWYOJTrNZCdh/yJE+t910Y128BoyY+zBLWhL3r0TYzlTmFdXrPwHL9DyFZmlEXNQQolTZh3KHV31QDhyA=="],
+
+ "bun-types": ["bun-types@1.3.5", "", { "dependencies": { "@types/node": "*" } }, "sha512-inmAYe2PFLs0SUbFOWSVD24sg1jFlMPxOjOSSCYqUgn4Hsc3rDc7dFvfVYjFPNHtov6kgUeulV4SxbuIV/stPw=="],
+
+ "esbuild": ["esbuild@0.25.12", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.25.12", "@esbuild/android-arm": "0.25.12", "@esbuild/android-arm64": "0.25.12", "@esbuild/android-x64": "0.25.12", "@esbuild/darwin-arm64": "0.25.12", "@esbuild/darwin-x64": "0.25.12", "@esbuild/freebsd-arm64": "0.25.12", "@esbuild/freebsd-x64": "0.25.12", "@esbuild/linux-arm": "0.25.12", "@esbuild/linux-arm64": "0.25.12", "@esbuild/linux-ia32": "0.25.12", "@esbuild/linux-loong64": "0.25.12", "@esbuild/linux-mips64el": "0.25.12", "@esbuild/linux-ppc64": "0.25.12", "@esbuild/linux-riscv64": "0.25.12", "@esbuild/linux-s390x": "0.25.12", "@esbuild/linux-x64": "0.25.12", "@esbuild/netbsd-arm64": "0.25.12", "@esbuild/netbsd-x64": "0.25.12", "@esbuild/openbsd-arm64": "0.25.12", "@esbuild/openbsd-x64": "0.25.12", "@esbuild/openharmony-arm64": "0.25.12", "@esbuild/sunos-x64": "0.25.12", "@esbuild/win32-arm64": "0.25.12", "@esbuild/win32-ia32": "0.25.12", "@esbuild/win32-x64": "0.25.12" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg=="],
+
+ "fdir": ["fdir@6.5.0", "", { "peerDependencies": { "picomatch": "^3 || ^4" }, "optionalPeers": ["picomatch"] }, "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg=="],
+
+ "fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="],
+
+ "nanoid": ["nanoid@3.3.11", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w=="],
+
+ "perfect-arrows": ["perfect-arrows@0.3.7", "", {}, "sha512-wEN2gerTPVWl3yqoFEF8OeGbg3aRe2sxNUi9rnyYrCsL4JcI6K2tBDezRtqVrYG0BNtsWLdYiiTrYm+X//8yLQ=="],
+
+ "perfect-freehand": ["perfect-freehand@1.2.2", "", {}, "sha512-eh31l019WICQ03pkF3FSzHxB8n07ItqIQ++G5UV8JX0zVOXzgTGCqnRR0jJ2h9U8/2uW4W4mtGJELt9kEV0CFQ=="],
+
+ "picocolors": ["picocolors@1.1.1", "", {}, "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="],
+
+ "picomatch": ["picomatch@4.0.3", "", {}, "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q=="],
+
+ "postcss": ["postcss@8.5.6", "", { "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg=="],
+
+ "rollup": ["rollup@4.54.0", "", { "dependencies": { "@types/estree": "1.0.8" }, "optionalDependencies": { "@rollup/rollup-android-arm-eabi": "4.54.0", "@rollup/rollup-android-arm64": "4.54.0", "@rollup/rollup-darwin-arm64": "4.54.0", "@rollup/rollup-darwin-x64": "4.54.0", "@rollup/rollup-freebsd-arm64": "4.54.0", "@rollup/rollup-freebsd-x64": "4.54.0", "@rollup/rollup-linux-arm-gnueabihf": "4.54.0", "@rollup/rollup-linux-arm-musleabihf": "4.54.0", "@rollup/rollup-linux-arm64-gnu": "4.54.0", "@rollup/rollup-linux-arm64-musl": "4.54.0", "@rollup/rollup-linux-loong64-gnu": "4.54.0", "@rollup/rollup-linux-ppc64-gnu": "4.54.0", "@rollup/rollup-linux-riscv64-gnu": "4.54.0", "@rollup/rollup-linux-riscv64-musl": "4.54.0", "@rollup/rollup-linux-s390x-gnu": "4.54.0", "@rollup/rollup-linux-x64-gnu": "4.54.0", "@rollup/rollup-linux-x64-musl": "4.54.0", "@rollup/rollup-openharmony-arm64": "4.54.0", "@rollup/rollup-win32-arm64-msvc": "4.54.0", "@rollup/rollup-win32-ia32-msvc": "4.54.0", "@rollup/rollup-win32-x64-gnu": "4.54.0", "@rollup/rollup-win32-x64-msvc": "4.54.0", "fsevents": "~2.3.2" }, "bin": { "rollup": "dist/bin/rollup" } }, "sha512-3nk8Y3a9Ea8szgKhinMlGMhGMw89mqule3KWczxhIzqudyHdCIOHw8WJlj/r329fACjKLEh13ZSk7oE22kyeIw=="],
+
+ "source-map-js": ["source-map-js@1.2.1", "", {}, "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA=="],
+
+ "tinyglobby": ["tinyglobby@0.2.15", "", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.3" } }, "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ=="],
+
+ "typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="],
+
+ "undici-types": ["undici-types@6.21.0", "", {}, "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ=="],
+
+ "uuid": ["uuid@9.0.1", "", { "bin": { "uuid": "dist/bin/uuid" } }, "sha512-b+1eJOlsR9K8HJpow9Ok3fiWOWSIcIzXodvv0rQjVoOVNpWMpxf1wZNpt4y9h10odCNrqnYp1OBzRktckBe3sA=="],
+
+ "vite": ["vite@6.4.1", "", { "dependencies": { "esbuild": "^0.25.0", "fdir": "^6.4.4", "picomatch": "^4.0.2", "postcss": "^8.5.3", "rollup": "^4.34.9", "tinyglobby": "^0.2.13" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", "jiti": ">=1.21.0", "less": "*", "lightningcss": "^1.21.0", "sass": "*", "sass-embedded": "*", "stylus": "*", "sugarss": "*", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "jiti", "less", "lightningcss", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-+Oxm7q9hDoLMyJOYfUYBuHQo+dkAloi33apOPP56pzj+vsdJDzr+j1NISE5pyaAuKL4A3UD34qd0lx5+kfKp2g=="],
+ }
+}
diff --git a/docker-compose.yml b/docker-compose.yml
new file mode 100644
index 0000000..75ed454
--- /dev/null
+++ b/docker-compose.yml
@@ -0,0 +1,35 @@
+version: "3.8"
+
+services:
+ rspace:
+ build: .
+ container_name: rspace-online
+ restart: unless-stopped
+ volumes:
+ - rspace-data:/data/communities
+ environment:
+ - NODE_ENV=production
+ - STORAGE_DIR=/data/communities
+ - PORT=3000
+ labels:
+ - "traefik.enable=true"
+ # Main domain
+ - "traefik.http.routers.rspace.rule=Host(`rspace.online`) || Host(`www.rspace.online`)"
+ - "traefik.http.routers.rspace.entrypoints=websecure"
+ - "traefik.http.routers.rspace.tls=true"
+ # Wildcard subdomain routing
+ - "traefik.http.routers.rspace-wildcard.rule=HostRegexp(`{subdomain:[a-z0-9-]+}.rspace.online`)"
+ - "traefik.http.routers.rspace-wildcard.entrypoints=websecure"
+ - "traefik.http.routers.rspace-wildcard.tls=true"
+ # Service configuration
+ - "traefik.http.services.rspace.loadbalancer.server.port=3000"
+ - "traefik.docker.network=traefik-public"
+ networks:
+ - traefik-public
+
+volumes:
+ rspace-data:
+
+networks:
+ traefik-public:
+ external: true
diff --git a/lib/DOMRectTransform.ts b/lib/DOMRectTransform.ts
new file mode 100644
index 0000000..5e4fbe3
--- /dev/null
+++ b/lib/DOMRectTransform.ts
@@ -0,0 +1,256 @@
+import { Matrix } from "./Matrix";
+import type { Point } from "./types";
+
+interface DOMRectTransformInit {
+ height?: number;
+ width?: number;
+ x?: number;
+ y?: number;
+ rotation?: number;
+ transformOrigin?: Point;
+ rotateOrigin?: Point;
+}
+
+/**
+ * Represents a rectangle with position, size, and rotation,
+ * capable of transforming points between local and parent coordinate spaces.
+ */
+export class DOMRectTransform implements DOMRect {
+ #x: number;
+ #y: number;
+ #width: number;
+ #height: number;
+ #rotation: number;
+ #transformOrigin: Point;
+ #rotateOrigin: Point;
+ #transformMatrix: Matrix;
+ #inverseMatrix: Matrix;
+
+ constructor(init: DOMRectTransformInit = {}) {
+ this.#x = init.x ?? 0;
+ this.#y = init.y ?? 0;
+ this.#width = init.width ?? 0;
+ this.#height = init.height ?? 0;
+ this.#rotation = init.rotation ?? 0;
+ this.#transformOrigin = init.transformOrigin ?? { x: 0.5, y: 0.5 };
+ this.#rotateOrigin = init.rotateOrigin ?? { x: 0.5, y: 0.5 };
+ this.#transformMatrix = Matrix.Identity();
+ this.#inverseMatrix = Matrix.Identity();
+ this.#updateMatrices();
+ }
+
+ get x(): number {
+ return this.#x;
+ }
+ set x(value: number) {
+ this.#x = value;
+ this.#updateMatrices();
+ }
+
+ get y(): number {
+ return this.#y;
+ }
+ set y(value: number) {
+ this.#y = value;
+ this.#updateMatrices();
+ }
+
+ get width(): number {
+ return this.#width;
+ }
+ set width(value: number) {
+ this.#width = value;
+ this.#updateMatrices();
+ }
+
+ get height(): number {
+ return this.#height;
+ }
+ set height(value: number) {
+ this.#height = value;
+ this.#updateMatrices();
+ }
+
+ get rotation(): number {
+ return this.#rotation;
+ }
+ set rotation(value: number) {
+ this.#rotation = value;
+ this.#updateMatrices();
+ }
+
+ get transformOrigin(): Point {
+ return this.#transformOrigin;
+ }
+ set transformOrigin(value: Point) {
+ this.#transformOrigin = value;
+ this.#updateMatrices();
+ }
+
+ get rotateOrigin(): Point {
+ return this.#rotateOrigin;
+ }
+ set rotateOrigin(value: Point) {
+ this.#rotateOrigin = value;
+ this.#updateMatrices();
+ }
+
+ get left(): number {
+ return this.x;
+ }
+ get top(): number {
+ return this.y;
+ }
+ get right(): number {
+ return this.x + this.width;
+ }
+ get bottom(): number {
+ return this.y + this.height;
+ }
+
+ #updateMatrices() {
+ this.#transformMatrix.identity();
+ const transformOrigin = this.#getAbsoluteTransformOrigin();
+ const rotateOrigin = this.#getAbsoluteRotateOrigin();
+
+ this.#transformMatrix
+ .translate(this.#x, this.#y)
+ .translate(transformOrigin.x, transformOrigin.y)
+ .translate(rotateOrigin.x - transformOrigin.x, rotateOrigin.y - transformOrigin.y)
+ .rotate(this.#rotation)
+ .translate(-(rotateOrigin.x - transformOrigin.x), -(rotateOrigin.y - transformOrigin.y))
+ .translate(-transformOrigin.x, -transformOrigin.y);
+
+ this.#inverseMatrix = this.#transformMatrix.clone().invert();
+ }
+
+ #getAbsoluteTransformOrigin(): Point {
+ return {
+ x: this.#width * this.#transformOrigin.x,
+ y: this.#height * this.#transformOrigin.y,
+ };
+ }
+
+ #getAbsoluteRotateOrigin(): Point {
+ return {
+ x: this.#width * this.#rotateOrigin.x,
+ y: this.#height * this.#rotateOrigin.y,
+ };
+ }
+
+ get transformMatrix(): Matrix {
+ return this.#transformMatrix;
+ }
+
+ get inverseMatrix(): Matrix {
+ return this.#inverseMatrix;
+ }
+
+ toLocalSpace(point: Point): Point {
+ return this.#inverseMatrix.applyToPoint(point);
+ }
+
+ toParentSpace(point: Point): Point {
+ return this.#transformMatrix.applyToPoint(point);
+ }
+
+ get topLeft(): Point {
+ return { x: 0, y: 0 };
+ }
+ get topRight(): Point {
+ return { x: this.width, y: 0 };
+ }
+ get bottomRight(): Point {
+ return { x: this.width, y: this.height };
+ }
+ get bottomLeft(): Point {
+ return { x: 0, y: this.height };
+ }
+ get center(): Point {
+ return { x: this.x + this.width / 2, y: this.y + this.height / 2 };
+ }
+
+ set topLeft(point: Point) {
+ const bottomRightBefore = this.toParentSpace(this.bottomRight);
+ const deltaWidth = this.#width - point.x;
+ const deltaHeight = this.#height - point.y;
+ this.#x += point.x;
+ this.#y += point.y;
+ this.#width = deltaWidth;
+ this.#height = deltaHeight;
+ this.#updateMatrices();
+ const bottomRightAfter = this.toParentSpace(this.bottomRight);
+ this.#x -= bottomRightAfter.x - bottomRightBefore.x;
+ this.#y -= bottomRightAfter.y - bottomRightBefore.y;
+ this.#updateMatrices();
+ }
+
+ set topRight(point: Point) {
+ const bottomLeftBefore = this.toParentSpace(this.bottomLeft);
+ const deltaWidth = point.x;
+ const deltaHeight = this.#height - point.y;
+ this.#y += point.y;
+ this.#width = deltaWidth;
+ this.#height = deltaHeight;
+ this.#updateMatrices();
+ const bottomLeftAfter = this.toParentSpace(this.bottomLeft);
+ this.#x -= bottomLeftAfter.x - bottomLeftBefore.x;
+ this.#y -= bottomLeftAfter.y - bottomLeftBefore.y;
+ this.#updateMatrices();
+ }
+
+ set bottomRight(point: Point) {
+ const topLeftBefore = this.toParentSpace(this.topLeft);
+ this.#width = point.x;
+ this.#height = point.y;
+ this.#updateMatrices();
+ const topLeftAfter = this.toParentSpace(this.topLeft);
+ this.#x -= topLeftAfter.x - topLeftBefore.x;
+ this.#y -= topLeftAfter.y - topLeftBefore.y;
+ this.#updateMatrices();
+ }
+
+ set bottomLeft(point: Point) {
+ const topRightBefore = this.toParentSpace(this.topRight);
+ const deltaWidth = this.#width - point.x;
+ const deltaHeight = point.y;
+ this.#x += point.x;
+ this.#width = deltaWidth;
+ this.#height = deltaHeight;
+ this.#updateMatrices();
+ const topRightAfter = this.toParentSpace(this.topRight);
+ this.#x -= topRightAfter.x - topRightBefore.x;
+ this.#y -= topRightAfter.y - topRightBefore.y;
+ this.#updateMatrices();
+ }
+
+ vertices(): Point[] {
+ return [this.topLeft, this.topRight, this.bottomRight, this.bottomLeft];
+ }
+
+ toCssString(): string {
+ return this.transformMatrix.toCssString();
+ }
+
+ toJSON() {
+ return {
+ x: this.x,
+ y: this.y,
+ width: this.width,
+ height: this.height,
+ rotation: this.rotation,
+ };
+ }
+}
+
+export class DOMRectTransformReadonly extends DOMRectTransform {
+ constructor(init: DOMRectTransformInit = {}) {
+ super(init);
+ }
+
+ override set x(_: number) {}
+ override set y(_: number) {}
+ override set width(_: number) {}
+ override set height(_: number) {}
+ override set rotation(_: number) {}
+}
diff --git a/lib/Matrix.ts b/lib/Matrix.ts
new file mode 100644
index 0000000..973e1ee
--- /dev/null
+++ b/lib/Matrix.ts
@@ -0,0 +1,138 @@
+import type { Point } from "./types";
+
+export const toDOMPrecision = (value: number) => Math.round(value * 1e4) / 1e4;
+
+const PI2 = Math.PI * 2;
+const TAU = Math.PI / 2;
+
+export interface MatrixInit {
+ a: number;
+ b: number;
+ c: number;
+ d: number;
+ e: number;
+ f: number;
+}
+
+export class Matrix implements MatrixInit {
+ constructor(
+ public a: number = 1,
+ public b: number = 0,
+ public c: number = 0,
+ public d: number = 1,
+ public e: number = 0,
+ public f: number = 0,
+ ) {}
+
+ equals(m: MatrixInit) {
+ return (
+ this.a === m.a &&
+ this.b === m.b &&
+ this.c === m.c &&
+ this.d === m.d &&
+ this.e === m.e &&
+ this.f === m.f
+ );
+ }
+
+ identity() {
+ this.a = 1.0;
+ this.b = 0.0;
+ this.c = 0.0;
+ this.d = 1.0;
+ this.e = 0.0;
+ this.f = 0.0;
+ return this;
+ }
+
+ multiply(m: MatrixInit) {
+ const { a, b, c, d, e, f } = this;
+ this.a = a * m.a + c * m.b;
+ this.c = a * m.c + c * m.d;
+ this.e = a * m.e + c * m.f + e;
+ this.b = b * m.a + d * m.b;
+ this.d = b * m.c + d * m.d;
+ this.f = b * m.e + d * m.f + f;
+ return this;
+ }
+
+ rotate(r: number, cx?: number, cy?: number) {
+ if (r === 0) return this;
+ if (cx === undefined) return this.multiply(Matrix.Rotate(r));
+ return this.translate(cx, cy!).multiply(Matrix.Rotate(r)).translate(-cx, -cy!);
+ }
+
+ translate(x: number, y: number): Matrix {
+ return this.multiply(Matrix.Translate(x, y!));
+ }
+
+ scale(x: number, y: number) {
+ return this.multiply(Matrix.Scale(x, y));
+ }
+
+ invert() {
+ const { a, b, c, d, e, f } = this;
+ const denominator = a * d - b * c;
+ this.a = d / denominator;
+ this.b = b / -denominator;
+ this.c = c / -denominator;
+ this.d = a / denominator;
+ this.e = (d * e - c * f) / -denominator;
+ this.f = (b * e - a * f) / denominator;
+ return this;
+ }
+
+ applyToPoint(point: Point) {
+ return Matrix.applyToPoint(this, point);
+ }
+
+ clone() {
+ return new Matrix(this.a, this.b, this.c, this.d, this.e, this.f);
+ }
+
+ toCssString() {
+ return Matrix.ToCssString(this);
+ }
+
+ static Rotate(r: number, cx?: number, cy?: number) {
+ if (r === 0) return Matrix.Identity();
+ const cosAngle = Math.cos(r);
+ const sinAngle = Math.sin(r);
+ const rotationMatrix = new Matrix(cosAngle, sinAngle, -sinAngle, cosAngle, 0.0, 0.0);
+ if (cx === undefined) return rotationMatrix;
+ return Matrix.Compose(Matrix.Translate(cx, cy!), rotationMatrix, Matrix.Translate(-cx, -cy!));
+ }
+
+ static Scale(x: number, y: number, cx?: number, cy?: number) {
+ const scaleMatrix = new Matrix(x, 0, 0, y, 0, 0);
+ if (cx === undefined) return scaleMatrix;
+ return Matrix.Compose(Matrix.Translate(cx, cy!), scaleMatrix, Matrix.Translate(-cx, -cy!));
+ }
+
+ static Compose(...matrices: MatrixInit[]) {
+ const matrix = Matrix.Identity();
+ for (let i = 0, n = matrices.length; i < n; i++) {
+ matrix.multiply(matrices[i]);
+ }
+ return matrix;
+ }
+
+ static Identity() {
+ return new Matrix(1.0, 0.0, 0.0, 1.0, 0.0, 0.0);
+ }
+
+ static Translate(x: number, y: number) {
+ return new Matrix(1.0, 0.0, 0.0, 1.0, x, y);
+ }
+
+ static applyToPoint(m: MatrixInit, point: Point) {
+ return {
+ x: m.a * point.x + m.c * point.y + m.e,
+ y: m.b * point.x + m.d * point.y + m.f,
+ };
+ }
+
+ static ToCssString(m: MatrixInit) {
+ return `matrix(${toDOMPrecision(m.a)}, ${toDOMPrecision(m.b)}, ${toDOMPrecision(m.c)}, ${toDOMPrecision(m.d)}, ${toDOMPrecision(m.e)}, ${toDOMPrecision(m.f)})`;
+ }
+}
diff --git a/lib/TransformEvent.ts b/lib/TransformEvent.ts
new file mode 100644
index 0000000..85e50e1
--- /dev/null
+++ b/lib/TransformEvent.ts
@@ -0,0 +1,66 @@
+import type { DOMRectTransformReadonly } from "./DOMRectTransform";
+
+declare global {
+ interface HTMLElementEventMap {
+ transform: TransformEvent;
+ }
+}
+
+export class TransformEvent extends Event {
+ readonly #current: DOMRectTransformReadonly;
+ readonly #previous: DOMRectTransformReadonly;
+
+ constructor(current: DOMRectTransformReadonly, previous?: DOMRectTransformReadonly) {
+ super("transform", { cancelable: true, bubbles: true });
+ this.#current = current;
+ this.#previous = previous ?? current;
+ }
+
+ get current() {
+ return this.#current;
+ }
+
+ get previous() {
+ return this.#previous;
+ }
+
+ #xPrevented = false;
+ get xPrevented() {
+ return this.defaultPrevented || this.#xPrevented;
+ }
+ preventX() {
+ this.#xPrevented = true;
+ }
+
+ #yPrevented = false;
+ get yPrevented() {
+ return this.defaultPrevented || this.#yPrevented;
+ }
+ preventY() {
+ this.#yPrevented = true;
+ }
+
+ #heightPrevented = false;
+ get heightPrevented() {
+ return this.defaultPrevented || this.#heightPrevented;
+ }
+ preventHeight() {
+ this.#heightPrevented = true;
+ }
+
+ #widthPrevented = false;
+ get widthPrevented() {
+ return this.defaultPrevented || this.#widthPrevented;
+ }
+ preventWidth() {
+ this.#widthPrevented = true;
+ }
+
+ #rotatePrevented = false;
+ get rotatePrevented() {
+ return this.defaultPrevented || this.#rotatePrevented;
+ }
+ preventRotate() {
+ this.#rotatePrevented = true;
+ }
+}
diff --git a/lib/Vector.ts b/lib/Vector.ts
new file mode 100644
index 0000000..8b9971b
--- /dev/null
+++ b/lib/Vector.ts
@@ -0,0 +1,89 @@
+import type { Point } from "./types";
+
+const { hypot, cos, sin, atan2 } = Math;
+
+export class Vector {
+ static zero(): Point {
+ return { x: 0, y: 0 };
+ }
+
+ static sub(a: Point, b: Point): Point {
+ return { x: a.x - b.x, y: a.y - b.y };
+ }
+
+ static add(a: Point, b: Point): Point {
+ return { x: a.x + b.x, y: a.y + b.y };
+ }
+
+ static scale(v: Point, scaleFactor: number): Point {
+ return { x: v.x * scaleFactor, y: v.y * scaleFactor };
+ }
+
+ static mag(v: Point): number {
+ return Math.sqrt(v.x * v.x + v.y * v.y);
+ }
+
+ static normalized(v: Point): Point {
+ const { x, y } = v;
+ const magnitude = hypot(x, y);
+ if (magnitude === 0) return { x: 0, y: 0 };
+ const invMag = 1 / magnitude;
+ return { x: x * invMag, y: y * invMag };
+ }
+
+ static distance(a: Point, b: Point): number {
+ const dx = a.x - b.x;
+ const dy = a.y - b.y;
+ return Math.sqrt(dx * dx + dy * dy);
+ }
+
+ static distanceSquared(a: Point, b: Point): number {
+ const dx = a.x - b.x;
+ const dy = a.y - b.y;
+ return dx * dx + dy * dy;
+ }
+
+ static lerp(a: Point, b: Point, t: number): Point {
+ return {
+ x: a.x + (b.x - a.x) * t,
+ y: a.y + (b.y - a.y) * t,
+ };
+ }
+
+ static rotate(v: Point, angle: number): Point {
+ const _cos = cos(angle);
+ const _sin = sin(angle);
+ return {
+ x: v.x * _cos - v.y * _sin,
+ y: v.x * _sin + v.y * _cos,
+ };
+ }
+
+ static rotateAround(point: Point, pivot: Point, angle: number): Point {
+ const dx = point.x - pivot.x;
+ const dy = point.y - pivot.y;
+ const c = cos(angle);
+ const s = sin(angle);
+ return {
+ x: pivot.x + dx * c - dy * s,
+ y: pivot.y + dx * s + dy * c,
+ };
+ }
+
+ static angle(v: Point): number {
+ return atan2(v.y, v.x);
+ }
+
+ static angleTo(a: Point, b: Point = { x: 1, y: 0 }): number {
+ const angleA = Vector.angle(a);
+ const angleB = Vector.angle(b);
+ return angleA - angleB;
+ }
+
+ static angleFromOrigin(point: Point, origin: Point): number {
+ return Vector.angleTo({
+ x: point.x - origin.x,
+ y: point.y - origin.y,
+ });
+ }
+}
diff --git a/lib/cursors.ts b/lib/cursors.ts
new file mode 100644
index 0000000..d026191
--- /dev/null
+++ b/lib/cursors.ts
@@ -0,0 +1,35 @@
+const resizeCursorCache = new Map();
+const rotateCursorCache = new Map();
+
+function getRoundedDegree(degrees: number, interval: number = 1): number {
+ return (Math.round(degrees / interval) * interval) % 360;
+}
+
+export function getResizeCursorUrl(degrees: number): string {
+ degrees = getRoundedDegree(degrees);
+ if (degrees > 180) {
+ degrees -= 180;
+ }
+
+ if (!resizeCursorCache.has(degrees)) {
+ const url = resizeCursorUrl(degrees);
+ resizeCursorCache.set(degrees, url);
+ }
+ return resizeCursorCache.get(degrees)!;
+}
+
+export function getRotateCursorUrl(degrees: number): string {
+ degrees = getRoundedDegree(degrees);
+
+ if (!rotateCursorCache.has(degrees)) {
+ const url = rotateCursorUrl(degrees);
+ rotateCursorCache.set(degrees, url);
+ }
+ return rotateCursorCache.get(degrees)!;
+}
+
+const resizeCursorUrl = (degrees: number) =>
+ `url("data:image/svg+xml,") 16 16, nwse-resize`;
+
+const rotateCursorUrl = (degrees: number) =>
+ `url("data:image/svg+xml,") 16 16, pointer`;
diff --git a/lib/folk-element.ts b/lib/folk-element.ts
new file mode 100644
index 0000000..223ac8f
--- /dev/null
+++ b/lib/folk-element.ts
@@ -0,0 +1,15 @@
+import { ReactiveElement } from "@lit/reactive-element";
+
+/**
+ * Base class for all custom elements. Extends Lit's `ReactiveElement` and adds utilities for defining the element.
+ */
+export class FolkElement extends ReactiveElement {
+ /** Defines the name of the custom element, must include a hyphen. */
+ static tagName = "";
+
+ /** Defines the custom element with the global CustomElementRegistry. */
+ static define() {
+ if (customElements.get(this.tagName)) return;
+ customElements.define(this.tagName, this);
+ }
+}
diff --git a/lib/folk-markdown.ts b/lib/folk-markdown.ts
new file mode 100644
index 0000000..75c491f
--- /dev/null
+++ b/lib/folk-markdown.ts
@@ -0,0 +1,269 @@
+import { FolkShape } from "./folk-shape";
+import { css, html } from "./tags";
+
+const styles = css`
+ :host {
+ background: white;
+ border-radius: 8px;
+ box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
+ min-width: 200px;
+ min-height: 100px;
+ }
+
+ .header {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ padding: 8px 12px;
+ background: #14b8a6;
+ color: white;
+ border-radius: 8px 8px 0 0;
+ font-size: 12px;
+ font-weight: 600;
+ cursor: move;
+ }
+
+ .header-title {
+ display: flex;
+ align-items: center;
+ gap: 6px;
+ }
+
+ .header-actions {
+ display: flex;
+ gap: 4px;
+ }
+
+ .header-actions button {
+ background: transparent;
+ border: none;
+ color: white;
+ cursor: pointer;
+ padding: 2px 6px;
+ border-radius: 4px;
+ font-size: 14px;
+ }
+
+ .header-actions button:hover {
+ background: rgba(255, 255, 255, 0.2);
+ }
+
+ .content {
+ padding: 12px;
+ height: calc(100% - 36px);
+ overflow: auto;
+ }
+
+ .editor {
+ width: 100%;
+ height: 100%;
+ border: none;
+ outline: none;
+ resize: none;
+ font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
+ font-size: 14px;
+ line-height: 1.5;
+ }
+
+ .markdown-preview {
+ font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
+ font-size: 14px;
+ line-height: 1.6;
+ }
+
+ .markdown-preview h1 {
+ font-size: 1.5em;
+ margin: 0 0 0.5em;
+ color: #14b8a6;
+ }
+
+ .markdown-preview h2 {
+ font-size: 1.25em;
+ margin: 0.5em 0;
+ color: #14b8a6;
+ }
+
+ .markdown-preview p {
+ margin: 0.5em 0;
+ }
+
+ .markdown-preview code {
+ background: #f1f5f9;
+ padding: 2px 4px;
+ border-radius: 3px;
+ font-family: monospace;
+ }
+
+ .markdown-preview pre {
+ background: #f1f5f9;
+ padding: 12px;
+ border-radius: 6px;
+ overflow-x: auto;
+ }
+
+ .markdown-preview pre code {
+ background: none;
+ padding: 0;
+ }
+
+ .markdown-preview ul,
+ .markdown-preview ol {
+ margin: 0.5em 0;
+ padding-left: 1.5em;
+ }
+
+ .markdown-preview blockquote {
+ border-left: 3px solid #14b8a6;
+ margin: 0.5em 0;
+ padding-left: 1em;
+ color: #64748b;
+ }
+`;
+
+declare global {
+ interface HTMLElementTagNameMap {
+ "folk-markdown": FolkMarkdown;
+ }
+}
+
+export class FolkMarkdown extends FolkShape {
+ static override tagName = "folk-markdown";
+
+ // Merge parent and child styles
+ static {
+ const sheet = new CSSStyleSheet();
+ const parentRules = Array.from(FolkShape.styles.cssRules).map((r) => r.cssText).join("\n");
+ const childRules = Array.from(styles.cssRules).map((r) => r.cssText).join("\n");
+ sheet.replaceSync(`${parentRules}\n${childRules}`);
+ this.styles = sheet;
+ }
+
+ #content = "";
+ #isEditing = false;
+
+ get content() {
+ return this.#content;
+ }
+
+ set content(value: string) {
+ this.#content = value;
+ this.requestUpdate("content");
+ this.dispatchEvent(new CustomEvent("content-change", { detail: { content: value } }));
+ }
+
+ override createRenderRoot() {
+ const root = super.createRenderRoot();
+
+ // Add markdown-specific UI
+ const wrapper = document.createElement("div");
+ wrapper.innerHTML = html`
+
+
+ `;
+
+ // Move existing slot content into our wrapper
+ const slot = root.querySelector("slot");
+ if (slot) {
+ slot.parentElement?.replaceChild(wrapper, slot.parentElement.querySelector("div")!);
+ }
+
+ // Get references to elements
+ const preview = wrapper.querySelector(".markdown-preview") as HTMLElement;
+ const editor = wrapper.querySelector(".editor") as HTMLTextAreaElement;
+ const editBtn = wrapper.querySelector(".edit-btn") as HTMLButtonElement;
+ const closeBtn = wrapper.querySelector(".close-btn") as HTMLButtonElement;
+
+ // Edit toggle
+ editBtn.addEventListener("click", (e) => {
+ e.stopPropagation();
+ this.#isEditing = !this.#isEditing;
+ if (this.#isEditing) {
+ editor.style.display = "block";
+ preview.style.display = "none";
+ editor.value = this.#content;
+ editor.focus();
+ } else {
+ editor.style.display = "none";
+ preview.style.display = "block";
+ this.content = editor.value;
+ preview.innerHTML = this.#renderMarkdown(this.#content);
+ }
+ });
+
+ // Close button
+ closeBtn.addEventListener("click", (e) => {
+ e.stopPropagation();
+ this.dispatchEvent(new CustomEvent("close"));
+ });
+
+ // Editor input
+ editor.addEventListener("input", () => {
+ this.#content = editor.value;
+ });
+
+ editor.addEventListener("blur", () => {
+ this.#isEditing = false;
+ editor.style.display = "none";
+ preview.style.display = "block";
+ this.content = editor.value;
+ preview.innerHTML = this.#renderMarkdown(this.#content);
+ });
+
+ // Initial render
+ this.#content = this.getAttribute("content") || "# Hello World\n\nStart typing...";
+ preview.innerHTML = this.#renderMarkdown(this.#content);
+
+ return root;
+ }
+
+ #renderMarkdown(text: string): string {
+ // Simple markdown renderer
+ return text
+ .replace(/^### (.+)$/gm, "$1
")
+ .replace(/^## (.+)$/gm, "$1
")
+ .replace(/^# (.+)$/gm, "$1
")
+ .replace(/\*\*(.+?)\*\*/g, "$1")
+ .replace(/\*(.+?)\*/g, "$1")
+ .replace(/`(.+?)`/g, "$1")
+ .replace(/^- (.+)$/gm, "$1")
+ .replace(/(.*<\/li>)/s, "")
+ .replace(/^> (.+)$/gm, "$1
")
+ .replace(/\n\n/g, "
")
+ .replace(/^(.+)$/gm, (match) => {
+ if (
+ match.startsWith("${match}
`;
+ });
+ }
+
+ toJSON() {
+ return {
+ type: "folk-markdown",
+ id: this.id,
+ x: this.x,
+ y: this.y,
+ width: this.width,
+ height: this.height,
+ rotation: this.rotation,
+ content: this.content,
+ };
+ }
+}
diff --git a/lib/folk-shape.ts b/lib/folk-shape.ts
new file mode 100644
index 0000000..425433d
--- /dev/null
+++ b/lib/folk-shape.ts
@@ -0,0 +1,544 @@
+import { getResizeCursorUrl, getRotateCursorUrl } from "./cursors";
+import { DOMRectTransform, DOMRectTransformReadonly } from "./DOMRectTransform";
+import { FolkElement } from "./folk-element";
+import { ResizeManager } from "./resize-manager";
+import { css, html } from "./tags";
+import { TransformEvent } from "./TransformEvent";
+import type { Point } from "./types";
+import { MAX_Z_INDEX } from "./utils";
+import { Vector } from "./Vector";
+import type { PropertyValues } from "@lit/reactive-element";
+
+const resizeManager = new ResizeManager();
+
+type ResizeHandle =
+ | "resize-top-left"
+ | "resize-top-right"
+ | "resize-bottom-right"
+ | "resize-bottom-left";
+type RotateHandle =
+ | "rotation-top-left"
+ | "rotation-top-right"
+ | "rotation-bottom-right"
+ | "rotation-bottom-left";
+type Handle = ResizeHandle | RotateHandle | "move";
+export type Dimension = number | "auto";
+
+type HandleMap = Record;
+
+const oppositeHandleMap: HandleMap = {
+ "resize-bottom-right": "resize-top-left",
+ "resize-bottom-left": "resize-top-right",
+ "resize-top-left": "resize-bottom-right",
+ "resize-top-right": "resize-bottom-left",
+};
+
+const flipXHandleMap: HandleMap = {
+ "resize-bottom-right": "resize-bottom-left",
+ "resize-bottom-left": "resize-bottom-right",
+ "resize-top-left": "resize-top-right",
+ "resize-top-right": "resize-top-left",
+};
+
+const flipYHandleMap: HandleMap = {
+ "resize-bottom-right": "resize-top-right",
+ "resize-bottom-left": "resize-top-left",
+ "resize-top-left": "resize-bottom-left",
+ "resize-top-right": "resize-bottom-right",
+};
+
+const styles = css`
+ * {
+ box-sizing: border-box;
+ }
+
+ :host {
+ display: block;
+ position: absolute;
+ top: 0;
+ left: 0;
+ cursor: move;
+ transform-origin: 0 0;
+ box-sizing: border-box;
+ }
+
+ :host::before {
+ content: "";
+ position: absolute;
+ inset: -15px;
+ z-index: -1;
+ }
+
+ div {
+ height: 100%;
+ width: 100%;
+ overflow: scroll;
+ pointer-events: none;
+ }
+
+ ::slotted(*) {
+ cursor: default;
+ pointer-events: auto;
+ }
+
+ :host(:focus-within),
+ :host(:focus-visible) {
+ z-index: calc(${MAX_Z_INDEX} - 1);
+ outline: solid 1px hsl(214, 84%, 56%);
+ }
+
+ :host(:hover),
+ :host(:state(highlighted)) {
+ outline: solid 2px hsl(214, 84%, 56%);
+ }
+
+ :host(:state(move)),
+ :host(:state(rotate)),
+ :host(:state(resize-top-left)),
+ :host(:state(resize-top-right)),
+ :host(:state(resize-bottom-right)),
+ :host(:state(resize-bottom-left)) {
+ user-select: none;
+ }
+
+ [part] {
+ aspect-ratio: 1;
+ display: none;
+ position: absolute;
+ z-index: calc(${MAX_Z_INDEX} - 1);
+ padding: 0;
+ }
+
+ [part^="resize"] {
+ background: hsl(210, 20%, 98%);
+ width: 9px;
+ transform: translate(-50%, -50%);
+ border: 1.5px solid hsl(214, 84%, 56%);
+ border-radius: 2px;
+ }
+
+ [part^="rotation"] {
+ opacity: 0;
+ width: 16px;
+ }
+
+ [part$="top-left"] {
+ top: 0;
+ left: 0;
+ }
+
+ [part="rotation-top-left"] {
+ translate: -100% -100%;
+ }
+
+ [part$="top-right"] {
+ top: 0;
+ left: 100%;
+ }
+
+ [part="rotation-top-right"] {
+ translate: 0% -100%;
+ }
+
+ [part$="bottom-right"] {
+ top: 100%;
+ left: 100%;
+ }
+
+ [part="rotation-bottom-right"] {
+ translate: 0% 0%;
+ }
+
+ [part$="bottom-left"] {
+ top: 100%;
+ left: 0;
+ }
+
+ [part="rotation-bottom-left"] {
+ translate: -100% 0%;
+ }
+
+ :host(:focus-within) :is([part^="resize"], [part^="rotation"]) {
+ display: block;
+ }
+`;
+
+declare global {
+ interface HTMLElementTagNameMap {
+ "folk-shape": FolkShape;
+ }
+}
+
+export class FolkShape extends FolkElement {
+ static tagName = "folk-shape";
+ static styles = styles;
+
+ #internals = this.attachInternals();
+ #attrWidth: Dimension = 0;
+ #attrHeight: Dimension = 0;
+ #rect = new DOMRectTransform();
+ #previousRect = new DOMRectTransform();
+ #readonlyRect = new DOMRectTransformReadonly();
+ #handles!: Record;
+ #startAngle = 0;
+
+ get x() {
+ return this.#rect.x;
+ }
+ set x(x) {
+ this.#previousRect.x = this.#rect.x;
+ this.#rect.x = x;
+ this.requestUpdate("x");
+ }
+
+ get y() {
+ return this.#rect.y;
+ }
+ set y(y) {
+ this.#previousRect.y = this.#rect.y;
+ this.#rect.y = y;
+ this.requestUpdate("y");
+ }
+
+ get width(): number {
+ return this.#rect.width;
+ }
+ set width(width: Dimension) {
+ if (width === "auto") {
+ resizeManager.observe(this, this.#onAutoResize);
+ } else {
+ if (this.#attrWidth === "auto" && this.#attrHeight !== "auto") {
+ resizeManager.unobserve(this, this.#onAutoResize);
+ }
+ this.#previousRect.width = this.#rect.width;
+ this.#rect.width = width;
+ }
+ this.#attrWidth = width;
+ this.requestUpdate("width");
+ }
+
+ get height(): number {
+ return this.#rect.height;
+ }
+ set height(height: Dimension) {
+ if (height === "auto") {
+ resizeManager.observe(this, this.#onAutoResize);
+ } else {
+ if (this.#attrHeight === "auto" && this.#attrWidth !== "auto") {
+ resizeManager.unobserve(this, this.#onAutoResize);
+ }
+ this.#previousRect.height = this.#rect.height;
+ this.#rect.height = height;
+ }
+ this.#attrHeight = height;
+ this.requestUpdate("height");
+ }
+
+ get rotation(): number {
+ return this.#rect.rotation;
+ }
+ set rotation(rotation: number) {
+ this.#previousRect.rotation = this.#rect.rotation;
+ this.#rect.rotation = rotation;
+ this.requestUpdate("rotation");
+ }
+
+ #highlighted = false;
+ get highlighted() {
+ return this.#highlighted;
+ }
+ set highlighted(highlighted) {
+ if (this.#highlighted === highlighted) return;
+ this.#highlighted = highlighted;
+ highlighted
+ ? this.#internals.states.add("highlighted")
+ : this.#internals.states.delete("highlighted");
+ }
+
+ override createRenderRoot() {
+ const root = super.createRenderRoot();
+
+ this.addEventListener("pointerdown", this);
+ this.addEventListener("touchmove", this, { passive: false });
+ this.addEventListener("keydown", this);
+
+ (root as ShadowRoot).setHTMLUnsafe(
+ html`
+
+
+
+
+
+
+
+
`,
+ );
+
+ this.#handles = Object.fromEntries(
+ Array.from(root.querySelectorAll("[part]")).map((el) => [
+ el.getAttribute("part") as ResizeHandle | RotateHandle,
+ el as HTMLElement,
+ ]),
+ ) as Record;
+
+ this.#updateCursors();
+
+ this.x = Number(this.getAttribute("x")) || 0;
+ this.y = Number(this.getAttribute("y")) || 0;
+ this.width = Number(this.getAttribute("width")) || "auto";
+ this.height = Number(this.getAttribute("height")) || "auto";
+ this.rotation = (Number(this.getAttribute("rotation")) || 0) * (Math.PI / 180);
+
+ this.#rect.transformOrigin = { x: 0, y: 0 };
+ this.#rect.rotateOrigin = { x: 0.5, y: 0.5 };
+
+ this.#previousRect = new DOMRectTransform(this.#rect);
+
+ this.setAttribute("tabindex", "0");
+
+ return root;
+ }
+
+ getTransformDOMRect() {
+ return this.#readonlyRect;
+ }
+
+ handleEvent(event: PointerEvent | KeyboardEvent | TouchEvent) {
+ if (event instanceof TouchEvent) {
+ event.preventDefault();
+ return;
+ }
+
+ const focusedElement = (this.renderRoot as ShadowRoot).activeElement as HTMLElement | null;
+ const target = event.composedPath()[0] as HTMLElement;
+ let handle: Handle | null = null;
+ if (target) {
+ handle = target.getAttribute("part") as Handle | null;
+ } else if (focusedElement) {
+ handle = focusedElement.getAttribute("part") as Handle | null;
+ }
+
+ if (event instanceof PointerEvent) {
+ event.stopPropagation();
+ if (event.type === "pointerdown") {
+ if (target !== this && !handle) return;
+
+ if (handle?.startsWith("rotation")) {
+ const parentRotateOrigin = this.#rect.toParentSpace({
+ x: this.#rect.width * this.#rect.rotateOrigin.x,
+ y: this.#rect.height * this.#rect.rotateOrigin.y,
+ });
+ const mousePos = { x: event.clientX, y: event.clientY };
+ this.#startAngle = Vector.angleFromOrigin(mousePos, parentRotateOrigin) - this.#rect.rotation;
+ }
+
+ target.addEventListener("pointermove", this);
+ target.addEventListener("lostpointercapture", this);
+ target.setPointerCapture(event.pointerId);
+ this.#internals.states.add(handle || "move");
+ this.focus();
+ return;
+ }
+
+ if (event.type === "lostpointercapture") {
+ this.#internals.states.delete(handle || "move");
+ target.removeEventListener("pointermove", this);
+ target.removeEventListener("lostpointercapture", this);
+ this.#updateCursors();
+ if (handle?.startsWith("rotation")) {
+ target.style.removeProperty("cursor");
+ }
+ return;
+ }
+ }
+
+ let moveDelta: Point | null = null;
+ if (event instanceof KeyboardEvent) {
+ const MOVEMENT_MUL = event.shiftKey ? 20 : 2;
+ const arrowKeys = ["ArrowUp", "ArrowDown", "ArrowLeft", "ArrowRight"];
+ if (!arrowKeys.includes(event.key)) return;
+
+ moveDelta = {
+ x: (event.key === "ArrowRight" ? 1 : event.key === "ArrowLeft" ? -1 : 0) * MOVEMENT_MUL,
+ y: (event.key === "ArrowDown" ? 1 : event.key === "ArrowUp" ? -1 : 0) * MOVEMENT_MUL,
+ };
+ } else if (event.type === "pointermove") {
+ if (!target) return;
+ const zoom = window.visualViewport?.scale ?? 1;
+ moveDelta = {
+ x: event.movementX / zoom,
+ y: event.movementY / zoom,
+ };
+ }
+
+ if (!moveDelta) return;
+
+ if (target === this || (!handle && event instanceof KeyboardEvent)) {
+ if (event instanceof KeyboardEvent && event.altKey) {
+ const ROTATION_MUL = event.shiftKey ? Math.PI / 12 : Math.PI / 36;
+ const rotationDelta = moveDelta.x !== 0 ? (moveDelta.x > 0 ? ROTATION_MUL : -ROTATION_MUL) : 0;
+ this.rotation += rotationDelta;
+ } else {
+ this.x += moveDelta.x;
+ this.y += moveDelta.y;
+ }
+ event.preventDefault();
+ return;
+ }
+
+ if (handle?.startsWith("resize")) {
+ const rect = this.#rect;
+ const corner = {
+ "resize-top-left": rect.topLeft,
+ "resize-top-right": rect.topRight,
+ "resize-bottom-right": rect.bottomRight,
+ "resize-bottom-left": rect.bottomLeft,
+ }[handle as ResizeHandle];
+
+ const currentPos = rect.toParentSpace(corner);
+ const mousePos =
+ event instanceof KeyboardEvent
+ ? { x: currentPos.x + moveDelta.x, y: currentPos.y + moveDelta.y }
+ : { x: event.clientX, y: event.clientY };
+
+ this.#handleResize(
+ handle as ResizeHandle,
+ mousePos,
+ target,
+ event instanceof PointerEvent ? event : undefined,
+ );
+ event.preventDefault();
+ return;
+ }
+
+ if (handle?.startsWith("rotation") && event instanceof PointerEvent) {
+ const parentRotateOrigin = this.#rect.toParentSpace({
+ x: this.#rect.width * this.#rect.rotateOrigin.x,
+ y: this.#rect.height * this.#rect.rotateOrigin.y,
+ });
+ const currentAngle = Vector.angleFromOrigin(
+ { x: event.clientX, y: event.clientY },
+ parentRotateOrigin,
+ );
+ this.rotation = currentAngle - this.#startAngle;
+
+ const degrees = (this.#rect.rotation * 180) / Math.PI;
+ const cursorRotation = {
+ "rotation-top-left": degrees,
+ "rotation-top-right": (degrees + 90) % 360,
+ "rotation-bottom-right": (degrees + 180) % 360,
+ "rotation-bottom-left": (degrees + 270) % 360,
+ }[handle as RotateHandle];
+
+ target.style.setProperty("cursor", getRotateCursorUrl(cursorRotation));
+ return;
+ }
+ }
+
+ protected override update(changedProperties: PropertyValues): void {
+ this.#dispatchTransformEvent();
+ super.update(changedProperties);
+ }
+
+ #dispatchTransformEvent() {
+ const emittedRect = new DOMRectTransform(this.#rect);
+ const event = new TransformEvent(emittedRect, this.#previousRect);
+ this.dispatchEvent(event);
+
+ if (event.xPrevented) emittedRect.x = this.#previousRect.x;
+ if (event.yPrevented) emittedRect.y = this.#previousRect.y;
+ if (event.widthPrevented) emittedRect.width = this.#previousRect.width;
+ if (event.heightPrevented) emittedRect.height = this.#previousRect.height;
+ if (event.rotatePrevented) emittedRect.rotation = this.#previousRect.rotation;
+
+ this.style.transform = emittedRect.toCssString();
+ this.style.width = this.#attrWidth === "auto" ? "" : `${emittedRect.width}px`;
+ this.style.height = this.#attrHeight === "auto" ? "" : `${emittedRect.height}px`;
+
+ this.#readonlyRect = new DOMRectTransformReadonly(emittedRect);
+ }
+
+ #onAutoResize = (entry: ResizeObserverEntry) => {
+ this.#previousRect.height = this.#rect.height;
+ this.#rect.height = entry.contentRect.height;
+ this.#previousRect.width = this.#rect.width;
+ this.#rect.width = entry.contentRect.width;
+ this.#dispatchTransformEvent();
+ };
+
+ #updateCursors() {
+ const degrees = (this.#rect.rotation * 180) / Math.PI;
+
+ const resizeCursor0 = getResizeCursorUrl(degrees);
+ const resizeCursor90 = getResizeCursorUrl((degrees + 90) % 360);
+
+ this.#handles["resize-top-left"].style.setProperty("cursor", resizeCursor0);
+ this.#handles["resize-bottom-right"].style.setProperty("cursor", resizeCursor0);
+ this.#handles["resize-top-right"].style.setProperty("cursor", resizeCursor90);
+ this.#handles["resize-bottom-left"].style.setProperty("cursor", resizeCursor90);
+
+ this.#handles["rotation-top-left"].style.setProperty("cursor", getRotateCursorUrl(degrees));
+ this.#handles["rotation-top-right"].style.setProperty(
+ "cursor",
+ getRotateCursorUrl((degrees + 90) % 360),
+ );
+ this.#handles["rotation-bottom-right"].style.setProperty(
+ "cursor",
+ getRotateCursorUrl((degrees + 180) % 360),
+ );
+ this.#handles["rotation-bottom-left"].style.setProperty(
+ "cursor",
+ getRotateCursorUrl((degrees + 270) % 360),
+ );
+ }
+
+ #handleResize(handle: ResizeHandle, pointerPos: Point, target: HTMLElement, event?: PointerEvent) {
+ const localPointer = this.#rect.toLocalSpace(pointerPos);
+
+ switch (handle) {
+ case "resize-bottom-right":
+ this.#rect.bottomRight = localPointer;
+ break;
+ case "resize-bottom-left":
+ this.#rect.bottomLeft = localPointer;
+ break;
+ case "resize-top-left":
+ this.#rect.topLeft = localPointer;
+ break;
+ case "resize-top-right":
+ this.#rect.topRight = localPointer;
+ break;
+ }
+
+ let nextHandle: ResizeHandle = handle;
+ const flipWidth = this.#rect.width < 0;
+ const flipHeight = this.#rect.height < 0;
+
+ if (flipWidth && flipHeight) {
+ nextHandle = oppositeHandleMap[handle];
+ } else if (flipWidth) {
+ nextHandle = flipXHandleMap[handle];
+ } else if (flipHeight) {
+ nextHandle = flipYHandleMap[handle];
+ }
+
+ const newTarget = this.renderRoot.querySelector(`[part="${nextHandle}"]`) as HTMLElement;
+
+ if (newTarget) {
+ newTarget.focus();
+ this.#internals.states.delete(handle);
+ this.#internals.states.add(nextHandle);
+
+ if (event && "setPointerCapture" in target) {
+ target.removeEventListener("pointermove", this);
+ target.removeEventListener("lostpointercapture", this);
+ newTarget.addEventListener("pointermove", this);
+ newTarget.addEventListener("lostpointercapture", this);
+ target.releasePointerCapture(event.pointerId);
+ newTarget.setPointerCapture(event.pointerId);
+ }
+ }
+
+ this.requestUpdate();
+ }
+}
diff --git a/lib/index.ts b/lib/index.ts
new file mode 100644
index 0000000..8813697
--- /dev/null
+++ b/lib/index.ts
@@ -0,0 +1,23 @@
+// Core types
+export * from "./types";
+
+// Base element
+export * from "./folk-element";
+
+// Math utilities
+export * from "./Matrix";
+export * from "./Vector";
+
+// DOM transforms
+export * from "./DOMRectTransform";
+export * from "./TransformEvent";
+
+// Utilities
+export * from "./resize-manager";
+export * from "./cursors";
+export * from "./utils";
+export * from "./tags";
+
+// Components
+export * from "./folk-shape";
+export * from "./folk-markdown";
diff --git a/lib/resize-manager.ts b/lib/resize-manager.ts
new file mode 100644
index 0000000..bcbaf56
--- /dev/null
+++ b/lib/resize-manager.ts
@@ -0,0 +1,43 @@
+export type ResizeManagerEntryCallback = (entry: ResizeObserverEntry) => void;
+
+/** A composition interface for ResizeObserver, allowing multiple observers per element. */
+export class ResizeManager {
+ #elementMap = new WeakMap>();
+ #elementEntry = new WeakMap();
+
+ #vo = new ResizeObserver((entries) => {
+ for (const entry of entries) {
+ this.#elementEntry.set(entry.target, entry);
+ this.#elementMap.get(entry.target)?.forEach((callback) => callback(entry));
+ }
+ });
+
+ observe(target: Element, callback: ResizeManagerEntryCallback): void {
+ let callbacks = this.#elementMap.get(target);
+
+ if (callbacks === undefined) {
+ this.#vo.observe(target);
+ this.#elementMap.set(target, (callbacks = new Set()));
+ } else {
+ const entry = this.#elementEntry.get(target);
+ if (entry) {
+ callback(entry);
+ }
+ }
+
+ callbacks.add(callback);
+ }
+
+ unobserve(target: Element, callback: ResizeManagerEntryCallback): void {
+ const callbacks = this.#elementMap.get(target);
+
+ if (callbacks === undefined) return;
+
+ callbacks.delete(callback);
+
+ if (callbacks.size === 0) {
+ this.#vo.unobserve(target);
+ this.#elementMap.delete(target);
+ }
+ }
+}
diff --git a/lib/tags.ts b/lib/tags.ts
new file mode 100644
index 0000000..4086511
--- /dev/null
+++ b/lib/tags.ts
@@ -0,0 +1,8 @@
+/** A raw tagged template literal that just provides HTML syntax highlighting/LSP support. */
+export const html = String.raw;
+
+export function css(strings: TemplateStringsArray, ...values: unknown[]) {
+ const styles = new CSSStyleSheet();
+ styles.replaceSync(String.raw(strings, ...values));
+ return styles;
+}
diff --git a/lib/types.ts b/lib/types.ts
new file mode 100644
index 0000000..5816552
--- /dev/null
+++ b/lib/types.ts
@@ -0,0 +1 @@
+export type Point = { x: number; y: number };
diff --git a/lib/utils.ts b/lib/utils.ts
new file mode 100644
index 0000000..44b93fb
--- /dev/null
+++ b/lib/utils.ts
@@ -0,0 +1 @@
+export const MAX_Z_INDEX = 2147483647;
diff --git a/package.json b/package.json
new file mode 100644
index 0000000..a894195
--- /dev/null
+++ b/package.json
@@ -0,0 +1,24 @@
+{
+ "name": "rspace-online",
+ "version": "0.1.0",
+ "type": "module",
+ "scripts": {
+ "dev": "vite",
+ "build": "tsc --noEmit && vite build",
+ "preview": "vite preview",
+ "server": "bun run server/index.ts",
+ "start": "bun run build && bun run server"
+ },
+ "dependencies": {
+ "@automerge/automerge": "^2.2.8",
+ "@lit/reactive-element": "^2.0.4",
+ "perfect-arrows": "^0.3.7",
+ "perfect-freehand": "^1.2.2"
+ },
+ "devDependencies": {
+ "@types/node": "^22.10.1",
+ "bun-types": "^1.1.38",
+ "typescript": "^5.7.2",
+ "vite": "^6.0.3"
+ }
+}
diff --git a/server/community-store.ts b/server/community-store.ts
new file mode 100644
index 0000000..efa587c
--- /dev/null
+++ b/server/community-store.ts
@@ -0,0 +1,114 @@
+import { mkdir, readdir } from "node:fs/promises";
+
+const STORAGE_DIR = process.env.STORAGE_DIR || "./data/communities";
+
+export interface CommunityMeta {
+ name: string;
+ slug: string;
+ createdAt: string;
+}
+
+export interface CommunityDoc {
+ meta: CommunityMeta;
+ shapes: Record<
+ string,
+ {
+ type: string;
+ id: string;
+ x: number;
+ y: number;
+ width: number;
+ height: number;
+ rotation?: number;
+ content?: string;
+ }
+ >;
+}
+
+// In-memory cache of community docs
+const communities = new Map();
+
+// Ensure storage directory exists
+await mkdir(STORAGE_DIR, { recursive: true });
+
+export async function loadCommunity(slug: string): Promise {
+ // Check cache first
+ if (communities.has(slug)) {
+ return communities.get(slug)!;
+ }
+
+ // Try to load from disk
+ const path = `${STORAGE_DIR}/${slug}.json`;
+ const file = Bun.file(path);
+
+ if (await file.exists()) {
+ try {
+ const data = (await file.json()) as CommunityDoc;
+ communities.set(slug, data);
+ return data;
+ } catch (e) {
+ console.error(`Failed to load community ${slug}:`, e);
+ return null;
+ }
+ }
+
+ return null;
+}
+
+export async function saveCommunity(slug: string, doc: CommunityDoc): Promise {
+ communities.set(slug, doc);
+ const path = `${STORAGE_DIR}/${slug}.json`;
+ await Bun.write(path, JSON.stringify(doc, null, 2));
+}
+
+export async function createCommunity(name: string, slug: string): Promise {
+ const doc: CommunityDoc = {
+ meta: {
+ name,
+ slug,
+ createdAt: new Date().toISOString(),
+ },
+ shapes: {},
+ };
+
+ await saveCommunity(slug, doc);
+ return doc;
+}
+
+export async function communityExists(slug: string): Promise {
+ if (communities.has(slug)) return true;
+
+ const path = `${STORAGE_DIR}/${slug}.json`;
+ const file = Bun.file(path);
+ return file.exists();
+}
+
+export async function listCommunities(): Promise {
+ try {
+ const files = await readdir(STORAGE_DIR);
+ return files.filter((f) => f.endsWith(".json")).map((f) => f.replace(".json", ""));
+ } catch {
+ return [];
+ }
+}
+
+export function updateShape(
+ slug: string,
+ shapeId: string,
+ data: CommunityDoc["shapes"][string],
+): void {
+ const doc = communities.get(slug);
+ if (doc) {
+ doc.shapes[shapeId] = data;
+ // Save async without blocking
+ saveCommunity(slug, doc);
+ }
+}
+
+export function deleteShape(slug: string, shapeId: string): void {
+ const doc = communities.get(slug);
+ if (doc) {
+ delete doc.shapes[shapeId];
+ saveCommunity(slug, doc);
+ }
+}
diff --git a/server/index.ts b/server/index.ts
new file mode 100644
index 0000000..9020101
--- /dev/null
+++ b/server/index.ts
@@ -0,0 +1,260 @@
+import { resolve } from "node:path";
+import type { ServerWebSocket } from "bun";
+import {
+ communityExists,
+ createCommunity,
+ deleteShape,
+ loadCommunity,
+ updateShape,
+ type CommunityDoc,
+} from "./community-store";
+
+const PORT = Number(process.env.PORT) || 3000;
+const DIST_DIR = resolve(import.meta.dir, "../dist");
+
+// WebSocket data type
+interface WSData {
+ communitySlug: string;
+}
+
+// Track connected clients per community
+const communityClients = new Map>>();
+
+// Helper to broadcast to all clients in a community
+function broadcastToCommunity(slug: string, message: object, excludeWs?: ServerWebSocket) {
+ const clients = communityClients.get(slug);
+ if (!clients) return;
+
+ const data = JSON.stringify(message);
+ for (const client of clients) {
+ if (client !== excludeWs && client.readyState === WebSocket.OPEN) {
+ client.send(data);
+ }
+ }
+}
+
+// Parse subdomain from host header
+function getSubdomain(host: string | null): string | null {
+ if (!host) return null;
+
+ // Handle localhost for development
+ if (host.includes("localhost") || host.includes("127.0.0.1")) {
+ return null;
+ }
+
+ // Extract subdomain from *.rspace.online
+ const parts = host.split(".");
+ if (parts.length >= 3 && parts.slice(-2).join(".") === "rspace.online") {
+ const subdomain = parts[0];
+ if (subdomain !== "www" && subdomain !== "rspace") {
+ return subdomain;
+ }
+ }
+
+ return null;
+}
+
+// Serve static files
+async function serveStatic(path: string): Promise {
+ const filePath = resolve(DIST_DIR, path);
+ const file = Bun.file(filePath);
+
+ if (await file.exists()) {
+ const contentType = getContentType(path);
+ return new Response(file, {
+ headers: { "Content-Type": contentType },
+ });
+ }
+
+ return null;
+}
+
+function getContentType(path: string): string {
+ if (path.endsWith(".html")) return "text/html";
+ if (path.endsWith(".js")) return "application/javascript";
+ if (path.endsWith(".css")) return "text/css";
+ if (path.endsWith(".json")) return "application/json";
+ if (path.endsWith(".svg")) return "image/svg+xml";
+ return "text/plain";
+}
+
+// Main server
+const server = Bun.serve({
+ port: PORT,
+
+ async fetch(req, server) {
+ const url = new URL(req.url);
+ const host = req.headers.get("host");
+ const subdomain = getSubdomain(host);
+
+ // Handle WebSocket upgrade
+ if (url.pathname.startsWith("/ws/")) {
+ const communitySlug = url.pathname.split("/")[2];
+ if (communitySlug) {
+ const upgraded = server.upgrade(req, { data: { communitySlug } });
+ if (upgraded) return undefined;
+ }
+ return new Response("WebSocket upgrade failed", { status: 400 });
+ }
+
+ // API routes
+ if (url.pathname.startsWith("/api/")) {
+ return handleAPI(req, url);
+ }
+
+ // Community canvas route (subdomain detected)
+ if (subdomain) {
+ const community = await loadCommunity(subdomain);
+ if (!community) {
+ return new Response("Community not found", { status: 404 });
+ }
+
+ // Serve canvas.html for community
+ const canvasHtml = await serveStatic("canvas.html");
+ if (canvasHtml) return canvasHtml;
+ }
+
+ // Static files
+ let filePath = url.pathname;
+ if (filePath === "/") filePath = "/index.html";
+ if (filePath === "/canvas") filePath = "/canvas.html";
+
+ // Remove leading slash
+ filePath = filePath.slice(1);
+
+ const staticResponse = await serveStatic(filePath);
+ if (staticResponse) return staticResponse;
+
+ // Fallback to index.html for SPA routing
+ const indexResponse = await serveStatic("index.html");
+ if (indexResponse) return indexResponse;
+
+ return new Response("Not Found", { status: 404 });
+ },
+
+ websocket: {
+ open(ws: ServerWebSocket) {
+ const { communitySlug } = ws.data;
+
+ // Add to clients set
+ if (!communityClients.has(communitySlug)) {
+ communityClients.set(communitySlug, new Set());
+ }
+ communityClients.get(communitySlug)!.add(ws);
+
+ console.log(`Client connected to ${communitySlug}`);
+
+ // Send current state
+ loadCommunity(communitySlug).then((doc) => {
+ if (doc) {
+ ws.send(JSON.stringify({ type: "sync", shapes: doc.shapes }));
+ }
+ });
+ },
+
+ message(ws: ServerWebSocket, message: string | Buffer) {
+ const { communitySlug } = ws.data;
+
+ try {
+ const data = JSON.parse(message.toString());
+
+ if (data.type === "update" && data.id && data.data) {
+ // Update local store
+ updateShape(communitySlug, data.id, data.data);
+
+ // Broadcast to other clients
+ broadcastToCommunity(communitySlug, data, ws);
+ } else if (data.type === "delete" && data.id) {
+ // Delete from store
+ deleteShape(communitySlug, data.id);
+
+ // Broadcast to other clients
+ broadcastToCommunity(communitySlug, data, ws);
+ }
+ } catch (e) {
+ console.error("Failed to parse WebSocket message:", e);
+ }
+ },
+
+ close(ws: ServerWebSocket) {
+ const { communitySlug } = ws.data;
+
+ // Remove from clients set
+ const clients = communityClients.get(communitySlug);
+ if (clients) {
+ clients.delete(ws);
+ if (clients.size === 0) {
+ communityClients.delete(communitySlug);
+ }
+ }
+
+ console.log(`Client disconnected from ${communitySlug}`);
+ },
+ },
+});
+
+// API handler
+async function handleAPI(req: Request, url: URL): Promise {
+ const corsHeaders = {
+ "Access-Control-Allow-Origin": "*",
+ "Access-Control-Allow-Methods": "GET, POST, OPTIONS",
+ "Access-Control-Allow-Headers": "Content-Type",
+ };
+
+ if (req.method === "OPTIONS") {
+ return new Response(null, { headers: corsHeaders });
+ }
+
+ // POST /api/communities - Create new community
+ if (url.pathname === "/api/communities" && req.method === "POST") {
+ try {
+ const body = (await req.json()) as { name?: string; slug?: string };
+ const { name, slug } = body;
+
+ if (!name || !slug) {
+ return Response.json({ error: "Name and slug are required" }, { status: 400, headers: corsHeaders });
+ }
+
+ // Validate slug format
+ if (!/^[a-z0-9-]+$/.test(slug)) {
+ return Response.json(
+ { error: "Slug must contain only lowercase letters, numbers, and hyphens" },
+ { status: 400, headers: corsHeaders },
+ );
+ }
+
+ // Check if exists
+ if (await communityExists(slug)) {
+ return Response.json({ error: "Community already exists" }, { status: 409, headers: corsHeaders });
+ }
+
+ // Create community
+ await createCommunity(name, slug);
+
+ // Return URL to new community
+ return Response.json(
+ { url: `https://${slug}.rspace.online`, slug, name },
+ { headers: corsHeaders },
+ );
+ } catch (e) {
+ console.error("Failed to create community:", e);
+ return Response.json({ error: "Failed to create community" }, { status: 500, headers: corsHeaders });
+ }
+ }
+
+ // GET /api/communities/:slug - Get community info
+ if (url.pathname.startsWith("/api/communities/") && req.method === "GET") {
+ const slug = url.pathname.split("/")[3];
+ const community = await loadCommunity(slug);
+
+ if (!community) {
+ return Response.json({ error: "Community not found" }, { status: 404, headers: corsHeaders });
+ }
+
+ return Response.json({ meta: community.meta }, { headers: corsHeaders });
+ }
+
+ return Response.json({ error: "Not found" }, { status: 404, headers: corsHeaders });
+}
+
+console.log(`rSpace server running on http://localhost:${PORT}`);
diff --git a/tsconfig.json b/tsconfig.json
new file mode 100644
index 0000000..8a21a42
--- /dev/null
+++ b/tsconfig.json
@@ -0,0 +1,25 @@
+{
+ "compilerOptions": {
+ "target": "ESNext",
+ "module": "ESNext",
+ "moduleResolution": "bundler",
+ "isolatedModules": true,
+ "noEmit": true,
+ "strict": true,
+ "noFallthroughCasesInSwitch": true,
+ "allowImportingTsExtensions": true,
+ "useDefineForClassFields": false,
+ "experimentalDecorators": true,
+ "skipLibCheck": true,
+ "noUnusedLocals": false,
+ "lib": ["DOM", "DOM.Iterable", "ESNext"],
+ "types": ["@types/node", "bun-types"],
+ "baseUrl": ".",
+ "paths": {
+ "@lib": ["lib"],
+ "@lib/*": ["lib/*"]
+ }
+ },
+ "include": ["**/*.ts", "vite.config.ts"],
+ "exclude": ["node_modules/**/*", "dist/**/*"]
+}
diff --git a/vite.config.ts b/vite.config.ts
new file mode 100644
index 0000000..0d064ee
--- /dev/null
+++ b/vite.config.ts
@@ -0,0 +1,28 @@
+import { resolve } from "node:path";
+import { defineConfig } from "vite";
+
+export default defineConfig({
+ root: "website",
+ resolve: {
+ alias: {
+ "@lib": resolve(__dirname, "./lib"),
+ },
+ },
+ build: {
+ target: "esnext",
+ rollupOptions: {
+ input: {
+ index: resolve(__dirname, "./website/index.html"),
+ canvas: resolve(__dirname, "./website/canvas.html"),
+ },
+ },
+ modulePreload: {
+ polyfill: false,
+ },
+ outDir: "../dist",
+ emptyOutDir: true,
+ },
+ server: {
+ port: 5173,
+ },
+});
diff --git a/website/canvas.html b/website/canvas.html
new file mode 100644
index 0000000..476654c
--- /dev/null
+++ b/website/canvas.html
@@ -0,0 +1,302 @@
+
+
+
+
+
+ rSpace Canvas
+
+
+
+
+
+
+
+
+
+
+
+
+ Connecting...
+
+
+
+
+
+
diff --git a/website/index.html b/website/index.html
new file mode 100644
index 0000000..f6d37b2
--- /dev/null
+++ b/website/index.html
@@ -0,0 +1,261 @@
+
+
+
+
+
+ rSpace - Collaborative Community Spaces
+
+
+
+
+
rSpace
+
Collaborative community spaces powered by FolkJS
+
+
+
+
+
+
🎨
+
Spatial Canvas
+
Infinite collaborative workspace
+
+
+
🔄
+
Real-time Sync
+
Powered by Automerge CRDT
+
+
+
🌐
+
Your Subdomain
+
community.rspace.online
+
+
+
+
+
+
+