/** * — notebook and note management. * * Browse notebooks, create/edit notes with rich text, * search, tag management. * * Notebook list: REST (GET /api/notebooks) * Notebook detail + notes: Automerge sync via WebSocket * Search: REST (GET /api/notes?q=...) */ import * as Automerge from '@automerge/automerge'; interface Notebook { id: string; title: string; description: string; cover_color: string; note_count: string; updated_at: string; } interface Note { id: string; title: string; content: string; content_plain: string; type: string; tags: string[] | null; is_pinned: boolean; created_at: string; updated_at: string; } /** Shape of Automerge notebook doc (matches PG→Automerge migration) */ interface NotebookDoc { meta: { module: string; collection: string; version: number; spaceSlug: string; createdAt: number }; notebook: { id: string; title: string; slug: string; description: string; coverColor: string; isPublic: boolean; createdAt: number; updatedAt: number; }; items: Record; } class FolkNotesApp extends HTMLElement { private shadow: ShadowRoot; private space = ""; private view: "notebooks" | "notebook" | "note" = "notebooks"; private notebooks: Notebook[] = []; private selectedNotebook: (Notebook & { notes: Note[] }) | null = null; private selectedNote: Note | null = null; private searchQuery = ""; private searchResults: Note[] = []; private loading = false; private error = ""; // Automerge sync state private ws: WebSocket | null = null; private doc: Automerge.Doc | null = null; private syncState: Automerge.SyncState = Automerge.initSyncState(); private subscribedDocId: string | null = null; private syncConnected = false; private pingInterval: ReturnType | null = null; constructor() { super(); this.shadow = this.attachShadow({ mode: "open" }); } // ── Demo data ── private demoNotebooks: (Notebook & { notes: Note[] })[] = []; connectedCallback() { this.space = this.getAttribute("space") || "demo"; if (this.space === "demo") { this.loadDemoData(); return; } this.connectSync(); this.loadNotebooks(); } private loadDemoData() { const now = Date.now(); const hour = 3600000; const day = 86400000; const projectNotes: Note[] = [ { id: "demo-note-1", title: "Cosmolocal Marketplace", content: "Build a decentralized marketplace connecting local makers with global designers. Use rCart for orders, rFunds for revenue splits.", content_plain: "Build a decentralized marketplace connecting local makers with global designers. Use rCart for orders, rFunds for revenue splits.", type: "NOTE", tags: ["cosmolocal", "marketplace"], is_pinned: true, created_at: new Date(now - 2 * day).toISOString(), updated_at: new Date(now - hour).toISOString(), }, { id: "demo-note-2", title: "Community Garden App", content: "Track plots, plantings, and harvests. Share surplus through a local exchange network.", content_plain: "Track plots, plantings, and harvests. Share surplus through a local exchange network.", type: "NOTE", tags: ["community", "local"], is_pinned: false, created_at: new Date(now - 3 * day).toISOString(), updated_at: new Date(now - 2 * hour).toISOString(), }, { id: "demo-note-3", title: "Mesh Network Map", content: "Visualize community mesh networks using rMaps. Show signal strength, coverage areas.", content_plain: "Visualize community mesh networks using rMaps. Show signal strength, coverage areas.", type: "NOTE", tags: ["mesh", "infrastructure"], is_pinned: false, created_at: new Date(now - 5 * day).toISOString(), updated_at: new Date(now - 3 * hour).toISOString(), }, { id: "demo-note-4", title: "Open Hardware Library", content: "Catalog open-source hardware designs. Link to local fabrication providers.", content_plain: "Catalog open-source hardware designs. Link to local fabrication providers.", type: "NOTE", tags: ["hardware", "open-source"], is_pinned: false, created_at: new Date(now - 7 * day).toISOString(), updated_at: new Date(now - 4 * hour).toISOString(), }, ]; const meetingNotes: Note[] = [ { id: "demo-note-5", title: "Sprint Planning \u2014 Feb 24", content: "Discussed module porting progress. Canvas and books done. Next: work, cal, vote...", content_plain: "Discussed module porting progress. Canvas and books done. Next: work, cal, vote...", type: "NOTE", tags: ["sprint", "planning"], is_pinned: false, created_at: new Date(now - 4 * day).toISOString(), updated_at: new Date(now - 3 * hour).toISOString(), }, { id: "demo-note-6", title: "Design Review \u2014 Feb 22", content: "Reviewed new shell header. Consensus on simplified nav. Action items: finalize color palette.", content_plain: "Reviewed new shell header. Consensus on simplified nav. Action items: finalize color palette.", type: "NOTE", tags: ["design", "review"], is_pinned: false, created_at: new Date(now - 6 * day).toISOString(), updated_at: new Date(now - 6 * hour).toISOString(), }, { id: "demo-note-7", title: "Community Call \u2014 Feb 20", content: "30 participants. Demoed rFunds river view. Positive feedback on enoughness score.", content_plain: "30 participants. Demoed rFunds river view. Positive feedback on enoughness score.", type: "NOTE", tags: ["community", "call"], is_pinned: false, created_at: new Date(now - 8 * day).toISOString(), updated_at: new Date(now - 8 * hour).toISOString(), }, { id: "demo-note-8", title: "Infrastructure Sync \u2014 Feb 18", content: "Mailcow migration complete. All 20 domains verified. DKIM keys rotated.", content_plain: "Mailcow migration complete. All 20 domains verified. DKIM keys rotated.", type: "NOTE", tags: ["infra", "mail"], is_pinned: false, created_at: new Date(now - 10 * day).toISOString(), updated_at: new Date(now - 10 * hour).toISOString(), }, { id: "demo-note-9", title: "Retrospective \u2014 Feb 15", content: "What went well: EncryptID launch. What to improve: documentation coverage.", content_plain: "What went well: EncryptID launch. What to improve: documentation coverage.", type: "NOTE", tags: ["retro"], is_pinned: false, created_at: new Date(now - 13 * day).toISOString(), updated_at: new Date(now - 13 * hour).toISOString(), }, { id: "demo-note-10", title: "Onboarding Session \u2014 Feb 12", content: "Walked 3 new contributors through rSpace setup. Created video guide.", content_plain: "Walked 3 new contributors through rSpace setup. Created video guide.", type: "NOTE", tags: ["onboarding"], is_pinned: false, created_at: new Date(now - 16 * day).toISOString(), updated_at: new Date(now - 16 * hour).toISOString(), }, ]; const readingNotes: Note[] = [ { id: "demo-note-11", title: "Governing the Commons", content: "Ostrom's 8 principles for managing shared resources. Especially relevant to our governance module.", content_plain: "Ostrom's 8 principles for managing shared resources. Especially relevant to our governance module.", type: "NOTE", tags: ["book", "governance"], is_pinned: false, created_at: new Date(now - 14 * day).toISOString(), updated_at: new Date(now - day).toISOString(), }, { id: "demo-note-12", title: "Entangled Life", content: "Sheldrake's exploration of fungal networks. The wood wide web metaphor maps perfectly to mesh networks.", content_plain: "Sheldrake's exploration of fungal networks. The wood wide web metaphor maps perfectly to mesh networks.", type: "NOTE", tags: ["book", "mycelium"], is_pinned: false, created_at: new Date(now - 20 * day).toISOString(), updated_at: new Date(now - 2 * day).toISOString(), }, { id: "demo-note-13", title: "Doughnut Economics", content: "Raworth's framework for staying within planetary boundaries while meeting human needs.", content_plain: "Raworth's framework for staying within planetary boundaries while meeting human needs.", type: "NOTE", tags: ["book", "economics"], is_pinned: false, created_at: new Date(now - 25 * day).toISOString(), updated_at: new Date(now - 3 * day).toISOString(), }, ]; this.demoNotebooks = [ { id: "demo-nb-1", title: "Project Ideas", description: "Ideas for new projects and features", cover_color: "#6366f1", note_count: "4", updated_at: new Date(now - hour).toISOString(), notes: projectNotes, space: "demo", } as any, { id: "demo-nb-2", title: "Meeting Notes", description: "Team meetings and sync calls", cover_color: "#22c55e", note_count: "6", updated_at: new Date(now - 3 * hour).toISOString(), notes: meetingNotes, space: "demo", } as any, { id: "demo-nb-3", title: "Reading Journal", description: "Books, articles, and reflections", cover_color: "#f59e0b", note_count: "3", updated_at: new Date(now - day).toISOString(), notes: readingNotes, space: "demo", } as any, ]; this.notebooks = this.demoNotebooks.map(({ notes, ...nb }) => nb as Notebook); this.loading = false; this.render(); } private demoSearchNotes(query: string) { if (!query.trim()) { this.searchResults = []; this.render(); return; } const q = query.toLowerCase(); const all = this.demoNotebooks.flatMap(nb => nb.notes); this.searchResults = all.filter(n => n.title.toLowerCase().includes(q) || n.content_plain.toLowerCase().includes(q) || (n.tags && n.tags.some(t => t.toLowerCase().includes(q))) ); this.render(); } private demoLoadNotebook(id: string) { const nb = this.demoNotebooks.find(n => n.id === id); if (nb) { this.selectedNotebook = { ...nb }; } else { this.error = "Notebook not found"; } this.loading = false; this.render(); } private demoLoadNote(id: string) { const allNotes = this.demoNotebooks.flatMap(nb => nb.notes); this.selectedNote = allNotes.find(n => n.id === id) || null; this.render(); } private demoCreateNotebook() { const title = prompt("Notebook name:"); if (!title?.trim()) return; const now = Date.now(); const nb = { id: `demo-nb-${now}`, title, description: "", cover_color: "#8b5cf6", note_count: "0", updated_at: new Date(now).toISOString(), notes: [] as Note[], space: "demo", } as any; this.demoNotebooks.push(nb); this.notebooks = this.demoNotebooks.map(({ notes, ...rest }) => rest as Notebook); this.render(); } private demoCreateNote() { if (!this.selectedNotebook) return; const now = Date.now(); const noteId = `demo-note-${now}`; const newNote: Note = { id: noteId, title: "Untitled Note", content: "", content_plain: "", type: "NOTE", tags: null, is_pinned: false, created_at: new Date(now).toISOString(), updated_at: new Date(now).toISOString(), }; // Add to the matching demoNotebook const demoNb = this.demoNotebooks.find(n => n.id === this.selectedNotebook!.id); if (demoNb) { demoNb.notes.push(newNote); demoNb.note_count = String(demoNb.notes.length); } this.selectedNotebook.notes.push(newNote); this.selectedNotebook.note_count = String(this.selectedNotebook.notes.length); this.selectedNote = newNote; this.view = "note"; this.render(); } disconnectedCallback() { this.disconnectSync(); } // ── WebSocket Sync ── private connectSync() { const proto = location.protocol === "https:" ? "wss:" : "ws:"; const wsUrl = `${proto}//${location.host}/ws/${this.space}`; this.ws = new WebSocket(wsUrl); this.ws.onopen = () => { this.syncConnected = true; // Keepalive ping every 30s this.pingInterval = setInterval(() => { if (this.ws?.readyState === WebSocket.OPEN) { this.ws.send(JSON.stringify({ type: "ping", timestamp: Date.now() })); } }, 30000); // If we had a pending subscription, re-subscribe if (this.subscribedDocId && this.doc) { this.subscribeNotebook(this.subscribedDocId.split(":").pop()!); } }; this.ws.onmessage = (e) => { try { const msg = JSON.parse(e.data); if (msg.type === "sync" && msg.docId === this.subscribedDocId) { this.handleSyncMessage(new Uint8Array(msg.data)); } // pong and other messages are ignored } catch { // ignore parse errors } }; this.ws.onclose = () => { this.syncConnected = false; if (this.pingInterval) clearInterval(this.pingInterval); // Reconnect after 3s setTimeout(() => { if (this.isConnected) this.connectSync(); }, 3000); }; this.ws.onerror = () => { // onclose will fire after this }; } private disconnectSync() { if (this.pingInterval) clearInterval(this.pingInterval); if (this.ws) { this.ws.onclose = null; // prevent reconnect this.ws.close(); this.ws = null; } this.syncConnected = false; } private handleSyncMessage(syncMsg: Uint8Array) { if (!this.doc) return; const [newDoc, newSyncState] = Automerge.receiveSyncMessage( this.doc, this.syncState, syncMsg ); this.doc = newDoc; this.syncState = newSyncState; // Send reply if needed const [nextState, reply] = Automerge.generateSyncMessage(this.doc, this.syncState); this.syncState = nextState; if (reply && this.ws?.readyState === WebSocket.OPEN) { this.ws.send(JSON.stringify({ type: "sync", docId: this.subscribedDocId, data: Array.from(reply), })); } this.renderFromDoc(); } private subscribeNotebook(notebookId: string) { this.subscribedDocId = `${this.space}:notes:notebooks:${notebookId}`; this.doc = Automerge.init(); this.syncState = Automerge.initSyncState(); if (!this.ws || this.ws.readyState !== WebSocket.OPEN) return; // Send subscribe this.ws.send(JSON.stringify({ type: "subscribe", docIds: [this.subscribedDocId] })); // Send initial sync message to kick off handshake const [s, m] = Automerge.generateSyncMessage(this.doc, this.syncState); this.syncState = s; if (m) { this.ws.send(JSON.stringify({ type: "sync", docId: this.subscribedDocId, data: Array.from(m), })); } } private unsubscribeNotebook() { if (this.subscribedDocId && this.ws?.readyState === WebSocket.OPEN) { this.ws.send(JSON.stringify({ type: "unsubscribe", docIds: [this.subscribedDocId] })); } this.subscribedDocId = null; this.doc = null; this.syncState = Automerge.initSyncState(); } /** Extract notebook + notes from Automerge doc into component state */ private renderFromDoc() { if (!this.doc) return; const nb = this.doc.notebook; const items = this.doc.items; if (!nb) return; // doc not yet synced // Build notebook data from doc const notes: Note[] = []; if (items) { for (const [, item] of Object.entries(items)) { notes.push({ id: item.id, title: item.title || "Untitled", content: item.content || "", content_plain: item.contentPlain || "", type: item.type || "NOTE", tags: item.tags?.length ? Array.from(item.tags) : null, is_pinned: item.isPinned || false, created_at: item.createdAt ? new Date(item.createdAt).toISOString() : new Date().toISOString(), updated_at: item.updatedAt ? new Date(item.updatedAt).toISOString() : new Date().toISOString(), }); } } // Sort: pinned first, then by sort order, then by updated_at desc notes.sort((a, b) => { if (a.is_pinned !== b.is_pinned) return a.is_pinned ? -1 : 1; return new Date(b.updated_at).getTime() - new Date(a.updated_at).getTime(); }); this.selectedNotebook = { id: nb.id, title: nb.title, description: nb.description || "", cover_color: nb.coverColor || "#3b82f6", note_count: String(notes.length), updated_at: nb.updatedAt ? new Date(nb.updatedAt).toISOString() : new Date().toISOString(), notes, }; // If viewing a specific note, update it from doc too if (this.view === "note" && this.selectedNote) { const noteItem = items?.[this.selectedNote.id]; if (noteItem) { this.selectedNote = { id: noteItem.id, title: noteItem.title || "Untitled", content: noteItem.content || "", content_plain: noteItem.contentPlain || "", type: noteItem.type || "NOTE", tags: noteItem.tags?.length ? Array.from(noteItem.tags) : null, is_pinned: noteItem.isPinned || false, created_at: noteItem.createdAt ? new Date(noteItem.createdAt).toISOString() : new Date().toISOString(), updated_at: noteItem.updatedAt ? new Date(noteItem.updatedAt).toISOString() : new Date().toISOString(), }; } } this.loading = false; this.render(); } // ── Automerge mutations ── private createNoteViaSync() { if (!this.doc || !this.selectedNotebook) return; const noteId = crypto.randomUUID(); const now = Date.now(); this.doc = Automerge.change(this.doc, "Create note", (d: NotebookDoc) => { if (!d.items) (d as any).items = {}; d.items[noteId] = { id: noteId, notebookId: this.selectedNotebook!.id, title: "Untitled Note", content: "", contentPlain: "", type: "NOTE", tags: [], isPinned: false, sortOrder: 0, createdAt: now, updatedAt: now, }; }); this.sendSyncAfterChange(); this.renderFromDoc(); // Open the new note for editing this.selectedNote = { id: noteId, title: "Untitled Note", content: "", content_plain: "", type: "NOTE", tags: null, is_pinned: false, created_at: new Date(now).toISOString(), updated_at: new Date(now).toISOString(), }; this.view = "note"; this.render(); } private updateNoteField(noteId: string, field: string, value: string) { if (!this.doc || !this.doc.items?.[noteId]) return; this.doc = Automerge.change(this.doc, `Update ${field}`, (d: NotebookDoc) => { (d.items[noteId] as any)[field] = value; d.items[noteId].updatedAt = Date.now(); }); this.sendSyncAfterChange(); } private sendSyncAfterChange() { if (!this.doc || !this.ws || this.ws.readyState !== WebSocket.OPEN) return; const [newState, msg] = Automerge.generateSyncMessage(this.doc, this.syncState); this.syncState = newState; if (msg) { this.ws.send(JSON.stringify({ type: "sync", docId: this.subscribedDocId, data: Array.from(msg), })); } } // ── REST (notebook list + search) ── private getApiBase(): string { const path = window.location.pathname; const match = path.match(/^\/([^/]+)\/notes/); return match ? `/${match[1]}/notes` : ""; } private async loadNotebooks() { this.loading = true; this.render(); try { const base = this.getApiBase(); const res = await fetch(`${base}/api/notebooks`); const data = await res.json(); this.notebooks = data.notebooks || []; } catch { this.error = "Failed to load notebooks"; } this.loading = false; this.render(); } private async loadNotebook(id: string) { this.loading = true; this.render(); // Unsubscribe from any previous notebook this.unsubscribeNotebook(); // Subscribe to the new notebook via Automerge this.subscribeNotebook(id); // Set a timeout — if doc doesn't arrive in 5s, fall back to REST setTimeout(() => { if (this.loading && this.view === "notebook") { this.loadNotebookREST(id); } }, 5000); } /** REST fallback for notebook detail */ private async loadNotebookREST(id: string) { try { const base = this.getApiBase(); const res = await fetch(`${base}/api/notebooks/${id}`); this.selectedNotebook = await res.json(); } catch { this.error = "Failed to load notebook"; } this.loading = false; this.render(); } private loadNote(id: string) { // Note is already in the Automerge doc — just select it if (this.doc?.items?.[id]) { const item = this.doc.items[id]; this.selectedNote = { id: item.id, title: item.title || "Untitled", content: item.content || "", content_plain: item.contentPlain || "", type: item.type || "NOTE", tags: item.tags?.length ? Array.from(item.tags) : null, is_pinned: item.isPinned || false, created_at: item.createdAt ? new Date(item.createdAt).toISOString() : new Date().toISOString(), updated_at: item.updatedAt ? new Date(item.updatedAt).toISOString() : new Date().toISOString(), }; } else if (this.selectedNotebook?.notes) { // Fallback: find in REST-loaded data this.selectedNote = this.selectedNotebook.notes.find(n => n.id === id) || null; } this.render(); } private async searchNotes(query: string) { if (!query.trim()) { this.searchResults = []; this.render(); return; } try { const base = this.getApiBase(); const res = await fetch(`${base}/api/notes?q=${encodeURIComponent(query)}`); const data = await res.json(); this.searchResults = data.notes || []; } catch { this.searchResults = []; } this.render(); } private async createNotebook() { const title = prompt("Notebook name:"); if (!title?.trim()) return; try { const base = this.getApiBase(); await fetch(`${base}/api/notebooks`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ title }), }); await this.loadNotebooks(); } catch { this.error = "Failed to create notebook"; this.render(); } } private getNoteIcon(type: string): string { switch (type) { case "NOTE": return "📝"; case "CODE": return "💻"; case "BOOKMARK": return "🔗"; case "IMAGE": return "🖼"; case "AUDIO": return "🎤"; case "FILE": return "📎"; case "CLIP": return "✂️"; default: return "📄"; } } private formatDate(dateStr: string): string { const d = new Date(dateStr); const now = new Date(); const diffMs = now.getTime() - d.getTime(); const diffDays = Math.floor(diffMs / (1000 * 60 * 60 * 24)); if (diffDays === 0) return "Today"; if (diffDays === 1) return "Yesterday"; if (diffDays < 7) return `${diffDays}d ago`; return d.toLocaleDateString(); } private render() { this.shadow.innerHTML = ` ${this.error ? `
${this.esc(this.error)}
` : ""} ${this.loading ? '
Loading...
' : ""} ${!this.loading ? this.renderView() : ""} `; this.attachListeners(); } private renderView(): string { if (this.view === "note" && this.selectedNote) return this.renderNote(); if (this.view === "notebook" && this.selectedNotebook) return this.renderNotebook(); return this.renderNotebooks(); } private renderNotebooks(): string { return `
Notebooks
${this.searchQuery && this.searchResults.length > 0 ? `
${this.searchResults.length} results for "${this.esc(this.searchQuery)}"
${this.searchResults.map((n) => this.renderNoteItem(n)).join("")} ` : ""} ${!this.searchQuery ? `
${this.notebooks.map((nb) => `
${this.esc(nb.title)}
${this.esc(nb.description || "")}
${nb.note_count} notes · ${this.formatDate(nb.updated_at)}
`).join("")}
${this.notebooks.length === 0 ? '
No notebooks yet. Create one to get started.
' : ""} ` : ""} `; } private renderNotebook(): string { const nb = this.selectedNotebook!; const syncBadge = this.subscribedDocId ? `` : ""; return `
${this.esc(nb.title)}${syncBadge}
${nb.notes && nb.notes.length > 0 ? nb.notes.map((n) => this.renderNoteItem(n)).join("") : '
No notes in this notebook.
' } `; } private renderNoteItem(n: Note): string { return `
${this.getNoteIcon(n.type)}
${n.is_pinned ? '📌 ' : ""}${this.esc(n.title)}
${this.esc(n.content_plain || "")}
${this.formatDate(n.updated_at)} ${n.type} ${n.tags ? n.tags.map((t) => `${this.esc(t)}`).join("") : ""}
`; } private renderNote(): string { const n = this.selectedNote!; const isDemo = this.space === "demo"; const isAutomerge = !!(this.doc?.items?.[n.id]); const isEditable = isAutomerge || isDemo; return `
${isEditable ? `` : `${this.getNoteIcon(n.type)} ${this.esc(n.title)}` }
${n.content || 'Empty note'}
Type: ${n.type} Created: ${this.formatDate(n.created_at)} Updated: ${this.formatDate(n.updated_at)} ${n.tags ? n.tags.map((t) => `${this.esc(t)}`).join("") : ""} ${isAutomerge ? 'Live' : ""} ${isDemo ? 'Demo' : ""}
`; } private attachListeners() { const isDemo = this.space === "demo"; // Create notebook this.shadow.getElementById("create-notebook")?.addEventListener("click", () => { isDemo ? this.demoCreateNotebook() : this.createNotebook(); }); // Create note (Automerge or demo) this.shadow.getElementById("create-note")?.addEventListener("click", () => { isDemo ? this.demoCreateNote() : this.createNoteViaSync(); }); // Search const searchInput = this.shadow.getElementById("search-input") as HTMLInputElement; let searchTimeout: any; searchInput?.addEventListener("input", () => { clearTimeout(searchTimeout); this.searchQuery = searchInput.value; searchTimeout = setTimeout(() => { isDemo ? this.demoSearchNotes(this.searchQuery) : this.searchNotes(this.searchQuery); }, 300); }); // Notebook cards this.shadow.querySelectorAll("[data-notebook]").forEach((el) => { el.addEventListener("click", () => { const id = (el as HTMLElement).dataset.notebook!; this.view = "notebook"; isDemo ? this.demoLoadNotebook(id) : this.loadNotebook(id); }); }); // Note items this.shadow.querySelectorAll("[data-note]").forEach((el) => { el.addEventListener("click", () => { const id = (el as HTMLElement).dataset.note!; this.view = "note"; isDemo ? this.demoLoadNote(id) : this.loadNote(id); }); }); // Back buttons this.shadow.querySelectorAll("[data-back]").forEach((el) => { el.addEventListener("click", (e) => { e.stopPropagation(); const target = (el as HTMLElement).dataset.back; if (target === "notebooks") { this.view = "notebooks"; if (!isDemo) this.unsubscribeNotebook(); this.selectedNotebook = null; this.selectedNote = null; this.render(); } else if (target === "notebook") { this.view = "notebook"; this.render(); } }); }); // Editable note title (debounced) — demo: update local data; live: Automerge const titleInput = this.shadow.getElementById("note-title-input") as HTMLInputElement; if (titleInput && this.selectedNote) { let titleTimeout: any; const noteId = this.selectedNote.id; titleInput.addEventListener("input", () => { clearTimeout(titleTimeout); titleTimeout = setTimeout(() => { if (isDemo) { this.demoUpdateNoteField(noteId, "title", titleInput.value); } else { this.updateNoteField(noteId, "title", titleInput.value); } }, 500); }); } // Editable note content (debounced) — demo: update local data; live: Automerge const contentEl = this.shadow.getElementById("note-content-editable"); if (contentEl && this.selectedNote) { let contentTimeout: any; const noteId = this.selectedNote.id; contentEl.addEventListener("input", () => { clearTimeout(contentTimeout); contentTimeout = setTimeout(() => { const html = contentEl.innerHTML; const plain = contentEl.textContent?.trim() || ""; if (isDemo) { this.demoUpdateNoteField(noteId, "content", html); this.demoUpdateNoteField(noteId, "content_plain", plain); } else { this.updateNoteField(noteId, "content", html); this.updateNoteField(noteId, "contentPlain", plain); } }, 800); }); } } private demoUpdateNoteField(noteId: string, field: string, value: string) { // Update in the selectedNote if (this.selectedNote && this.selectedNote.id === noteId) { (this.selectedNote as any)[field] = value; this.selectedNote.updated_at = new Date().toISOString(); } // Update in the matching demoNotebook for (const nb of this.demoNotebooks) { const note = nb.notes.find(n => n.id === noteId); if (note) { (note as any)[field] = value; note.updated_at = new Date().toISOString(); break; } } // Update in selectedNotebook notes if (this.selectedNotebook?.notes) { const note = this.selectedNotebook.notes.find(n => n.id === noteId); if (note) { (note as any)[field] = value; note.updated_at = new Date().toISOString(); } } } private esc(s: string): string { const d = document.createElement("div"); d.textContent = s || ""; return d.innerHTML; } } customElements.define("folk-notes-app", FolkNotesApp);