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 { ViewHistory } from "../../../shared/view-history.js";
|
||||||
import { getAccessToken, getUsername } from "../../../lib/rspace-header";
|
import { getAccessToken, getUsername } from "../../../lib/rspace-header";
|
||||||
import { startPresenceHeartbeat } from '../../../shared/collab-presence';
|
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';
|
type ComposeMode = 'new' | 'reply' | 'reply-all' | 'forward';
|
||||||
|
|
||||||
|
|
@ -44,6 +58,12 @@ class FolkInboxClient extends HTMLElement {
|
||||||
private _fwdModalOpen = false;
|
private _fwdModalOpen = false;
|
||||||
private _fwdBusy = false;
|
private _fwdBusy = false;
|
||||||
private _fwdError = '';
|
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[] = [
|
private demoApprovals: any[] = [
|
||||||
{
|
{
|
||||||
id: "a1", subject: "Re: Q1 Budget approval for rSpace infrastructure", status: "PENDING",
|
id: "a1", subject: "Re: Q1 Budget approval for rSpace infrastructure", status: "PENDING",
|
||||||
|
|
@ -126,6 +146,7 @@ class FolkInboxClient extends HTMLElement {
|
||||||
}
|
}
|
||||||
|
|
||||||
disconnectedCallback() {
|
disconnectedCallback() {
|
||||||
|
this.destroyEditors();
|
||||||
for (const unsub of this._offlineUnsubs) unsub();
|
for (const unsub of this._offlineUnsubs) unsub();
|
||||||
this._offlineUnsubs = [];
|
this._offlineUnsubs = [];
|
||||||
this._stopPresence?.();
|
this._stopPresence?.();
|
||||||
|
|
@ -545,6 +566,7 @@ class FolkInboxClient extends HTMLElement {
|
||||||
}
|
}
|
||||||
|
|
||||||
private render() {
|
private render() {
|
||||||
|
this.destroyEditors();
|
||||||
this.shadow.innerHTML = `
|
this.shadow.innerHTML = `
|
||||||
<style>
|
<style>
|
||||||
:host { display: block; min-height: 60vh; font-family: system-ui, sans-serif; color: var(--rs-text-primary); -webkit-tap-highlight-color: transparent; }
|
: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-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-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-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 */
|
||||||
.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); }
|
.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-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-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-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); }
|
.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 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 { 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; }
|
.approval-card.status-pending { border-left-color: #fbbf24; }
|
||||||
|
|
@ -959,7 +1042,7 @@ class FolkInboxClient extends HTMLElement {
|
||||||
${t.is_starred ? `<span class="star">★</span>` : ""}
|
${t.is_starred ? `<span class="star">★</span>` : ""}
|
||||||
</div>
|
</div>
|
||||||
</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">
|
<div class="thread-actions">
|
||||||
<button data-action="reply">↩ Reply</button>
|
<button data-action="reply">↩ Reply</button>
|
||||||
<button data-action="reply-all">↩↩ Reply All</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-author">${cm.username || this.displayName(cm.author_id) || "Anonymous"}</span>
|
||||||
<span class="comment-time">${this.timeAgo(cm.created_at)}</span>
|
<span class="comment-time">${this.timeAgo(cm.created_at)}</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="comment-body">${cm.body}</div>
|
<div class="comment-body">${this.renderCommentBody(cm)}</div>
|
||||||
</div>
|
</div>
|
||||||
`).join("")}
|
`).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>` : ""}
|
${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>
|
||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
|
|
@ -1031,9 +1120,10 @@ class FolkInboxClient extends HTMLElement {
|
||||||
<label>Subject</label>
|
<label>Subject</label>
|
||||||
<input id="compose-subject" type="text" value="${this.escapeHtml(defaultSubject)}" />
|
<input id="compose-subject" type="text" value="${this.escapeHtml(defaultSubject)}" />
|
||||||
</div>
|
</div>
|
||||||
<div class="compose-field">
|
<div class="compose-field" style="margin-top:0.5rem">
|
||||||
<label>Body</label>
|
<label>Body <span style="font-weight:400;color:var(--rs-text-muted)">(Markdown supported)</span></label>
|
||||||
<textarea id="compose-body" placeholder="Write your message...">${this.escapeHtml(defaultBody)}</textarea>
|
${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>
|
||||||
<div class="compose-threshold">
|
<div class="compose-threshold">
|
||||||
🔒 This email will be submitted for multi-sig approval before sending.
|
🔒 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 {
|
private escapeHtml(s: string): string {
|
||||||
return s.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
|
return s.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
|
||||||
}
|
}
|
||||||
|
|
@ -1062,9 +1397,10 @@ class FolkInboxClient extends HTMLElement {
|
||||||
<label>Subject</label>
|
<label>Subject</label>
|
||||||
<input id="compose-subject" type="text" placeholder="Email subject" />
|
<input id="compose-subject" type="text" placeholder="Email subject" />
|
||||||
</div>
|
</div>
|
||||||
<div class="compose-field">
|
<div class="compose-field" style="margin-top:0.5rem">
|
||||||
<label>Body</label>
|
<label>Body <span style="font-weight:400;color:var(--rs-text-muted)">(Markdown supported)</span></label>
|
||||||
<textarea id="compose-body" placeholder="Write the email body. This will be reviewed and co-signed by your team before sending."></textarea>
|
${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>
|
||||||
<div class="compose-threshold">
|
<div class="compose-threshold">
|
||||||
🔒 This email requires <strong style="color:#818cf8">multi-sig</strong> team member signatures before it will be sent.
|
🔒 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 to = (this.shadow.getElementById("compose-to") as HTMLInputElement)?.value.trim();
|
||||||
const subject = (this.shadow.getElementById("compose-subject") 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; }
|
if (!subject || !body) { alert("Please fill subject and body."); return; }
|
||||||
|
|
||||||
const base = window.location.pathname.replace(/\/$/, "");
|
const base = window.location.pathname.replace(/\/$/, "");
|
||||||
|
|
@ -1537,6 +1876,29 @@ class FolkInboxClient extends HTMLElement {
|
||||||
} catch { alert("Failed to submit. Please try again."); }
|
} 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)
|
// Compose (from approvals view)
|
||||||
const composeBtn = this.shadow.querySelector("[data-action='compose']");
|
const composeBtn = this.shadow.querySelector("[data-action='compose']");
|
||||||
if (composeBtn) {
|
if (composeBtn) {
|
||||||
|
|
@ -1559,7 +1921,10 @@ class FolkInboxClient extends HTMLElement {
|
||||||
}
|
}
|
||||||
const to = (this.shadow.getElementById("compose-to") as HTMLInputElement)?.value.trim();
|
const to = (this.shadow.getElementById("compose-to") as HTMLInputElement)?.value.trim();
|
||||||
const subject = (this.shadow.getElementById("compose-subject") 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; }
|
if (!to || !subject || !body) { alert("Please fill all fields."); return; }
|
||||||
try {
|
try {
|
||||||
const base = window.location.pathname.replace(/\/$/, "");
|
const base = window.location.pathname.replace(/\/$/, "");
|
||||||
|
|
@ -1686,6 +2051,27 @@ class FolkInboxClient extends HTMLElement {
|
||||||
this.loadAgentInboxes();
|
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
|
author_did: c.authorId, // In Automerge, authorId IS the DID
|
||||||
username: null as string | null,
|
username: null as string | null,
|
||||||
body: c.body,
|
body: c.body,
|
||||||
|
content_format: c.contentFormat || null,
|
||||||
mentions: c.mentions,
|
mentions: c.mentions,
|
||||||
created_at: new Date(c.createdAt).toISOString(),
|
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 threadId = c.req.param("id");
|
||||||
const body = await c.req.json();
|
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);
|
if (!text) return c.json({ error: "text required" }, 400);
|
||||||
|
|
||||||
const found = findThreadById(threadId);
|
const found = findThreadById(threadId);
|
||||||
|
|
@ -728,6 +729,7 @@ routes.post("/api/threads/:id/comments", async (c) => {
|
||||||
const [docId] = found;
|
const [docId] = found;
|
||||||
const commentId = generateId();
|
const commentId = generateId();
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
|
const fmt = content_format === 'tiptap-json' ? 'tiptap-json' : undefined;
|
||||||
|
|
||||||
_syncServer!.changeDoc<MailboxDoc>(docId, `Add comment to thread ${threadId}`, (d) => {
|
_syncServer!.changeDoc<MailboxDoc>(docId, `Add comment to thread ${threadId}`, (d) => {
|
||||||
const t = d.threads[threadId];
|
const t = d.threads[threadId];
|
||||||
|
|
@ -737,6 +739,7 @@ routes.post("/api/threads/:id/comments", async (c) => {
|
||||||
threadId,
|
threadId,
|
||||||
authorId: claims.sub,
|
authorId: claims.sub,
|
||||||
body: text,
|
body: text,
|
||||||
|
contentFormat: fmt,
|
||||||
mentions: mentions || [],
|
mentions: mentions || [],
|
||||||
createdAt: now,
|
createdAt: now,
|
||||||
});
|
});
|
||||||
|
|
@ -747,6 +750,7 @@ routes.post("/api/threads/:id/comments", async (c) => {
|
||||||
threadId,
|
threadId,
|
||||||
authorId: claims.sub,
|
authorId: claims.sub,
|
||||||
body: text,
|
body: text,
|
||||||
|
contentFormat: fmt,
|
||||||
mentions: mentions || [],
|
mentions: mentions || [],
|
||||||
createdAt: now,
|
createdAt: now,
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -24,6 +24,7 @@ export interface ThreadComment {
|
||||||
threadId: string;
|
threadId: string;
|
||||||
authorId: string;
|
authorId: string;
|
||||||
body: string;
|
body: string;
|
||||||
|
contentFormat?: 'html' | 'tiptap-json';
|
||||||
mentions: string[];
|
mentions: string[];
|
||||||
createdAt: number;
|
createdAt: number;
|
||||||
}
|
}
|
||||||
|
|
@ -83,6 +84,9 @@ export interface ApprovalItem {
|
||||||
inReplyTo: string | null;
|
inReplyTo: string | null;
|
||||||
references: string[];
|
references: string[];
|
||||||
replyType: 'reply' | 'reply-all' | 'forward' | 'new';
|
replyType: 'reply' | 'reply-all' | 'forward' | 'new';
|
||||||
|
// Rich content format
|
||||||
|
contentFormat?: 'html' | 'tiptap-json';
|
||||||
|
collabEnabled?: boolean;
|
||||||
// Newsletter approval bridge
|
// Newsletter approval bridge
|
||||||
approvalType?: 'email' | 'newsletter';
|
approvalType?: 'email' | 'newsletter';
|
||||||
listmonkCampaignId?: number | null;
|
listmonkCampaignId?: number | null;
|
||||||
|
|
|
||||||
|
|
@ -1,458 +1,9 @@
|
||||||
/**
|
/**
|
||||||
* Core Markdown ↔ TipTap JSON conversion utility.
|
* Re-export from shared location.
|
||||||
*
|
* Markdown ↔ TipTap conversion is now shared across modules.
|
||||||
* All import/export converters pass through this module.
|
|
||||||
* - Import: source format → markdown → TipTap JSON
|
|
||||||
* - Export: TipTap JSON → markdown → source format
|
|
||||||
*/
|
*/
|
||||||
|
export {
|
||||||
import { marked } from 'marked';
|
markdownToTiptap,
|
||||||
|
tiptapToMarkdown,
|
||||||
// ── Markdown → TipTap JSON ──
|
extractPlainTextFromTiptap,
|
||||||
|
} from '../../../shared/markdown-tiptap';
|
||||||
/**
|
|
||||||
* 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' : '');
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -1,208 +1,5 @@
|
||||||
/**
|
/**
|
||||||
* Custom Yjs WebSocket provider that bridges over the existing rSpace WebSocket.
|
* Re-export from shared location.
|
||||||
*
|
* The Yjs provider is now shared across modules (rNotes, rInbox, etc.).
|
||||||
* 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.
|
|
||||||
*/
|
*/
|
||||||
|
export { RSpaceYjsProvider } from '../../shared/yjs-ws-provider';
|
||||||
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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -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