Collaborative Editor Component
The CollaborativeEditor component provides a rich text editing experience with real-time collaboration via TipTap and Yjs.
Location: src/lib/components/editor/CollaborativeEditor.svelte
Features
| Feature | Description |
|---|---|
| Rich Text | Bold, italic, lists, links |
| Real-Time Sync | Yjs CRDT via Supabase Realtime |
| Remote Cursors | See other users' cursor positions |
| Auto-Save | Debounced save to database |
| Offline Support | IndexedDB persistence |
| Placeholders | Visual placeholder for empty content |
Usage
<script lang="ts">
import CollaborativeEditor from '$lib/components/editor/CollaborativeEditor.svelte';
let { sectionId, initialContent, currentUser, readonly = false } = $props();
</script>
<CollaborativeEditor
{sectionId}
{initialContent}
{currentUser}
{readonly}
onSave={(content) => {
// Persist content to database
saveSection(sectionId, content);
}}
onUsersChange={(users) => {
// Update presence indicators
activeUsers = users;
}}
onStatusChange={(status) => {
// Show connection status
connectionStatus = status;
}}
/>Props
| Prop | Type | Required | Description |
|---|---|---|---|
sectionId | string | Yes | Unique section identifier |
initialContent | JSONContent | null | Yes | TipTap JSON content |
currentUser | CollaborationUser | Yes | Authenticated user info |
readonly | boolean | No | Disable editing |
onSave | (content: JSONContent) => void | No | Save callback |
onUsersChange | (users: CollaborationUser[]) => void | No | Users changed callback |
onStatusChange | (status: string) => void | No | Connection status callback |
Architecture
Content Format
Content is stored as TipTap JSON:
{
"type": "doc",
"content": [
{
"type": "paragraph",
"content": [
{ "type": "text", "text": "Hello " },
{ "type": "text", "marks": [{ "type": "bold" }], "text": "world" }
]
},
{
"type": "bulletList",
"content": [
{
"type": "listItem",
"content": [{ "type": "paragraph", "content": [{ "type": "text", "text": "Item 1" }] }]
}
]
}
]
}Toolbar Commands
| Button | Command | Shortcut |
|---|---|---|
| B | Toggle bold | Cmd+B |
| I | Toggle italic | Cmd+I |
| • | Toggle bullet list | - |
| 1. | Toggle ordered list | - |
| Link | Insert/edit link | - |
Remote Cursor Extension
Location: src/lib/components/editor/cursor-extension.ts
A custom TipTap extension that displays remote cursor positions without using ProseMirror's plugin state:
// Key design decisions:
// 1. Stores cursor data in PER-EDITOR storage (editor.storage.customCursor), not
// plugin state and not module-level globals. A page mounts one editor per
// show-notes section (issue #299)
// 2. Uses requestAnimationFrame for smooth positioning
// 3. Shows cursor labels after 800ms of no movement
// 4. Completely decoupled from ProseMirror transactions
// 5. Broadcasts the local caret only while this editor has focus, and retracts
// it on blur via onCursorClearEach editor also keeps an IntersectionObserver on its own DOM, because a lens-inactive editor is display: none and cannot be measured; the position pass re-runs when it becomes visible.
Cursor Display
<!-- Overlay, one per editor, appended to the editor's own parent -->
<div class="collaboration-cursors-container" aria-hidden="true">
<div class="collaboration-cursor" data-user-id="{userId}" style="background-color: {color}">
<div class="collaboration-cursor__label">{userName}</div>
</div>
</div>Sync Strategy
The editor uses a careful initialization sequence to avoid content conflicts:
provider.onSynced = (hasLocalContent, receivedRemoteSync) => {
if (receivedRemoteSync) {
// Remote state already applied to Yjs doc
// Editor will show this content automatically
return;
}
if (!hasLocalContent && initialContent) {
// No cached or remote content
// Initialize from database-provided content
editor.commands.setContent(initialContent);
}
};Auto-Save
Content is automatically saved on change with debouncing:
// In the editor update handler
const content = editor.getJSON();
// Debounce saves to avoid overwhelming the database
clearTimeout(saveTimeout);
saveTimeout = setTimeout(() => {
onSave?.(content);
}, 1000); // 1 second debounceAccessibility
The editor follows accessibility best practices:
- Keyboard Navigation: Full keyboard support via TipTap
- Focus Management: Proper focus indicators
- Screen Readers: ARIA labels on toolbar buttons
- Color Contrast: Cursor colors meet WCAG contrast ratios
Styling
The editor uses Tailwind CSS with custom styles for:
/* Placeholder text */
.ProseMirror p.is-editor-empty:first-child::before {
content: attr(data-placeholder);
color: var(--muted-foreground);
pointer-events: none;
}
/* Remote cursor overlay, one container per editor */
.collaboration-cursors-container {
position: absolute;
inset: 0;
pointer-events: none;
overflow: hidden;
}
/* The caret itself; background-color is set inline per user */
.collaboration-cursor {
position: absolute;
width: 2px;
pointer-events: none;
}
/* Name label, hidden until the caret has been still for 800ms */
.collaboration-cursor__label {
position: absolute;
font-size: 11px;
padding: 2px 6px;
border-radius: 3px 3px 3px 0;
opacity: 0;
}
.collaboration-cursor__label--visible {
opacity: 1;
}UI Components Using the Editor
Both routes below mount CollaborativeEditor directly, one instance per section, with the section markup written inline in the page. There is no intermediate section component: the two wrappers that used to exist (e/ShowNotesSection.svelte and guest/GuestShowNotesSection.svelte) were duplicates of that inline markup, mounted by no route, and were deleted.
Page Integration
Host Show Notes Page
Location: src/routes/(app)/p/[slug]/e/[episodeSlug]/show-notes/+page.svelte
Full-screen editor layout with chat sidebar:
┌────────────────────────────────────────────────┐
│ Header: Episode Title | Presence Indicators │
├────────────────────────────┬───────────────────┤
│ │ │
│ Shared Section │ │
│ ───────────────── │ Chat Sidebar │
│ │ │
│ Host Private Section │ Messages │
│ ───────────────── │ Activity Feed │
│ │ │
│ Guest Sections │ │
│ │ │
└────────────────────────────┴───────────────────┘Guest Portal Page
Location: src/routes/(guest)/guest/e/[episodeId]/+page.svelte
Magic link authenticated view for guests:
┌────────────────────────────────────────────────┐
│ Header: Episode Title | Recording Date │
├────────────────────────────────────────────────┤
│ │
│ Shared Notes (Editable) │
│ ───────────────────────────── │
│ │
│ My Notes (Editable) │
│ ───────────────────────────── │
│ │
│ [Host Private and Other Guest sections are │
│ not visible to guests] │
│ │
└────────────────────────────────────────────────┘