Real-time collaborative rich text editing with TinyMCE, Yjs CRDTs, and the collab_docs backend.
collab_richtext provides a Google Docs-style WYSIWYG editing experience on top of the collab_docs persistence and permission system. The server is intentionally thin (two page routes), delegating all document lifecycle, Yjs sync, and permissions to collab_docs_server. The client pairs TinyMCE with a custom TinyMCEBinding class that bridges the editor's HTML content model to Yjs text CRDTs for real-time multi-user collaboration.
collab_richtext.doh.yaml
├── collab_docs (nodejs — shared backend: Yjs lifecycle, permissions, DB)
├── collab_richtext_server (nodejs — page routes only)
└── collab_richtext_client (browser)
├── TinyMCEBinding (Yjs ↔ TinyMCE HTML sync)
├── CollabRichtextEditor (editor container + provider lifecycle)
├── CollabRichtextToolbar (title, export, print, delete, user avatars)
├── CollabRichtextStatusBar (sync indicator, client count)
└── CollabRichtextList (document list + create)
Depends on: collab_docs (server), collab_shared (browser), tinymce (browser)
Loaded by: chat.doh.yaml
| File | Purpose |
|---|---|
collab_richtext.doh.yaml |
Package manifest |
collab_richtext_server.js |
Two page routes (~20 lines) |
collab_richtext_client.js |
TinyMCE binding, 4 Patterns, 2 bootstrap modules (~620 lines) |
collab_richtext.css |
Editor, list, toolbar, status bar styles |
| Method | Path | Auth | Description |
|---|---|---|---|
| GET | /richtext |
Yes | Document list and create page |
| GET | /richtext/:id |
Yes | Fullscreen rich text editor |
Both routes serve HTML pages that bootstrap browser modules. All document CRUD, permissions, and Yjs sync are handled by collab_docs_server.
Bridges TinyMCE's HTML content model to a Yjs Y.Text instance using character-level diff.
new TinyMCEBinding(ytext, editor, awareness)
| Param | Type | Description |
|---|---|---|
ytext |
Y.Text |
Yjs text type from ydoc.getText('content') |
editor |
TinyMCE Editor | Initialized TinyMCE instance |
awareness |
awarenessProtocol.Awareness |
Cursor/presence tracking |
| Property | Type | Description |
|---|---|---|
ytext |
Y.Text |
Yjs text instance |
editor |
TinyMCE Editor | TinyMCE editor |
awareness |
Awareness | Yjs awareness |
_lastHtml |
string | Last known HTML content (for diffing) |
_updating |
boolean | Lock flag during remote updates |
_destroyed |
boolean | Destruction flag |
Local → Remote (_applyLocalChange):
getContent({ format: 'raw' })_lastHtmlytext.delete() + ytext.insert()Remote → Local (_applyRemoteChange):
ytext.toString() for new contentsetContent(newContent, { format: 'raw' })moveToBookmark()| Event | Purpose |
|---|---|
input |
Text input changes |
change |
Content changes |
ExecCommand |
Toolbar formatting commands |
Undo |
Undo action |
Redo |
Redo action |
destroy() — Unobserves Yjs text, removes all TinyMCE event listeners.
All socket events are handled by collab_docs_server. The richtext client uses the same protocol as collab_docs_client.
| Event | Payload | Response | Used By |
|---|---|---|---|
collab:doc:open |
{ doc_id } |
{ doc_meta, readOnly, syncStep1, syncStep2, awarenessStates, clientCount } |
Editor |
collab:doc:update_meta |
{ doc_id, updates: { title } } |
{ error? } |
Toolbar |
collab:doc:delete |
{ doc_id } |
{ error? } |
Toolbar |
collab:doc:list |
{ room_id? } |
{ docs: [...] } |
List |
collab:doc:create |
{ title, room_id?, type: 'richtext', language: 'html' } |
{ error?, doc } |
List |
| Event | Payload | Action |
|---|---|---|
collab:doc:meta_updated |
{ doc } |
Update toolbar title |
collab:doc:deleted |
{ doc_id } |
Show "deleted" message, destroy binding |
collab:doc:client_count |
{ doc_id, count } |
Update status bar collaborator count |
collab:doc:created |
{ doc } |
Refresh document list |
collab:doc:sync |
{ doc_id, message: number[] } |
Yjs sync (via CollabSocketProvider) |
collab:doc:awareness |
{ doc_id, message: number[] } |
Cursor/presence (via CollabSocketProvider) |
Main editor container managing the Yjs provider lifecycle.
Key Properties:
| Property | Type | Description |
|---|---|---|
doc_id |
string | Document ID |
doc_meta |
object | Document metadata from server |
read_only |
boolean | Read-only mode flag |
mode |
string | 'embedded' or 'fullscreen' |
_ydoc |
Y.Doc |
Yjs document instance |
_provider |
CollabSocketProvider |
Socket.IO ↔ Yjs bridge |
_binding |
TinyMCEBinding |
TinyMCE ↔ Yjs binding |
_tinymce |
Editor | TinyMCE editor reference |
Sub-objects:
| Name | Pattern | Purpose |
|---|---|---|
toolbar |
CollabRichtextToolbar |
Title, actions, user avatars |
editorContainer |
html |
TinyMCE iframe host |
statusBar |
CollabRichtextStatusBar |
Sync/connection indicators |
Lifecycle:
object_phase — Create Y.Doc and CollabSocketProvider, wire socket listenerspost_builder_phase — Connect toolbar to editor instancehtml_phase — Emit collab:doc:open, initialize TinyMCE on response, create bindingTinyMCE Configuration:
lists, link, table, code, wordcount, searchreplace, autolink, image, charmap, emoticonsKey Methods:
| Method | Description |
|---|---|
requestClose() |
Navigate to /richtext or postMessage to parent |
showError(message) |
Display error in editor container |
onDocumentDeleted() |
Show deleted message, destroy binding and editor |
destroy() |
Clean up binding, provider, ydoc, and TinyMCE |
Title editing, document actions, and collaborator avatars.
Sub-objects: back_btn, title_display, spacer, users_container, fullscreen_btn
Key Methods:
| Method | Description |
|---|---|
startTitleEdit() |
Make title contenteditable with select-all |
saveTitle(newTitle) |
Emit collab:doc:update_meta with new title |
downloadHtml() |
Build full HTML document and trigger download |
printDoc() |
Access editor iframe and call contentWindow.print() |
deleteDocument() |
Confirm and emit collab:doc:delete |
updateMeta(doc) |
Update displayed title from metadata |
updateUsers(awareness) |
Render up to 5 user avatars with overflow counter |
toggleFullscreen() |
Toggle fullscreen class or postMessage to parent |
Sync state indicator, collaborator count, read-only badge.
Sub-objects: sync_status, client_count, readonly_indicator
Methods:
| Method | Params | Description |
|---|---|---|
setSyncStatus(status) |
'syncing' | 'synced' | 'disconnected' |
Update sync dot and label |
setClientCount(count) |
number | Show "N collaborators" or empty |
setReadOnly(readOnly) |
boolean | Show "View only" badge |
Document browser with create functionality.
Key Properties:
| Property | Type | Description |
|---|---|---|
room_id |
string | null | Optional room filter |
docs |
array | Loaded document list |
onDocSelected |
function | Callback when a document is clicked |
Sub-objects: header (with title_area and create_btn), list_container
Key Methods:
| Method | Description |
|---|---|
loadDocs() |
Emit collab:doc:list, sort by updated_at descending |
renderList() |
Render document items with owner avatar, type badge, active indicator |
createDocument() |
Prompt for title, emit collab:doc:create with type: 'richtext' |
| Module | Route | Behavior |
|---|---|---|
collab_richtext_list_page |
/richtext |
Full-page CollabRichtextList, navigates to /richtext/:id or /collab/:id |
collab_richtext_page |
/richtext/:id |
Full-page CollabRichtextEditor in fullscreen mode |
return { TinyMCEBinding };
collab_docs_servertype: 'richtext' use this editor; type: 'plaintext' uses the Monaco editor from collab_docsCollabSocketProvider from collab_shared handles the Yjs ↔ Socket.IO bridge/richtext — verify document list loads