perf(canvas): batch bulk forget into single Automerge transaction

The bulk forget dialog was freezing because each shape triggered a
separate Automerge change, IndexedDB write, and WebSocket sync. New
bulkForget() method batches all shapes into one transaction with DOM
updates applied immediately for responsiveness.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Jeff Emmett 2026-03-24 17:52:11 -07:00
parent cefc1aa5b7
commit 1ae0609f14
2 changed files with 177 additions and 38 deletions

View File

@ -765,6 +765,52 @@ export class CommunitySync extends EventTarget {
this.#syncToServer(); this.#syncToServer();
} }
/**
* Bulk forget/delete shapes in a single Automerge transaction.
* Shapes already forgotten get hard-deleted; others get soft-forgotten.
*/
bulkForget(shapeIds: string[], did: string): void {
const changes: Array<{ id: string; before: unknown; action: 'forget' | 'delete' }> = [];
for (const id of shapeIds) {
const state = this.getShapeVisualState(id);
changes.push({ id, before: this.#cloneShapeData(id), action: state === 'forgotten' ? 'delete' : 'forget' });
}
this.#doc = Automerge.change(this.#doc, makeChangeMessage(`Bulk forget ${shapeIds.length} shapes`), (doc) => {
if (!doc.shapes) return;
for (const c of changes) {
const shape = doc.shapes[c.id] as Record<string, unknown> | undefined;
if (!shape) continue;
if (c.action === 'delete') {
shape.deleted = true;
} else {
if (!shape.forgottenBy || typeof shape.forgottenBy !== 'object') {
shape.forgottenBy = {};
}
(shape.forgottenBy as Record<string, number>)[did] = Date.now();
shape.forgotten = true;
shape.forgottenAt = Date.now();
}
}
});
// Post-transaction: undo stack, DOM updates, events
for (const c of changes) {
if (c.action === 'delete') {
this.#pushUndo(c.id, c.before, null);
this.#removeShapeFromDOM(c.id);
} else {
this.#pushUndo(c.id, c.before, this.#cloneShapeData(c.id));
}
this.dispatchEvent(new CustomEvent("shape-state-changed", {
detail: { shapeId: c.id, state: c.action === 'delete' ? 'deleted' : 'forgotten', data: this.#doc.shapes?.[c.id] }
}));
}
this.#saveImmediate();
this.#syncToServer();
}
/** /**
* Get the visual state of a shape: 'present' | 'forgotten' | 'deleted' * Get the visual state of a shape: 'present' | 'forgotten' | 'deleted'
*/ */

View File

@ -151,6 +151,47 @@
cursor: default; cursor: default;
} }
/* Picker modal — inline replacement for browser prompt() */
.picker-modal-overlay {
position: fixed; inset: 0; z-index: 9999;
background: rgba(0,0,0,0.4); display: flex; align-items: center; justify-content: center;
}
.picker-modal {
background: var(--rs-toolbar-panel-bg, #1e1e2e); color: var(--rs-toolbar-text, #e0e0e0);
border-radius: 12px; box-shadow: 0 8px 32px rgba(0,0,0,0.4);
min-width: 320px; max-width: 420px; max-height: 70vh; display: flex; flex-direction: column;
overflow: hidden; font-family: system-ui, sans-serif;
}
.picker-modal-head {
display: flex; justify-content: space-between; align-items: center;
padding: 12px 16px; border-bottom: 1px solid rgba(255,255,255,0.08);
}
.picker-modal-head h3 { margin: 0; font-size: 14px; font-weight: 600; }
.picker-modal-close {
background: none; border: none; color: var(--rs-toolbar-text, #e0e0e0);
font-size: 18px; cursor: pointer; padding: 2px 6px; border-radius: 4px;
}
.picker-modal-close:hover { background: rgba(255,255,255,0.1); }
.picker-modal-search {
margin: 8px 12px; padding: 8px 10px; border-radius: 8px;
border: 1px solid rgba(255,255,255,0.12); background: rgba(255,255,255,0.06);
color: inherit; font-size: 13px; outline: none;
}
.picker-modal-search:focus { border-color: var(--rs-primary, #3b82f6); }
.picker-modal-list {
flex: 1; overflow-y: auto; padding: 4px 8px 8px;
}
.picker-modal-item {
padding: 8px 12px; border-radius: 8px; cursor: pointer; font-size: 13px;
transition: background 0.1s;
}
.picker-modal-item:hover, .picker-modal-item.focused {
background: rgba(255,255,255,0.08);
}
.picker-modal-empty {
padding: 16px; text-align: center; color: rgba(255,255,255,0.4); font-size: 13px;
}
/* Popout panel — renders group tools to the right of toolbar */ /* Popout panel — renders group tools to the right of toolbar */
#toolbar-panel { #toolbar-panel {
position: fixed; position: fixed;
@ -1872,7 +1913,8 @@
<button class="toolbar-group-toggle" title="Note"><span class="tg-icon">📝</span><span class="tg-label">Note</span></button> <button class="toolbar-group-toggle" title="Note"><span class="tg-icon">📝</span><span class="tg-label">Note</span></button>
<div class="toolbar-dropdown"> <div class="toolbar-dropdown">
<div class="toolbar-dropdown-header">Note</div> <div class="toolbar-dropdown-header">Note</div>
<button id="new-markdown" title="New Note">📝 Note</button> <button id="new-markdown" title="New Blank Note">📝 Blank Note</button>
<button id="from-rnotes" title="From rNotes">📋 From rNotes</button>
<button id="new-slide" title="New Slide">🎞️ Slide</button> <button id="new-slide" title="New Slide">🎞️ Slide</button>
</div> </div>
</div> </div>
@ -2962,25 +3004,78 @@
} catch { return null; } } catch { return null; }
} }
/** Inline modal picker — replaces browser prompt(). Returns selected item or null. */
function showPickerModal(items, labelFn, title) {
return new Promise((resolve) => {
if (items.length === 0) return resolve(null);
if (items.length === 1) return resolve(items[0]);
let focusIdx = 0;
const overlay = document.createElement("div");
overlay.className = "picker-modal-overlay";
const cleanup = (result) => { overlay.remove(); resolve(result); };
overlay.addEventListener("click", (e) => { if (e.target === overlay) cleanup(null); });
const modal = document.createElement("div");
modal.className = "picker-modal";
modal.innerHTML = `
<div class="picker-modal-head">
<h3>${title}</h3>
<button class="picker-modal-close">&times;</button>
</div>
<input class="picker-modal-search" placeholder="Search..." autocomplete="off">
<div class="picker-modal-list"></div>
`;
overlay.appendChild(modal);
const searchInput = modal.querySelector(".picker-modal-search");
const listEl = modal.querySelector(".picker-modal-list");
modal.querySelector(".picker-modal-close").addEventListener("click", () => cleanup(null));
function renderList(filter) {
const q = (filter || "").toLowerCase();
const filtered = items.map((item, i) => ({ item, i, label: labelFn(item) }))
.filter(({ label }) => !q || label.toLowerCase().includes(q));
focusIdx = Math.min(focusIdx, Math.max(0, filtered.length - 1));
if (filtered.length === 0) {
listEl.innerHTML = '<div class="picker-modal-empty">No matches</div>';
return;
}
listEl.innerHTML = filtered.map(({ label }, fi) =>
`<div class="picker-modal-item ${fi === focusIdx ? 'focused' : ''}" data-fi="${fi}">${label}</div>`
).join("");
listEl.querySelectorAll(".picker-modal-item").forEach((el, fi) => {
el.addEventListener("click", () => cleanup(filtered[fi].item));
});
}
searchInput.addEventListener("input", () => { focusIdx = 0; renderList(searchInput.value); });
searchInput.addEventListener("keydown", (e) => {
const listItems = listEl.querySelectorAll(".picker-modal-item");
if (e.key === "ArrowDown") { e.preventDefault(); focusIdx = Math.min(focusIdx + 1, listItems.length - 1); renderList(searchInput.value); }
else if (e.key === "ArrowUp") { e.preventDefault(); focusIdx = Math.max(focusIdx - 1, 0); renderList(searchInput.value); }
else if (e.key === "Enter") {
e.preventDefault();
const focused = listEl.querySelector(".picker-modal-item.focused");
if (focused) focused.click();
}
else if (e.key === "Escape") cleanup(null);
});
document.body.appendChild(overlay);
renderList("");
searchInput.focus();
});
}
async function pickTrip() { async function pickTrip() {
const data = await fetchTripData(); const data = await fetchTripData();
if (data.trips.length === 0) return null; if (data.trips.length === 0) return null;
if (data.trips.length === 1) return data.trips[0].id; if (data.trips.length === 1) return data.trips[0].id;
const labels = data.trips.map((t, i) => `${i + 1}. ${t.title}`).join("\n"); const trip = await showPickerModal(data.trips, t => t.title, "Select a trip");
const choice = prompt(`Select a trip:\n\n${labels}`, "1"); return trip?.id || null;
if (!choice) return null;
const idx = parseInt(choice) - 1;
return data.trips[idx]?.id || null;
}
function pickFromList(items, labelFn, promptTitle) {
if (items.length === 0) return null;
if (items.length === 1) return items[0];
const labels = items.map((item, i) => `${i + 1}. ${labelFn(item)}`).join("\n");
const choice = prompt(`${promptTitle}:\n\n${labels}`, "1");
if (!choice) return null;
const idx = parseInt(choice) - 1;
return items[idx] || null;
} }
const TRAVEL_BTN_IDS = ["new-itinerary", "new-destination", "new-budget", "new-packing-list", "new-booking"]; const TRAVEL_BTN_IDS = ["new-itinerary", "new-destination", "new-budget", "new-packing-list", "new-booking"];
@ -3008,24 +3103,9 @@
} catch { return { notes: [], fetchedAt: Date.now() }; } } catch { return { notes: [], fetchedAt: Date.now() }; }
} }
const NOTE_BTN_IDS = ["new-markdown"];
async function updateNoteToolbarState() {
const data = await fetchNotesData();
const hasNotes = data.notes.length > 0;
for (const id of NOTE_BTN_IDS) {
const btn = document.getElementById(id);
if (btn) {
btn.classList.toggle("toolbar-disabled", !hasNotes);
if (!hasNotes) btn.title = "No notes yet — create notes in rNotes first";
}
}
}
// Non-blocking: check data availability after page settles // Non-blocking: check data availability after page settles
setTimeout(() => { setTimeout(() => {
updateTravelToolbarState(); updateTravelToolbarState();
updateNoteToolbarState();
}, 1500); }, 1500);
// Initialize Presence for real-time cursors // Initialize Presence for real-time cursors
@ -4053,13 +4133,17 @@ Use real coordinates, YYYY-MM-DD dates, ISO currency codes. Ask clarifying quest
} }
// Toolbar button handlers — set pending tool for click-to-place // Toolbar button handlers — set pending tool for click-to-place
document.getElementById("new-markdown").addEventListener("click", async () => { document.getElementById("new-markdown").addEventListener("click", () => {
setPendingTool("folk-markdown", { content: "# New Note\n\nStart typing..." });
});
document.getElementById("from-rnotes").addEventListener("click", async () => {
const data = await fetchNotesData(); const data = await fetchNotesData();
if (data.notes.length === 0) { if (data.notes.length === 0) {
setPendingTool("folk-markdown", { content: "# New Note\n\nStart typing..." }); setPendingTool("folk-markdown", { content: "# New Note\n\nStart typing..." });
return; return;
} }
const note = pickFromList(data.notes, n => n.title || "Untitled", "Select a note"); const note = await showPickerModal(data.notes, n => n.title || "Untitled", "Select a note from rNotes");
if (!note) return; if (!note) return;
setPendingTool("folk-markdown", { content: note.content || `# ${note.title}\n\n${note.content_plain || ""}` }); setPendingTool("folk-markdown", { content: note.content || `# ${note.title}\n\n${note.content_plain || ""}` });
}); });
@ -4177,7 +4261,7 @@ Use real coordinates, YYYY-MM-DD dates, ISO currency codes. Ask clarifying quest
const trip = await fetchTripDetail(tripId); const trip = await fetchTripDetail(tripId);
if (!trip) { setPendingTool("folk-destination"); return; } if (!trip) { setPendingTool("folk-destination"); return; }
const dests = trip.destinations || []; const dests = trip.destinations || [];
const dest = pickFromList(dests, d => d.name || "Unnamed", "Select a destination"); const dest = await showPickerModal(dests, d => d.name || "Unnamed", "Select a destination");
if (!dest) { setPendingTool("folk-destination"); return; } if (!dest) { setPendingTool("folk-destination"); return; }
setPendingTool("folk-destination", { setPendingTool("folk-destination", {
destName: dest.name || "", destName: dest.name || "",
@ -4230,7 +4314,7 @@ Use real coordinates, YYYY-MM-DD dates, ISO currency codes. Ask clarifying quest
const trip = await fetchTripDetail(tripId); const trip = await fetchTripDetail(tripId);
if (!trip) { setPendingTool("folk-booking"); return; } if (!trip) { setPendingTool("folk-booking"); return; }
const bookings = trip.bookings || []; const bookings = trip.bookings || [];
const booking = pickFromList(bookings, b => `${b.type || "OTHER"} — ${b.provider || "Unknown"}`, "Select a booking"); const booking = await showPickerModal(bookings, b => `${b.type || "OTHER"} — ${b.provider || "Unknown"}`, "Select a booking");
if (!booking) { setPendingTool("folk-booking"); return; } if (!booking) { setPendingTool("folk-booking"); return; }
setPendingTool("folk-booking", { setPendingTool("folk-booking", {
bookingType: booking.type || "OTHER", bookingType: booking.type || "OTHER",
@ -6323,17 +6407,26 @@ Use real coordinates, YYYY-MM-DD dates, ISO currency codes. Ask clarifying quest
// ── Delete selected shapes (forget, not hard-delete) ── // ── Delete selected shapes (forget, not hard-delete) ──
function doDeleteSelected() { function doDeleteSelected() {
const did = getLocalDID(); const did = getLocalDID();
for (const id of selectedShapeIds) { const ids = [...selectedShapeIds];
if (ids.length === 0) return;
// Update DOM immediately for responsiveness
for (const id of ids) {
const el = document.getElementById(id); const el = document.getElementById(id);
const state = sync.getShapeVisualState(id); const state = sync.getShapeVisualState(id);
if (state === 'forgotten') { if (state === 'forgotten') {
sync.hardDeleteShape(id);
if (el) el.remove(); if (el) el.remove();
} else { } else {
sync.forgetShape(id, did);
if (el) el.forgotten = true; if (el) el.forgotten = true;
} }
} }
// Batch into single Automerge transaction
if (ids.length > 1 && sync.bulkForget) {
sync.bulkForget(ids, did);
} else {
const state = sync.getShapeVisualState(ids[0]);
if (state === 'forgotten') sync.hardDeleteShape(ids[0]);
else sync.forgetShape(ids[0], did);
}
deselectAll(); deselectAll();
} }