diff --git a/lib/community-sync.ts b/lib/community-sync.ts index 5d72303..b7f213c 100644 --- a/lib/community-sync.ts +++ b/lib/community-sync.ts @@ -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 | 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)[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' */ diff --git a/website/canvas.html b/website/canvas.html index 27b229c..3a05c71 100644 --- a/website/canvas.html +++ b/website/canvas.html @@ -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 @@
Note
- + +
@@ -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 = ` +
+

${title}

+ +
+ +
+ `; + 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 = '
No matches
'; + return; + } + listEl.innerHTML = filtered.map(({ label }, fi) => + `
${label}
` + ).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(); }