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:
parent
15f5c58214
commit
58e319b8c8
|
|
@ -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">★</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">↩ Reply</button>
|
||||
<button data-action="reply-all">↩↩ 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 — 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">
|
||||
🔒 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">•</button>
|
||||
<button type="button" data-cmd="orderedList" title="Numbered list">1.</button>
|
||||
<button type="button" data-cmd="taskList" title="Task list">☑</button>
|
||||
<div class="toolbar-sep"></div>
|
||||
<button type="button" data-cmd="code" title="Inline code"></></button>
|
||||
<button type="button" data-cmd="codeBlock" title="Code block">⌨</button>
|
||||
<button type="button" data-cmd="blockquote" title="Quote">“</button>
|
||||
<button type="button" data-cmd="link" title="Insert link">🔗</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, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
|
||||
}
|
||||
|
|
@ -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">
|
||||
🔒 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();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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: ``,
|
||||
});
|
||||
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(``);
|
||||
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';
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -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: ``,
|
||||
});
|
||||
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(``);
|
||||
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' : '');
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue