1210 lines
37 KiB
TypeScript
1210 lines
37 KiB
TypeScript
/**
|
|
* <folk-pubs-editor> — Collaborative markdown editor with 3-step wizard flow.
|
|
*
|
|
* Step 1: Write — full-width markdown editor with format dropdown
|
|
* Step 2: Preview — full-width flipbook with download/edit actions
|
|
* Step 3: Publish — centered publish panel (Share/DIY/Order)
|
|
*
|
|
* 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';
|
|
import { TourEngine } from '../../../shared/tour-engine';
|
|
|
|
interface BookFormat {
|
|
id: string;
|
|
name: string;
|
|
widthMm: number;
|
|
heightMm: number;
|
|
description: string;
|
|
minPages: number;
|
|
maxPages: number;
|
|
}
|
|
|
|
interface DraftEntry {
|
|
docId: DocumentId;
|
|
draftId: string;
|
|
title: string;
|
|
updatedAt: number;
|
|
}
|
|
|
|
const SAMPLE_CONTENT = `# The Commons
|
|
|
|
## What Are Commons?
|
|
|
|
Commons are shared resources managed by communities. They can be natural resources like forests, fisheries, and water systems, or digital resources like open-source software, Wikipedia, and creative works.
|
|
|
|
The concept of the commons has deep historical roots. In medieval England, common land was shared among villagers for grazing animals, gathering firewood, and growing food. These weren't unmanaged free-for-alls — they operated under sophisticated rules developed over generations.
|
|
|
|
## Elinor Ostrom's Design Principles
|
|
|
|
Elinor Ostrom, who won the Nobel Prize in Economics in 2009, identified eight design principles for successful commons governance:
|
|
|
|
1. Clearly defined boundaries
|
|
2. Rules adapted to local conditions
|
|
3. Collective-choice arrangements
|
|
4. Monitoring by community members
|
|
5. Graduated sanctions for rule violators
|
|
6. Accessible conflict-resolution mechanisms
|
|
7. Recognition of the right to organize
|
|
8. Nested enterprises for larger-scale resources
|
|
|
|
> The tragedy of the commons is not inevitable. Communities around the world have managed shared resources sustainably for centuries when given the tools and authority to do so.
|
|
|
|
## The Digital Commons
|
|
|
|
The internet has created entirely new forms of commons. Open-source software, Creative Commons licensing, and collaborative platforms demonstrate that the commons model scales beyond physical resources.
|
|
|
|
---
|
|
|
|
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";
|
|
private _selectedFormat = "digest";
|
|
private _loading = false;
|
|
private _error: string | null = null;
|
|
private _pdfUrl: string | null = null;
|
|
private _pdfInfo: string | null = null;
|
|
private _pdfPageCount = 0;
|
|
|
|
// ── Wizard view state ──
|
|
private _view: "write" | "preview" | "publish" = "write";
|
|
|
|
// ── Dropdown state ──
|
|
private _formatDropdownOpen = false;
|
|
private _draftsDropdownOpen = false;
|
|
|
|
// ── 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;
|
|
private _tour!: TourEngine;
|
|
private static readonly TOUR_STEPS = [
|
|
{ target: '.content-area', title: "Editor", message: "Write or paste markdown content here. Drag-and-drop text files also works.", advanceOnClick: false },
|
|
{ target: '.format-dropdown-btn', title: "Format", message: "Choose a pocket-book format — digest, half-letter, A6, and more.", advanceOnClick: false },
|
|
{ target: '.btn-generate', title: "Generate PDF", message: "Generate a print-ready PDF and advance to the preview step.", advanceOnClick: false },
|
|
{ target: '.drafts-dropdown-btn', title: "Drafts", message: "Save multiple drafts with real-time collaborative sync.", advanceOnClick: false },
|
|
{ target: '.btn-zine-gen', title: "Zine Generator", message: "Generate an AI-illustrated 8-page zine — pick a topic, style, and tone, then edit any section before printing.", advanceOnClick: false },
|
|
];
|
|
|
|
// ── Close dropdowns on outside click ──
|
|
private _outsideClickHandler = (e: MouseEvent) => {
|
|
if (!this.shadowRoot) return;
|
|
const path = e.composedPath();
|
|
if (this._formatDropdownOpen) {
|
|
const dd = this.shadowRoot.querySelector('.format-dropdown');
|
|
if (dd && !path.includes(dd)) {
|
|
this._formatDropdownOpen = false;
|
|
this.renderDropdowns();
|
|
}
|
|
}
|
|
if (this._draftsDropdownOpen) {
|
|
const dd = this.shadowRoot.querySelector('.drafts-dropdown');
|
|
if (dd && !path.includes(dd)) {
|
|
this._draftsDropdownOpen = false;
|
|
this.renderDropdowns();
|
|
}
|
|
}
|
|
};
|
|
|
|
set formats(val: BookFormat[]) {
|
|
this._formats = val;
|
|
if (this.shadowRoot) this.render();
|
|
}
|
|
|
|
set spaceSlug(val: string) {
|
|
this._spaceSlug = val;
|
|
}
|
|
|
|
async connectedCallback() {
|
|
if (!this.shadowRoot) this.attachShadow({ mode: "open" });
|
|
this._tour = new TourEngine(
|
|
this.shadowRoot!,
|
|
FolkPubsEditor.TOUR_STEPS,
|
|
"rpubs_tour_done",
|
|
() => this.shadowRoot!.host as HTMLElement,
|
|
);
|
|
this.render();
|
|
|
|
const space = this.getAttribute("space") || "";
|
|
this._spaceSlug = space || this._spaceSlug;
|
|
|
|
// Connect to the local-first runtime
|
|
await this.initRuntime();
|
|
|
|
if (space === "demo" && !this._activeDocId) {
|
|
this.loadDemoContent();
|
|
}
|
|
if (!localStorage.getItem("rpubs_tour_done")) {
|
|
setTimeout(() => this._tour.start(), 1200);
|
|
}
|
|
|
|
document.addEventListener("click", this._outsideClickHandler);
|
|
}
|
|
|
|
private async initRuntime() {
|
|
const runtime = (window as any).__rspaceOfflineRuntime;
|
|
if (!runtime?.isInitialized) return;
|
|
|
|
this._runtime = runtime;
|
|
|
|
try {
|
|
const docs = await runtime.subscribeModule('pubs', 'drafts', pubsDraftSchema);
|
|
this._syncConnected = runtime.isOnline;
|
|
|
|
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,
|
|
});
|
|
}
|
|
|
|
this._drafts.sort((a, b) => b.updatedAt - a.updatedAt);
|
|
|
|
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);
|
|
|
|
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();
|
|
});
|
|
|
|
this._drafts.unshift({
|
|
docId,
|
|
draftId,
|
|
title: 'Untitled',
|
|
updatedAt: Date.now(),
|
|
});
|
|
|
|
await this.switchDraft(docId, draftId);
|
|
}
|
|
|
|
private async switchDraft(docId: DocumentId, draftId: string) {
|
|
if (this._unsubChange) {
|
|
this._unsubChange();
|
|
this._unsubChange = null;
|
|
}
|
|
|
|
this._activeDocId = docId;
|
|
this._activeDraftId = draftId;
|
|
|
|
// Reset to write view on draft switch
|
|
this._view = "write";
|
|
if (this._pdfUrl) {
|
|
URL.revokeObjectURL(this._pdfUrl);
|
|
this._pdfUrl = null;
|
|
this._pdfInfo = null;
|
|
}
|
|
|
|
if (!this._runtime) return;
|
|
|
|
const doc = await this._runtime.subscribe(docId, pubsDraftSchema);
|
|
this.populateFromDoc(doc);
|
|
|
|
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;
|
|
}
|
|
});
|
|
}
|
|
|
|
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;
|
|
|
|
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;
|
|
}
|
|
}
|
|
|
|
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();
|
|
}
|
|
}
|
|
|
|
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();
|
|
});
|
|
|
|
if (field === 'title') {
|
|
const entry = this._drafts.find((d) => d.docId === this._activeDocId);
|
|
if (entry) {
|
|
entry.title = value || 'Untitled';
|
|
entry.updatedAt = Date.now();
|
|
}
|
|
}
|
|
}
|
|
|
|
private loadDemoContent() {
|
|
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) textarea.value = SAMPLE_CONTENT;
|
|
if (titleInput) titleInput.value = "The Commons";
|
|
if (authorInput) authorInput.value = "rSpace Community";
|
|
});
|
|
}
|
|
|
|
disconnectedCallback() {
|
|
if (this._pdfUrl) URL.revokeObjectURL(this._pdfUrl);
|
|
if (this._unsubChange) this._unsubChange();
|
|
if (this._syncTimer) clearTimeout(this._syncTimer);
|
|
document.removeEventListener("click", this._outsideClickHandler);
|
|
}
|
|
|
|
private renderDropdowns() {
|
|
if (!this.shadowRoot) return;
|
|
|
|
// Format dropdown panel
|
|
const formatPanel = this.shadowRoot.querySelector('.format-dropdown-panel') as HTMLElement;
|
|
if (formatPanel) formatPanel.hidden = !this._formatDropdownOpen;
|
|
|
|
// Drafts dropdown panel
|
|
const draftsPanel = this.shadowRoot.querySelector('.drafts-dropdown-panel') as HTMLElement;
|
|
if (draftsPanel) draftsPanel.hidden = !this._draftsDropdownOpen;
|
|
}
|
|
|
|
private getWordCount(): number {
|
|
if (!this.shadowRoot) return 0;
|
|
const textarea = this.shadowRoot.querySelector(".content-area") as HTMLTextAreaElement;
|
|
if (!textarea || !textarea.value.trim()) return 0;
|
|
return textarea.value.trim().split(/\s+/).length;
|
|
}
|
|
|
|
private getContent(): string {
|
|
if (!this.shadowRoot) return '';
|
|
const textarea = this.shadowRoot.querySelector(".content-area") as HTMLTextAreaElement;
|
|
return textarea?.value || '';
|
|
}
|
|
|
|
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 currentFormat = this._formats.find(f => f.id === this._selectedFormat);
|
|
const formatLabel = currentFormat ? currentFormat.name : this._selectedFormat;
|
|
|
|
this.shadowRoot.innerHTML = `
|
|
${this.getStyles()}
|
|
<div class="wizard-layout">
|
|
<!-- Toolbar -->
|
|
<div class="editor-toolbar">
|
|
<div class="toolbar-left">
|
|
<input type="text" class="title-input" placeholder="Title (optional)" />
|
|
<input type="text" class="author-input" placeholder="Author (optional)" />
|
|
</div>
|
|
<div class="toolbar-right">
|
|
<!-- Format dropdown -->
|
|
<div class="format-dropdown">
|
|
<button class="format-dropdown-btn toolbar-btn" title="Book format">
|
|
${this.escapeHtml(formatLabel)}
|
|
<span class="dropdown-arrow">▾</span>
|
|
</button>
|
|
<div class="format-dropdown-panel" hidden>
|
|
${this._formats.map(f => `
|
|
<button class="format-option ${f.id === this._selectedFormat ? 'active' : ''}" data-format="${f.id}">
|
|
<span class="format-option-name">${f.name}</span>
|
|
<span class="format-option-desc">${f.widthMm}\u00D7${f.heightMm}mm \u00B7 ${f.minPages}\u2013${f.maxPages}pp</span>
|
|
</button>
|
|
`).join('')}
|
|
</div>
|
|
</div>
|
|
|
|
${this._runtime ? `
|
|
<!-- Drafts dropdown -->
|
|
<div class="drafts-dropdown">
|
|
<button class="drafts-dropdown-btn toolbar-btn" title="Drafts">
|
|
Drafts${this._drafts.length > 0 ? ` <span class="draft-count">${this._drafts.length}</span>` : ''}
|
|
</button>
|
|
<div class="drafts-dropdown-panel" hidden>
|
|
${this._drafts.map(d => `
|
|
<button class="draft-option ${d.docId === this._activeDocId ? 'active' : ''}" data-doc-id="${d.docId}" data-draft-id="${d.draftId}">
|
|
<span class="draft-option-title">${this.escapeHtml(d.title || 'Untitled')}</span>
|
|
<span class="draft-option-time">${this.formatTime(d.updatedAt)}</span>
|
|
</button>
|
|
`).join('')}
|
|
<button class="draft-option new-draft-option">+ New Draft</button>
|
|
</div>
|
|
</div>
|
|
` : ''}
|
|
|
|
${this._syncConnected ? '<span class="sync-badge" title="Syncing with collaborators">Synced</span>' : ''}
|
|
<button class="toolbar-btn btn-sample" title="Load sample content">Sample</button>
|
|
<label class="toolbar-btn btn-upload" title="Open a text file">
|
|
<input type="file" accept=".md,.txt,.markdown" style="display:none" />
|
|
Open File
|
|
</label>
|
|
<a class="toolbar-btn btn-zine-gen" href="/${this._spaceSlug}/rpubs/zine" title="AI Zine Generator">
|
|
📰 Zine
|
|
</a>
|
|
<button class="toolbar-btn btn-tour-trigger" title="Take a tour" style="opacity:0.7">Tour</button>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Step indicator -->
|
|
<div class="step-indicator">
|
|
<div class="steps">
|
|
<button class="step ${this._view === 'write' ? 'active' : ''} ${this._pdfUrl ? 'completed' : ''}" data-step="write">
|
|
<span class="step-num">${this._pdfUrl ? '✓' : '1'}</span>
|
|
<span class="step-label">Create</span>
|
|
</button>
|
|
<div class="step-line ${this._view !== 'write' ? 'filled' : ''}"></div>
|
|
<button class="step ${this._view === 'preview' ? 'active' : ''} ${this._view === 'publish' ? 'completed' : ''}" data-step="preview" ${!this._pdfUrl ? 'disabled' : ''}>
|
|
<span class="step-num">${this._view === 'publish' ? '✓' : '2'}</span>
|
|
<span class="step-label">Preview</span>
|
|
</button>
|
|
<div class="step-line ${this._view === 'publish' ? 'filled' : ''}"></div>
|
|
<button class="step ${this._view === 'publish' ? 'active' : ''}" data-step="publish" ${!this._pdfUrl ? 'disabled' : ''}>
|
|
<span class="step-num">3</span>
|
|
<span class="step-label">Publish</span>
|
|
</button>
|
|
</div>
|
|
${this._pdfInfo ? `<span class="step-info">${this._pdfInfo}</span>` : ''}
|
|
</div>
|
|
|
|
<!-- Step content -->
|
|
<div class="step-content">
|
|
${this._view === 'write' ? this.renderWriteStep() : ''}
|
|
${this._view === 'preview' ? this.renderPreviewStep() : ''}
|
|
${this._view === 'publish' ? this.renderPublishStep() : ''}
|
|
</div>
|
|
</div>
|
|
`;
|
|
|
|
this.bindEvents();
|
|
this._tour.renderOverlay();
|
|
|
|
// 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 renderWriteStep(): string {
|
|
return `
|
|
<div class="write-step">
|
|
<textarea class="content-area" placeholder="Drop in your markdown or plain text here..."></textarea>
|
|
<div class="write-bottom-bar">
|
|
<div class="write-meta">
|
|
<span class="word-count"></span>
|
|
<span class="format-badge">${this.escapeHtml(this._formats.find(f => f.id === this._selectedFormat)?.name || this._selectedFormat)}</span>
|
|
</div>
|
|
${this._error ? `<div class="inline-error">${this.escapeHtml(this._error)}</div>` : ''}
|
|
<button class="btn-generate" ${this._loading ? "disabled" : ""}>
|
|
${this._loading ? "Generating..." : "Generate Preview \u2192"}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
`;
|
|
}
|
|
|
|
private renderPreviewStep(): string {
|
|
return `
|
|
<div class="preview-step">
|
|
<div class="preview-actions">
|
|
<button class="action-btn" data-action="back-to-write">\u2190 Edit</button>
|
|
<button class="action-btn" data-action="fullscreen-toggle">Fullscreen</button>
|
|
<a class="action-btn" href="${this._pdfUrl}" download>Download PDF</a>
|
|
</div>
|
|
<div class="preview-flipbook">
|
|
<folk-pubs-flipbook pdf-url="${this._pdfUrl}"></folk-pubs-flipbook>
|
|
</div>
|
|
<div class="preview-bottom-bar">
|
|
<span class="preview-info">${this._pdfInfo || ''}</span>
|
|
<button class="btn-publish-next">Publish \u2192</button>
|
|
</div>
|
|
</div>
|
|
`;
|
|
}
|
|
|
|
private renderPublishStep(): string {
|
|
return `
|
|
<div class="publish-step">
|
|
<button class="back-link" data-action="back-to-preview">\u2190 Back to preview</button>
|
|
<folk-pubs-publish-panel
|
|
pdf-url="${this._pdfUrl}"
|
|
format-id="${this._selectedFormat}"
|
|
format-name="${this.escapeHtml(this._formats.find(f => f.id === this._selectedFormat)?.name || this._selectedFormat)}"
|
|
page-count="${this._pdfPageCount || 0}"
|
|
space-slug="${this._spaceSlug}"
|
|
></folk-pubs-publish-panel>
|
|
</div>
|
|
`;
|
|
}
|
|
|
|
startTour() {
|
|
this._tour.start();
|
|
}
|
|
|
|
private bindEvents() {
|
|
if (!this.shadowRoot) return;
|
|
|
|
// Tour
|
|
this.shadowRoot.querySelector(".btn-tour-trigger")?.addEventListener("click", () => this.startTour());
|
|
|
|
// Step navigation
|
|
this.shadowRoot.querySelectorAll(".step[data-step]").forEach(btn => {
|
|
btn.addEventListener("click", () => {
|
|
const step = (btn as HTMLElement).dataset.step as "write" | "preview" | "publish";
|
|
if (step === "write") {
|
|
this._view = "write";
|
|
this.render();
|
|
} else if (step === "preview" && this._pdfUrl) {
|
|
this._view = "preview";
|
|
this.render();
|
|
} else if (step === "publish" && this._pdfUrl) {
|
|
this._view = "publish";
|
|
this.render();
|
|
}
|
|
});
|
|
});
|
|
|
|
// Back navigation buttons
|
|
this.shadowRoot.querySelector('[data-action="back-to-write"]')?.addEventListener("click", () => {
|
|
this._view = "write";
|
|
this.render();
|
|
});
|
|
this.shadowRoot.querySelector('[data-action="back-to-preview"]')?.addEventListener("click", () => {
|
|
this._view = "preview";
|
|
this.render();
|
|
});
|
|
this.shadowRoot.querySelector('.btn-publish-next')?.addEventListener("click", () => {
|
|
this._view = "publish";
|
|
this.render();
|
|
});
|
|
|
|
// Fullscreen toggle
|
|
this.shadowRoot.querySelector('[data-action="fullscreen-toggle"]')?.addEventListener("click", () => {
|
|
const flipbook = this.shadowRoot!.querySelector("folk-pubs-flipbook");
|
|
if (!flipbook) return;
|
|
if (document.fullscreenElement) {
|
|
document.exitFullscreen();
|
|
} else {
|
|
flipbook.requestFullscreen().catch(() => {});
|
|
}
|
|
});
|
|
|
|
// Format dropdown
|
|
this.shadowRoot.querySelector('.format-dropdown-btn')?.addEventListener("click", (e) => {
|
|
e.stopPropagation();
|
|
this._formatDropdownOpen = !this._formatDropdownOpen;
|
|
this._draftsDropdownOpen = false;
|
|
this.renderDropdowns();
|
|
});
|
|
this.shadowRoot.querySelectorAll('.format-option').forEach(btn => {
|
|
btn.addEventListener("click", () => {
|
|
this._selectedFormat = (btn as HTMLElement).dataset.format!;
|
|
this._formatDropdownOpen = false;
|
|
this.syncMetaToDoc('format', this._selectedFormat);
|
|
// Clear previous PDF on format change
|
|
if (this._pdfUrl) {
|
|
URL.revokeObjectURL(this._pdfUrl);
|
|
this._pdfUrl = null;
|
|
this._pdfInfo = null;
|
|
this._view = "write";
|
|
}
|
|
this._error = null;
|
|
this.render();
|
|
});
|
|
});
|
|
|
|
// Drafts dropdown
|
|
this.shadowRoot.querySelector('.drafts-dropdown-btn')?.addEventListener("click", (e) => {
|
|
e.stopPropagation();
|
|
this._draftsDropdownOpen = !this._draftsDropdownOpen;
|
|
this._formatDropdownOpen = false;
|
|
this.renderDropdowns();
|
|
});
|
|
this.shadowRoot.querySelectorAll('.draft-option:not(.new-draft-option)').forEach(btn => {
|
|
btn.addEventListener("click", () => {
|
|
const el = btn as HTMLElement;
|
|
const docId = el.dataset.docId as DocumentId;
|
|
const draftId = el.dataset.draftId!;
|
|
this._draftsDropdownOpen = false;
|
|
if (docId !== this._activeDocId) {
|
|
this.switchDraft(docId, draftId);
|
|
} else {
|
|
this.renderDropdowns();
|
|
}
|
|
});
|
|
});
|
|
this.shadowRoot.querySelector('.new-draft-option')?.addEventListener("click", () => {
|
|
this._draftsDropdownOpen = false;
|
|
this.createNewDraft();
|
|
});
|
|
|
|
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;
|
|
const generateBtn = this.shadowRoot.querySelector(".btn-generate") as HTMLButtonElement;
|
|
const sampleBtn = this.shadowRoot.querySelector(".btn-sample");
|
|
const fileInput = this.shadowRoot.querySelector('input[type="file"]') as HTMLInputElement;
|
|
|
|
// Content sync + word count update
|
|
textarea?.addEventListener("input", () => {
|
|
this.syncContentToDoc(textarea.value);
|
|
this.updateWordCount();
|
|
});
|
|
|
|
// Update word count on initial render
|
|
if (textarea) {
|
|
requestAnimationFrame(() => this.updateWordCount());
|
|
}
|
|
|
|
// Title/Author sync
|
|
titleInput?.addEventListener("input", () => {
|
|
this.syncMetaToDoc('title', titleInput.value);
|
|
});
|
|
authorInput?.addEventListener("input", () => {
|
|
this.syncMetaToDoc('author', authorInput.value);
|
|
});
|
|
|
|
// Sample content
|
|
sampleBtn?.addEventListener("click", () => {
|
|
if (!textarea) return;
|
|
textarea.value = SAMPLE_CONTENT;
|
|
if (titleInput) titleInput.value = "";
|
|
if (authorInput) authorInput.value = "";
|
|
this.syncContentToDoc(SAMPLE_CONTENT);
|
|
this.syncMetaToDoc('title', '');
|
|
this.syncMetaToDoc('author', '');
|
|
this.updateWordCount();
|
|
});
|
|
|
|
// File upload
|
|
fileInput?.addEventListener("change", () => {
|
|
const file = fileInput.files?.[0];
|
|
if (file && textarea) {
|
|
const reader = new FileReader();
|
|
reader.onload = () => {
|
|
const text = reader.result as string;
|
|
textarea.value = text;
|
|
this.syncContentToDoc(text);
|
|
this.updateWordCount();
|
|
};
|
|
reader.readAsText(file);
|
|
}
|
|
});
|
|
|
|
// Drag-and-drop
|
|
textarea?.addEventListener("dragover", (e) => {
|
|
e.preventDefault();
|
|
textarea.classList.add("dragover");
|
|
});
|
|
textarea?.addEventListener("dragleave", () => {
|
|
textarea.classList.remove("dragover");
|
|
});
|
|
textarea?.addEventListener("drop", (e) => {
|
|
e.preventDefault();
|
|
textarea.classList.remove("dragover");
|
|
const file = (e as DragEvent).dataTransfer?.files[0];
|
|
if (file && (file.type.startsWith("text/") || file.name.match(/\.(md|txt|markdown)$/))) {
|
|
const reader = new FileReader();
|
|
reader.onload = () => {
|
|
const text = reader.result as string;
|
|
textarea.value = text;
|
|
this.syncContentToDoc(text);
|
|
this.updateWordCount();
|
|
};
|
|
reader.readAsText(file);
|
|
}
|
|
});
|
|
|
|
// Generate PDF
|
|
generateBtn?.addEventListener("click", async () => {
|
|
const content = textarea?.value?.trim();
|
|
if (!content) {
|
|
this._error = "Please enter some content first.";
|
|
this.render();
|
|
return;
|
|
}
|
|
|
|
this._loading = true;
|
|
this._error = null;
|
|
if (this._pdfUrl) URL.revokeObjectURL(this._pdfUrl);
|
|
this._pdfUrl = null;
|
|
this.render();
|
|
|
|
try {
|
|
const res = await fetch(`/${this._spaceSlug}/rpubs/api/generate`, {
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify({
|
|
content,
|
|
title: titleInput?.value?.trim() || undefined,
|
|
author: authorInput?.value?.trim() || undefined,
|
|
format: this._selectedFormat,
|
|
}),
|
|
});
|
|
|
|
if (!res.ok) {
|
|
const err = await res.json();
|
|
throw new Error(err.error || "Generation failed");
|
|
}
|
|
|
|
const blob = await res.blob();
|
|
const pageCount = res.headers.get("X-Page-Count") || "?";
|
|
const format = this._formats.find((f) => f.id === this._selectedFormat);
|
|
|
|
this._pdfUrl = URL.createObjectURL(blob);
|
|
this._pdfPageCount = parseInt(pageCount) || 0;
|
|
this._pdfInfo = `${pageCount} pages \u00B7 ${format?.name || this._selectedFormat}`;
|
|
this._loading = false;
|
|
|
|
// Auto-advance to preview
|
|
this._view = "preview";
|
|
this.render();
|
|
} catch (e: any) {
|
|
this._loading = false;
|
|
this._error = e.message;
|
|
this.render();
|
|
}
|
|
});
|
|
}
|
|
|
|
private updateWordCount() {
|
|
if (!this.shadowRoot) return;
|
|
const el = this.shadowRoot.querySelector('.word-count');
|
|
if (!el) return;
|
|
const count = this.getWordCount();
|
|
el.textContent = count > 0 ? `${count} words` : '';
|
|
}
|
|
|
|
private getStyles(): string {
|
|
return `<style>
|
|
:host {
|
|
display: block;
|
|
height: calc(100vh - 140px);
|
|
background: var(--rs-bg-page);
|
|
color: var(--rs-text-primary);
|
|
}
|
|
|
|
.wizard-layout {
|
|
display: flex;
|
|
flex-direction: column;
|
|
height: 100%;
|
|
}
|
|
|
|
/* ── Toolbar ── */
|
|
|
|
.editor-toolbar {
|
|
display: flex;
|
|
justify-content: space-between;
|
|
align-items: center;
|
|
gap: 0.75rem;
|
|
padding: 0.5rem 0.75rem;
|
|
border-bottom: 1px solid var(--rs-border-subtle);
|
|
flex-wrap: wrap;
|
|
}
|
|
|
|
.toolbar-left {
|
|
display: flex;
|
|
gap: 0.5rem;
|
|
flex: 1;
|
|
}
|
|
|
|
.toolbar-right {
|
|
display: flex;
|
|
gap: 0.5rem;
|
|
align-items: center;
|
|
flex-wrap: wrap;
|
|
}
|
|
|
|
.toolbar-btn {
|
|
padding: 0.375rem 0.75rem;
|
|
border: 1px solid var(--rs-border);
|
|
border-radius: 0.375rem;
|
|
background: var(--rs-bg-surface);
|
|
color: var(--rs-text-secondary);
|
|
font-size: 0.8rem;
|
|
cursor: pointer;
|
|
text-decoration: none;
|
|
display: inline-flex;
|
|
align-items: center;
|
|
gap: 0.25rem;
|
|
}
|
|
.toolbar-btn:hover { border-color: var(--rs-primary); color: var(--rs-text-primary); }
|
|
|
|
.btn-zine-gen {
|
|
background: linear-gradient(135deg, #f59e0b, #ef4444);
|
|
color: #fff;
|
|
border-color: transparent;
|
|
font-weight: 600;
|
|
}
|
|
.btn-zine-gen:hover { opacity: 0.85; color: #fff; }
|
|
|
|
.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 {
|
|
padding: 0.375rem 0.625rem;
|
|
border: 1px solid var(--rs-input-border);
|
|
border-radius: 0.375rem;
|
|
background: var(--rs-input-bg);
|
|
color: var(--rs-input-text);
|
|
font-size: 0.8rem;
|
|
min-width: 120px;
|
|
}
|
|
.title-input { flex: 1; max-width: 240px; }
|
|
.author-input { flex: 0.7; max-width: 180px; }
|
|
.title-input::placeholder, .author-input::placeholder { color: var(--rs-text-muted); }
|
|
.title-input:focus, .author-input:focus { outline: none; border-color: var(--rs-primary); }
|
|
|
|
/* ── Dropdowns ── */
|
|
|
|
.format-dropdown, .drafts-dropdown {
|
|
position: relative;
|
|
}
|
|
|
|
.dropdown-arrow { font-size: 0.6rem; }
|
|
|
|
.format-dropdown-panel, .drafts-dropdown-panel {
|
|
position: absolute;
|
|
top: calc(100% + 4px);
|
|
right: 0;
|
|
min-width: 220px;
|
|
background: var(--rs-bg-surface);
|
|
border: 1px solid var(--rs-border);
|
|
border-radius: 0.5rem;
|
|
box-shadow: 0 8px 24px rgba(0,0,0,0.3);
|
|
z-index: 100;
|
|
display: flex;
|
|
flex-direction: column;
|
|
padding: 0.25rem;
|
|
max-height: 320px;
|
|
overflow-y: auto;
|
|
}
|
|
|
|
.format-option, .draft-option {
|
|
display: flex;
|
|
justify-content: space-between;
|
|
align-items: center;
|
|
padding: 0.5rem 0.625rem;
|
|
border: none;
|
|
border-radius: 0.375rem;
|
|
background: transparent;
|
|
color: var(--rs-text-primary);
|
|
font-size: 0.8rem;
|
|
cursor: pointer;
|
|
text-align: left;
|
|
gap: 0.5rem;
|
|
}
|
|
.format-option:hover, .draft-option:hover { background: rgba(255,255,255,0.05); }
|
|
.format-option.active { background: rgba(59, 130, 246, 0.15); color: var(--rs-primary); }
|
|
.draft-option.active { background: rgba(59, 130, 246, 0.15); }
|
|
|
|
.format-option-name { font-weight: 500; }
|
|
.format-option-desc { font-size: 0.65rem; color: var(--rs-text-muted); white-space: nowrap; }
|
|
|
|
.draft-option-title {
|
|
flex: 1;
|
|
overflow: hidden;
|
|
text-overflow: ellipsis;
|
|
white-space: nowrap;
|
|
}
|
|
.draft-option-time { font-size: 0.65rem; color: var(--rs-text-muted); flex-shrink: 0; }
|
|
|
|
.new-draft-option {
|
|
border-top: 1px solid var(--rs-border-subtle);
|
|
margin-top: 0.25rem;
|
|
padding-top: 0.625rem;
|
|
color: var(--rs-text-secondary);
|
|
font-style: italic;
|
|
}
|
|
|
|
.draft-count {
|
|
font-size: 0.6rem;
|
|
background: var(--rs-primary);
|
|
color: #fff;
|
|
border-radius: 1rem;
|
|
padding: 0.05rem 0.4rem;
|
|
font-weight: 600;
|
|
margin-left: 0.125rem;
|
|
}
|
|
|
|
/* ── Step indicator ── */
|
|
|
|
.step-indicator {
|
|
display: flex;
|
|
justify-content: space-between;
|
|
align-items: center;
|
|
padding: 0.5rem 1rem;
|
|
border-bottom: 1px solid var(--rs-border-subtle);
|
|
background: var(--rs-bg-surface);
|
|
}
|
|
|
|
.steps {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 0;
|
|
}
|
|
|
|
.step {
|
|
display: inline-flex;
|
|
align-items: center;
|
|
gap: 0.375rem;
|
|
padding: 0.35rem 0.75rem;
|
|
border: 1px solid var(--rs-border);
|
|
border-radius: 2rem;
|
|
background: transparent;
|
|
color: var(--rs-text-muted);
|
|
font-size: 0.78rem;
|
|
cursor: pointer;
|
|
transition: all 0.15s;
|
|
}
|
|
.step:disabled { cursor: not-allowed; opacity: 0.4; }
|
|
.step:not(:disabled):hover { border-color: var(--rs-primary); color: var(--rs-text-primary); }
|
|
.step.active {
|
|
background: var(--rs-primary);
|
|
border-color: var(--rs-primary);
|
|
color: #fff;
|
|
font-weight: 600;
|
|
}
|
|
.step.completed:not(.active) {
|
|
border-color: var(--rs-success, #22c55e);
|
|
color: var(--rs-success, #22c55e);
|
|
}
|
|
|
|
.step-num { font-weight: 700; font-size: 0.75rem; }
|
|
|
|
.step-line {
|
|
width: 24px;
|
|
height: 2px;
|
|
background: var(--rs-border);
|
|
margin: 0 0.25rem;
|
|
transition: background 0.15s;
|
|
}
|
|
.step-line.filled { background: var(--rs-primary); }
|
|
|
|
.step-info {
|
|
font-size: 0.75rem;
|
|
color: var(--rs-text-secondary);
|
|
}
|
|
|
|
/* ── Step content ── */
|
|
|
|
.step-content {
|
|
flex: 1;
|
|
display: flex;
|
|
flex-direction: column;
|
|
min-height: 0;
|
|
}
|
|
|
|
/* ── Write step ── */
|
|
|
|
.write-step {
|
|
flex: 1;
|
|
display: flex;
|
|
flex-direction: column;
|
|
min-height: 0;
|
|
}
|
|
|
|
.content-area {
|
|
flex: 1;
|
|
padding: 1rem 1.5rem;
|
|
border: none;
|
|
background: var(--rs-bg-page);
|
|
color: var(--rs-text-primary);
|
|
font-family: "JetBrains Mono", "Fira Code", monospace;
|
|
font-size: 0.85rem;
|
|
line-height: 1.6;
|
|
resize: none;
|
|
}
|
|
.content-area::placeholder { color: var(--rs-text-secondary); }
|
|
.content-area:focus { outline: none; }
|
|
.content-area.dragover {
|
|
background: var(--rs-bg-surface);
|
|
outline: 2px dashed var(--rs-primary);
|
|
outline-offset: -4px;
|
|
}
|
|
|
|
.write-bottom-bar {
|
|
display: flex;
|
|
justify-content: space-between;
|
|
align-items: center;
|
|
padding: 0.5rem 1rem;
|
|
border-top: 1px solid var(--rs-border-subtle);
|
|
gap: 1rem;
|
|
}
|
|
|
|
.write-meta {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 0.75rem;
|
|
font-size: 0.75rem;
|
|
color: var(--rs-text-muted);
|
|
}
|
|
|
|
.format-badge {
|
|
padding: 0.15rem 0.5rem;
|
|
border-radius: 1rem;
|
|
background: rgba(59, 130, 246, 0.1);
|
|
color: var(--rs-primary);
|
|
font-size: 0.65rem;
|
|
font-weight: 500;
|
|
}
|
|
|
|
.inline-error {
|
|
color: #f87171;
|
|
font-size: 0.75rem;
|
|
}
|
|
|
|
.btn-generate {
|
|
padding: 0.5rem 1.25rem;
|
|
border: none;
|
|
border-radius: 0.5rem;
|
|
background: var(--rs-primary);
|
|
color: #fff;
|
|
font-size: 0.85rem;
|
|
font-weight: 600;
|
|
cursor: pointer;
|
|
transition: background 0.15s;
|
|
white-space: nowrap;
|
|
}
|
|
.btn-generate:hover { background: var(--rs-primary-hover); }
|
|
.btn-generate:disabled { opacity: 0.5; cursor: not-allowed; }
|
|
|
|
/* ── Preview step ── */
|
|
|
|
.preview-step {
|
|
flex: 1;
|
|
display: flex;
|
|
flex-direction: column;
|
|
min-height: 0;
|
|
}
|
|
|
|
.preview-actions {
|
|
display: flex;
|
|
gap: 0.5rem;
|
|
padding: 0.5rem 1rem;
|
|
border-bottom: 1px solid var(--rs-border-subtle);
|
|
}
|
|
|
|
.action-btn {
|
|
padding: 0.375rem 0.75rem;
|
|
border: 1px solid var(--rs-border);
|
|
border-radius: 0.375rem;
|
|
background: var(--rs-bg-surface);
|
|
color: var(--rs-text-secondary);
|
|
font-size: 0.8rem;
|
|
cursor: pointer;
|
|
text-decoration: none;
|
|
display: inline-flex;
|
|
align-items: center;
|
|
}
|
|
.action-btn:hover { border-color: var(--rs-primary); color: var(--rs-text-primary); }
|
|
|
|
.preview-flipbook {
|
|
flex: 1;
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
padding: 1rem;
|
|
min-height: 300px;
|
|
overflow: auto;
|
|
}
|
|
|
|
.preview-flipbook folk-pubs-flipbook {
|
|
max-width: 100%;
|
|
max-height: 100%;
|
|
}
|
|
|
|
.preview-bottom-bar {
|
|
display: flex;
|
|
justify-content: space-between;
|
|
align-items: center;
|
|
padding: 0.5rem 1rem;
|
|
border-top: 1px solid var(--rs-border-subtle);
|
|
}
|
|
|
|
.preview-info {
|
|
font-size: 0.75rem;
|
|
color: var(--rs-text-secondary);
|
|
}
|
|
|
|
.btn-publish-next {
|
|
padding: 0.5rem 1.25rem;
|
|
border: none;
|
|
border-radius: 0.5rem;
|
|
background: var(--rs-primary);
|
|
color: #fff;
|
|
font-size: 0.85rem;
|
|
font-weight: 600;
|
|
cursor: pointer;
|
|
transition: background 0.15s;
|
|
}
|
|
.btn-publish-next:hover { background: var(--rs-primary-hover); }
|
|
|
|
/* ── Publish step ── */
|
|
|
|
.publish-step {
|
|
flex: 1;
|
|
display: flex;
|
|
flex-direction: column;
|
|
align-items: center;
|
|
padding: 1.5rem 1rem;
|
|
overflow-y: auto;
|
|
}
|
|
|
|
.publish-step folk-pubs-publish-panel {
|
|
width: 100%;
|
|
max-width: 32rem;
|
|
}
|
|
|
|
.back-link {
|
|
align-self: flex-start;
|
|
max-width: 32rem;
|
|
width: 100%;
|
|
margin: 0 auto 1rem;
|
|
background: none;
|
|
border: none;
|
|
color: var(--rs-text-secondary);
|
|
font-size: 0.8rem;
|
|
cursor: pointer;
|
|
padding: 0;
|
|
text-align: left;
|
|
}
|
|
.back-link:hover { color: var(--rs-text-primary); }
|
|
|
|
/* ── Responsive ── */
|
|
|
|
@media (max-width: 640px) {
|
|
:host { height: auto; min-height: calc(100vh - 56px); }
|
|
.editor-toolbar { gap: 0.5rem; }
|
|
.toolbar-left { flex-direction: column; gap: 0.375rem; }
|
|
.title-input, .author-input { max-width: 100%; flex: 1; }
|
|
.step-label { display: none; }
|
|
.step { padding: 0.3rem 0.5rem; }
|
|
.content-area { min-height: 45vh; padding: 0.75rem; }
|
|
.write-bottom-bar { flex-wrap: wrap; }
|
|
.preview-flipbook { padding: 0.5rem; }
|
|
}
|
|
@media (max-width: 480px) {
|
|
.toolbar-right { width: 100%; flex-wrap: wrap; }
|
|
.toolbar-btn { flex: 1; text-align: center; justify-content: center; font-size: 0.75rem; padding: 0.3rem 0.5rem; }
|
|
.format-dropdown-panel, .drafts-dropdown-panel { right: auto; left: 0; min-width: 200px; }
|
|
}
|
|
</style>`;
|
|
}
|
|
|
|
private escapeHtml(s: string): string {
|
|
return s.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">");
|
|
}
|
|
}
|
|
|
|
customElements.define("folk-pubs-editor", FolkPubsEditor);
|