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` +
+ + 📝 + Markdown + +
+ + +
+
+
+
+ +
+ `; + + // 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, "
      $1
    ") + .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 + + + +
    +

    Loading...

    +

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

    + +
    +
    + + +
    +
    + + +

    Your space will be at: ___.rspace.online

    +
    + + +
    + +
    +
    +
    🎨
    +
    Spatial Canvas
    +
    Infinite collaborative workspace
    +
    +
    +
    🔄
    +
    Real-time Sync
    +
    Powered by Automerge CRDT
    +
    +
    +
    🌐
    +
    Your Subdomain
    +
    community.rspace.online
    +
    +
    +
    + + + +