feat(rpubs): add collaborative document editing via Automerge CRDT
Wire rPubs editor to the shared local-first runtime so multiple users can co-author publications in real time. Drafts persist to IndexedDB and sync via WebSocket automatically. - New schemas.ts with PubsDoc schema and pubsDocId helper - Editor connects to runtime, discovers/creates drafts - Debounced content sync (800ms) with cursor preservation - Title/author/format metadata sync between collaborators - Draft list sidebar with create/switch functionality - Sync status badge in toolbar Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
7177b2882c
commit
f0cecc1529
|
|
@ -1,10 +1,15 @@
|
||||||
/**
|
/**
|
||||||
* <folk-pubs-editor> — Markdown editor with format selector and PDF generation.
|
* <folk-pubs-editor> — Collaborative markdown editor with format selector and PDF generation.
|
||||||
*
|
*
|
||||||
* Drop in markdown text, pick a pocket-book format, generate a print-ready PDF.
|
* 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 {
|
interface BookFormat {
|
||||||
id: string;
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
|
|
@ -15,6 +20,13 @@ interface BookFormat {
|
||||||
maxPages: number;
|
maxPages: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface DraftEntry {
|
||||||
|
docId: DocumentId;
|
||||||
|
draftId: string;
|
||||||
|
title: string;
|
||||||
|
updatedAt: number;
|
||||||
|
}
|
||||||
|
|
||||||
const SAMPLE_CONTENT = `# The Commons
|
const SAMPLE_CONTENT = `# The Commons
|
||||||
|
|
||||||
## What Are 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.`;
|
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 {
|
export class FolkPubsEditor extends HTMLElement {
|
||||||
private _formats: BookFormat[] = [];
|
private _formats: BookFormat[] = [];
|
||||||
private _spaceSlug = "personal";
|
private _spaceSlug = "personal";
|
||||||
|
|
@ -55,6 +69,16 @@ export class FolkPubsEditor extends HTMLElement {
|
||||||
private _pdfUrl: string | null = null;
|
private _pdfUrl: string | null = null;
|
||||||
private _pdfInfo: 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<typeof setTimeout> | null = null;
|
||||||
|
private _syncConnected = false;
|
||||||
|
|
||||||
set formats(val: BookFormat[]) {
|
set formats(val: BookFormat[]) {
|
||||||
this._formats = val;
|
this._formats = val;
|
||||||
if (this.shadowRoot) this.render();
|
if (this.shadowRoot) this.render();
|
||||||
|
|
@ -64,15 +88,225 @@ export class FolkPubsEditor extends HTMLElement {
|
||||||
this._spaceSlug = val;
|
this._spaceSlug = val;
|
||||||
}
|
}
|
||||||
|
|
||||||
connectedCallback() {
|
async connectedCallback() {
|
||||||
this.attachShadow({ mode: "open" });
|
this.attachShadow({ mode: "open" });
|
||||||
this.render();
|
this.render();
|
||||||
|
|
||||||
const space = this.getAttribute("space") || "";
|
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();
|
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() {
|
private loadDemoContent() {
|
||||||
requestAnimationFrame(() => {
|
requestAnimationFrame(() => {
|
||||||
if (!this.shadowRoot) return;
|
if (!this.shadowRoot) return;
|
||||||
|
|
@ -87,11 +321,50 @@ export class FolkPubsEditor extends HTMLElement {
|
||||||
|
|
||||||
disconnectedCallback() {
|
disconnectedCallback() {
|
||||||
if (this._pdfUrl) URL.revokeObjectURL(this._pdfUrl);
|
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) => `
|
||||||
|
<button class="draft-item ${d.docId === this._activeDocId ? 'active' : ''}" data-doc-id="${d.docId}" data-draft-id="${d.draftId}">
|
||||||
|
<span class="draft-title">${this.escapeHtml(d.title || 'Untitled')}</span>
|
||||||
|
<span class="draft-time">${this.formatTime(d.updatedAt)}</span>
|
||||||
|
</button>
|
||||||
|
`).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() {
|
private render() {
|
||||||
if (!this.shadowRoot) return;
|
if (!this.shadowRoot) return;
|
||||||
|
|
||||||
|
const hasDrafts = this._drafts.length > 0;
|
||||||
|
|
||||||
this.shadowRoot.innerHTML = `
|
this.shadowRoot.innerHTML = `
|
||||||
${this.getStyles()}
|
${this.getStyles()}
|
||||||
<div class="editor-layout">
|
<div class="editor-layout">
|
||||||
|
|
@ -102,6 +375,7 @@ export class FolkPubsEditor extends HTMLElement {
|
||||||
<input type="text" class="author-input" placeholder="Author (optional)" />
|
<input type="text" class="author-input" placeholder="Author (optional)" />
|
||||||
</div>
|
</div>
|
||||||
<div class="toolbar-right">
|
<div class="toolbar-right">
|
||||||
|
${this._syncConnected ? '<span class="sync-badge" title="Syncing with collaborators">Synced</span>' : ''}
|
||||||
<button class="btn-sample" title="Load sample content">Sample</button>
|
<button class="btn-sample" title="Load sample content">Sample</button>
|
||||||
<label class="btn-upload" title="Open a text file">
|
<label class="btn-upload" title="Open a text file">
|
||||||
<input type="file" accept=".md,.txt,.markdown" style="display:none" />
|
<input type="file" accept=".md,.txt,.markdown" style="display:none" />
|
||||||
|
|
@ -112,6 +386,19 @@ export class FolkPubsEditor extends HTMLElement {
|
||||||
<textarea class="content-area" placeholder="Drop in your markdown or plain text here..."></textarea>
|
<textarea class="content-area" placeholder="Drop in your markdown or plain text here..."></textarea>
|
||||||
</div>
|
</div>
|
||||||
<div class="sidebar">
|
<div class="sidebar">
|
||||||
|
${this._runtime ? `
|
||||||
|
<h3>Drafts</h3>
|
||||||
|
<button class="btn-new-draft">+ New Draft</button>
|
||||||
|
<div class="draft-list">
|
||||||
|
${this._drafts.map((d) => `
|
||||||
|
<button class="draft-item ${d.docId === this._activeDocId ? 'active' : ''}" data-doc-id="${d.docId}" data-draft-id="${d.draftId}">
|
||||||
|
<span class="draft-title">${this.escapeHtml(d.title || 'Untitled')}</span>
|
||||||
|
<span class="draft-time">${this.formatTime(d.updatedAt)}</span>
|
||||||
|
</button>
|
||||||
|
`).join("")}
|
||||||
|
</div>
|
||||||
|
` : ''}
|
||||||
|
|
||||||
<h3>Format</h3>
|
<h3>Format</h3>
|
||||||
<div class="format-grid">
|
<div class="format-grid">
|
||||||
${this._formats.map((f) => `
|
${this._formats.map((f) => `
|
||||||
|
|
@ -153,6 +440,12 @@ export class FolkPubsEditor extends HTMLElement {
|
||||||
`;
|
`;
|
||||||
|
|
||||||
this.bindEvents();
|
this.bindEvents();
|
||||||
|
|
||||||
|
// Populate fields from current doc after render
|
||||||
|
if (this._runtime && this._activeDocId) {
|
||||||
|
const doc = this._runtime.get(this._activeDocId);
|
||||||
|
if (doc) this.populateFromDoc(doc);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private bindEvents() {
|
private bindEvents() {
|
||||||
|
|
@ -164,11 +457,43 @@ export class FolkPubsEditor extends HTMLElement {
|
||||||
const generateBtn = this.shadowRoot.querySelector(".btn-generate") as HTMLButtonElement;
|
const generateBtn = this.shadowRoot.querySelector(".btn-generate") as HTMLButtonElement;
|
||||||
const sampleBtn = this.shadowRoot.querySelector(".btn-sample");
|
const sampleBtn = this.shadowRoot.querySelector(".btn-sample");
|
||||||
const fileInput = this.shadowRoot.querySelector('input[type="file"]') as HTMLInputElement;
|
const fileInput = this.shadowRoot.querySelector('input[type="file"]') as HTMLInputElement;
|
||||||
|
const newDraftBtn = this.shadowRoot.querySelector(".btn-new-draft");
|
||||||
|
|
||||||
|
// ── Content sync → Automerge (debounced) ──
|
||||||
|
textarea?.addEventListener("input", () => {
|
||||||
|
this.syncContentToDoc(textarea.value);
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Title/Author sync → Automerge (immediate) ──
|
||||||
|
titleInput?.addEventListener("input", () => {
|
||||||
|
this.syncMetaToDoc('title', titleInput.value);
|
||||||
|
});
|
||||||
|
authorInput?.addEventListener("input", () => {
|
||||||
|
this.syncMetaToDoc('author', authorInput.value);
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── New draft button ──
|
||||||
|
newDraftBtn?.addEventListener("click", () => {
|
||||||
|
this.createNewDraft();
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Draft list items ──
|
||||||
|
this.shadowRoot.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);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
// Format buttons
|
// Format buttons
|
||||||
this.shadowRoot.querySelectorAll(".format-btn").forEach((btn) => {
|
this.shadowRoot.querySelectorAll(".format-btn").forEach((btn) => {
|
||||||
btn.addEventListener("click", () => {
|
btn.addEventListener("click", () => {
|
||||||
this._selectedFormat = (btn as HTMLElement).dataset.format!;
|
this._selectedFormat = (btn as HTMLElement).dataset.format!;
|
||||||
|
this.syncMetaToDoc('format', this._selectedFormat);
|
||||||
// Clear previous PDF on format change
|
// Clear previous PDF on format change
|
||||||
if (this._pdfUrl) {
|
if (this._pdfUrl) {
|
||||||
URL.revokeObjectURL(this._pdfUrl);
|
URL.revokeObjectURL(this._pdfUrl);
|
||||||
|
|
@ -185,6 +510,9 @@ export class FolkPubsEditor extends HTMLElement {
|
||||||
textarea.value = SAMPLE_CONTENT;
|
textarea.value = SAMPLE_CONTENT;
|
||||||
titleInput.value = "";
|
titleInput.value = "";
|
||||||
authorInput.value = "";
|
authorInput.value = "";
|
||||||
|
this.syncContentToDoc(SAMPLE_CONTENT);
|
||||||
|
this.syncMetaToDoc('title', '');
|
||||||
|
this.syncMetaToDoc('author', '');
|
||||||
});
|
});
|
||||||
|
|
||||||
// File upload
|
// File upload
|
||||||
|
|
@ -193,7 +521,9 @@ export class FolkPubsEditor extends HTMLElement {
|
||||||
if (file) {
|
if (file) {
|
||||||
const reader = new FileReader();
|
const reader = new FileReader();
|
||||||
reader.onload = () => {
|
reader.onload = () => {
|
||||||
textarea.value = reader.result as string;
|
const text = reader.result as string;
|
||||||
|
textarea.value = text;
|
||||||
|
this.syncContentToDoc(text);
|
||||||
};
|
};
|
||||||
reader.readAsText(file);
|
reader.readAsText(file);
|
||||||
}
|
}
|
||||||
|
|
@ -214,7 +544,9 @@ export class FolkPubsEditor extends HTMLElement {
|
||||||
if (file && (file.type.startsWith("text/") || file.name.match(/\.(md|txt|markdown)$/))) {
|
if (file && (file.type.startsWith("text/") || file.name.match(/\.(md|txt|markdown)$/))) {
|
||||||
const reader = new FileReader();
|
const reader = new FileReader();
|
||||||
reader.onload = () => {
|
reader.onload = () => {
|
||||||
textarea.value = reader.result as string;
|
const text = reader.result as string;
|
||||||
|
textarea.value = text;
|
||||||
|
this.syncContentToDoc(text);
|
||||||
};
|
};
|
||||||
reader.readAsText(file);
|
reader.readAsText(file);
|
||||||
}
|
}
|
||||||
|
|
@ -308,6 +640,16 @@ export class FolkPubsEditor extends HTMLElement {
|
||||||
.toolbar-right {
|
.toolbar-right {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 0.5rem;
|
gap: 0.5rem;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sync-badge {
|
||||||
|
font-size: 0.65rem;
|
||||||
|
padding: 0.2rem 0.5rem;
|
||||||
|
border-radius: 1rem;
|
||||||
|
background: rgba(34, 197, 94, 0.15);
|
||||||
|
color: var(--rs-success, #22c55e);
|
||||||
|
font-weight: 500;
|
||||||
}
|
}
|
||||||
|
|
||||||
.title-input, .author-input {
|
.title-input, .author-input {
|
||||||
|
|
@ -373,6 +715,62 @@ export class FolkPubsEditor extends HTMLElement {
|
||||||
letter-spacing: 0.05em;
|
letter-spacing: 0.05em;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ── Draft list ── */
|
||||||
|
|
||||||
|
.btn-new-draft {
|
||||||
|
padding: 0.375rem 0.625rem;
|
||||||
|
border: 1px dashed var(--rs-border);
|
||||||
|
border-radius: 0.375rem;
|
||||||
|
background: transparent;
|
||||||
|
color: var(--rs-text-secondary);
|
||||||
|
font-size: 0.8rem;
|
||||||
|
cursor: pointer;
|
||||||
|
width: 100%;
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
.btn-new-draft:hover { border-color: var(--rs-primary); color: var(--rs-text-primary); }
|
||||||
|
|
||||||
|
.draft-list {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.25rem;
|
||||||
|
max-height: 180px;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.draft-item {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
padding: 0.375rem 0.5rem;
|
||||||
|
border: 1px solid transparent;
|
||||||
|
border-radius: 0.375rem;
|
||||||
|
background: transparent;
|
||||||
|
color: var(--rs-text-primary);
|
||||||
|
font-size: 0.8rem;
|
||||||
|
cursor: pointer;
|
||||||
|
text-align: left;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
.draft-item:hover { background: var(--rs-bg-surface); }
|
||||||
|
.draft-item.active {
|
||||||
|
border-color: var(--rs-primary);
|
||||||
|
background: rgba(59, 130, 246, 0.1);
|
||||||
|
}
|
||||||
|
.draft-title {
|
||||||
|
flex: 1;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.draft-time {
|
||||||
|
font-size: 0.65rem;
|
||||||
|
color: var(--rs-text-muted);
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Format grid ── */
|
||||||
|
|
||||||
.format-grid {
|
.format-grid {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: 1fr 1fr;
|
grid-template-columns: 1fr 1fr;
|
||||||
|
|
@ -497,6 +895,7 @@ export class FolkPubsEditor extends HTMLElement {
|
||||||
.editor-toolbar { gap: 0.5rem; }
|
.editor-toolbar { gap: 0.5rem; }
|
||||||
.format-grid { grid-template-columns: repeat(3, 1fr); }
|
.format-grid { grid-template-columns: repeat(3, 1fr); }
|
||||||
.btn-generate { font-size: 0.8rem; padding: 0.5rem; }
|
.btn-generate { font-size: 0.8rem; padding: 0.5rem; }
|
||||||
|
.draft-list { max-height: 120px; }
|
||||||
}
|
}
|
||||||
@media (max-width: 480px) {
|
@media (max-width: 480px) {
|
||||||
.format-grid { grid-template-columns: 1fr 1fr; }
|
.format-grid { grid-template-columns: 1fr 1fr; }
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,65 @@
|
||||||
|
/**
|
||||||
|
* rPubs Automerge document schemas.
|
||||||
|
*
|
||||||
|
* Granularity: one Automerge document per draft.
|
||||||
|
* DocId format: {space}:pubs:drafts:{draftId}
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { DocSchema } from '../../shared/local-first/document';
|
||||||
|
|
||||||
|
// ── Document types ──
|
||||||
|
|
||||||
|
export interface PubsDraftMeta {
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
author: string;
|
||||||
|
format: string; // selected book format id
|
||||||
|
createdAt: number;
|
||||||
|
updatedAt: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PubsDoc {
|
||||||
|
meta: {
|
||||||
|
module: string;
|
||||||
|
collection: string;
|
||||||
|
version: number;
|
||||||
|
spaceSlug: string;
|
||||||
|
createdAt: number;
|
||||||
|
};
|
||||||
|
draft: PubsDraftMeta;
|
||||||
|
content: string; // markdown content (Automerge text)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Schema registration ──
|
||||||
|
|
||||||
|
export const pubsDraftSchema: DocSchema<PubsDoc> = {
|
||||||
|
module: 'pubs',
|
||||||
|
collection: 'drafts',
|
||||||
|
version: 1,
|
||||||
|
init: (): PubsDoc => ({
|
||||||
|
meta: {
|
||||||
|
module: 'pubs',
|
||||||
|
collection: 'drafts',
|
||||||
|
version: 1,
|
||||||
|
spaceSlug: '',
|
||||||
|
createdAt: Date.now(),
|
||||||
|
},
|
||||||
|
draft: {
|
||||||
|
id: '',
|
||||||
|
title: '',
|
||||||
|
author: '',
|
||||||
|
format: 'digest',
|
||||||
|
createdAt: Date.now(),
|
||||||
|
updatedAt: Date.now(),
|
||||||
|
},
|
||||||
|
content: '',
|
||||||
|
}),
|
||||||
|
migrate: (doc: PubsDoc, _fromVersion: number): PubsDoc => doc,
|
||||||
|
};
|
||||||
|
|
||||||
|
// ── Helpers ──
|
||||||
|
|
||||||
|
/** Generate a docId for a draft. */
|
||||||
|
export function pubsDocId(space: string, draftId: string) {
|
||||||
|
return `${space}:pubs:drafts:${draftId}` as const;
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue