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
*/
#applySyncMessage(message: Uint8Array): void {
const oldDoc = this.#doc;
const result = Automerge.receiveSyncMessage(
this.#doc,
this.#syncState,
@ -276,10 +277,16 @@ export class CommunitySync extends EventTarget {
this.#scheduleSave();
this.#persistSyncState();
// Apply changes to DOM if we received new patches
const patch = result[2] as { patches: Automerge.Patch[] } | null;
if (patch && patch.patches && patch.patches.length > 0) {
this.#applyPatchesToDOM(patch.patches);
// Apply changes to DOM if the document changed
if (this.#doc !== oldDoc) {
const patch = result[2] as { patches: Automerge.Patch[] } | null;
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

View File

@ -249,6 +249,13 @@
touch-action: none; /* Prevent browser gestures, handle manually */
}
#canvas-content {
position: absolute;
top: 0;
left: 0;
transform-origin: 0 0;
}
/* Touch-friendly resize handles */
@media (pointer: coarse) {
folk-shape::part(resize-top-left),
@ -326,6 +333,34 @@
outline: 3px solid #22c55e !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>
</head>
<body>
@ -381,7 +416,7 @@
<span id="status-text">Connecting...</span>
</div>
<div id="canvas"></div>
<div id="canvas"><div id="canvas-content"></div></div>
<script type="module">
import {
@ -487,6 +522,7 @@
document.getElementById("community-slug").textContent = `${communitySlug}.rspace.online`;
const canvas = document.getElementById("canvas");
const canvasContent = document.getElementById("canvas-content");
const status = document.getElementById("status");
const statusText = document.getElementById("status-text");
let shapeCounter = 0;
@ -599,7 +635,7 @@
const shape = newShapeElement(data);
if (shape) {
setupShapeEventListeners(shape);
canvas.appendChild(shape);
canvasContent.appendChild(shape);
sync.registerShape(shape);
}
} catch (err) {
@ -906,7 +942,7 @@
try {
setupShapeEventListeners(shape);
canvas.appendChild(shape);
canvasContent.appendChild(shape);
sync.registerShape(shape);
} catch (e) {
console.error(`[Canvas] Failed to create shape ${tagName}:`, e);
@ -996,7 +1032,7 @@
arrow.sourceId = mint.id;
arrow.targetId = ledger.id;
arrow.color = "#8b5cf6";
canvas.appendChild(arrow);
canvasContent.appendChild(arrow);
sync.registerShape(arrow);
}
}
@ -1097,7 +1133,7 @@
arrow.targetId = target.id;
arrow.color = colors[Math.floor(Math.random() * colors.length)];
canvas.appendChild(arrow);
canvasContent.appendChild(arrow);
sync.registerShape(arrow);
// Reset connection mode
@ -1198,12 +1234,16 @@
let scale = 1;
let panX = 0;
let panY = 0;
const minScale = 0.25;
const maxScale = 4;
const minScale = 0.05;
const maxScale = 20;
function updateCanvasTransform() {
canvas.style.transform = `translate(${panX}px, ${panY}px) scale(${scale})`;
canvas.style.transformOrigin = "center center";
// Transform only the content layer — canvas viewport stays fixed
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", () => {
@ -1244,6 +1284,10 @@
canvas.addEventListener("touchstart", (e) => {
if (e.touches.length === 2) {
e.preventDefault();
// Cancel any single-finger pan to avoid conflict
isPanning = false;
panPointerId = null;
canvas.style.cursor = "";
initialDistance = getTouchDistance(e.touches);
initialScale = scale;
lastTouchCenter = getTouchCenter(e.touches);
@ -1286,6 +1330,48 @@
updateCanvasTransform();
}, { 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
setInterval(() => {
try {