feat(rinbox): add markdown rendering + TipTap collaborative editing

- Extract markdown-tiptap.ts and yjs-ws-provider.ts to shared/ for reuse
- Thread bodies render as formatted markdown via marked
- Replace compose textarea with TipTap rich-text editor + Yjs collab
- Add formatting toolbar (bold, italic, lists, code, links, etc.)
- Add TipTap comment editor with tiptap-json storage format
- Server accepts content_format on comment API
- Full ProseMirror + collab cursor CSS scoped to shadow DOM
- Editors cleaned up on navigation and disconnect

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Jeff Emmett 2026-03-30 20:40:49 -07:00
parent 15f5c58214
commit 58e319b8c8
7 changed files with 1092 additions and 674 deletions

View File

@ -12,6 +12,20 @@ import { TourEngine } from "../../../shared/tour-engine";
import { ViewHistory } from "../../../shared/view-history.js";
import { getAccessToken, getUsername } from "../../../lib/rspace-header";
import { startPresenceHeartbeat } from '../../../shared/collab-presence';
import { Editor } from '@tiptap/core';
import StarterKit from '@tiptap/starter-kit';
import Link from '@tiptap/extension-link';
import TaskList from '@tiptap/extension-task-list';
import TaskItem from '@tiptap/extension-task-item';
import Placeholder from '@tiptap/extension-placeholder';
import Underline from '@tiptap/extension-underline';
import { Markdown } from 'tiptap-markdown';
import * as Y from 'yjs';
import { IndexeddbPersistence } from 'y-indexeddb';
import { ySyncPlugin, yUndoPlugin, yCursorPlugin } from '@tiptap/y-tiptap';
import { RSpaceYjsProvider } from '../../../shared/yjs-ws-provider';
import { tiptapToMarkdown, extractPlainTextFromTiptap } from '../../../shared/markdown-tiptap';
import { marked } from 'marked';
type ComposeMode = 'new' | 'reply' | 'reply-all' | 'forward';
@ -44,6 +58,12 @@ class FolkInboxClient extends HTMLElement {
private _fwdModalOpen = false;
private _fwdBusy = false;
private _fwdError = '';
// TipTap / Yjs editor instances
private composeEditor: Editor | null = null;
private composeYdoc: Y.Doc | null = null;
private composeYjsProvider: RSpaceYjsProvider | null = null;
private composeYIndexedDb: IndexeddbPersistence | null = null;
private commentEditor: Editor | null = null;
private demoApprovals: any[] = [
{
id: "a1", subject: "Re: Q1 Budget approval for rSpace infrastructure", status: "PENDING",
@ -126,6 +146,7 @@ class FolkInboxClient extends HTMLElement {
}
disconnectedCallback() {
this.destroyEditors();
for (const unsub of this._offlineUnsubs) unsub();
this._offlineUnsubs = [];
this._stopPresence?.();
@ -545,6 +566,7 @@ class FolkInboxClient extends HTMLElement {
}
private render() {
this.destroyEditors();
this.shadow.innerHTML = `
<style>
:host { display: block; min-height: 60vh; font-family: system-ui, sans-serif; color: var(--rs-text-primary); -webkit-tap-highlight-color: transparent; }
@ -646,7 +668,9 @@ class FolkInboxClient extends HTMLElement {
.detail-header { margin-bottom: 1rem; padding-bottom: 1rem; border-bottom: 1px solid var(--rs-border); }
.detail-subject { font-size: 1.25rem; font-weight: 600; margin-bottom: 0.5rem; color: var(--rs-text-primary); }
.detail-meta { font-size: 0.8rem; color: var(--rs-text-muted); display: flex; gap: 0.5rem; align-items: center; flex-wrap: wrap; }
.detail-body { font-size: 0.9rem; line-height: 1.6; color: var(--rs-text-secondary); margin-bottom: 1.5rem; white-space: pre-wrap; }
.detail-body { font-size: 0.9rem; line-height: 1.6; color: var(--rs-text-secondary); margin-bottom: 1.5rem; }
.detail-body p { margin: 0 0 0.5rem; }
.detail-body p:last-child { margin-bottom: 0; }
/* Thread actions */
.thread-actions { display: flex; gap: 0.5rem; margin-bottom: 1.5rem; padding: 0.75rem 0; border-top: 1px solid var(--rs-border); border-bottom: 1px solid var(--rs-border); }
@ -661,8 +685,67 @@ class FolkInboxClient extends HTMLElement {
.comment-author { font-size: 0.8rem; font-weight: 600; color: #818cf8; }
.comment-body { font-size: 0.85rem; color: var(--rs-text-secondary); line-height: 1.5; }
.comment-time { font-size: 0.7rem; color: var(--rs-text-muted); }
.comment-body p { margin: 0 0 0.4rem; }
.comment-body p:last-child { margin-bottom: 0; }
.comment-body code { font-size: 0.8rem; background: rgba(99,102,241,0.1); padding: 0.1rem 0.3rem; border-radius: 3px; }
.comment-body blockquote { margin: 0.25rem 0; padding-left: 0.5rem; border-left: 2px solid var(--rs-border-strong); color: var(--rs-text-muted); }
.comment-input { margin-top: 0.75rem; }
.comment-input-editor { background: var(--rs-input-bg); border: 1px solid var(--rs-input-border); border-radius: 8px; padding: 0.5rem 0.75rem; min-height: 48px; transition: border-color 0.15s; }
.comment-input-editor:focus-within { border-color: var(--rs-primary); }
.comment-input-actions { display: flex; gap: 0.5rem; margin-top: 0.5rem; justify-content: flex-end; }
.btn-comment { padding: 0.35rem 1rem; border-radius: 6px; border: none; background: var(--rs-primary); color: white; cursor: pointer; font-size: 0.8rem; font-weight: 500; transition: opacity 0.15s; }
.btn-comment:hover { opacity: 0.9; }
.empty { text-align: center; padding: 3rem; color: var(--rs-text-muted); }
/* TipTap editor */
.ProseMirror { outline: none; min-height: 80px; font-size: 0.85rem; line-height: 1.6; color: var(--rs-input-text); }
.ProseMirror p { margin: 0 0 0.5rem; }
.ProseMirror p:last-child { margin-bottom: 0; }
.ProseMirror h1, .ProseMirror h2, .ProseMirror h3 { margin: 0.75rem 0 0.25rem; font-weight: 600; color: var(--rs-text-primary); }
.ProseMirror h1 { font-size: 1.3rem; }
.ProseMirror h2 { font-size: 1.1rem; }
.ProseMirror h3 { font-size: 0.95rem; }
.ProseMirror ul, .ProseMirror ol { padding-left: 1.5rem; margin: 0.25rem 0; }
.ProseMirror li { margin-bottom: 0.15rem; }
.ProseMirror blockquote { margin: 0.5rem 0; padding-left: 0.75rem; border-left: 3px solid var(--rs-border-strong); color: var(--rs-text-secondary); }
.ProseMirror code { font-size: 0.82rem; background: rgba(99,102,241,0.1); padding: 0.1rem 0.35rem; border-radius: 4px; font-family: monospace; }
.ProseMirror pre { background: var(--rs-bg-surface-sunken); border-radius: 8px; padding: 0.75rem; margin: 0.5rem 0; overflow-x: auto; }
.ProseMirror pre code { background: none; padding: 0; border-radius: 0; font-size: 0.8rem; }
.ProseMirror hr { border: none; border-top: 1px solid var(--rs-border); margin: 0.75rem 0; }
.ProseMirror a { color: #818cf8; text-decoration: underline; }
.ProseMirror ul[data-type="taskList"] { list-style: none; padding-left: 0; }
.ProseMirror ul[data-type="taskList"] li { display: flex; align-items: flex-start; gap: 0.35rem; }
.ProseMirror ul[data-type="taskList"] li > label { margin-top: 0.15rem; }
.ProseMirror .placeholder::before { content: attr(data-placeholder); color: var(--rs-text-muted); pointer-events: none; position: absolute; opacity: 0.6; }
.comment-input-editor .ProseMirror { min-height: 32px; }
/* Compose toolbar */
.compose-toolbar { display: flex; gap: 2px; padding: 0.35rem 0; margin-bottom: 0.35rem; border-bottom: 1px solid var(--rs-border); flex-wrap: wrap; }
.compose-toolbar button { width: 28px; height: 28px; border-radius: 4px; border: none; background: transparent; color: var(--rs-text-muted); cursor: pointer; display: flex; align-items: center; justify-content: center; transition: all 0.12s; font-size: 0.75rem; font-weight: 700; }
.compose-toolbar button:hover { background: rgba(99,102,241,0.12); color: var(--rs-text-primary); }
.compose-toolbar button.active { background: rgba(99,102,241,0.2); color: #818cf8; }
.compose-toolbar .toolbar-sep { width: 1px; height: 20px; background: var(--rs-border); margin: 4px 3px; flex-shrink: 0; }
/* Collab cursor */
.collab-cursor { border-left: 2px solid; position: relative; margin-left: -1px; margin-right: -1px; pointer-events: none; }
.collab-cursor-label { position: absolute; top: -1.4em; left: -1px; padding: 1px 6px; border-radius: 3px; font-size: 0.65rem; white-space: nowrap; color: white; pointer-events: none; user-select: none; }
/* Markdown body rendering */
.detail-body h1, .detail-body h2, .detail-body h3, .detail-body h4 { margin: 0.75rem 0 0.25rem; font-weight: 600; color: var(--rs-text-primary); }
.detail-body h1 { font-size: 1.4rem; }
.detail-body h2 { font-size: 1.15rem; }
.detail-body h3 { font-size: 1rem; }
.detail-body ul, .detail-body ol { padding-left: 1.5rem; margin: 0.25rem 0; }
.detail-body blockquote { margin: 0.5rem 0; padding-left: 0.75rem; border-left: 3px solid var(--rs-border-strong); color: var(--rs-text-muted); }
.detail-body code { font-size: 0.82rem; background: rgba(99,102,241,0.1); padding: 0.1rem 0.35rem; border-radius: 4px; font-family: monospace; }
.detail-body pre { background: var(--rs-bg-surface-sunken); border-radius: 8px; padding: 0.75rem; margin: 0.5rem 0; overflow-x: auto; }
.detail-body pre code { background: none; padding: 0; border-radius: 0; }
.detail-body a { color: #818cf8; text-decoration: underline; }
.detail-body table { border-collapse: collapse; margin: 0.5rem 0; width: 100%; font-size: 0.85rem; }
.detail-body th, .detail-body td { border: 1px solid var(--rs-border); padding: 0.35rem 0.65rem; text-align: left; }
.detail-body th { background: var(--rs-bg-surface-sunken); font-weight: 600; }
.detail-body img { max-width: 100%; border-radius: 6px; }
/* Approval cards */
.approval-card { background: var(--rs-bg-surface); border: 1px solid var(--rs-border); border-radius: 12px; padding: 1.25rem; margin-bottom: 0.75rem; border-left: 4px solid var(--rs-border-strong); transition: border-color 0.2s; }
.approval-card.status-pending { border-left-color: #fbbf24; }
@ -959,7 +1042,7 @@ class FolkInboxClient extends HTMLElement {
${t.is_starred ? `<span class="star">&#9733;</span>` : ""}
</div>
</div>
<div class="detail-body">${t.body_text || t.body_html || "(no content)"}</div>
<div class="detail-body">${this.renderBodyMarkdown(t)}</div>
<div class="thread-actions">
<button data-action="reply">&#8617; Reply</button>
<button data-action="reply-all">&#8617;&#8617; Reply All</button>
@ -974,10 +1057,16 @@ class FolkInboxClient extends HTMLElement {
<span class="comment-author">${cm.username || this.displayName(cm.author_id) || "Anonymous"}</span>
<span class="comment-time">${this.timeAgo(cm.created_at)}</span>
</div>
<div class="comment-body">${cm.body}</div>
<div class="comment-body">${this.renderCommentBody(cm)}</div>
</div>
`).join("")}
${comments.length === 0 ? `<div style="font-size:0.8rem;color:var(--rs-text-muted);padding:0.75rem">No comments yet &mdash; discuss this thread with your team before replying.</div>` : ""}
<div class="comment-input">
<div id="comment-editor-mount" class="comment-input-editor"></div>
<div class="comment-input-actions">
<button class="btn-comment" data-action="submit-comment">Add Comment</button>
</div>
</div>
</div>
</div>
`;
@ -1031,9 +1120,10 @@ class FolkInboxClient extends HTMLElement {
<label>Subject</label>
<input id="compose-subject" type="text" value="${this.escapeHtml(defaultSubject)}" />
</div>
<div class="compose-field">
<label>Body</label>
<textarea id="compose-body" placeholder="Write your message...">${this.escapeHtml(defaultBody)}</textarea>
<div class="compose-field" style="margin-top:0.5rem">
<label>Body <span style="font-weight:400;color:var(--rs-text-muted)">(Markdown supported)</span></label>
${this.getComposeToolbarHtml()}
<div id="compose-editor-mount" style="background:var(--rs-input-bg);border:1px solid var(--rs-input-border);border-radius:8px;padding:0.5rem 0.75rem;min-height:120px;transition:border-color 0.15s;" data-default-body="${this.escapeHtml(defaultBody)}"></div>
</div>
<div class="compose-threshold">
&#128274; This email will be submitted for multi-sig approval before sending.
@ -1046,6 +1136,251 @@ class FolkInboxClient extends HTMLElement {
`;
}
/** Render thread body as markdown HTML (read-only). */
private renderBodyMarkdown(t: any): string {
if (t.body_html) return t.body_html;
if (!t.body_text) return '(no content)';
try {
return marked.parse(t.body_text, { async: false }) as string;
} catch {
return `<pre style="white-space:pre-wrap">${this.escapeHtml(t.body_text)}</pre>`;
}
}
/** Render a comment body — supports tiptap-json, markdown, or plain text. */
private renderCommentBody(cm: any): string {
if (cm.content_format === 'tiptap-json') {
try {
const md = tiptapToMarkdown(cm.body);
return marked.parse(md, { async: false }) as string;
} catch { /* fallback */ }
}
// Treat plain text as markdown
try {
return marked.parse(cm.body || '', { async: false }) as string;
} catch {
return this.escapeHtml(cm.body || '');
}
}
/** Compose toolbar HTML (bold, italic, underline, strike, link, code, lists). */
private getComposeToolbarHtml(): string {
return `
<div class="compose-toolbar" id="compose-toolbar">
<button type="button" data-cmd="bold" title="Bold (Ctrl+B)"><strong>B</strong></button>
<button type="button" data-cmd="italic" title="Italic (Ctrl+I)"><em>I</em></button>
<button type="button" data-cmd="underline" title="Underline (Ctrl+U)"><u>U</u></button>
<button type="button" data-cmd="strike" title="Strikethrough"><s>S</s></button>
<div class="toolbar-sep"></div>
<button type="button" data-cmd="bulletList" title="Bullet list">&bull;</button>
<button type="button" data-cmd="orderedList" title="Numbered list">1.</button>
<button type="button" data-cmd="taskList" title="Task list">&#9745;</button>
<div class="toolbar-sep"></div>
<button type="button" data-cmd="code" title="Inline code">&lt;/&gt;</button>
<button type="button" data-cmd="codeBlock" title="Code block">&#9000;</button>
<button type="button" data-cmd="blockquote" title="Quote">&#8220;</button>
<button type="button" data-cmd="link" title="Insert link">&#128279;</button>
</div>
`;
}
/** Mount a TipTap compose editor with optional Yjs collaboration. */
private mountComposeEditor(defaultBody: string, roomSuffix: string) {
const container = this.shadow.getElementById('compose-editor-mount');
if (!container) return;
this.destroyComposeEditor();
const runtime = (window as any).__rspaceOfflineRuntime;
const spaceSlug = runtime?.space || this.space;
// Create Y.Doc for collaborative editing
this.composeYdoc = new Y.Doc();
const fragment = this.composeYdoc.getXmlFragment('content');
const roomName = `rinbox:${spaceSlug}:compose:${roomSuffix}`;
// IndexedDB persistence for offline drafts
this.composeYIndexedDb = new IndexeddbPersistence(roomName, this.composeYdoc);
// Connect Yjs provider if runtime available
if (runtime?.isInitialized) {
this.composeYjsProvider = new RSpaceYjsProvider(roomName, this.composeYdoc, runtime);
const username = getUsername() || 'Anonymous';
this.composeYjsProvider.awareness.setLocalStateField('user', {
name: username,
color: this.userColor(username),
});
}
// Migrate default body into Yjs if fragment is empty
this.composeYIndexedDb.on('synced', () => {
if (fragment.length === 0 && defaultBody.trim() && this.composeEditor) {
this.composeEditor.commands.setContent(defaultBody, { emitUpdate: false });
}
});
this.composeEditor = new Editor({
element: container,
editable: true,
extensions: [
StarterKit.configure({
heading: { levels: [1, 2, 3] },
undoRedo: false,
link: false,
underline: false,
}),
Link.configure({ openOnClick: false }),
TaskList, TaskItem.configure({ nested: true }),
Placeholder.configure({ placeholder: 'Write your message... (Markdown supported)' }),
Underline,
Markdown.configure({ html: true, transformPastedText: true, transformCopiedText: true }),
],
onSelectionUpdate: () => this.updateComposeToolbarState(),
});
// Register Yjs plugins
this.composeEditor.registerPlugin(ySyncPlugin(fragment));
this.composeEditor.registerPlugin(yUndoPlugin());
if (this.composeYjsProvider) {
this.composeEditor.registerPlugin(
yCursorPlugin(this.composeYjsProvider.awareness, {
cursorBuilder: this.buildCollabCursor.bind(this),
})
);
}
// Wire toolbar
this.wireComposeToolbar();
}
/** Mount lightweight TipTap editor for comments (no Yjs). */
private mountCommentEditor() {
const container = this.shadow.getElementById('comment-editor-mount');
if (!container) return;
this.destroyCommentEditor();
this.commentEditor = new Editor({
element: container,
editable: true,
extensions: [
StarterKit.configure({
heading: { levels: [1, 2, 3] },
link: false,
underline: false,
}),
Link.configure({ openOnClick: false }),
Placeholder.configure({ placeholder: 'Add a comment...' }),
Underline,
Markdown.configure({ html: true, transformPastedText: true, transformCopiedText: true }),
],
});
}
/** Wire compose toolbar button clicks to editor commands. */
private wireComposeToolbar() {
const toolbar = this.shadow.getElementById('compose-toolbar');
if (!toolbar || !this.composeEditor) return;
toolbar.querySelectorAll('button[data-cmd]').forEach(btn => {
btn.addEventListener('click', (e) => {
e.preventDefault();
const cmd = (btn as HTMLElement).dataset.cmd!;
const editor = this.composeEditor;
if (!editor) return;
switch (cmd) {
case 'bold': editor.chain().focus().toggleBold().run(); break;
case 'italic': editor.chain().focus().toggleItalic().run(); break;
case 'underline': editor.chain().focus().toggleUnderline().run(); break;
case 'strike': editor.chain().focus().toggleStrike().run(); break;
case 'bulletList': editor.chain().focus().toggleBulletList().run(); break;
case 'orderedList': editor.chain().focus().toggleOrderedList().run(); break;
case 'taskList': editor.chain().focus().toggleTaskList().run(); break;
case 'code': editor.chain().focus().toggleCode().run(); break;
case 'codeBlock': editor.chain().focus().toggleCodeBlock().run(); break;
case 'blockquote': editor.chain().focus().toggleBlockquote().run(); break;
case 'link': {
const url = prompt('Link URL:');
if (url) editor.chain().focus().setLink({ href: url }).run();
break;
}
}
this.updateComposeToolbarState();
});
});
}
/** Update active state on compose toolbar buttons. */
private updateComposeToolbarState() {
const toolbar = this.shadow.getElementById('compose-toolbar');
if (!toolbar || !this.composeEditor) return;
const editor = this.composeEditor;
toolbar.querySelectorAll('button[data-cmd]').forEach(btn => {
const cmd = (btn as HTMLElement).dataset.cmd!;
let active = false;
try {
switch (cmd) {
case 'bold': active = editor.isActive('bold'); break;
case 'italic': active = editor.isActive('italic'); break;
case 'underline': active = editor.isActive('underline'); break;
case 'strike': active = editor.isActive('strike'); break;
case 'bulletList': active = editor.isActive('bulletList'); break;
case 'orderedList': active = editor.isActive('orderedList'); break;
case 'taskList': active = editor.isActive('taskList'); break;
case 'code': active = editor.isActive('code'); break;
case 'codeBlock': active = editor.isActive('codeBlock'); break;
case 'blockquote': active = editor.isActive('blockquote'); break;
case 'link': active = editor.isActive('link'); break;
}
} catch { /* ignore */ }
btn.classList.toggle('active', active);
});
}
/** Build collab cursor element for Yjs awareness. */
private buildCollabCursor(user: { name: string; color: string }) {
const cursor = document.createElement('span');
cursor.className = 'collab-cursor';
cursor.style.borderLeftColor = user.color;
const label = document.createElement('span');
label.className = 'collab-cursor-label';
label.style.backgroundColor = user.color;
label.textContent = user.name;
cursor.appendChild(label);
return cursor;
}
/** Deterministic color from string. */
private userColor(id: string): string {
let hash = 0;
for (let i = 0; i < id.length; i++) {
hash = ((hash << 5) - hash + id.charCodeAt(i)) | 0;
}
return `hsl(${Math.abs(hash) % 360}, 70%, 50%)`;
}
/** Destroy compose editor and Yjs resources. */
private destroyComposeEditor() {
this.composeEditor?.destroy();
this.composeEditor = null;
this.composeYjsProvider?.destroy();
this.composeYjsProvider = null;
this.composeYIndexedDb?.destroy();
this.composeYIndexedDb = null;
if (this.composeYdoc) { this.composeYdoc.destroy(); this.composeYdoc = null; }
}
/** Destroy comment editor. */
private destroyCommentEditor() {
this.commentEditor?.destroy();
this.commentEditor = null;
}
/** Destroy all editors. */
private destroyEditors() {
this.destroyComposeEditor();
this.destroyCommentEditor();
}
private escapeHtml(s: string): string {
return s.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
}
@ -1062,9 +1397,10 @@ class FolkInboxClient extends HTMLElement {
<label>Subject</label>
<input id="compose-subject" type="text" placeholder="Email subject" />
</div>
<div class="compose-field">
<label>Body</label>
<textarea id="compose-body" placeholder="Write the email body. This will be reviewed and co-signed by your team before sending."></textarea>
<div class="compose-field" style="margin-top:0.5rem">
<label>Body <span style="font-weight:400;color:var(--rs-text-muted)">(Markdown supported)</span></label>
${this.getComposeToolbarHtml()}
<div id="compose-editor-mount" style="background:var(--rs-input-bg);border:1px solid var(--rs-input-border);border-radius:8px;padding:0.5rem 0.75rem;min-height:120px;transition:border-color 0.15s;" data-default-body=""></div>
</div>
<div class="compose-threshold">
&#128274; This email requires <strong style="color:#818cf8">multi-sig</strong> team member signatures before it will be sent.
@ -1506,7 +1842,10 @@ class FolkInboxClient extends HTMLElement {
const to = (this.shadow.getElementById("compose-to") as HTMLInputElement)?.value.trim();
const subject = (this.shadow.getElementById("compose-subject") as HTMLInputElement)?.value.trim();
const body = (this.shadow.getElementById("compose-body") as HTMLTextAreaElement)?.value.trim();
// Extract body from TipTap editor as markdown
const body = this.composeEditor
? tiptapToMarkdown(JSON.stringify(this.composeEditor.getJSON())).trim()
: '';
if (!subject || !body) { alert("Please fill subject and body."); return; }
const base = window.location.pathname.replace(/\/$/, "");
@ -1537,6 +1876,29 @@ class FolkInboxClient extends HTMLElement {
} catch { alert("Failed to submit. Please try again."); }
});
// Submit comment from thread detail
this.shadow.querySelector("[data-action='submit-comment']")?.addEventListener("click", async () => {
if (this.space === "demo" || this.showingSampleData) {
alert("Comments are disabled in demo mode.");
return;
}
const t = this.currentThread;
if (!t || !this.commentEditor) return;
const json = JSON.stringify(this.commentEditor.getJSON());
const text = this.commentEditor.getText().trim();
if (!text) { alert("Please write a comment."); return; }
try {
const base = window.location.pathname.replace(/\/$/, "");
await fetch(`${base}/api/threads/${t.id}/comments`, {
method: "POST",
headers: this.getAuthHeaders(),
body: JSON.stringify({ text: json, content_format: 'tiptap-json' }),
});
this.commentEditor.commands.clearContent();
this.loadThread(t.id);
} catch { alert("Failed to post comment."); }
});
// Compose (from approvals view)
const composeBtn = this.shadow.querySelector("[data-action='compose']");
if (composeBtn) {
@ -1559,7 +1921,10 @@ class FolkInboxClient extends HTMLElement {
}
const to = (this.shadow.getElementById("compose-to") as HTMLInputElement)?.value.trim();
const subject = (this.shadow.getElementById("compose-subject") as HTMLInputElement)?.value.trim();
const body = (this.shadow.getElementById("compose-body") as HTMLTextAreaElement)?.value.trim();
// Extract body from TipTap editor as markdown
const body = this.composeEditor
? tiptapToMarkdown(JSON.stringify(this.composeEditor.getJSON())).trim()
: '';
if (!to || !subject || !body) { alert("Please fill all fields."); return; }
try {
const base = window.location.pathname.replace(/\/$/, "");
@ -1686,6 +2051,27 @@ class FolkInboxClient extends HTMLElement {
this.loadAgentInboxes();
});
});
// ── Mount TipTap editors after DOM is ready ──
this.mountEditorsAfterRender();
}
/** Mount compose and comment editors if their containers exist in DOM. */
private mountEditorsAfterRender() {
// Compose editor (thread reply or approval compose)
const composeMountEl = this.shadow.getElementById('compose-editor-mount');
if (composeMountEl) {
const defaultBody = composeMountEl.dataset.defaultBody || '';
const threadId = this.currentThread?.id || 'new';
const suffix = this.view === 'approvals' ? `approval:${threadId}` : `thread:${threadId}:${this.composeMode}`;
this.mountComposeEditor(defaultBody, suffix);
}
// Comment editor (thread detail view)
const commentMountEl = this.shadow.getElementById('comment-editor-mount');
if (commentMountEl) {
this.mountCommentEditor();
}
}
}

View File

@ -267,6 +267,7 @@ function commentToRest(c: ThreadComment) {
author_did: c.authorId, // In Automerge, authorId IS the DID
username: null as string | null,
body: c.body,
content_format: c.contentFormat || null,
mentions: c.mentions,
created_at: new Date(c.createdAt).toISOString(),
};
@ -719,7 +720,7 @@ routes.post("/api/threads/:id/comments", async (c) => {
const threadId = c.req.param("id");
const body = await c.req.json();
const { text, mentions } = body;
const { text, mentions, content_format } = body;
if (!text) return c.json({ error: "text required" }, 400);
const found = findThreadById(threadId);
@ -728,6 +729,7 @@ routes.post("/api/threads/:id/comments", async (c) => {
const [docId] = found;
const commentId = generateId();
const now = Date.now();
const fmt = content_format === 'tiptap-json' ? 'tiptap-json' : undefined;
_syncServer!.changeDoc<MailboxDoc>(docId, `Add comment to thread ${threadId}`, (d) => {
const t = d.threads[threadId];
@ -737,6 +739,7 @@ routes.post("/api/threads/:id/comments", async (c) => {
threadId,
authorId: claims.sub,
body: text,
contentFormat: fmt,
mentions: mentions || [],
createdAt: now,
});
@ -747,6 +750,7 @@ routes.post("/api/threads/:id/comments", async (c) => {
threadId,
authorId: claims.sub,
body: text,
contentFormat: fmt,
mentions: mentions || [],
createdAt: now,
};

View File

@ -24,6 +24,7 @@ export interface ThreadComment {
threadId: string;
authorId: string;
body: string;
contentFormat?: 'html' | 'tiptap-json';
mentions: string[];
createdAt: number;
}
@ -83,6 +84,9 @@ export interface ApprovalItem {
inReplyTo: string | null;
references: string[];
replyType: 'reply' | 'reply-all' | 'forward' | 'new';
// Rich content format
contentFormat?: 'html' | 'tiptap-json';
collabEnabled?: boolean;
// Newsletter approval bridge
approvalType?: 'email' | 'newsletter';
listmonkCampaignId?: number | null;

View File

@ -1,458 +1,9 @@
/**
* Core Markdown TipTap JSON conversion utility.
*
* All import/export converters pass through this module.
* - Import: source format markdown TipTap JSON
* - Export: TipTap JSON markdown source format
* Re-export from shared location.
* Markdown TipTap conversion is now shared across modules.
*/
import { marked } from 'marked';
// ── Markdown → TipTap JSON ──
/**
* Convert a markdown string to TipTap-compatible JSON.
* Uses `marked` to parse markdown HTML tokens, then builds TipTap JSON nodes.
*/
export function markdownToTiptap(md: string): string {
const tokens = marked.lexer(md);
const doc = {
type: 'doc',
content: tokensToTiptap(tokens),
};
return JSON.stringify(doc);
}
/** Convert marked tokens to TipTap JSON node array. */
function tokensToTiptap(tokens: any[]): any[] {
const nodes: any[] = [];
for (const token of tokens) {
switch (token.type) {
case 'heading':
nodes.push({
type: 'heading',
attrs: { level: token.depth },
content: inlineToTiptap(token.tokens || []),
});
break;
case 'paragraph':
nodes.push({
type: 'paragraph',
content: inlineToTiptap(token.tokens || []),
});
break;
case 'blockquote':
nodes.push({
type: 'blockquote',
content: tokensToTiptap(token.tokens || []),
});
break;
case 'list': {
const listType = token.ordered ? 'orderedList' : 'bulletList';
const attrs: any = {};
if (token.ordered && token.start !== 1) attrs.start = token.start;
nodes.push({
type: listType,
...(Object.keys(attrs).length ? { attrs } : {}),
content: token.items.map((item: any) => {
// Check if this is a task list item
if (item.task) {
return {
type: 'taskItem',
attrs: { checked: item.checked || false },
content: tokensToTiptap(item.tokens || []),
};
}
return {
type: 'listItem',
content: tokensToTiptap(item.tokens || []),
};
}),
});
// If any items were task items, wrap in taskList instead
const lastNode = nodes[nodes.length - 1];
if (lastNode.content?.some((c: any) => c.type === 'taskItem')) {
lastNode.type = 'taskList';
}
break;
}
case 'code':
nodes.push({
type: 'codeBlock',
attrs: { language: token.lang || null },
content: [{ type: 'text', text: token.text }],
});
break;
case 'hr':
nodes.push({ type: 'horizontalRule' });
break;
case 'table': {
const rows: any[] = [];
// Header row
if (token.header && token.header.length > 0) {
rows.push({
type: 'tableRow',
content: token.header.map((cell: any) => ({
type: 'tableHeader',
content: [{
type: 'paragraph',
content: inlineToTiptap(cell.tokens || []),
}],
})),
});
}
// Body rows
if (token.rows) {
for (const row of token.rows) {
rows.push({
type: 'tableRow',
content: row.map((cell: any) => ({
type: 'tableCell',
content: [{
type: 'paragraph',
content: inlineToTiptap(cell.tokens || []),
}],
})),
});
}
}
nodes.push({ type: 'table', content: rows });
break;
}
case 'image':
nodes.push({
type: 'image',
attrs: {
src: token.href,
alt: token.text || null,
title: token.title || null,
},
});
break;
case 'html':
// Pass through raw HTML as a paragraph with text
if (token.text.trim()) {
nodes.push({
type: 'paragraph',
content: [{ type: 'text', text: token.text.trim() }],
});
}
break;
case 'space':
// Ignore whitespace-only tokens
break;
default:
// Fallback: treat as paragraph if there are tokens
if ((token as any).tokens) {
nodes.push({
type: 'paragraph',
content: inlineToTiptap((token as any).tokens),
});
} else if ((token as any).text) {
nodes.push({
type: 'paragraph',
content: [{ type: 'text', text: (token as any).text }],
});
}
}
}
return nodes;
}
/** Convert inline marked tokens to TipTap inline content. */
function inlineToTiptap(tokens: any[]): any[] {
const result: any[] = [];
for (const token of tokens) {
switch (token.type) {
case 'text':
if (token.text) {
result.push({ type: 'text', text: token.text });
}
break;
case 'strong':
for (const child of inlineToTiptap(token.tokens || [])) {
result.push(addMark(child, { type: 'bold' }));
}
break;
case 'em':
for (const child of inlineToTiptap(token.tokens || [])) {
result.push(addMark(child, { type: 'italic' }));
}
break;
case 'del':
for (const child of inlineToTiptap(token.tokens || [])) {
result.push(addMark(child, { type: 'strike' }));
}
break;
case 'codespan':
result.push({
type: 'text',
text: token.text,
marks: [{ type: 'code' }],
});
break;
case 'link':
for (const child of inlineToTiptap(token.tokens || [])) {
result.push(addMark(child, {
type: 'link',
attrs: { href: token.href, target: '_blank' },
}));
}
break;
case 'image':
// Inline images become their own node — push text before if any
result.push({
type: 'text',
text: `![${token.text || ''}](${token.href})`,
});
break;
case 'br':
result.push({ type: 'hardBreak' });
break;
case 'escape':
result.push({ type: 'text', text: token.text });
break;
default:
if ((token as any).text) {
result.push({ type: 'text', text: (token as any).text });
}
}
}
return result;
}
/** Add a mark to a TipTap text node, preserving existing marks. */
function addMark(node: any, mark: any): any {
const marks = [...(node.marks || []), mark];
return { ...node, marks };
}
// ── TipTap JSON → Markdown ──
/**
* Convert TipTap JSON string to markdown.
* Walks the TipTap node tree and produces CommonMark-compatible output.
*/
export function tiptapToMarkdown(json: string): string {
try {
const doc = JSON.parse(json);
if (!doc.content) return '';
return nodesToMarkdown(doc.content).trim();
} catch {
// If it's not valid JSON, return as-is (might already be markdown/plain text)
return json;
}
}
/** Convert an array of TipTap nodes to markdown. */
function nodesToMarkdown(nodes: any[], indent = ''): string {
const parts: string[] = [];
for (const node of nodes) {
switch (node.type) {
case 'heading': {
const level = node.attrs?.level || 1;
const prefix = '#'.repeat(level);
parts.push(`${prefix} ${inlineToMarkdown(node.content || [])}`);
parts.push('');
break;
}
case 'paragraph': {
const text = inlineToMarkdown(node.content || []);
parts.push(`${indent}${text}`);
parts.push('');
break;
}
case 'blockquote': {
const inner = nodesToMarkdown(node.content || []);
const lines = inner.split('\n').filter((l: string) => l !== '' || parts.length === 0);
for (const line of lines) {
parts.push(line ? `> ${line}` : '>');
}
parts.push('');
break;
}
case 'bulletList': {
for (const item of node.content || []) {
const inner = nodesToMarkdown(item.content || [], ' ').trim();
const lines = inner.split('\n');
parts.push(`- ${lines[0]}`);
for (let i = 1; i < lines.length; i++) {
parts.push(` ${lines[i]}`);
}
}
parts.push('');
break;
}
case 'orderedList': {
const start = node.attrs?.start || 1;
const items = node.content || [];
for (let i = 0; i < items.length; i++) {
const num = start + i;
const inner = nodesToMarkdown(items[i].content || [], ' ').trim();
const lines = inner.split('\n');
parts.push(`${num}. ${lines[0]}`);
for (let j = 1; j < lines.length; j++) {
parts.push(` ${lines[j]}`);
}
}
parts.push('');
break;
}
case 'taskList': {
for (const item of node.content || []) {
const checked = item.attrs?.checked ? 'x' : ' ';
const inner = nodesToMarkdown(item.content || [], ' ').trim();
parts.push(`- [${checked}] ${inner}`);
}
parts.push('');
break;
}
case 'codeBlock': {
const lang = node.attrs?.language || '';
const text = node.content?.map((c: any) => c.text || '').join('') || '';
parts.push(`\`\`\`${lang}`);
parts.push(text);
parts.push('```');
parts.push('');
break;
}
case 'horizontalRule':
parts.push('---');
parts.push('');
break;
case 'image': {
const alt = node.attrs?.alt || '';
const src = node.attrs?.src || '';
const title = node.attrs?.title ? ` "${node.attrs.title}"` : '';
parts.push(`![${alt}](${src}${title})`);
parts.push('');
break;
}
case 'table': {
const rows = node.content || [];
if (rows.length === 0) break;
for (let r = 0; r < rows.length; r++) {
const cells = rows[r].content || [];
const cellTexts = cells.map((cell: any) => {
const inner = nodesToMarkdown(cell.content || []).trim();
return inner || ' ';
});
parts.push(`| ${cellTexts.join(' | ')} |`);
// Add separator after header row
if (r === 0) {
parts.push(`| ${cellTexts.map(() => '---').join(' | ')} |`);
}
}
parts.push('');
break;
}
case 'hardBreak':
parts.push(' ');
break;
default:
// Unknown node type — try to extract text
if (node.content) {
parts.push(nodesToMarkdown(node.content, indent));
} else if (node.text) {
parts.push(node.text);
}
}
}
return parts.join('\n');
}
/** Convert TipTap inline content nodes to markdown string. */
function inlineToMarkdown(nodes: any[]): string {
return nodes.map((node) => {
if (node.type === 'hardBreak') return ' \n';
let text = node.text || '';
if (!text && node.content) {
text = inlineToMarkdown(node.content);
}
if (node.marks) {
for (const mark of node.marks) {
switch (mark.type) {
case 'bold':
text = `**${text}**`;
break;
case 'italic':
text = `*${text}*`;
break;
case 'strike':
text = `~~${text}~~`;
break;
case 'code':
text = `\`${text}\``;
break;
case 'link':
text = `[${text}](${mark.attrs?.href || ''})`;
break;
case 'underline':
// No standard markdown for underline, use HTML
text = `<u>${text}</u>`;
break;
}
}
}
return text;
}).join('');
}
// ── Utility: extract plain text from TipTap JSON ──
/** Recursively extract plain text from a TipTap JSON string. */
export function extractPlainTextFromTiptap(json: string): string {
try {
const doc = JSON.parse(json);
return walkPlainText(doc).trim();
} catch {
return json.replace(/<[^>]*>/g, ' ').replace(/\s+/g, ' ').trim();
}
}
function walkPlainText(node: any): string {
if (node.text) return node.text;
if (!node.content) return '';
return node.content.map(walkPlainText).join(node.type === 'paragraph' ? '\n' : '');
}
export {
markdownToTiptap,
tiptapToMarkdown,
extractPlainTextFromTiptap,
} from '../../../shared/markdown-tiptap';

View File

@ -1,208 +1,5 @@
/**
* Custom Yjs WebSocket provider that bridges over the existing rSpace WebSocket.
*
* Instead of opening a separate y-websocket connection, this provider sends
* Yjs sync/awareness messages as JSON payloads through the rSpace runtime's
* WebSocket, using `yjs-sync` and `yjs-awareness` message types.
*
* The server simply relays these messages to all other peers in the same space.
* Re-export from shared location.
* The Yjs provider is now shared across modules (rNotes, rInbox, etc.).
*/
import * as Y from 'yjs';
import { writeSyncStep1, writeUpdate, readSyncMessage } from 'y-protocols/sync';
import {
Awareness,
encodeAwarenessUpdate,
applyAwarenessUpdate,
removeAwarenessStates,
} from 'y-protocols/awareness';
import {
createEncoder,
toUint8Array,
length as encoderLength,
} from 'lib0/encoding';
import { createDecoder } from 'lib0/decoding';
/** Minimal interface for the rSpace runtime's custom message API. */
interface RuntimeBridge {
sendCustom(msg: Record<string, any>): void;
onCustomMessage(type: string, cb: (msg: any) => void): () => void;
onConnect(cb: () => void): () => void;
onDisconnect(cb: () => void): () => void;
isOnline: boolean;
}
export class RSpaceYjsProvider {
readonly doc: Y.Doc;
readonly awareness: Awareness;
readonly noteId: string;
private runtime: RuntimeBridge;
private unsubs: (() => void)[] = [];
private connected = false;
private synced = false;
constructor(noteId: string, ydoc: Y.Doc, runtime: RuntimeBridge) {
this.noteId = noteId;
this.doc = ydoc;
this.runtime = runtime;
this.awareness = new Awareness(ydoc);
this.setupListeners();
if (runtime.isOnline) {
this.onConnect();
}
}
private setupListeners(): void {
// Listen for Yjs sync messages from other peers
this.unsubs.push(
this.runtime.onCustomMessage('yjs-sync', (msg: any) => {
if (msg.noteId !== this.noteId) return;
this.handleSyncMessage(msg.data);
})
);
// Listen for awareness messages from other peers
this.unsubs.push(
this.runtime.onCustomMessage('yjs-awareness', (msg: any) => {
if (msg.noteId !== this.noteId) return;
this.handleAwarenessMessage(msg.data);
})
);
// Listen for connect/disconnect
this.unsubs.push(
this.runtime.onConnect(() => this.onConnect())
);
this.unsubs.push(
this.runtime.onDisconnect(() => this.onDisconnect())
);
// When local doc changes, send update to peers
const updateHandler = (update: Uint8Array, origin: any) => {
if (origin === 'remote') return; // Don't echo back remote updates
this.sendDocUpdate(update);
};
this.doc.on('update', updateHandler);
this.unsubs.push(() => this.doc.off('update', updateHandler));
// When local awareness changes, broadcast
const awarenessHandler = (changes: {
added: number[];
updated: number[];
removed: number[];
}, origin: string | null) => {
if (origin === 'remote') return;
const changedClients = changes.added.concat(changes.updated).concat(changes.removed);
const update = encodeAwarenessUpdate(this.awareness, changedClients);
this.runtime.sendCustom({
type: 'yjs-awareness',
noteId: this.noteId,
data: Array.from(update),
});
};
this.awareness.on('update', awarenessHandler);
this.unsubs.push(() => this.awareness.off('update', awarenessHandler));
}
private onConnect(): void {
if (this.connected) return;
this.connected = true;
// Send initial sync step 1 (state vector)
const encoder = createEncoder();
writeSyncStep1(encoder, this.doc);
this.runtime.sendCustom({
type: 'yjs-sync',
noteId: this.noteId,
data: Array.from(toUint8Array(encoder)),
});
// Send full awareness state
const awarenessUpdate = encodeAwarenessUpdate(
this.awareness,
[this.doc.clientID],
);
this.runtime.sendCustom({
type: 'yjs-awareness',
noteId: this.noteId,
data: Array.from(awarenessUpdate),
});
}
private onDisconnect(): void {
this.connected = false;
this.synced = false;
// Remove all remote awareness states on disconnect
const states = Array.from(this.awareness.getStates().keys())
.filter(client => client !== this.doc.clientID);
removeAwarenessStates(this.awareness, states, this);
}
private handleSyncMessage(data: number[]): void {
const decoder = createDecoder(new Uint8Array(data));
const encoder = createEncoder();
const messageType = readSyncMessage(
decoder, encoder, this.doc, 'remote'
);
// If the response encoder has content, send it back
if (encoderLength(encoder) > 0) {
this.runtime.sendCustom({
type: 'yjs-sync',
noteId: this.noteId,
data: Array.from(toUint8Array(encoder)),
});
}
// After receiving sync step 2, we're synced
if (messageType === 1) { // syncStep2
this.synced = true;
}
}
private handleAwarenessMessage(data: number[]): void {
applyAwarenessUpdate(
this.awareness,
new Uint8Array(data),
'remote',
);
}
private sendDocUpdate(update: Uint8Array): void {
if (!this.connected) return;
const encoder = createEncoder();
writeUpdate(encoder, update);
this.runtime.sendCustom({
type: 'yjs-sync',
noteId: this.noteId,
data: Array.from(toUint8Array(encoder)),
});
}
get isSynced(): boolean {
return this.synced;
}
get isConnected(): boolean {
return this.connected;
}
destroy(): void {
// Remove local awareness state
removeAwarenessStates(
this.awareness,
[this.doc.clientID],
this,
);
// Clean up all listeners
for (const unsub of this.unsubs) {
try { unsub(); } catch { /* ignore */ }
}
this.unsubs = [];
this.connected = false;
}
}
export { RSpaceYjsProvider } from '../../shared/yjs-ws-provider';

458
shared/markdown-tiptap.ts Normal file
View File

@ -0,0 +1,458 @@
/**
* Core Markdown TipTap JSON conversion utility.
*
* All import/export converters pass through this module.
* - Import: source format markdown TipTap JSON
* - Export: TipTap JSON markdown source format
*/
import { marked } from 'marked';
// ── Markdown → TipTap JSON ──
/**
* Convert a markdown string to TipTap-compatible JSON.
* Uses `marked` to parse markdown HTML tokens, then builds TipTap JSON nodes.
*/
export function markdownToTiptap(md: string): string {
const tokens = marked.lexer(md);
const doc = {
type: 'doc',
content: tokensToTiptap(tokens),
};
return JSON.stringify(doc);
}
/** Convert marked tokens to TipTap JSON node array. */
function tokensToTiptap(tokens: any[]): any[] {
const nodes: any[] = [];
for (const token of tokens) {
switch (token.type) {
case 'heading':
nodes.push({
type: 'heading',
attrs: { level: token.depth },
content: inlineToTiptap(token.tokens || []),
});
break;
case 'paragraph':
nodes.push({
type: 'paragraph',
content: inlineToTiptap(token.tokens || []),
});
break;
case 'blockquote':
nodes.push({
type: 'blockquote',
content: tokensToTiptap(token.tokens || []),
});
break;
case 'list': {
const listType = token.ordered ? 'orderedList' : 'bulletList';
const attrs: any = {};
if (token.ordered && token.start !== 1) attrs.start = token.start;
nodes.push({
type: listType,
...(Object.keys(attrs).length ? { attrs } : {}),
content: token.items.map((item: any) => {
// Check if this is a task list item
if (item.task) {
return {
type: 'taskItem',
attrs: { checked: item.checked || false },
content: tokensToTiptap(item.tokens || []),
};
}
return {
type: 'listItem',
content: tokensToTiptap(item.tokens || []),
};
}),
});
// If any items were task items, wrap in taskList instead
const lastNode = nodes[nodes.length - 1];
if (lastNode.content?.some((c: any) => c.type === 'taskItem')) {
lastNode.type = 'taskList';
}
break;
}
case 'code':
nodes.push({
type: 'codeBlock',
attrs: { language: token.lang || null },
content: [{ type: 'text', text: token.text }],
});
break;
case 'hr':
nodes.push({ type: 'horizontalRule' });
break;
case 'table': {
const rows: any[] = [];
// Header row
if (token.header && token.header.length > 0) {
rows.push({
type: 'tableRow',
content: token.header.map((cell: any) => ({
type: 'tableHeader',
content: [{
type: 'paragraph',
content: inlineToTiptap(cell.tokens || []),
}],
})),
});
}
// Body rows
if (token.rows) {
for (const row of token.rows) {
rows.push({
type: 'tableRow',
content: row.map((cell: any) => ({
type: 'tableCell',
content: [{
type: 'paragraph',
content: inlineToTiptap(cell.tokens || []),
}],
})),
});
}
}
nodes.push({ type: 'table', content: rows });
break;
}
case 'image':
nodes.push({
type: 'image',
attrs: {
src: token.href,
alt: token.text || null,
title: token.title || null,
},
});
break;
case 'html':
// Pass through raw HTML as a paragraph with text
if (token.text.trim()) {
nodes.push({
type: 'paragraph',
content: [{ type: 'text', text: token.text.trim() }],
});
}
break;
case 'space':
// Ignore whitespace-only tokens
break;
default:
// Fallback: treat as paragraph if there are tokens
if ((token as any).tokens) {
nodes.push({
type: 'paragraph',
content: inlineToTiptap((token as any).tokens),
});
} else if ((token as any).text) {
nodes.push({
type: 'paragraph',
content: [{ type: 'text', text: (token as any).text }],
});
}
}
}
return nodes;
}
/** Convert inline marked tokens to TipTap inline content. */
function inlineToTiptap(tokens: any[]): any[] {
const result: any[] = [];
for (const token of tokens) {
switch (token.type) {
case 'text':
if (token.text) {
result.push({ type: 'text', text: token.text });
}
break;
case 'strong':
for (const child of inlineToTiptap(token.tokens || [])) {
result.push(addMark(child, { type: 'bold' }));
}
break;
case 'em':
for (const child of inlineToTiptap(token.tokens || [])) {
result.push(addMark(child, { type: 'italic' }));
}
break;
case 'del':
for (const child of inlineToTiptap(token.tokens || [])) {
result.push(addMark(child, { type: 'strike' }));
}
break;
case 'codespan':
result.push({
type: 'text',
text: token.text,
marks: [{ type: 'code' }],
});
break;
case 'link':
for (const child of inlineToTiptap(token.tokens || [])) {
result.push(addMark(child, {
type: 'link',
attrs: { href: token.href, target: '_blank' },
}));
}
break;
case 'image':
// Inline images become their own node — push text before if any
result.push({
type: 'text',
text: `![${token.text || ''}](${token.href})`,
});
break;
case 'br':
result.push({ type: 'hardBreak' });
break;
case 'escape':
result.push({ type: 'text', text: token.text });
break;
default:
if ((token as any).text) {
result.push({ type: 'text', text: (token as any).text });
}
}
}
return result;
}
/** Add a mark to a TipTap text node, preserving existing marks. */
function addMark(node: any, mark: any): any {
const marks = [...(node.marks || []), mark];
return { ...node, marks };
}
// ── TipTap JSON → Markdown ──
/**
* Convert TipTap JSON string to markdown.
* Walks the TipTap node tree and produces CommonMark-compatible output.
*/
export function tiptapToMarkdown(json: string): string {
try {
const doc = JSON.parse(json);
if (!doc.content) return '';
return nodesToMarkdown(doc.content).trim();
} catch {
// If it's not valid JSON, return as-is (might already be markdown/plain text)
return json;
}
}
/** Convert an array of TipTap nodes to markdown. */
function nodesToMarkdown(nodes: any[], indent = ''): string {
const parts: string[] = [];
for (const node of nodes) {
switch (node.type) {
case 'heading': {
const level = node.attrs?.level || 1;
const prefix = '#'.repeat(level);
parts.push(`${prefix} ${inlineToMarkdown(node.content || [])}`);
parts.push('');
break;
}
case 'paragraph': {
const text = inlineToMarkdown(node.content || []);
parts.push(`${indent}${text}`);
parts.push('');
break;
}
case 'blockquote': {
const inner = nodesToMarkdown(node.content || []);
const lines = inner.split('\n').filter((l: string) => l !== '' || parts.length === 0);
for (const line of lines) {
parts.push(line ? `> ${line}` : '>');
}
parts.push('');
break;
}
case 'bulletList': {
for (const item of node.content || []) {
const inner = nodesToMarkdown(item.content || [], ' ').trim();
const lines = inner.split('\n');
parts.push(`- ${lines[0]}`);
for (let i = 1; i < lines.length; i++) {
parts.push(` ${lines[i]}`);
}
}
parts.push('');
break;
}
case 'orderedList': {
const start = node.attrs?.start || 1;
const items = node.content || [];
for (let i = 0; i < items.length; i++) {
const num = start + i;
const inner = nodesToMarkdown(items[i].content || [], ' ').trim();
const lines = inner.split('\n');
parts.push(`${num}. ${lines[0]}`);
for (let j = 1; j < lines.length; j++) {
parts.push(` ${lines[j]}`);
}
}
parts.push('');
break;
}
case 'taskList': {
for (const item of node.content || []) {
const checked = item.attrs?.checked ? 'x' : ' ';
const inner = nodesToMarkdown(item.content || [], ' ').trim();
parts.push(`- [${checked}] ${inner}`);
}
parts.push('');
break;
}
case 'codeBlock': {
const lang = node.attrs?.language || '';
const text = node.content?.map((c: any) => c.text || '').join('') || '';
parts.push(`\`\`\`${lang}`);
parts.push(text);
parts.push('```');
parts.push('');
break;
}
case 'horizontalRule':
parts.push('---');
parts.push('');
break;
case 'image': {
const alt = node.attrs?.alt || '';
const src = node.attrs?.src || '';
const title = node.attrs?.title ? ` "${node.attrs.title}"` : '';
parts.push(`![${alt}](${src}${title})`);
parts.push('');
break;
}
case 'table': {
const rows = node.content || [];
if (rows.length === 0) break;
for (let r = 0; r < rows.length; r++) {
const cells = rows[r].content || [];
const cellTexts = cells.map((cell: any) => {
const inner = nodesToMarkdown(cell.content || []).trim();
return inner || ' ';
});
parts.push(`| ${cellTexts.join(' | ')} |`);
// Add separator after header row
if (r === 0) {
parts.push(`| ${cellTexts.map(() => '---').join(' | ')} |`);
}
}
parts.push('');
break;
}
case 'hardBreak':
parts.push(' ');
break;
default:
// Unknown node type — try to extract text
if (node.content) {
parts.push(nodesToMarkdown(node.content, indent));
} else if (node.text) {
parts.push(node.text);
}
}
}
return parts.join('\n');
}
/** Convert TipTap inline content nodes to markdown string. */
function inlineToMarkdown(nodes: any[]): string {
return nodes.map((node) => {
if (node.type === 'hardBreak') return ' \n';
let text = node.text || '';
if (!text && node.content) {
text = inlineToMarkdown(node.content);
}
if (node.marks) {
for (const mark of node.marks) {
switch (mark.type) {
case 'bold':
text = `**${text}**`;
break;
case 'italic':
text = `*${text}*`;
break;
case 'strike':
text = `~~${text}~~`;
break;
case 'code':
text = `\`${text}\``;
break;
case 'link':
text = `[${text}](${mark.attrs?.href || ''})`;
break;
case 'underline':
// No standard markdown for underline, use HTML
text = `<u>${text}</u>`;
break;
}
}
}
return text;
}).join('');
}
// ── Utility: extract plain text from TipTap JSON ──
/** Recursively extract plain text from a TipTap JSON string. */
export function extractPlainTextFromTiptap(json: string): string {
try {
const doc = JSON.parse(json);
return walkPlainText(doc).trim();
} catch {
return json.replace(/<[^>]*>/g, ' ').replace(/\s+/g, ' ').trim();
}
}
function walkPlainText(node: any): string {
if (node.text) return node.text;
if (!node.content) return '';
return node.content.map(walkPlainText).join(node.type === 'paragraph' ? '\n' : '');
}

218
shared/yjs-ws-provider.ts Normal file
View File

@ -0,0 +1,218 @@
/**
* Custom Yjs WebSocket provider that bridges over the existing rSpace WebSocket.
*
* Instead of opening a separate y-websocket connection, this provider sends
* Yjs sync/awareness messages as JSON payloads through the rSpace runtime's
* WebSocket, using `yjs-sync` and `yjs-awareness` message types.
*
* The server simply relays these messages to all other peers in the same space.
*
* Shared across modules (rNotes, rInbox, etc.).
*/
import * as Y from 'yjs';
import { writeSyncStep1, writeUpdate, readSyncMessage } from 'y-protocols/sync';
import {
Awareness,
encodeAwarenessUpdate,
applyAwarenessUpdate,
removeAwarenessStates,
} from 'y-protocols/awareness';
import {
createEncoder,
toUint8Array,
length as encoderLength,
} from 'lib0/encoding';
import { createDecoder } from 'lib0/decoding';
/** Minimal interface for the rSpace runtime's custom message API. */
interface RuntimeBridge {
sendCustom(msg: Record<string, any>): void;
onCustomMessage(type: string, cb: (msg: any) => void): () => void;
onConnect(cb: () => void): () => void;
onDisconnect(cb: () => void): () => void;
isOnline: boolean;
}
export class RSpaceYjsProvider {
readonly doc: Y.Doc;
readonly awareness: Awareness;
readonly docId: string;
private runtime: RuntimeBridge;
private unsubs: (() => void)[] = [];
private connected = false;
private synced = false;
constructor(docId: string, ydoc: Y.Doc, runtime: RuntimeBridge) {
this.docId = docId;
this.doc = ydoc;
this.runtime = runtime;
this.awareness = new Awareness(ydoc);
this.setupListeners();
if (runtime.isOnline) {
this.onConnect();
}
}
private setupListeners(): void {
// Listen for Yjs sync messages from other peers
this.unsubs.push(
this.runtime.onCustomMessage('yjs-sync', (msg: any) => {
// Accept both noteId (legacy) and docId field names
const msgDocId = msg.docId || msg.noteId;
if (msgDocId !== this.docId) return;
this.handleSyncMessage(msg.data);
})
);
// Listen for awareness messages from other peers
this.unsubs.push(
this.runtime.onCustomMessage('yjs-awareness', (msg: any) => {
const msgDocId = msg.docId || msg.noteId;
if (msgDocId !== this.docId) return;
this.handleAwarenessMessage(msg.data);
})
);
// Listen for connect/disconnect
this.unsubs.push(
this.runtime.onConnect(() => this.onConnect())
);
this.unsubs.push(
this.runtime.onDisconnect(() => this.onDisconnect())
);
// When local doc changes, send update to peers
const updateHandler = (update: Uint8Array, origin: any) => {
if (origin === 'remote') return; // Don't echo back remote updates
this.sendDocUpdate(update);
};
this.doc.on('update', updateHandler);
this.unsubs.push(() => this.doc.off('update', updateHandler));
// When local awareness changes, broadcast
const awarenessHandler = (changes: {
added: number[];
updated: number[];
removed: number[];
}, origin: string | null) => {
if (origin === 'remote') return;
const changedClients = changes.added.concat(changes.updated).concat(changes.removed);
const update = encodeAwarenessUpdate(this.awareness, changedClients);
this.runtime.sendCustom({
type: 'yjs-awareness',
docId: this.docId,
noteId: this.docId, // backward compat
data: Array.from(update),
});
};
this.awareness.on('update', awarenessHandler);
this.unsubs.push(() => this.awareness.off('update', awarenessHandler));
}
private onConnect(): void {
if (this.connected) return;
this.connected = true;
// Send initial sync step 1 (state vector)
const encoder = createEncoder();
writeSyncStep1(encoder, this.doc);
this.runtime.sendCustom({
type: 'yjs-sync',
docId: this.docId,
noteId: this.docId, // backward compat
data: Array.from(toUint8Array(encoder)),
});
// Send full awareness state
const awarenessUpdate = encodeAwarenessUpdate(
this.awareness,
[this.doc.clientID],
);
this.runtime.sendCustom({
type: 'yjs-awareness',
docId: this.docId,
noteId: this.docId, // backward compat
data: Array.from(awarenessUpdate),
});
}
private onDisconnect(): void {
this.connected = false;
this.synced = false;
// Remove all remote awareness states on disconnect
const states = Array.from(this.awareness.getStates().keys())
.filter(client => client !== this.doc.clientID);
removeAwarenessStates(this.awareness, states, this);
}
private handleSyncMessage(data: number[]): void {
const decoder = createDecoder(new Uint8Array(data));
const encoder = createEncoder();
const messageType = readSyncMessage(
decoder, encoder, this.doc, 'remote'
);
// If the response encoder has content, send it back
if (encoderLength(encoder) > 0) {
this.runtime.sendCustom({
type: 'yjs-sync',
docId: this.docId,
noteId: this.docId,
data: Array.from(toUint8Array(encoder)),
});
}
// After receiving sync step 2, we're synced
if (messageType === 1) { // syncStep2
this.synced = true;
}
}
private handleAwarenessMessage(data: number[]): void {
applyAwarenessUpdate(
this.awareness,
new Uint8Array(data),
'remote',
);
}
private sendDocUpdate(update: Uint8Array): void {
if (!this.connected) return;
const encoder = createEncoder();
writeUpdate(encoder, update);
this.runtime.sendCustom({
type: 'yjs-sync',
docId: this.docId,
noteId: this.docId,
data: Array.from(toUint8Array(encoder)),
});
}
get isSynced(): boolean {
return this.synced;
}
get isConnected(): boolean {
return this.connected;
}
destroy(): void {
// Remove local awareness state
removeAwarenessStates(
this.awareness,
[this.doc.clientID],
this,
);
// Clean up all listeners
for (const unsub of this.unsubs) {
try { unsub(); } catch { /* ignore */ }
}
this.unsubs = [];
this.connected = false;
}
}