diff --git a/modules/rbnb/mod.ts b/modules/rbnb/mod.ts index 27140ed..d87c734 100644 --- a/modules/rbnb/mod.ts +++ b/modules/rbnb/mod.ts @@ -155,7 +155,7 @@ function endorsementToRow(e: Endorsement) { function seedDemoIfEmpty(space: string) { const docId = bnbDocId(space); const doc = ensureDoc(space); - if (Object.keys(doc.listings).length > 0) return; + if ((doc.meta as any)?.seeded || Object.keys(doc.listings).length > 0) return; _syncServer!.changeDoc(docId, 'seed demo data', (d) => { const now = Date.now(); @@ -537,6 +537,9 @@ function seedDemoIfEmpty(space: string) { }; }); + _syncServer!.changeDoc(docId, 'mark seeded', (d) => { + if (d.meta) (d.meta as any).seeded = true; + }); console.log("[rBnb] Demo data seeded: 6 listings, 3 stay requests, 2 endorsements"); } diff --git a/modules/rcal/mod.ts b/modules/rcal/mod.ts index 7c87e65..6a6b4f5 100644 --- a/modules/rcal/mod.ts +++ b/modules/rcal/mod.ts @@ -117,7 +117,7 @@ function sourceToRow(src: CalendarSource) { function seedDemoIfEmpty(space: string) { const docId = calendarDocId(space); const doc = ensureDoc(space); - if (Object.keys(doc.events).length > 0) return; + if ((doc.meta as any)?.seeded || Object.keys(doc.events).length > 0) return; _syncServer!.changeDoc(docId, 'seed demo data', (d) => { const now = Date.now(); @@ -252,6 +252,11 @@ function seedDemoIfEmpty(space: string) { } }); + // Mark as seeded so deleting all events doesn't re-trigger seeding + _syncServer!.changeDoc(docId, 'mark seeded', (d) => { + if (d.meta) (d.meta as any).seeded = true; + }); + console.log("[Cal] Demo data seeded: 2 sources, 7 events"); } @@ -842,8 +847,6 @@ routes.get("/api/context/:tool", async (c) => { routes.get("/", (c) => { const space = c.req.param("space") || "demo"; const dataSpace = c.get("effectiveSpace") || space; - // Seed sample data for any space that has no events yet - seedDemoIfEmpty(dataSpace); return c.html(renderShell({ title: `${space} — Calendar | rSpace`, moduleId: "rcal", diff --git a/modules/rnotes/mod.ts b/modules/rnotes/mod.ts index 48a467e..895605c 100644 --- a/modules/rnotes/mod.ts +++ b/modules/rnotes/mod.ts @@ -141,8 +141,9 @@ function seedDemoIfEmpty(space: string) { // Resolve effective data space (global for rnotes by default) const dataSpace = resolveDataSpace("rnotes", space); - // If the space already has notebooks, skip - if (listNotebooks(dataSpace).length > 0) return; + // If the space already has notebooks, skip (or was already seeded) + const _connectionsDoc = _syncServer!.getDoc(connectionsDocId(dataSpace)); + if ((_connectionsDoc?.meta as any)?.seeded || listNotebooks(dataSpace).length > 0) return; const now = Date.now(); @@ -230,6 +231,17 @@ function seedDemoIfEmpty(space: string) { }); } + // Mark this space as seeded so deletions don't trigger re-seeding + const _connDocId = connectionsDocId(dataSpace); + if (!_syncServer!.getDoc(_connDocId)) { + _syncServer!.setDoc(_connDocId, Automerge.change(Automerge.init(), 'init connections', (d) => { + d.meta = { module: 'notes', collection: 'connections', version: 1, spaceSlug: dataSpace, createdAt: Date.now() }; + })); + } + _syncServer!.changeDoc(_connDocId, 'mark seeded', (d) => { + if (d.meta) (d.meta as any).seeded = true; + }); + console.log("[Notes] Demo data seeded: 3 notebooks, 7 notes"); } diff --git a/modules/rtasks/mod.ts b/modules/rtasks/mod.ts index c8002e5..79ef34c 100644 --- a/modules/rtasks/mod.ts +++ b/modules/rtasks/mod.ts @@ -61,9 +61,10 @@ function getBoardDocIds(space: string): string[] { */ function seedDemoIfEmpty(space: string = 'rspace-dev') { if (!_syncServer) return; - // Check if this space already has tasks boards + // Check if this space already has tasks boards (or was already seeded) const spaceWorkDocs = _syncServer!.getDocIds().filter((id) => id.startsWith(`${space}:tasks:boards:`)); - if (spaceWorkDocs.length > 0) return; + const _seedCheckDoc = _syncServer!.getDoc(boardDocId(space, space)); + if ((_seedCheckDoc?.meta as any)?.seeded || spaceWorkDocs.length > 0) return; const docId = boardDocId(space, space); @@ -111,6 +112,9 @@ function seedDemoIfEmpty(space: string = 'rspace-dev') { }); _syncServer!.setDoc(docId, doc); + _syncServer!.changeDoc(docId, 'mark seeded', (d) => { + if (d.meta) (d.meta as any).seeded = true; + }); console.log(`[Tasks] Demo data seeded for "${space}": 1 board, 11 tasks`); } diff --git a/modules/rvnb/mod.ts b/modules/rvnb/mod.ts index 857905f..7e6783c 100644 --- a/modules/rvnb/mod.ts +++ b/modules/rvnb/mod.ts @@ -183,7 +183,7 @@ function endorsementToRow(e: Endorsement) { function seedDemoIfEmpty(space: string) { const docId = vnbDocId(space); const doc = ensureDoc(space); - if (Object.keys(doc.vehicles).length > 0) return; + if ((doc.meta as any)?.seeded || Object.keys(doc.vehicles).length > 0) return; _syncServer!.changeDoc(docId, 'seed demo data', (d) => { const now = Date.now(); @@ -574,6 +574,9 @@ function seedDemoIfEmpty(space: string) { }; }); + _syncServer!.changeDoc(docId, 'mark seeded', (d) => { + if (d.meta) (d.meta as any).seeded = true; + }); console.log("[rVnb] Demo data seeded: 4 vehicles, 3 rental requests, 2 endorsements"); } diff --git a/modules/rvote/mod.ts b/modules/rvote/mod.ts index 86cc446..38ee9d1 100644 --- a/modules/rvote/mod.ts +++ b/modules/rvote/mod.ts @@ -191,8 +191,9 @@ function newId(): string { // ── Seed demo data into Automerge ── function seedDemoIfEmpty(space: string = 'community') { if (!_syncServer) return; - // If this space already has proposals, skip - if (listProposalDocs(space).length > 0) return; + // If this space already has proposals, skip (or was already seeded) + const _seedConfigDoc = _syncServer!.getDoc(spaceConfigDocId(space)); + if ((_seedConfigDoc?.meta as any)?.seeded || listProposalDocs(space).length > 0) return; // Ensure space config exists ensureSpaceConfigDoc(space); @@ -261,6 +262,9 @@ function seedDemoIfEmpty(space: string = 'community') { _syncServer!.setDoc(docId, doc); } + _syncServer!.changeDoc(spaceConfigDocId(space), 'mark seeded', (d) => { + if (d.meta) (d.meta as any).seeded = true; + }); console.log(`[Vote] Demo data seeded for "${space}": 1 space config, 5 proposals`); } diff --git a/website/canvas.html b/website/canvas.html index 8a427ac..e86e3a3 100644 --- a/website/canvas.html +++ b/website/canvas.html @@ -3839,11 +3839,12 @@ // Collect bounding boxes of all visible shapes on the canvas // Only includes rApps and embedded content — slides, drawings, and arrows are excluded - function getExistingShapeRects() { + function getExistingShapeRects(excludeEl) { return [...canvasContent.children] .filter(el => el.tagName && el.tagName.includes('-') && !FolkShape.pushExemptTags.has(el.tagName.toLowerCase()) && !el.dataset?.wbDrawing && + el !== excludeEl && typeof el.x === 'number' && typeof el.width === 'number' && el.width > 0) .map(el => ({ x: el.x, y: el.y, width: el.width, height: el.height })); @@ -3851,7 +3852,7 @@ // Find a free position that doesn't overlap existing shapes. // If preferX/preferY are provided, use that as the anchor; otherwise use viewport center. - function findFreePosition(width, height, preferX, preferY) { + function findFreePosition(width, height, preferX, preferY, excludeEl) { let candidateX, candidateY; if (preferX !== undefined && preferY !== undefined) { candidateX = preferX - width / 2; @@ -3862,7 +3863,7 @@ candidateY = center.y - height / 2; } const gap = 20; - const existing = getExistingShapeRects(); + const existing = getExistingShapeRects(excludeEl); if (existing.length === 0) { return { x: candidateX, y: candidateY }; @@ -4831,6 +4832,241 @@ wbOverlay.style.cssText = "position:absolute;top:0;left:0;width:100%;height:100%;pointer-events:none;z-index:5;overflow:visible;"; canvasContent.appendChild(wbOverlay); + // SVG overlay for snap alignment guides (above wb drawings) + const snapOverlay = document.createElementNS("http://www.w3.org/2000/svg", "svg"); + snapOverlay.id = "snap-overlay"; + snapOverlay.style.cssText = "position:absolute;top:0;left:0;width:100%;height:100%;pointer-events:none;z-index:6;overflow:visible;"; + canvasContent.appendChild(snapOverlay); + + // --- Snap guides + drop suggestion --- + const SNAP_THRESHOLD = 8; + const SNAP_COLOR = "#14b8a6"; + let activeDragShape = null; + let unsnapX = 0, unsnapY = 0; + let snapCorrecting = false; + let dropGhostEl = null; + let dropGhostTimeout = null; + + function getSnapTargets(excludeEl) { + return [...canvasContent.children] + .filter(el => el instanceof FolkShape && + !FolkShape.pushExemptTags.has(el.tagName.toLowerCase()) && + !el.dataset?.wbDrawing && + el !== excludeEl && + el.width > 0) + .map(el => ({ x: el.x, y: el.y, width: el.width, height: el.height })); + } + + function computeSnaps(ux, uy, w, h, targets) { + let snapX = null, bestDx = SNAP_THRESHOLD; + let snapY = null, bestDy = SNAP_THRESHOLD; + let guideLineX = null, guideLineY = null; + + const dL = ux, dR = ux + w, dCx = ux + w / 2; + const dT = uy, dB = uy + h, dCy = uy + h / 2; + + for (const t of targets) { + const tL = t.x, tR = t.x + t.width, tCx = t.x + t.width / 2; + const tT = t.y, tB = t.y + t.height, tCy = t.y + t.height / 2; + + // 5 X-axis snap pairs: same-edge + opposite-edge + const xPairs = [ + [dL, tL], [dR, tR], [dCx, tCx], + [dL, tR], [dR, tL], + ]; + for (const [dEdge, tEdge] of xPairs) { + const dist = Math.abs(dEdge - tEdge); + if (dist < bestDx) { + bestDx = dist; + snapX = ux + (tEdge - dEdge); + guideLineX = tEdge; + } + } + + // 5 Y-axis snap pairs + const yPairs = [ + [dT, tT], [dB, tB], [dCy, tCy], + [dT, tB], [dB, tT], + ]; + for (const [dEdge, tEdge] of yPairs) { + const dist = Math.abs(dEdge - tEdge); + if (dist < bestDy) { + bestDy = dist; + snapY = uy + (tEdge - dEdge); + guideLineY = tEdge; + } + } + } + + // Compute guide line extents + const guides = []; + const finalY = snapY !== null ? snapY : uy; + const finalX = snapX !== null ? snapX : ux; + + if (guideLineX !== null) { + let minY = finalY, maxY = finalY + h; + for (const t of targets) { + const edges = [t.x, t.x + t.width, t.x + t.width / 2]; + if (edges.some(e => Math.abs(e - guideLineX) < 1)) { + minY = Math.min(minY, t.y); + maxY = Math.max(maxY, t.y + t.height); + } + } + guides.push({ x1: guideLineX, y1: minY - 20, x2: guideLineX, y2: maxY + 20 }); + } + + if (guideLineY !== null) { + let minX = finalX, maxX = finalX + w; + for (const t of targets) { + const edges = [t.y, t.y + t.height, t.y + t.height / 2]; + if (edges.some(e => Math.abs(e - guideLineY) < 1)) { + minX = Math.min(minX, t.x); + maxX = Math.max(maxX, t.x + t.width); + } + } + guides.push({ x1: minX - 20, y1: guideLineY, x2: maxX + 20, y2: guideLineY }); + } + + return { snapX, snapY, guides }; + } + + function renderSnapGuides(guides) { + snapOverlay.textContent = ''; + const sw = 1 / scale; + for (const g of guides) { + const line = document.createElementNS("http://www.w3.org/2000/svg", "line"); + line.setAttribute("x1", g.x1); + line.setAttribute("y1", g.y1); + line.setAttribute("x2", g.x2); + line.setAttribute("y2", g.y2); + line.setAttribute("stroke", SNAP_COLOR); + line.setAttribute("stroke-width", sw); + line.setAttribute("stroke-dasharray", `${4 * sw} ${4 * sw}`); + snapOverlay.appendChild(line); + } + } + + function clearSnapGuides() { + snapOverlay.textContent = ''; + } + + // Capturing listener: intercept folk-transform during drag to apply snap + canvasContent.addEventListener("folk-transform", (e) => { + if (!activeDragShape || e.target !== activeDragShape) return; + + if (snapCorrecting) { + snapCorrecting = false; + return; + } + + const shape = activeDragShape; + const cur = e.current; + const prev = e.previous; + const dx = cur.x - prev.x; + const dy = cur.y - prev.y; + + // Only snap during moves, not resize/rotate + if (dx === 0 && dy === 0) return; + + unsnapX += dx; + unsnapY += dy; + + const targets = getSnapTargets(shape); + const { snapX, snapY, guides } = computeSnaps( + unsnapX, unsnapY, shape.width, shape.height, targets + ); + + const finalX = snapX !== null ? snapX : unsnapX; + const finalY = snapY !== null ? snapY : unsnapY; + + // Apply snap correction if position differs from where drag placed it + if (Math.abs(finalX - cur.x) > 0.1 || Math.abs(finalY - cur.y) > 0.1) { + // Modify the emitted rect so CSS shows snap position this frame + cur.x = finalX; + cur.y = finalY; + // Update internal state (queues a correction update we'll skip) + snapCorrecting = true; + shape.x = finalX; + shape.y = finalY; + } + + if (guides.length > 0) { + renderSnapGuides(guides); + } else { + clearSnapGuides(); + } + }, { capture: true }); + + // Drop suggestion helpers + function clearDropGhost() { + if (dropGhostEl) { + dropGhostEl.remove(); + dropGhostEl = null; + } + if (dropGhostTimeout) { + clearTimeout(dropGhostTimeout); + dropGhostTimeout = null; + } + } + + function showDropGhost(shape, gx, gy) { + clearDropGhost(); + const ghost = document.createElement("div"); + ghost.style.cssText = ` + position: absolute; + left: ${gx}px; top: ${gy}px; + width: ${shape.width}px; height: ${shape.height}px; + border: 2px dashed ${SNAP_COLOR}; + border-radius: 8px; + pointer-events: auto; + cursor: pointer; + z-index: 4; + display: flex; + align-items: center; + justify-content: center; + font: 12px system-ui; + color: ${SNAP_COLOR}; + background: rgba(20, 184, 166, 0.05); + transition: opacity 0.3s; + `; + ghost.textContent = "Move here?"; + ghost.addEventListener("click", () => { + shape.x = gx; + shape.y = gy; + clearDropGhost(); + }); + canvasContent.appendChild(ghost); + dropGhostEl = ghost; + + // Auto-fade after 3 seconds + dropGhostTimeout = setTimeout(() => { + if (dropGhostEl === ghost) { + ghost.style.opacity = "0"; + setTimeout(() => { if (dropGhostEl === ghost) clearDropGhost(); }, 300); + } + }, 3000); + + // Dismiss on any other canvas click + const dismissHandler = (ev) => { + if (ev.target !== ghost) { + clearDropGhost(); + canvas.removeEventListener("pointerdown", dismissHandler, { capture: true }); + } + }; + canvas.addEventListener("pointerdown", dismissHandler, { capture: true }); + } + + function checkDropSuggestion(shape) { + const shapeRect = { x: shape.x, y: shape.y, width: shape.width, height: shape.height }; + const existing = getExistingShapeRects(shape); + const overlaps = existing.some(e => rectsOverlap(shapeRect, e, 0)); + if (!overlaps) return; + + const center = { x: shape.x + shape.width / 2, y: shape.y + shape.height / 2 }; + const pos = findFreePosition(shape.width, shape.height, center.x, center.y, shape); + showDropGhost(shape, pos.x, pos.y); + } + // ── Helpers for converting SVG drawings into folk-shape elements ── // Compute bounding box of SVG markup by temporarily rendering it @@ -6294,6 +6530,7 @@ document.addEventListener("keydown", (e) => { if ((e.key === "Delete" || e.key === "Backspace") && !e.target.closest("input, textarea, [contenteditable]") && + !bulkDeleteOverlay && selectedShapeIds.size > 0) { if (selectedShapeIds.size > 5) { showBulkDeleteConfirm(selectedShapeIds.size); @@ -6317,7 +6554,12 @@ }); // ── Bulk delete confirmation dialog ── + let bulkDeleteOverlay = null; + function dismissBulkDelete() { + if (bulkDeleteOverlay) { bulkDeleteOverlay.remove(); bulkDeleteOverlay = null; } + } function showBulkDeleteConfirm(count) { + if (bulkDeleteOverlay) return; // prevent stacking from key repeat const overlay = document.createElement("div"); overlay.style.cssText = "position:fixed;inset:0;background:rgba(0,0,0,0.6);z-index:10000;display:flex;align-items:center;justify-content:center"; const dialog = document.createElement("div"); @@ -6331,13 +6573,19 @@ `; overlay.appendChild(dialog); document.body.appendChild(overlay); + bulkDeleteOverlay = overlay; - overlay.addEventListener("click", (e) => { if (e.target === overlay) { overlay.remove(); } }); - dialog.querySelector("#bulk-delete-cancel").addEventListener("click", () => overlay.remove()); + overlay.addEventListener("click", (e) => { if (e.target === overlay) dismissBulkDelete(); }); + dialog.querySelector("#bulk-delete-cancel").addEventListener("click", dismissBulkDelete); dialog.querySelector("#bulk-delete-confirm").addEventListener("click", () => { - overlay.remove(); + dismissBulkDelete(); doDeleteSelected(); }); + // Escape key closes dialog + const escHandler = (e) => { + if (e.key === "Escape") { dismissBulkDelete(); document.removeEventListener("keydown", escHandler); } + }; + document.addEventListener("keydown", escHandler); } // ── Canvas pointer interaction: pan-first + hold-to-select ── @@ -6870,70 +7118,6 @@ sync.connect(wsUrl); - // --- Ambient overlap repulsion --- - // Shapes that overlap slowly drift apart each frame. - const REPEL_GAP = FolkShape.GAP; // 8px desired gap - const REPEL_STRENGTH = 0.08; // resolve 8% of overlap per frame - const REPEL_THRESHOLD = 0.5; // ignore sub-pixel overlaps - - function repulsionLoop() { - const shapes = []; - for (const el of canvasContent.children) { - if (!(el instanceof FolkShape)) continue; - if (FolkShape.pushExemptTags.has(el.tagName.toLowerCase())) continue; - if (el.dataset?.wbDrawing) continue; // wb drawings can overlap freely - shapes.push(el); - } - - for (let i = 0; i < shapes.length; i++) { - const a = shapes[i]; - for (let j = i + 1; j < shapes.length; j++) { - const b = shapes[j]; - - // AABB overlap check (with gap) - const ox = Math.min(a.x + a.width + REPEL_GAP, b.x + b.width + REPEL_GAP) - - Math.max(a.x, b.x); - const oy = Math.min(a.y + a.height + REPEL_GAP, b.y + b.height + REPEL_GAP) - - Math.max(a.y, b.y); - if (ox <= REPEL_THRESHOLD || oy <= REPEL_THRESHOLD) continue; - - // Resolve along axis of least overlap - const push = (ox < oy ? ox : oy) * REPEL_STRENGTH; - if (push < REPEL_THRESHOLD) continue; - - const half = push / 2; - const aLocked = a.locked; - const bLocked = b.locked; - if (aLocked && bLocked) continue; // both locked, skip - if (ox < oy) { - // Push apart horizontally - const sign = (a.x + a.width / 2) < (b.x + b.width / 2) ? -1 : 1; - if (aLocked) { - b.x -= sign * push; - } else if (bLocked) { - a.x += sign * push; - } else { - a.x += sign * half; - b.x -= sign * half; - } - } else { - // Push apart vertically - const sign = (a.y + a.height / 2) < (b.y + b.height / 2) ? -1 : 1; - if (aLocked) { - b.y -= sign * push; - } else if (bLocked) { - a.y += sign * push; - } else { - a.y += sign * half; - b.y -= sign * half; - } - } - } - } - requestAnimationFrame(repulsionLoop); - } - requestAnimationFrame(repulsionLoop); - // Debug: expose sync for console inspection window.sync = sync; @@ -7138,9 +7322,21 @@ } } - // Stubs — drag-to-calendar removed; schedule via 📅 icon only - function onShapeMoveStart(shape) {} - function onShapeMoveEnd() {} + // Snap guide drag hooks (implementation above, near snap overlay) + function onShapeMoveStart(shape) { + activeDragShape = shape; + unsnapX = shape.x; + unsnapY = shape.y; + snapCorrecting = false; + clearDropGhost(); + } + function onShapeMoveEnd() { + if (!activeDragShape) return; + const shape = activeDragShape; + activeDragShape = null; + clearSnapGuides(); + checkDropSuggestion(shape); + } rwPrev.addEventListener("click", () => { rwDate = new Date(rwDate.getFullYear(), rwDate.getMonth() - 1, 1);