Merge branch 'dev'

This commit is contained in:
Jeff Emmett 2026-03-22 14:25:02 -07:00
commit 6cccd158c4
7 changed files with 309 additions and 84 deletions

View File

@ -155,7 +155,7 @@ function endorsementToRow(e: Endorsement) {
function seedDemoIfEmpty(space: string) { function seedDemoIfEmpty(space: string) {
const docId = bnbDocId(space); const docId = bnbDocId(space);
const doc = ensureDoc(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<BnbDoc>(docId, 'seed demo data', (d) => { _syncServer!.changeDoc<BnbDoc>(docId, 'seed demo data', (d) => {
const now = Date.now(); const now = Date.now();
@ -537,6 +537,9 @@ function seedDemoIfEmpty(space: string) {
}; };
}); });
_syncServer!.changeDoc<BnbDoc>(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"); console.log("[rBnb] Demo data seeded: 6 listings, 3 stay requests, 2 endorsements");
} }

View File

@ -117,7 +117,7 @@ function sourceToRow(src: CalendarSource) {
function seedDemoIfEmpty(space: string) { function seedDemoIfEmpty(space: string) {
const docId = calendarDocId(space); const docId = calendarDocId(space);
const doc = ensureDoc(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<CalendarDoc>(docId, 'seed demo data', (d) => { _syncServer!.changeDoc<CalendarDoc>(docId, 'seed demo data', (d) => {
const now = Date.now(); 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<CalendarDoc>(docId, 'mark seeded', (d) => {
if (d.meta) (d.meta as any).seeded = true;
});
console.log("[Cal] Demo data seeded: 2 sources, 7 events"); console.log("[Cal] Demo data seeded: 2 sources, 7 events");
} }
@ -842,8 +847,6 @@ routes.get("/api/context/:tool", async (c) => {
routes.get("/", (c) => { routes.get("/", (c) => {
const space = c.req.param("space") || "demo"; const space = c.req.param("space") || "demo";
const dataSpace = c.get("effectiveSpace") || space; const dataSpace = c.get("effectiveSpace") || space;
// Seed sample data for any space that has no events yet
seedDemoIfEmpty(dataSpace);
return c.html(renderShell({ return c.html(renderShell({
title: `${space} — Calendar | rSpace`, title: `${space} — Calendar | rSpace`,
moduleId: "rcal", moduleId: "rcal",

View File

@ -141,8 +141,9 @@ function seedDemoIfEmpty(space: string) {
// Resolve effective data space (global for rnotes by default) // Resolve effective data space (global for rnotes by default)
const dataSpace = resolveDataSpace("rnotes", space); const dataSpace = resolveDataSpace("rnotes", space);
// If the space already has notebooks, skip // If the space already has notebooks, skip (or was already seeded)
if (listNotebooks(dataSpace).length > 0) return; const _connectionsDoc = _syncServer!.getDoc<ConnectionsDoc>(connectionsDocId(dataSpace));
if ((_connectionsDoc?.meta as any)?.seeded || listNotebooks(dataSpace).length > 0) return;
const now = Date.now(); 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<ConnectionsDoc>(_connDocId)) {
_syncServer!.setDoc(_connDocId, Automerge.change(Automerge.init<ConnectionsDoc>(), 'init connections', (d) => {
d.meta = { module: 'notes', collection: 'connections', version: 1, spaceSlug: dataSpace, createdAt: Date.now() };
}));
}
_syncServer!.changeDoc<ConnectionsDoc>(_connDocId, 'mark seeded', (d) => {
if (d.meta) (d.meta as any).seeded = true;
});
console.log("[Notes] Demo data seeded: 3 notebooks, 7 notes"); console.log("[Notes] Demo data seeded: 3 notebooks, 7 notes");
} }

View File

@ -61,9 +61,10 @@ function getBoardDocIds(space: string): string[] {
*/ */
function seedDemoIfEmpty(space: string = 'rspace-dev') { function seedDemoIfEmpty(space: string = 'rspace-dev') {
if (!_syncServer) return; 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:`)); const spaceWorkDocs = _syncServer!.getDocIds().filter((id) => id.startsWith(`${space}:tasks:boards:`));
if (spaceWorkDocs.length > 0) return; const _seedCheckDoc = _syncServer!.getDoc<BoardDoc>(boardDocId(space, space));
if ((_seedCheckDoc?.meta as any)?.seeded || spaceWorkDocs.length > 0) return;
const docId = boardDocId(space, space); const docId = boardDocId(space, space);
@ -111,6 +112,9 @@ function seedDemoIfEmpty(space: string = 'rspace-dev') {
}); });
_syncServer!.setDoc(docId, doc); _syncServer!.setDoc(docId, doc);
_syncServer!.changeDoc<BoardDoc>(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`); console.log(`[Tasks] Demo data seeded for "${space}": 1 board, 11 tasks`);
} }

View File

@ -183,7 +183,7 @@ function endorsementToRow(e: Endorsement) {
function seedDemoIfEmpty(space: string) { function seedDemoIfEmpty(space: string) {
const docId = vnbDocId(space); const docId = vnbDocId(space);
const doc = ensureDoc(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<VnbDoc>(docId, 'seed demo data', (d) => { _syncServer!.changeDoc<VnbDoc>(docId, 'seed demo data', (d) => {
const now = Date.now(); const now = Date.now();
@ -574,6 +574,9 @@ function seedDemoIfEmpty(space: string) {
}; };
}); });
_syncServer!.changeDoc<VnbDoc>(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"); console.log("[rVnb] Demo data seeded: 4 vehicles, 3 rental requests, 2 endorsements");
} }

View File

@ -191,8 +191,9 @@ function newId(): string {
// ── Seed demo data into Automerge ── // ── Seed demo data into Automerge ──
function seedDemoIfEmpty(space: string = 'community') { function seedDemoIfEmpty(space: string = 'community') {
if (!_syncServer) return; if (!_syncServer) return;
// If this space already has proposals, skip // If this space already has proposals, skip (or was already seeded)
if (listProposalDocs(space).length > 0) return; const _seedConfigDoc = _syncServer!.getDoc<ProposalDoc>(spaceConfigDocId(space));
if ((_seedConfigDoc?.meta as any)?.seeded || listProposalDocs(space).length > 0) return;
// Ensure space config exists // Ensure space config exists
ensureSpaceConfigDoc(space); ensureSpaceConfigDoc(space);
@ -261,6 +262,9 @@ function seedDemoIfEmpty(space: string = 'community') {
_syncServer!.setDoc(docId, doc); _syncServer!.setDoc(docId, doc);
} }
_syncServer!.changeDoc<ProposalDoc>(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`); console.log(`[Vote] Demo data seeded for "${space}": 1 space config, 5 proposals`);
} }

View File

@ -3839,11 +3839,12 @@
// Collect bounding boxes of all visible shapes on the canvas // Collect bounding boxes of all visible shapes on the canvas
// Only includes rApps and embedded content — slides, drawings, and arrows are excluded // Only includes rApps and embedded content — slides, drawings, and arrows are excluded
function getExistingShapeRects() { function getExistingShapeRects(excludeEl) {
return [...canvasContent.children] return [...canvasContent.children]
.filter(el => el.tagName && el.tagName.includes('-') && .filter(el => el.tagName && el.tagName.includes('-') &&
!FolkShape.pushExemptTags.has(el.tagName.toLowerCase()) && !FolkShape.pushExemptTags.has(el.tagName.toLowerCase()) &&
!el.dataset?.wbDrawing && !el.dataset?.wbDrawing &&
el !== excludeEl &&
typeof el.x === 'number' && typeof el.width === 'number' && typeof el.x === 'number' && typeof el.width === 'number' &&
el.width > 0) el.width > 0)
.map(el => ({ x: el.x, y: el.y, width: el.width, height: el.height })); .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. // Find a free position that doesn't overlap existing shapes.
// If preferX/preferY are provided, use that as the anchor; otherwise use viewport center. // 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; let candidateX, candidateY;
if (preferX !== undefined && preferY !== undefined) { if (preferX !== undefined && preferY !== undefined) {
candidateX = preferX - width / 2; candidateX = preferX - width / 2;
@ -3862,7 +3863,7 @@
candidateY = center.y - height / 2; candidateY = center.y - height / 2;
} }
const gap = 20; const gap = 20;
const existing = getExistingShapeRects(); const existing = getExistingShapeRects(excludeEl);
if (existing.length === 0) { if (existing.length === 0) {
return { x: candidateX, y: candidateY }; 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;"; wbOverlay.style.cssText = "position:absolute;top:0;left:0;width:100%;height:100%;pointer-events:none;z-index:5;overflow:visible;";
canvasContent.appendChild(wbOverlay); 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 ── // ── Helpers for converting SVG drawings into folk-shape elements ──
// Compute bounding box of SVG markup by temporarily rendering it // Compute bounding box of SVG markup by temporarily rendering it
@ -6294,6 +6530,7 @@
document.addEventListener("keydown", (e) => { document.addEventListener("keydown", (e) => {
if ((e.key === "Delete" || e.key === "Backspace") && if ((e.key === "Delete" || e.key === "Backspace") &&
!e.target.closest("input, textarea, [contenteditable]") && !e.target.closest("input, textarea, [contenteditable]") &&
!bulkDeleteOverlay &&
selectedShapeIds.size > 0) { selectedShapeIds.size > 0) {
if (selectedShapeIds.size > 5) { if (selectedShapeIds.size > 5) {
showBulkDeleteConfirm(selectedShapeIds.size); showBulkDeleteConfirm(selectedShapeIds.size);
@ -6317,7 +6554,12 @@
}); });
// ── Bulk delete confirmation dialog ── // ── Bulk delete confirmation dialog ──
let bulkDeleteOverlay = null;
function dismissBulkDelete() {
if (bulkDeleteOverlay) { bulkDeleteOverlay.remove(); bulkDeleteOverlay = null; }
}
function showBulkDeleteConfirm(count) { function showBulkDeleteConfirm(count) {
if (bulkDeleteOverlay) return; // prevent stacking from key repeat
const overlay = document.createElement("div"); 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"; 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"); const dialog = document.createElement("div");
@ -6331,13 +6573,19 @@
</div>`; </div>`;
overlay.appendChild(dialog); overlay.appendChild(dialog);
document.body.appendChild(overlay); document.body.appendChild(overlay);
bulkDeleteOverlay = overlay;
overlay.addEventListener("click", (e) => { if (e.target === overlay) { overlay.remove(); } }); overlay.addEventListener("click", (e) => { if (e.target === overlay) dismissBulkDelete(); });
dialog.querySelector("#bulk-delete-cancel").addEventListener("click", () => overlay.remove()); dialog.querySelector("#bulk-delete-cancel").addEventListener("click", dismissBulkDelete);
dialog.querySelector("#bulk-delete-confirm").addEventListener("click", () => { dialog.querySelector("#bulk-delete-confirm").addEventListener("click", () => {
overlay.remove(); dismissBulkDelete();
doDeleteSelected(); 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 ── // ── Canvas pointer interaction: pan-first + hold-to-select ──
@ -6870,70 +7118,6 @@
sync.connect(wsUrl); 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 // Debug: expose sync for console inspection
window.sync = sync; window.sync = sync;
@ -7138,9 +7322,21 @@
} }
} }
// Stubs — drag-to-calendar removed; schedule via 📅 icon only // Snap guide drag hooks (implementation above, near snap overlay)
function onShapeMoveStart(shape) {} function onShapeMoveStart(shape) {
function onShapeMoveEnd() {} 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", () => { rwPrev.addEventListener("click", () => {
rwDate = new Date(rwDate.getFullYear(), rwDate.getMonth() - 1, 1); rwDate = new Date(rwDate.getFullYear(), rwDate.getMonth() - 1, 1);