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:
parent
cefc1aa5b7
commit
1ae0609f14
|
|
@ -765,6 +765,52 @@ export class CommunitySync extends EventTarget {
|
|||
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'
|
||||
*/
|
||||
|
|
|
|||
|
|
@ -151,6 +151,47 @@
|
|||
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 */
|
||||
#toolbar-panel {
|
||||
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>
|
||||
<div class="toolbar-dropdown">
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -2962,25 +3004,78 @@
|
|||
} 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">×</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() {
|
||||
const data = await fetchTripData();
|
||||
if (data.trips.length === 0) return null;
|
||||
if (data.trips.length === 1) return data.trips[0].id;
|
||||
const labels = data.trips.map((t, i) => `${i + 1}. ${t.title}`).join("\n");
|
||||
const choice = prompt(`Select a trip:\n\n${labels}`, "1");
|
||||
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 trip = await showPickerModal(data.trips, t => t.title, "Select a trip");
|
||||
return trip?.id || null;
|
||||
}
|
||||
|
||||
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() }; }
|
||||
}
|
||||
|
||||
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
|
||||
setTimeout(() => {
|
||||
updateTravelToolbarState();
|
||||
updateNoteToolbarState();
|
||||
}, 1500);
|
||||
|
||||
// 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
|
||||
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();
|
||||
if (data.notes.length === 0) {
|
||||
setPendingTool("folk-markdown", { content: "# New Note\n\nStart typing..." });
|
||||
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;
|
||||
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);
|
||||
if (!trip) { setPendingTool("folk-destination"); return; }
|
||||
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; }
|
||||
setPendingTool("folk-destination", {
|
||||
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);
|
||||
if (!trip) { setPendingTool("folk-booking"); return; }
|
||||
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; }
|
||||
setPendingTool("folk-booking", {
|
||||
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) ──
|
||||
function doDeleteSelected() {
|
||||
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 state = sync.getShapeVisualState(id);
|
||||
if (state === 'forgotten') {
|
||||
sync.hardDeleteShape(id);
|
||||
if (el) el.remove();
|
||||
} else {
|
||||
sync.forgetShape(id, did);
|
||||
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();
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Reference in New Issue