Advanced
Advanced Topics
Deep dives into performance, the plugin system, real-time collaboration patterns, security hardening, and accessibility standards compliance.
Performance Optimization
Handling large documents and memory efficiency
For documents over 10,000 words, consider virtual scrolling or chunked editing.
// Optimizing for large documents (10k+ words)
// 1. Virtual scrolling for very long documents
class VirtualDocumentEditor {
constructor(container, content) {
this.container = container;
this.chunks = this.splitIntoChunks(content);
this.visibleRange = { start: 0, end: 10 };
this.editors = new Map();
this.setupIntersectionObserver();
}
splitIntoChunks(html) {
// Split by paragraphs, keeping 50-100 paragraphs per chunk
const doc = new DOMParser().parseFromString(html, 'text/html');
const paragraphs = Array.from(doc.body.children);
const chunks = [];
for (let i = 0; i < paragraphs.length; i += 50) {
chunks.push(paragraphs.slice(i, i + 50).map(p => p.outerHTML).join(''));
}
return chunks;
}
setupIntersectionObserver() {
this.observer = new IntersectionObserver((entries) => {
entries.forEach(entry => {
const chunkIndex = parseInt(entry.target.dataset.chunk);
if (entry.isIntersecting) {
this.activateChunk(chunkIndex);
} else {
this.deactivateChunk(chunkIndex);
}
});
}, { rootMargin: '200px' });
}
activateChunk(index) {
if (this.editors.has(index)) return;
const chunkEl = this.container.querySelector(`[data-chunk="${index}"]`);
const editor = Scribe.init(chunkEl, {
// Minimal features for inactive chunks
onChange: (html) => {
this.chunks[index] = html;
}
});
this.editors.set(index, editor);
}
deactivateChunk(index) {
const editor = this.editors.get(index);
if (editor) {
// Save content before destroying
this.chunks[index] = editor.getHTML();
editor.destroy();
this.editors.delete(index);
}
}
}
// 2. Debounced change handlers
const editor = Scribe.init('#editor', {
// Don't use onChange for expensive operations
});
// Debounce expensive operations
let saveTimer;
editor.on('change', (html) => {
clearTimeout(saveTimer);
saveTimer = setTimeout(() => {
saveToServer(html);
updateWordCount(html);
}, 500);
});
// 3. Use requestAnimationFrame for DOM measurements
editor.on('selectionChange', (selection) => {
requestAnimationFrame(() => {
positionToolbar(selection?.rect);
});
});Plugin Architecture
Extend Scribe with custom functionality
Plugin Lifecycle
┌─────────────────────────────────────────────────┐
│ Plugin Registration │
│ │
│ createEditor({ plugins: [myPlugin] }) │
│ │ │
│ ▼ │
│ ┌──────────────────────────────────────────┐ │
│ │ For each plugin: │ │
│ │ 1. Register commands → commands.set() │ │
│ │ 2. Register shortcuts → shortcuts.push() │ │
│ │ 3. Call init(editor) │ │
│ └──────────────────────────────────────────┘ │
│ │ │
│ ▼ │
│ ┌──────────────────────────────────────────┐ │
│ │ Editor ready - plugins active │ │
│ │ Commands available via editor.exec() │ │
│ └──────────────────────────────────────────┘ │
│ │ │
│ ▼ │
│ ┌──────────────────────────────────────────┐ │
│ │ editor.destroy() │ │
│ │ → Calls plugin.destroy() for cleanup │ │
│ └──────────────────────────────────────────┘ │
└─────────────────────────────────────────────────┘// Scribe Plugin Interface
interface Plugin {
name: string;
// Lifecycle hooks
init?(editor: EditorInstance): void;
destroy?(): void;
// Extend functionality
commands?: Record<string, CommandHandler>;
shortcuts?: ShortcutConfig[];
toolbarItems?: ToolbarItem[];
}
// Example: Custom highlight plugin
const highlightPlugin: Plugin = {
name: 'highlight',
commands: {
highlight: (editor, color: string = '#ffff00') => {
const selection = window.getSelection();
if (!selection || selection.isCollapsed) return;
const range = selection.getRangeAt(0);
const mark = document.createElement('mark');
mark.style.backgroundColor = color;
range.surroundContents(mark);
},
removeHighlight: (editor) => {
const selection = window.getSelection();
if (!selection) return;
const mark = selection.anchorNode?.parentElement;
if (mark?.tagName === 'MARK') {
const parent = mark.parentNode;
while (mark.firstChild) {
parent?.insertBefore(mark.firstChild, mark);
}
parent?.removeChild(mark);
}
}
},
shortcuts: [
{ key: 'h', ctrl: true, shift: true, command: 'highlight' }
],
toolbarItems: [
{
name: 'highlight',
icon: 'highlighter',
title: 'Highlight (Ctrl+Shift+H)',
command: 'highlight',
isActive: (state) => false // Would need custom state detection
}
],
init(editor) {
console.log('Highlight plugin initialized');
// Add custom styles
const style = document.createElement('style');
style.textContent = `
mark { padding: 0.1em 0.2em; border-radius: 0.2em; }
`;
editor.getDocument().head.appendChild(style);
},
destroy() {
console.log('Highlight plugin destroyed');
}
};
// Register plugin
const editor = createEditor({
target: '#editor',
plugins: [highlightPlugin]
});
// Use plugin command
editor.exec('highlight', '#ff0');Real-time Collaboration
Multi-user editing with conflict resolution
Collaboration requires a WebSocket server. For production, use Yjs with a provider like Liveblocks, Hocuspocus, or y-websocket.
// Collaboration architecture overview
//
// Two main approaches:
// 1. Operational Transformation (OT) - Google Docs style
// 2. CRDT - Yjs, Automerge style
//
// Scribe is designed to be "collaboration-ready" - you can integrate
// either approach without modifying the core editor.
// Basic WebSocket sync (last-write-wins)
class BasicCollaboration {
constructor(editor, roomId) {
this.editor = editor;
this.ws = new WebSocket(`wss://collab.example.com/${roomId}`);
this.isRemoteChange = false;
this.ws.onmessage = this.handleRemoteChange.bind(this);
this.editor.on('change', this.handleLocalChange.bind(this));
}
handleLocalChange(html) {
if (this.isRemoteChange) return;
this.ws.send(JSON.stringify({
type: 'update',
html,
timestamp: Date.now()
}));
}
handleRemoteChange(event) {
const { type, html } = JSON.parse(event.data);
if (type !== 'update') return;
// Prevent echo
this.isRemoteChange = true;
this.editor.setHTML(html);
this.isRemoteChange = false;
}
}Security
XSS prevention and content sandboxing
Never Allow
<script>, <iframe>,onclick, javascript:Careful With
<style>, style attr,data: URLsAlways Sanitize
Pasted content, user uploads, external HTML
// XSS Prevention in Rich Text Editors
//
// The #1 security concern: user-generated HTML can contain malicious scripts
// Scribe's built-in sanitizer
const editor = Scribe.init('#editor', {
sanitize: {
// Whitelist approach - only allow specific tags
allowedTags: [
'p', 'br', 'span', 'div',
'strong', 'b', 'em', 'i', 'u', 's', 'del', 'ins',
'a', 'img',
'ul', 'ol', 'li',
'blockquote', 'pre', 'code',
'h1', 'h2', 'h3', 'h4', 'h5', 'h6',
'table', 'tr', 'td', 'th', 'thead', 'tbody',
'hr'
],
// Whitelist attributes per tag
allowedAttributes: {
'a': ['href', 'target', 'rel'],
'img': ['src', 'alt', 'width', 'height'],
'td': ['colspan', 'rowspan'],
'*': ['class', 'id'] // All tags can have class/id
},
// URL schemes - blocks javascript:, data:, etc.
allowedSchemes: ['http', 'https', 'mailto', 'tel'],
// Remove empty elements
stripEmpty: true
}
});
// What the sanitizer removes:
// - <script> tags
// - <iframe> tags
// - onclick, onerror, etc. event handlers
// - javascript: URLs
// - data: URLs (except for allowed images)
// - <style> tags and style attributes (optional)
// - Unknown tagsAccessibility
ARIA, keyboard navigation, and screen reader support
Required ARIA
role="textbox"aria-multiline="true"aria-label="..."aria-describedby="..."Toolbar ARIA
role="toolbar"aria-label="Formatting"aria-pressed on toggle buttonsArrow key navigation
// Accessibility best practices for RTEs
// 1. ARIA attributes
const editorEl = document.getElementById('editor');
editorEl.setAttribute('role', 'textbox');
editorEl.setAttribute('aria-multiline', 'true');
editorEl.setAttribute('aria-label', 'Rich text editor');
editorEl.setAttribute('aria-describedby', 'editor-help');
// Help text for screen readers
const helpText = document.createElement('div');
helpText.id = 'editor-help';
helpText.className = 'sr-only'; // Visually hidden
helpText.textContent = 'Use Ctrl+B for bold, Ctrl+I for italic, Ctrl+K for link';
// 2. Toolbar accessibility
function createAccessibleToolbar(editor) {
const toolbar = document.createElement('div');
toolbar.setAttribute('role', 'toolbar');
toolbar.setAttribute('aria-label', 'Formatting options');
toolbar.setAttribute('aria-controls', 'editor');
// Button with proper ARIA
const boldBtn = document.createElement('button');
boldBtn.setAttribute('aria-label', 'Bold');
boldBtn.setAttribute('aria-pressed', 'false');
boldBtn.innerHTML = '<strong>B</strong>';
boldBtn.onclick = () => {
editor.bold();
boldBtn.setAttribute('aria-pressed',
boldBtn.getAttribute('aria-pressed') === 'true' ? 'false' : 'true'
);
};
toolbar.appendChild(boldBtn);
// Keyboard navigation within toolbar
toolbar.addEventListener('keydown', (e) => {
const buttons = toolbar.querySelectorAll('button');
const current = document.activeElement;
const index = Array.from(buttons).indexOf(current);
if (e.key === 'ArrowRight') {
e.preventDefault();
buttons[(index + 1) % buttons.length].focus();
} else if (e.key === 'ArrowLeft') {
e.preventDefault();
buttons[(index - 1 + buttons.length) % buttons.length].focus();
}
});
return toolbar;
}
// 3. Announce formatting changes
function announceFormat(format, applied) {
const announcement = document.createElement('div');
announcement.setAttribute('role', 'status');
announcement.setAttribute('aria-live', 'polite');
announcement.className = 'sr-only';
announcement.textContent = `${format} ${applied ? 'applied' : 'removed'}`;
document.body.appendChild(announcement);
setTimeout(() => announcement.remove(), 1000);
}
// 4. Focus management
editor.on('focus', () => {
// Ensure toolbar buttons show focus state
toolbar.classList.add('editor-focused');
});
editor.on('blur', () => {
toolbar.classList.remove('editor-focused');
});