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();
|
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'
|
||||||
*/
|
*/
|
||||||
|
|
|
||||||
|
|
@ -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">×</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();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue