feat: mobile toolbar + infinite canvas pan/zoom

- Mobile toolbar: icon-only scrollable strip with horizontal swipe
- Infinite canvas: separate viewport (grid) from content layer (shapes)
- Single-finger pan via pointer events on empty canvas background
- Widen zoom range from 0.25-4x to 0.05-20x
- Fix Automerge sync fallback to full doc reconciliation when no patches

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Jeff Emmett 2026-02-19 02:32:48 +00:00
parent 7fcef2c2b2
commit 34a1e3b640
2 changed files with 106 additions and 13 deletions

View File

@ -263,6 +263,7 @@ export class CommunitySync extends EventTarget {
* Apply incoming Automerge sync message * Apply incoming Automerge sync message
*/ */
#applySyncMessage(message: Uint8Array): void { #applySyncMessage(message: Uint8Array): void {
const oldDoc = this.#doc;
const result = Automerge.receiveSyncMessage( const result = Automerge.receiveSyncMessage(
this.#doc, this.#doc,
this.#syncState, this.#syncState,
@ -276,10 +277,16 @@ export class CommunitySync extends EventTarget {
this.#scheduleSave(); this.#scheduleSave();
this.#persistSyncState(); this.#persistSyncState();
// Apply changes to DOM if we received new patches // Apply changes to DOM if the document changed
const patch = result[2] as { patches: Automerge.Patch[] } | null; if (this.#doc !== oldDoc) {
if (patch && patch.patches && patch.patches.length > 0) { const patch = result[2] as { patches: Automerge.Patch[] } | null;
this.#applyPatchesToDOM(patch.patches); if (patch && patch.patches && patch.patches.length > 0) {
this.#applyPatchesToDOM(patch.patches);
} else {
// Automerge 2.x receiveSyncMessage may not return patches;
// fall back to full document-to-DOM reconciliation
this.#applyDocToDOM();
}
} }
// Generate response if needed // Generate response if needed

View File

@ -249,6 +249,13 @@
touch-action: none; /* Prevent browser gestures, handle manually */ touch-action: none; /* Prevent browser gestures, handle manually */
} }
#canvas-content {
position: absolute;
top: 0;
left: 0;
transform-origin: 0 0;
}
/* Touch-friendly resize handles */ /* Touch-friendly resize handles */
@media (pointer: coarse) { @media (pointer: coarse) {
folk-shape::part(resize-top-left), folk-shape::part(resize-top-left),
@ -326,6 +333,34 @@
outline: 3px solid #22c55e !important; outline: 3px solid #22c55e !important;
outline-offset: 4px !important; outline-offset: 4px !important;
} }
/* Mobile toolbar: icon-only scrollable strip */
@media (max-width: 768px) {
#toolbar {
max-width: calc(100vw - 32px);
overflow-x: auto;
scrollbar-width: none;
gap: 4px;
padding: 6px 8px;
touch-action: pan-x;
}
#toolbar::-webkit-scrollbar {
display: none;
}
#toolbar button {
max-width: 36px;
min-width: 36px;
padding: 8px;
overflow: hidden;
white-space: nowrap;
}
#community-info {
display: none;
}
#memory-panel {
max-width: calc(100vw - 32px);
}
}
</style> </style>
</head> </head>
<body> <body>
@ -381,7 +416,7 @@
<span id="status-text">Connecting...</span> <span id="status-text">Connecting...</span>
</div> </div>
<div id="canvas"></div> <div id="canvas"><div id="canvas-content"></div></div>
<script type="module"> <script type="module">
import { import {
@ -487,6 +522,7 @@
document.getElementById("community-slug").textContent = `${communitySlug}.rspace.online`; document.getElementById("community-slug").textContent = `${communitySlug}.rspace.online`;
const canvas = document.getElementById("canvas"); const canvas = document.getElementById("canvas");
const canvasContent = document.getElementById("canvas-content");
const status = document.getElementById("status"); const status = document.getElementById("status");
const statusText = document.getElementById("status-text"); const statusText = document.getElementById("status-text");
let shapeCounter = 0; let shapeCounter = 0;
@ -599,7 +635,7 @@
const shape = newShapeElement(data); const shape = newShapeElement(data);
if (shape) { if (shape) {
setupShapeEventListeners(shape); setupShapeEventListeners(shape);
canvas.appendChild(shape); canvasContent.appendChild(shape);
sync.registerShape(shape); sync.registerShape(shape);
} }
} catch (err) { } catch (err) {
@ -906,7 +942,7 @@
try { try {
setupShapeEventListeners(shape); setupShapeEventListeners(shape);
canvas.appendChild(shape); canvasContent.appendChild(shape);
sync.registerShape(shape); sync.registerShape(shape);
} catch (e) { } catch (e) {
console.error(`[Canvas] Failed to create shape ${tagName}:`, e); console.error(`[Canvas] Failed to create shape ${tagName}:`, e);
@ -996,7 +1032,7 @@
arrow.sourceId = mint.id; arrow.sourceId = mint.id;
arrow.targetId = ledger.id; arrow.targetId = ledger.id;
arrow.color = "#8b5cf6"; arrow.color = "#8b5cf6";
canvas.appendChild(arrow); canvasContent.appendChild(arrow);
sync.registerShape(arrow); sync.registerShape(arrow);
} }
} }
@ -1097,7 +1133,7 @@
arrow.targetId = target.id; arrow.targetId = target.id;
arrow.color = colors[Math.floor(Math.random() * colors.length)]; arrow.color = colors[Math.floor(Math.random() * colors.length)];
canvas.appendChild(arrow); canvasContent.appendChild(arrow);
sync.registerShape(arrow); sync.registerShape(arrow);
// Reset connection mode // Reset connection mode
@ -1198,12 +1234,16 @@
let scale = 1; let scale = 1;
let panX = 0; let panX = 0;
let panY = 0; let panY = 0;
const minScale = 0.25; const minScale = 0.05;
const maxScale = 4; const maxScale = 20;
function updateCanvasTransform() { function updateCanvasTransform() {
canvas.style.transform = `translate(${panX}px, ${panY}px) scale(${scale})`; // Transform only the content layer — canvas viewport stays fixed
canvas.style.transformOrigin = "center center"; canvasContent.style.transform = `translate(${panX}px, ${panY}px) scale(${scale})`;
// Adjust grid to track pan/zoom so it appears infinite
const gridSize = 20 * scale;
canvas.style.backgroundSize = `${gridSize}px ${gridSize}px`;
canvas.style.backgroundPosition = `${panX - 1}px ${panY - 1}px`;
} }
document.getElementById("zoom-in").addEventListener("click", () => { document.getElementById("zoom-in").addEventListener("click", () => {
@ -1244,6 +1284,10 @@
canvas.addEventListener("touchstart", (e) => { canvas.addEventListener("touchstart", (e) => {
if (e.touches.length === 2) { if (e.touches.length === 2) {
e.preventDefault(); e.preventDefault();
// Cancel any single-finger pan to avoid conflict
isPanning = false;
panPointerId = null;
canvas.style.cursor = "";
initialDistance = getTouchDistance(e.touches); initialDistance = getTouchDistance(e.touches);
initialScale = scale; initialScale = scale;
lastTouchCenter = getTouchCenter(e.touches); lastTouchCenter = getTouchCenter(e.touches);
@ -1286,6 +1330,48 @@
updateCanvasTransform(); updateCanvasTransform();
}, { passive: false }); }, { passive: false });
// Single-finger canvas pan (pointer events on empty background)
let isPanning = false;
let panPointerId = null;
let panStartX = 0;
let panStartY = 0;
canvas.addEventListener("pointerdown", (e) => {
if (e.target !== canvas && e.target !== canvasContent) return;
if (connectMode) return;
isPanning = true;
panPointerId = e.pointerId;
panStartX = e.clientX;
panStartY = e.clientY;
canvas.setPointerCapture(e.pointerId);
canvas.style.cursor = "grabbing";
});
canvas.addEventListener("pointermove", (e) => {
if (!isPanning || e.pointerId !== panPointerId) return;
const dx = e.clientX - panStartX;
const dy = e.clientY - panStartY;
panX += dx;
panY += dy;
panStartX = e.clientX;
panStartY = e.clientY;
updateCanvasTransform();
});
canvas.addEventListener("pointerup", (e) => {
if (e.pointerId !== panPointerId) return;
isPanning = false;
panPointerId = null;
canvas.style.cursor = "";
});
canvas.addEventListener("pointercancel", (e) => {
if (e.pointerId !== panPointerId) return;
isPanning = false;
panPointerId = null;
canvas.style.cursor = "";
});
// Keep-alive ping to prevent WebSocket idle timeout // Keep-alive ping to prevent WebSocket idle timeout
setInterval(() => { setInterval(() => {
try { try {