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:
parent
7fcef2c2b2
commit
34a1e3b640
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
Loading…
Reference in New Issue