Skip to content

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

FeatureDescription
Rich TextBold, italic, lists, links
Real-Time SyncYjs CRDT via Supabase Realtime
Remote CursorsSee other users' cursor positions
Auto-SaveDebounced save to database
Offline SupportIndexedDB persistence
PlaceholdersVisual placeholder for empty content

Usage

svelte
<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

PropTypeRequiredDescription
sectionIdstringYesUnique section identifier
initialContentJSONContent | nullYesTipTap JSON content
currentUserCollaborationUserYesAuthenticated user info
readonlybooleanNoDisable editing
onSave(content: JSONContent) => voidNoSave callback
onUsersChange(users: CollaborationUser[]) => voidNoUsers changed callback
onStatusChange(status: string) => voidNoConnection status callback

Architecture

Content Format

Content is stored as TipTap JSON:

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

ButtonCommandShortcut
BToggle boldCmd+B
IToggle italicCmd+I
Toggle bullet list-
1.Toggle ordered list-
LinkInsert/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:

typescript
// 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 onCursorClear

Each 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

html
<!-- 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:

typescript
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:

typescript
// 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 debounce

Accessibility

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:

css
/* 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]                      │
│                                                │
└────────────────────────────────────────────────┘

Internal documentation - Not for public distribution