diff --git a/modules/rpubs/components/folk-pubs-editor.ts b/modules/rpubs/components/folk-pubs-editor.ts index 28884c9..90fe731 100644 --- a/modules/rpubs/components/folk-pubs-editor.ts +++ b/modules/rpubs/components/folk-pubs-editor.ts @@ -1,10 +1,15 @@ /** - * — Markdown editor with format selector and PDF generation. + * — Collaborative markdown editor with format selector and PDF generation. * * Drop in markdown text, pick a pocket-book format, generate a print-ready PDF. - * Supports file drag-and-drop, sample content, and PDF preview/download. + * Supports file drag-and-drop, sample content, PDF preview/download, + * and real-time collaborative editing via Automerge CRDT. */ +import { pubsDraftSchema, pubsDocId } from '../schemas'; +import type { PubsDoc } from '../schemas'; +import type { DocumentId } from '../../../shared/local-first/document'; + interface BookFormat { id: string; name: string; @@ -15,6 +20,13 @@ interface BookFormat { maxPages: number; } +interface DraftEntry { + docId: DocumentId; + draftId: string; + title: string; + updatedAt: number; +} + const SAMPLE_CONTENT = `# The Commons ## What Are Commons? @@ -46,6 +58,8 @@ The internet has created entirely new forms of commons. Open-source software, Cr These ideas matter because they challenge the assumption that only private ownership or government control can manage resources effectively. The commons represent a third way — community governance of shared wealth.`; +const SYNC_DEBOUNCE_MS = 800; + export class FolkPubsEditor extends HTMLElement { private _formats: BookFormat[] = []; private _spaceSlug = "personal"; @@ -55,6 +69,16 @@ export class FolkPubsEditor extends HTMLElement { private _pdfUrl: string | null = null; private _pdfInfo: string | null = null; + // ── Automerge collaborative state ── + private _runtime: any = null; + private _activeDocId: DocumentId | null = null; + private _activeDraftId: string | null = null; + private _drafts: DraftEntry[] = []; + private _unsubChange: (() => void) | null = null; + private _isRemoteUpdate = false; + private _syncTimer: ReturnType | null = null; + private _syncConnected = false; + set formats(val: BookFormat[]) { this._formats = val; if (this.shadowRoot) this.render(); @@ -64,15 +88,225 @@ export class FolkPubsEditor extends HTMLElement { this._spaceSlug = val; } - connectedCallback() { + async connectedCallback() { this.attachShadow({ mode: "open" }); this.render(); + const space = this.getAttribute("space") || ""; - if (space === "demo") { + this._spaceSlug = space || this._spaceSlug; + + // Connect to the local-first runtime + await this.initRuntime(); + + if (space === "demo" && !this._activeDocId) { this.loadDemoContent(); } } + private async initRuntime() { + const runtime = (window as any).__rspaceOfflineRuntime; + if (!runtime?.isInitialized) return; + + this._runtime = runtime; + + try { + // Discover all existing drafts for this space + const docs = await runtime.subscribeModule('pubs', 'drafts', pubsDraftSchema); + this._syncConnected = runtime.isOnline; + + // Build draft list from discovered docs + this._drafts = []; + for (const [docId, doc] of docs) { + const parts = (docId as string).split(':'); + const draftId = parts[3] || ''; + this._drafts.push({ + docId: docId as DocumentId, + draftId, + title: (doc as any).draft?.title || 'Untitled', + updatedAt: (doc as any).draft?.updatedAt || (doc as any).meta?.createdAt || 0, + }); + } + + // Sort by most recently updated + this._drafts.sort((a, b) => b.updatedAt - a.updatedAt); + + // Open the most recent draft, or create one if none exist + if (this._drafts.length > 0) { + await this.switchDraft(this._drafts[0].docId, this._drafts[0].draftId); + } else { + await this.createNewDraft(); + } + } catch (e) { + console.error('[folk-pubs-editor] Runtime init failed:', e); + } + } + + private async createNewDraft() { + if (!this._runtime) return; + + const draftId = crypto.randomUUID(); + const dataSpace = this._runtime.resolveDocSpace?.('pubs') || this._spaceSlug; + const docId = pubsDocId(dataSpace, draftId) as DocumentId; + + const doc = await this._runtime.subscribe(docId, pubsDraftSchema); + + // Initialize the draft metadata + this._runtime.change(docId, 'Initialize draft', (d: PubsDoc) => { + d.meta.spaceSlug = this._spaceSlug; + d.meta.createdAt = Date.now(); + d.draft.id = draftId; + d.draft.createdAt = Date.now(); + d.draft.updatedAt = Date.now(); + }); + + // Add to local list + this._drafts.unshift({ + docId, + draftId, + title: 'Untitled', + updatedAt: Date.now(), + }); + + await this.switchDraft(docId, draftId); + } + + private async switchDraft(docId: DocumentId, draftId: string) { + // Unsubscribe from previous doc + if (this._unsubChange) { + this._unsubChange(); + this._unsubChange = null; + } + + this._activeDocId = docId; + this._activeDraftId = draftId; + + if (!this._runtime) return; + + // Subscribe to this doc (may already be subscribed from subscribeModule) + const doc = await this._runtime.subscribe(docId, pubsDraftSchema); + + // Populate UI from doc + this.populateFromDoc(doc); + + // Listen for remote changes + this._unsubChange = this._runtime.onChange(docId, (updated: any) => { + this._isRemoteUpdate = true; + this.applyRemoteUpdate(updated); + this.updateDraftListEntry(docId, updated); + this._isRemoteUpdate = false; + }); + + this.render(); + } + + private populateFromDoc(doc: any) { + if (!this.shadowRoot) return; + + requestAnimationFrame(() => { + if (!this.shadowRoot) return; + const textarea = this.shadowRoot.querySelector(".content-area") as HTMLTextAreaElement; + const titleInput = this.shadowRoot.querySelector(".title-input") as HTMLInputElement; + const authorInput = this.shadowRoot.querySelector(".author-input") as HTMLInputElement; + + if (textarea && doc.content != null) textarea.value = doc.content; + if (titleInput && doc.draft?.title != null) titleInput.value = doc.draft.title; + if (authorInput && doc.draft?.author != null) authorInput.value = doc.draft.author; + + if (doc.draft?.format) { + this._selectedFormat = doc.draft.format; + this.updateFormatButtons(); + } + }); + } + + private applyRemoteUpdate(doc: any) { + if (!this.shadowRoot) return; + + const textarea = this.shadowRoot.querySelector(".content-area") as HTMLTextAreaElement; + const titleInput = this.shadowRoot.querySelector(".title-input") as HTMLInputElement; + const authorInput = this.shadowRoot.querySelector(".author-input") as HTMLInputElement; + + // Preserve cursor position when applying remote content changes + if (textarea && doc.content != null && textarea.value !== doc.content) { + const start = textarea.selectionStart; + const end = textarea.selectionEnd; + textarea.value = doc.content; + textarea.selectionStart = Math.min(start, textarea.value.length); + textarea.selectionEnd = Math.min(end, textarea.value.length); + } + + if (titleInput && doc.draft?.title != null && titleInput.value !== doc.draft.title) { + titleInput.value = doc.draft.title; + } + + if (authorInput && doc.draft?.author != null && authorInput.value !== doc.draft.author) { + authorInput.value = doc.draft.author; + } + + if (doc.draft?.format && doc.draft.format !== this._selectedFormat) { + this._selectedFormat = doc.draft.format; + this.updateFormatButtons(); + } + } + + private updateFormatButtons() { + if (!this.shadowRoot) return; + this.shadowRoot.querySelectorAll(".format-btn").forEach((btn) => { + const el = btn as HTMLElement; + if (el.dataset.format === this._selectedFormat) { + el.classList.add("active"); + } else { + el.classList.remove("active"); + } + }); + + // Update format details visibility + this.shadowRoot.querySelectorAll(".format-detail").forEach((el) => { + const htmlEl = el as HTMLElement; + htmlEl.hidden = htmlEl.dataset.for !== this._selectedFormat; + }); + } + + private updateDraftListEntry(docId: DocumentId, doc: any) { + const entry = this._drafts.find((d) => d.docId === docId); + if (entry) { + entry.title = doc.draft?.title || 'Untitled'; + entry.updatedAt = doc.draft?.updatedAt || Date.now(); + this.renderDraftList(); + } + } + + private syncContentToDoc(content: string) { + if (!this._runtime || !this._activeDocId || this._isRemoteUpdate) return; + + if (this._syncTimer) clearTimeout(this._syncTimer); + this._syncTimer = setTimeout(() => { + this._runtime.change(this._activeDocId!, 'Update content', (d: PubsDoc) => { + d.content = content; + d.draft.updatedAt = Date.now(); + }); + }, SYNC_DEBOUNCE_MS); + } + + private syncMetaToDoc(field: 'title' | 'author' | 'format', value: string) { + if (!this._runtime || !this._activeDocId || this._isRemoteUpdate) return; + + this._runtime.change(this._activeDocId!, `Update ${field}`, (d: PubsDoc) => { + (d.draft as any)[field] = value; + d.draft.updatedAt = Date.now(); + }); + + // Update local draft list entry + if (field === 'title') { + const entry = this._drafts.find((d) => d.docId === this._activeDocId); + if (entry) { + entry.title = value || 'Untitled'; + entry.updatedAt = Date.now(); + this.renderDraftList(); + } + } + } + private loadDemoContent() { requestAnimationFrame(() => { if (!this.shadowRoot) return; @@ -87,11 +321,50 @@ export class FolkPubsEditor extends HTMLElement { disconnectedCallback() { if (this._pdfUrl) URL.revokeObjectURL(this._pdfUrl); + if (this._unsubChange) this._unsubChange(); + if (this._syncTimer) clearTimeout(this._syncTimer); + } + + private renderDraftList() { + if (!this.shadowRoot) return; + const container = this.shadowRoot.querySelector(".draft-list"); + if (!container) return; + + container.innerHTML = this._drafts.map((d) => ` + + `).join(""); + + // Bind click events on draft items + container.querySelectorAll(".draft-item").forEach((btn) => { + btn.addEventListener("click", () => { + const el = btn as HTMLElement; + const docId = el.dataset.docId as DocumentId; + const draftId = el.dataset.draftId!; + if (docId !== this._activeDocId) { + this.switchDraft(docId, draftId); + } + }); + }); + } + + private formatTime(ts: number): string { + if (!ts) return ''; + const d = new Date(ts); + const now = new Date(); + if (d.toDateString() === now.toDateString()) { + return d.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }); + } + return d.toLocaleDateString([], { month: 'short', day: 'numeric' }); } private render() { if (!this.shadowRoot) return; + const hasDrafts = this._drafts.length > 0; + this.shadowRoot.innerHTML = ` ${this.getStyles()}
@@ -102,6 +375,7 @@ export class FolkPubsEditor extends HTMLElement {
+ ${this._syncConnected ? 'Synced' : ''}