Merge branch 'dev'
This commit is contained in:
commit
6cccd158c4
|
|
@ -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");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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",
|
||||||
|
|
|
||||||
|
|
@ -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");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue