System Design
Part 5 of 6Google Docs Frontend System Architecture: Real-Time Collaboration at Scale
Google Docs Frontend System Architecture: Real-Time Collaboration at Scale
1. Product Overview & Scale
Google Docs pioneered browser-based document editing and remains the gold standard for real-time collaboration. The frontend must handle simultaneous editing by hundreds of users while maintaining document fidelity, sub-100ms synchronization, and pixel-perfect rendering.
Scale Metrics:
- 1+ billion monthly active users across Google Workspace
- Millions of concurrent editing sessions
- Documents ranging from 1 page to 1,000+ pages
- 100+ simultaneous editors per document (enterprise)
- Sub-100ms edit propagation latency target
- 99.9% sync consistency requirement
- Offline-first with seamless sync
- 40+ export formats supported
The technical challenge is unprecedented: multiple people typing in the same sentence simultaneously must result in a coherent, identical document for everyone—in real-time.
┌─────────────────────────────────────────────────────────────────────────────┐
│ GOOGLE DOCS COLLABORATION COMPLEXITY │
├─────────────────────────────────────────────────────────────────────────────┤
│ │
│ SCENARIO: 3 users editing the same paragraph simultaneously │
│ │
│ Time 0ms: Document state: "The quick brown fox" │
│ │ │
│ User A │ User B User C │
│ (Seattle) │ (London) (Tokyo) │
│ │ │ │ │ │
│ ▼ │ ▼ ▼ │
│ Types │ Types Types │
│ "very " │ "lazy " "jumps" │
│ at pos 4 │ at pos 16 at end │
│ │ │
│ Time 50ms: Operations arrive at server in different orders │
│ Server must transform operations to maintain consistency │
│ │ │
│ Time 100ms: All clients converge to same state: │
│ "The very quick brown lazy fox jumps" │
│ │
│ CHALLENGE: Network latency varies (10ms to 500ms+) │
│ Operations can arrive out of order │
│ Must handle conflicts without data loss │
│ Must feel instant to each user │
│ │
└─────────────────────────────────────────────────────────────────────────────┘
2. Core Frontend Challenges
The Real-Time Collaboration Problem
Unlike most web applications, Google Docs must solve a distributed systems problem in the browser: multiple writers, single document, eventual consistency.
┌─────────────────────────────────────────────────────────────────────────────┐
│ COLLABORATION CHALLENGES │
├─────────────────────────────────────────────────────────────────────────────┤
│ │
│ 1. CONFLICT RESOLUTION │
│ Two users type at the same position simultaneously │
│ → Operational Transformation (OT) resolves conflicts │
│ │
│ 2. LATENCY HIDING │
│ User types, sees result immediately │
│ Server confirmation comes 100-500ms later │
│ → Optimistic updates with rollback capability │
│ │
│ 3. CURSOR SYNCHRONIZATION │
│ Each user's cursor position must be visible to others │
│ Cursors move as document content changes │
│ → Position transformation relative to edits │
│ │
│ 4. OFFLINE SUPPORT │
│ User edits while disconnected │
│ Reconnects with potentially conflicting changes │
│ → Operation queue + conflict resolution on reconnect │
│ │
│ 5. LARGE DOCUMENT PERFORMANCE │
│ 1000+ page documents must remain responsive │
│ → Virtualization + incremental rendering │
│ │
│ 6. RICH CONTENT │
│ Text, images, tables, drawings, charts, equations │
│ Each has different editing semantics │
│ → Unified operation model across content types │
│ │
└─────────────────────────────────────────────────────────────────────────────┘
The Six Core Challenges
| Challenge | Impact | Complexity |
|---|---|---|
| Operational Transformation | Core of collaboration - transforms concurrent edits | Extreme - mathematical correctness required |
| Canvas Rendering | Pixel-perfect document layout, custom text rendering | High - reimplementing browser typography |
| Offline Sync | Edit without network, merge on reconnect | High - conflict resolution at scale |
| Large Documents | 1000+ pages must be editable | High - virtualization, lazy loading |
| Undo/Redo | Per-user history in collaborative context | High - tracking ownership of operations |
| Accessibility | Screen readers with custom rendering | Critical - must expose semantic structure |
3. High-Level Frontend Architecture
Google Docs uses a sophisticated architecture that diverges significantly from typical web applications:
┌─────────────────────────────────────────────────────────────────────────────┐
│ GOOGLE DOCS ARCHITECTURE │
├─────────────────────────────────────────────────────────────────────────────┤
│ │
│ ┌─────────────────────────────────────────────────────────────────────┐ │
│ │ COLLABORATION SERVER │ │
│ │ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ ┌────────────┐ │ │
│ │ │ Operation │ │ Transform │ │ Document │ │ Presence │ │ │
│ │ │ Queue │ │ Engine │ │ State │ │ Service │ │ │
│ │ └─────────────┘ └─────────────┘ └─────────────┘ └────────────┘ │ │
│ └─────────────────────────────────────────────────────────────────────┘ │
│ │ │
│ WebSocket / HTTP2 │
│ │ │
│ ┌─────────────────────────────────────────────────────────────────────┐ │
│ │ CLIENT APPLICATION │ │
│ │ │ │
│ │ ┌─────────────────────────────────────────────────────────────────┐ │ │
│ │ │ COLLABORATION LAYER │ │ │
│ │ │ ┌──────────┐ ┌──────────┐ ┌──────────┐ ┌──────────────────┐ │ │ │
│ │ │ │ Local OT │ │ Op Queue │ │ Conflict │ │ Cursor Manager │ │ │ │
│ │ │ │ Engine │ │ │ │ Resolver │ │ │ │ │ │
│ │ │ └──────────┘ └──────────┘ └──────────┘ └──────────────────┘ │ │ │
│ │ └─────────────────────────────────────────────────────────────────┘ │ │
│ │ │ │
│ │ ┌─────────────────────────────────────────────────────────────────┐ │ │
│ │ │ DOCUMENT MODEL │ │ │
│ │ │ ┌──────────┐ ┌──────────┐ ┌──────────┐ ┌──────────────────┐ │ │ │
│ │ │ │ Tree │ │ Segments │ │ Styling │ │ Comments/ │ │ │ │
│ │ │ │ Structure│ │ (Runs) │ │ Resolver │ │ Suggestions │ │ │ │
│ │ │ └──────────┘ └──────────┘ └──────────┘ └──────────────────┘ │ │ │
│ │ └─────────────────────────────────────────────────────────────────┘ │ │
│ │ │ │
│ │ ┌─────────────────────────────────────────────────────────────────┐ │ │
│ │ │ RENDERING ENGINE │ │ │
│ │ │ ┌──────────┐ ┌──────────┐ ┌──────────┐ ┌──────────────────┐ │ │ │
│ │ │ │ Layout │ │ Canvas │ │ Text │ │ Viewport │ │ │ │
│ │ │ │ Engine │ │ Painter │ │ Shaper │ │ Manager │ │ │ │
│ │ │ └──────────┘ └──────────┘ └──────────┘ └──────────────────┘ │ │ │
│ │ └─────────────────────────────────────────────────────────────────┘ │ │
│ │ │ │
│ │ ┌─────────────────────────────────────────────────────────────────┐ │ │
│ │ │ INPUT HANDLING │ │ │
│ │ │ ┌──────────┐ ┌──────────┐ ┌──────────┐ ┌──────────────────┐ │ │ │
│ │ │ │ Keyboard │ │ IME │ │ Selection│ │ Clipboard │ │ │ │
│ │ │ │ Handler │ │ Handler │ │ Manager │ │ Handler │ │ │ │
│ │ │ └──────────┘ └──────────┘ └──────────┘ └──────────────────┘ │ │ │
│ │ └─────────────────────────────────────────────────────────────────┘ │ │
│ └─────────────────────────────────────────────────────────────────────┘ │
│ │
└─────────────────────────────────────────────────────────────────────────────┘
Canvas-Based Rendering
In 2021, Google Docs switched from DOM-based rendering to canvas-based rendering. This was a monumental architectural change:
// Why Canvas over DOM?
// DOM Rendering (Old Approach)
// - Each character/run is a DOM element
// - Browser handles text layout
// - Limited control over rendering
// - Performance degrades with document size
// - Accessibility built-in
// Canvas Rendering (New Approach)
// - Full control over pixel placement
// - Consistent rendering across browsers
// - Better performance for large documents
// - Must implement text shaping manually
// - Must implement accessibility layer manually
interface CanvasRenderingAdvantages {
performance: {
// DOM: O(n) elements for n characters
// Canvas: O(1) canvas element, render visible portion only
largeDocuments: 'Significantly faster',
scrolling: '60fps even on 1000+ pages',
selection: 'Custom optimized hit testing',
};
consistency: {
// DOM: Browser differences in text rendering
// Canvas: Pixel-identical across all browsers
crossBrowser: 'Identical layout everywhere',
printing: 'WYSIWYG guaranteed',
};
features: {
// DOM: Limited by CSS/HTML capabilities
// Canvas: Full control
advancedTypography: 'Custom kerning, ligatures',
complexLayouts: 'Columns, text wrapping around images',
};
}
4. Operational Transformation Deep Dive
Operational Transformation (OT) is the mathematical foundation enabling real-time collaboration:
┌─────────────────────────────────────────────────────────────────────────────┐
│ OPERATIONAL TRANSFORMATION │
├─────────────────────────────────────────────────────────────────────────────┤
│ │
│ CORE CONCEPT: Transform operations so they can be applied in any order │
│ and produce the same final document state │
│ │
│ EXAMPLE: │
│ ───────── │
│ Initial document: "ABCD" │
│ │
│ User 1: Insert 'X' at position 1 → Op1: insert(1, 'X') │
│ User 2: Insert 'Y' at position 3 → Op2: insert(3, 'Y') │
│ │
│ If applied as-is to different clients: │
│ Client 1: "ABCD" → "AXBCD" → "AXBYCD" (Op1 then Op2) │
│ Client 2: "ABCD" → "ABYCD" → "AXBYCD" (Op2 then Op1, but Op1 needs │
│ transformation!) │
│ │
│ TRANSFORMATION: │
│ When Op1 is applied first, Op2's position must shift: │
│ Op2' = insert(4, 'Y') // Position increased by 1 │
│ │
│ When Op2 is applied first, Op1 stays the same: │
│ Op1' = insert(1, 'X') // Position before Op2's insert │
│ │
│ Both orderings now produce: "AXBYCD" ✓ │
│ │
└─────────────────────────────────────────────────────────────────────────────┘
OT Implementation
// Operation types for text documents
type Operation =
| { type: 'insert'; position: number; content: string; attributes?: TextAttributes }
| { type: 'delete'; position: number; length: number }
| { type: 'retain'; count: number; attributes?: TextAttributes }
| { type: 'format'; position: number; length: number; attributes: TextAttributes };
// Transform function: transform op2 against op1
// Returns op2' that can be applied after op1 to achieve same result
function transform(op1: Operation, op2: Operation): Operation {
// Insert vs Insert
if (op1.type === 'insert' && op2.type === 'insert') {
if (op1.position <= op2.position) {
// op1 inserts before or at op2's position
// Shift op2's position by length of op1's content
return {
...op2,
position: op2.position + op1.content.length,
};
} else {
// op1 inserts after op2's position - no change needed
return op2;
}
}
// Insert vs Delete
if (op1.type === 'insert' && op2.type === 'delete') {
if (op1.position <= op2.position) {
// Insert before delete - shift delete position
return {
...op2,
position: op2.position + op1.content.length,
};
} else if (op1.position >= op2.position + op2.length) {
// Insert after delete range - no change
return op2;
} else {
// Insert within delete range - split delete
return {
type: 'delete',
position: op2.position,
length: op2.length + op1.content.length,
};
}
}
// Delete vs Insert
if (op1.type === 'delete' && op2.type === 'insert') {
if (op1.position + op1.length <= op2.position) {
// Delete before insert - shift insert position back
return {
...op2,
position: op2.position - op1.length,
};
} else if (op1.position >= op2.position) {
// Delete after insert - no change
return op2;
} else {
// Delete overlaps insert position
return {
...op2,
position: op1.position,
};
}
}
// Delete vs Delete
if (op1.type === 'delete' && op2.type === 'delete') {
if (op1.position + op1.length <= op2.position) {
// op1 deletes before op2 - shift position
return {
...op2,
position: op2.position - op1.length,
};
} else if (op1.position >= op2.position + op2.length) {
// op1 deletes after op2 - no change
return op2;
} else {
// Overlapping deletes - complex case
return transformOverlappingDeletes(op1, op2);
}
}
return op2;
}
// Handle overlapping delete operations
function transformOverlappingDeletes(
op1: DeleteOperation,
op2: DeleteOperation
): DeleteOperation {
const start1 = op1.position;
const end1 = op1.position + op1.length;
const start2 = op2.position;
const end2 = op2.position + op2.length;
// Calculate the portion of op2 that wasn't deleted by op1
if (start2 >= start1 && end2 <= end1) {
// op2 is completely within op1 - nothing left to delete
return { type: 'delete', position: start1, length: 0 };
}
if (start2 < start1 && end2 > end1) {
// op2 spans op1 - reduce length by op1's length
return {
type: 'delete',
position: start2,
length: op2.length - op1.length,
};
}
if (start2 < start1) {
// op2 starts before op1 - truncate end
return {
type: 'delete',
position: start2,
length: start1 - start2,
};
}
// op2 starts within op1 - shift to op1's start and reduce length
return {
type: 'delete',
position: start1,
length: end2 - end1,
};
}
Client-Side OT Engine
// Client OT engine managing local and remote operations
interface OTState {
// Acknowledged document state (server confirmed)
serverState: DocumentState;
serverRevision: number;
// Local operations not yet acknowledged
pendingOperations: Operation[];
// Operations sent but not acknowledged
inflightOperations: Operation[];
// Current document state (server + pending + inflight)
localState: DocumentState;
}
class OTEngine {
private state: OTState;
private socket: WebSocket;
private operationBuffer: Operation[] = [];
private sendTimeout: number | null = null;
constructor(initialState: DocumentState, socket: WebSocket) {
this.state = {
serverState: initialState,
serverRevision: 0,
pendingOperations: [],
inflightOperations: [],
localState: initialState,
};
this.socket = socket;
this.setupSocketHandlers();
}
// Apply local operation (user edit)
applyLocal(operation: Operation): void {
// Apply to local state immediately (optimistic)
this.state.localState = applyOperation(this.state.localState, operation);
// Queue for sending
this.state.pendingOperations.push(operation);
// Debounce sending to batch rapid edits
this.scheduleSend();
// Emit local change event
this.emit('localChange', this.state.localState);
}
// Handle operation from server (remote edit)
handleRemote(serverOp: Operation, revision: number): void {
// Transform against inflight operations
let transformedOp = serverOp;
for (const inflightOp of this.state.inflightOperations) {
transformedOp = transform(inflightOp, transformedOp);
}
// Transform against pending operations
for (const pendingOp of this.state.pendingOperations) {
transformedOp = transform(pendingOp, transformedOp);
}
// Apply transformed operation to local state
this.state.localState = applyOperation(this.state.localState, transformedOp);
this.state.serverRevision = revision;
// Emit remote change event
this.emit('remoteChange', transformedOp, this.state.localState);
}
// Handle acknowledgment from server
handleAck(revision: number): void {
// Move inflight to acknowledged
const ackedOps = this.state.inflightOperations;
this.state.inflightOperations = [];
// Apply to server state
for (const op of ackedOps) {
this.state.serverState = applyOperation(this.state.serverState, op);
}
this.state.serverRevision = revision;
// Send pending operations if any
this.sendPending();
}
private scheduleSend(): void {
if (this.sendTimeout !== null) return;
// Batch operations for 50ms
this.sendTimeout = window.setTimeout(() => {
this.sendTimeout = null;
this.sendPending();
}, 50);
}
private sendPending(): void {
if (this.state.pendingOperations.length === 0) return;
if (this.state.inflightOperations.length > 0) return; // Wait for ack
// Compose pending operations into single operation
const composed = composeOperations(this.state.pendingOperations);
// Move to inflight
this.state.inflightOperations = this.state.pendingOperations;
this.state.pendingOperations = [];
// Send to server
this.socket.send(JSON.stringify({
type: 'operation',
operation: composed,
revision: this.state.serverRevision,
}));
}
private setupSocketHandlers(): void {
this.socket.onmessage = (event) => {
const message = JSON.parse(event.data);
switch (message.type) {
case 'operation':
this.handleRemote(message.operation, message.revision);
break;
case 'ack':
this.handleAck(message.revision);
break;
case 'presence':
this.emit('presence', message.users);
break;
}
};
}
}
// Compose multiple operations into one
function composeOperations(operations: Operation[]): Operation {
if (operations.length === 0) {
return { type: 'retain', count: 0 };
}
let composed = operations[0];
for (let i = 1; i < operations.length; i++) {
composed = compose(composed, operations[i]);
}
return composed;
}
5. Document Model Architecture
Google Docs uses a sophisticated document model that supports rich formatting:
// Document tree structure
interface Document {
id: string;
title: string;
body: Body;
headers: Record<string, Header>;
footers: Record<string, Footer>;
footnotes: Record<string, Footnote>;
styles: DocumentStyles;
namedRanges: Record<string, NamedRange>;
suggestions: Record<string, Suggestion>;
revisionHistory: RevisionHistory;
}
interface Body {
content: StructuralElement[];
}
type StructuralElement =
| Paragraph
| Table
| TableOfContents
| SectionBreak;
interface Paragraph {
type: 'paragraph';
elements: ParagraphElement[];
paragraphStyle: ParagraphStyle;
bullet?: Bullet;
}
type ParagraphElement =
| TextRun
| InlineObjectElement
| AutoText
| PageBreak
| ColumnBreak
| FootnoteReference
| HorizontalRule
| Equation
| Person
| RichLink;
interface TextRun {
type: 'textRun';
content: string;
textStyle: TextStyle;
suggestedTextStyleChanges?: Record<string, TextStyle>;
suggestedInsertionIds?: string[];
suggestedDeletionIds?: string[];
}
interface TextStyle {
bold?: boolean;
italic?: boolean;
underline?: boolean;
strikethrough?: boolean;
smallCaps?: boolean;
fontSize?: Dimension;
fontFamily?: string;
foregroundColor?: Color;
backgroundColor?: Color;
link?: Link;
baselineOffset?: 'NONE' | 'SUPERSCRIPT' | 'SUBSCRIPT';
weightedFontFamily?: WeightedFontFamily;
}
interface ParagraphStyle {
headingId?: string;
namedStyleType?: NamedStyleType;
alignment?: Alignment;
lineSpacing?: number;
direction?: 'LEFT_TO_RIGHT' | 'RIGHT_TO_LEFT';
spacingMode?: 'NEVER_COLLAPSE' | 'COLLAPSE_LISTS';
spaceAbove?: Dimension;
spaceBelow?: Dimension;
borderBetween?: ParagraphBorder;
borderTop?: ParagraphBorder;
borderBottom?: ParagraphBorder;
borderLeft?: ParagraphBorder;
borderRight?: ParagraphBorder;
indentFirstLine?: Dimension;
indentStart?: Dimension;
indentEnd?: Dimension;
tabStops?: TabStop[];
keepLinesTogether?: boolean;
keepWithNext?: boolean;
avoidWidowAndOrphan?: boolean;
shading?: Shading;
}
// Document model manager
class DocumentModel {
private document: Document;
private listeners: Set<(doc: Document) => void> = new Set();
constructor(initialDoc: Document) {
this.document = initialDoc;
}
// Apply operation to document
applyOperation(operation: Operation): void {
switch (operation.type) {
case 'insert':
this.insertContent(operation.position, operation.content, operation.attributes);
break;
case 'delete':
this.deleteContent(operation.position, operation.length);
break;
case 'format':
this.formatContent(operation.position, operation.length, operation.attributes);
break;
case 'retain':
// No-op for document model, but may carry attributes
if (operation.attributes) {
this.formatContent(0, operation.count, operation.attributes);
}
break;
}
this.notifyListeners();
}
// Get content at position with resolved styles
getContentAt(position: number): ResolvedContent {
const { element, offset } = this.findElementAtPosition(position);
if (element.type === 'textRun') {
return {
type: 'text',
character: element.content[offset],
style: this.resolveTextStyle(element),
paragraphStyle: this.getParagraphStyleForElement(element),
};
}
return { type: element.type, element };
}
// Find element at document position
private findElementAtPosition(position: number): {
element: ParagraphElement;
offset: number;
paragraph: Paragraph;
} {
let currentPosition = 0;
for (const structural of this.document.body.content) {
if (structural.type === 'paragraph') {
for (const element of structural.elements) {
const elementLength = this.getElementLength(element);
if (currentPosition + elementLength > position) {
return {
element,
offset: position - currentPosition,
paragraph: structural,
};
}
currentPosition += elementLength;
}
}
}
throw new Error(`Position ${position} out of bounds`);
}
private getElementLength(element: ParagraphElement): number {
switch (element.type) {
case 'textRun':
return element.content.length;
case 'pageBreak':
case 'columnBreak':
case 'footnoteReference':
case 'horizontalRule':
return 1;
case 'inlineObjectElement':
return 1;
default:
return 0;
}
}
private resolveTextStyle(textRun: TextRun): ResolvedTextStyle {
// Start with document default style
let resolved = { ...this.document.styles.defaultTextStyle };
// Apply named style if paragraph has one
// Apply direct formatting
resolved = { ...resolved, ...textRun.textStyle };
return resolved;
}
private notifyListeners(): void {
for (const listener of this.listeners) {
listener(this.document);
}
}
}
6. Canvas Rendering Engine
The rendering engine is responsible for converting the document model into pixels:
// Layout and rendering pipeline
interface LayoutEngine {
// Input: Document model
// Output: Layout tree with positioned elements
layout(document: Document, viewport: Viewport): LayoutTree;
}
interface RenderEngine {
// Input: Layout tree
// Output: Pixels on canvas
render(layoutTree: LayoutTree, ctx: CanvasRenderingContext2D): void;
}
// Layout tree structure
interface LayoutTree {
pages: PageLayout[];
totalHeight: number;
totalWidth: number;
}
interface PageLayout {
pageNumber: number;
bounds: Rectangle;
columns: ColumnLayout[];
header?: HeaderLayout;
footer?: FooterLayout;
marginBoxes: MarginBox[];
}
interface ColumnLayout {
bounds: Rectangle;
lines: LineLayout[];
}
interface LineLayout {
bounds: Rectangle;
baseline: number;
runs: RunLayout[];
lineHeight: number;
alignment: Alignment;
}
interface RunLayout {
bounds: Rectangle;
content: string;
style: ResolvedTextStyle;
glyphs: GlyphLayout[];
// For hit testing and selection
characterOffsets: number[];
}
interface GlyphLayout {
glyph: number; // Glyph ID in font
x: number;
y: number;
width: number;
height: number;
}
// Text shaping and layout
class TextLayoutEngine {
private fontCache: Map<string, Font> = new Map();
private measurementCanvas: OffscreenCanvas;
private measurementCtx: OffscreenCanvasRenderingContext2D;
constructor() {
this.measurementCanvas = new OffscreenCanvas(1, 1);
this.measurementCtx = this.measurementCanvas.getContext('2d')!;
}
// Layout a paragraph into lines
layoutParagraph(
paragraph: Paragraph,
availableWidth: number,
startY: number
): ParagraphLayout {
const lines: LineLayout[] = [];
let currentLine: RunLayout[] = [];
let currentLineWidth = 0;
let y = startY;
for (const element of paragraph.elements) {
if (element.type === 'textRun') {
const runs = this.layoutTextRun(element, availableWidth - currentLineWidth);
for (const run of runs) {
if (currentLineWidth + run.bounds.width > availableWidth && currentLine.length > 0) {
// Line break needed
lines.push(this.finalizeLine(currentLine, y, availableWidth, paragraph.paragraphStyle));
y += this.getLineHeight(currentLine, paragraph.paragraphStyle);
currentLine = [];
currentLineWidth = 0;
}
currentLine.push(run);
currentLineWidth += run.bounds.width;
}
} else if (element.type === 'inlineObjectElement') {
const objectRun = this.layoutInlineObject(element);
currentLine.push(objectRun);
currentLineWidth += objectRun.bounds.width;
}
}
// Finalize last line
if (currentLine.length > 0) {
lines.push(this.finalizeLine(currentLine, y, availableWidth, paragraph.paragraphStyle));
}
return {
bounds: {
x: 0,
y: startY,
width: availableWidth,
height: y - startY + this.getLineHeight(currentLine, paragraph.paragraphStyle),
},
lines,
};
}
// Layout a text run, potentially breaking into multiple runs
private layoutTextRun(textRun: TextRun, availableWidth: number): RunLayout[] {
const runs: RunLayout[] = [];
const font = this.getFont(textRun.textStyle);
// Shape text (convert to glyphs)
const shapedText = this.shapeText(textRun.content, font, textRun.textStyle);
let currentRun: GlyphLayout[] = [];
let currentWidth = 0;
let wordStart = 0;
for (let i = 0; i < shapedText.glyphs.length; i++) {
const glyph = shapedText.glyphs[i];
// Check for word boundary
if (textRun.content[i] === ' ' || i === shapedText.glyphs.length - 1) {
// End of word
if (currentWidth + glyph.width > availableWidth && currentRun.length > 0) {
// Need to break before this word
runs.push(this.createRunFromGlyphs(
currentRun,
textRun.content.substring(wordStart, i),
textRun.textStyle
));
currentRun = [];
currentWidth = 0;
wordStart = i;
}
}
currentRun.push(glyph);
currentWidth += glyph.width;
}
// Add remaining glyphs
if (currentRun.length > 0) {
runs.push(this.createRunFromGlyphs(
currentRun,
textRun.content.substring(wordStart),
textRun.textStyle
));
}
return runs;
}
// Shape text using HarfBuzz-like algorithm
private shapeText(text: string, font: Font, style: TextStyle): ShapedText {
const glyphs: GlyphLayout[] = [];
let x = 0;
for (let i = 0; i < text.length; i++) {
const char = text[i];
const glyphId = font.charToGlyph(char);
const metrics = font.getGlyphMetrics(glyphId);
// Apply kerning
let kerning = 0;
if (i > 0) {
kerning = font.getKerning(text.charCodeAt(i - 1), text.charCodeAt(i));
}
glyphs.push({
glyph: glyphId,
x: x + kerning,
y: 0, // Baseline relative
width: metrics.advanceWidth,
height: metrics.height,
});
x += metrics.advanceWidth + kerning;
}
return { glyphs, width: x };
}
private finalizeLine(
runs: RunLayout[],
y: number,
availableWidth: number,
style: ParagraphStyle
): LineLayout {
const lineWidth = runs.reduce((sum, run) => sum + run.bounds.width, 0);
const lineHeight = this.getLineHeight(runs, style);
const baseline = this.getBaseline(runs, style);
// Apply alignment
let xOffset = 0;
switch (style.alignment) {
case 'CENTER':
xOffset = (availableWidth - lineWidth) / 2;
break;
case 'END':
xOffset = availableWidth - lineWidth;
break;
case 'JUSTIFIED':
// Distribute space between words
this.justifyLine(runs, availableWidth - lineWidth);
break;
}
// Position runs
let x = xOffset;
for (const run of runs) {
run.bounds.x = x;
run.bounds.y = y;
x += run.bounds.width;
}
return {
bounds: { x: 0, y, width: availableWidth, height: lineHeight },
baseline,
runs,
lineHeight,
alignment: style.alignment || 'START',
};
}
}
// Canvas rendering
class CanvasRenderer {
private canvas: HTMLCanvasElement;
private ctx: CanvasRenderingContext2D;
private dpr: number;
constructor(canvas: HTMLCanvasElement) {
this.canvas = canvas;
this.ctx = canvas.getContext('2d')!;
this.dpr = window.devicePixelRatio || 1;
// Set up high-DPI rendering
this.setupHighDPI();
}
private setupHighDPI(): void {
const rect = this.canvas.getBoundingClientRect();
this.canvas.width = rect.width * this.dpr;
this.canvas.height = rect.height * this.dpr;
this.ctx.scale(this.dpr, this.dpr);
}
// Render visible portion of document
render(layoutTree: LayoutTree, scrollY: number, viewportHeight: number): void {
// Clear canvas
this.ctx.clearRect(0, 0, this.canvas.width, this.canvas.height);
// Find visible pages
const visiblePages = this.getVisiblePages(layoutTree, scrollY, viewportHeight);
for (const page of visiblePages) {
this.renderPage(page, scrollY);
}
}
private renderPage(page: PageLayout, scrollY: number): void {
const offsetY = page.bounds.y - scrollY;
// Draw page background
this.ctx.fillStyle = '#ffffff';
this.ctx.fillRect(page.bounds.x, offsetY, page.bounds.width, page.bounds.height);
// Draw page shadow
this.ctx.shadowColor = 'rgba(0, 0, 0, 0.2)';
this.ctx.shadowBlur = 10;
this.ctx.shadowOffsetY = 2;
this.ctx.fillRect(page.bounds.x, offsetY, page.bounds.width, page.bounds.height);
this.ctx.shadowColor = 'transparent';
// Render columns
for (const column of page.columns) {
this.renderColumn(column, offsetY);
}
// Render header/footer
if (page.header) {
this.renderHeader(page.header, offsetY);
}
if (page.footer) {
this.renderFooter(page.footer, offsetY + page.bounds.height);
}
}
private renderColumn(column: ColumnLayout, pageOffsetY: number): void {
for (const line of column.lines) {
this.renderLine(line, pageOffsetY);
}
}
private renderLine(line: LineLayout, pageOffsetY: number): void {
for (const run of line.runs) {
this.renderRun(run, line.baseline, pageOffsetY);
}
}
private renderRun(run: RunLayout, baseline: number, pageOffsetY: number): void {
const { style, content, bounds, glyphs } = run;
// Set font
this.ctx.font = this.buildFontString(style);
this.ctx.fillStyle = style.foregroundColor?.rgbColor
? `rgb(${style.foregroundColor.rgbColor.red * 255}, ${style.foregroundColor.rgbColor.green * 255}, ${style.foregroundColor.rgbColor.blue * 255})`
: '#000000';
// Draw background if present
if (style.backgroundColor) {
this.ctx.fillStyle = this.colorToCSS(style.backgroundColor);
this.ctx.fillRect(
bounds.x,
bounds.y + pageOffsetY,
bounds.width,
bounds.height
);
this.ctx.fillStyle = style.foregroundColor
? this.colorToCSS(style.foregroundColor)
: '#000000';
}
// Draw text
const y = bounds.y + pageOffsetY + baseline;
// Use glyph positions for precise rendering
for (let i = 0; i < glyphs.length; i++) {
const glyph = glyphs[i];
const char = content[i];
this.ctx.fillText(char, bounds.x + glyph.x, y);
}
// Draw underline
if (style.underline) {
this.ctx.beginPath();
this.ctx.moveTo(bounds.x, y + 2);
this.ctx.lineTo(bounds.x + bounds.width, y + 2);
this.ctx.stroke();
}
// Draw strikethrough
if (style.strikethrough) {
const strikeY = y - (bounds.height / 3);
this.ctx.beginPath();
this.ctx.moveTo(bounds.x, strikeY);
this.ctx.lineTo(bounds.x + bounds.width, strikeY);
this.ctx.stroke();
}
}
private buildFontString(style: TextStyle): string {
const parts: string[] = [];
if (style.italic) parts.push('italic');
if (style.bold) parts.push('bold');
parts.push(`${style.fontSize?.magnitude || 11}pt`);
parts.push(style.fontFamily || 'Arial');
return parts.join(' ');
}
private colorToCSS(color: Color): string {
if (color.rgbColor) {
const { red = 0, green = 0, blue = 0 } = color.rgbColor;
return `rgb(${red * 255}, ${green * 255}, ${blue * 255})`;
}
return '#000000';
}
private getVisiblePages(
layoutTree: LayoutTree,
scrollY: number,
viewportHeight: number
): PageLayout[] {
return layoutTree.pages.filter(page => {
const pageTop = page.bounds.y;
const pageBottom = page.bounds.y + page.bounds.height;
const viewportTop = scrollY;
const viewportBottom = scrollY + viewportHeight;
return pageBottom > viewportTop && pageTop < viewportBottom;
});
}
}
7. Selection & Cursor Management
Managing selections across collaborative editors is complex:
// Selection model for collaborative editing
interface Selection {
anchor: Position; // Where selection started
focus: Position; // Where selection ended (cursor position)
isCollapsed: boolean;
direction: 'forward' | 'backward' | 'none';
}
interface Position {
// Document position
offset: number;
// For sub-element positioning (e.g., within a table cell)
path: number[];
}
interface RemoteSelection {
userId: string;
userName: string;
color: string;
selection: Selection;
}
class SelectionManager {
private localSelection: Selection | null = null;
private remoteSelections: Map<string, RemoteSelection> = new Map();
private layoutEngine: LayoutEngine;
private otEngine: OTEngine;
// Convert screen coordinates to document position
screenToDocumentPosition(x: number, y: number): Position {
const layoutTree = this.layoutEngine.getCurrentLayout();
// Find page at y coordinate
const page = this.findPageAtY(layoutTree, y);
if (!page) return { offset: 0, path: [] };
// Find line at y coordinate
const line = this.findLineAtY(page, y);
if (!line) return { offset: 0, path: [] };
// Find character position at x coordinate
const position = this.findPositionInLine(line, x);
return position;
}
// Convert document position to screen coordinates
documentPositionToScreen(position: Position): { x: number; y: number } {
const layoutTree = this.layoutEngine.getCurrentLayout();
// Find the run containing this position
const { run, offsetInRun } = this.findRunAtPosition(layoutTree, position.offset);
if (!run) return { x: 0, y: 0 };
// Calculate x position within run
let x = run.bounds.x;
for (let i = 0; i < offsetInRun; i++) {
x += run.characterOffsets[i];
}
return { x, y: run.bounds.y };
}
// Update local selection
setSelection(selection: Selection): void {
this.localSelection = selection;
// Broadcast to other users
this.otEngine.broadcastPresence({
type: 'selection',
selection,
});
this.emit('selectionChange', selection);
}
// Handle remote selection update
handleRemoteSelection(userId: string, selection: Selection): void {
const userInfo = this.getUserInfo(userId);
this.remoteSelections.set(userId, {
userId,
userName: userInfo.name,
color: userInfo.color,
selection,
});
this.emit('remoteSelectionChange', this.remoteSelections);
}
// Transform selection when document changes
transformSelection(selection: Selection, operation: Operation): Selection {
return {
anchor: this.transformPosition(selection.anchor, operation),
focus: this.transformPosition(selection.focus, operation),
isCollapsed: selection.isCollapsed,
direction: selection.direction,
};
}
private transformPosition(position: Position, operation: Operation): Position {
let newOffset = position.offset;
switch (operation.type) {
case 'insert':
if (operation.position <= position.offset) {
newOffset += operation.content.length;
}
break;
case 'delete':
if (operation.position + operation.length <= position.offset) {
newOffset -= operation.length;
} else if (operation.position < position.offset) {
newOffset = operation.position;
}
break;
}
return { ...position, offset: Math.max(0, newOffset) };
}
private findLineAtY(page: PageLayout, y: number): LineLayout | null {
for (const column of page.columns) {
for (const line of column.lines) {
if (y >= line.bounds.y && y < line.bounds.y + line.bounds.height) {
return line;
}
}
}
return null;
}
private findPositionInLine(line: LineLayout, x: number): Position {
let offset = 0;
for (const run of line.runs) {
if (x < run.bounds.x) {
return { offset, path: [] };
}
if (x < run.bounds.x + run.bounds.width) {
// Position is within this run
let runX = run.bounds.x;
for (let i = 0; i < run.characterOffsets.length; i++) {
const charWidth = run.characterOffsets[i];
const charMidpoint = runX + charWidth / 2;
if (x < charMidpoint) {
return { offset: offset + i, path: [] };
}
runX += charWidth;
}
return { offset: offset + run.content.length, path: [] };
}
offset += run.content.length;
}
return { offset, path: [] };
}
}
// Render remote cursors
class CursorRenderer {
private canvas: HTMLCanvasElement;
private ctx: CanvasRenderingContext2D;
private selectionManager: SelectionManager;
render(remoteSelections: Map<string, RemoteSelection>): void {
this.ctx.clearRect(0, 0, this.canvas.width, this.canvas.height);
for (const [userId, remote] of remoteSelections) {
this.renderRemoteCursor(remote);
}
}
private renderRemoteCursor(remote: RemoteSelection): void {
const { selection, color, userName } = remote;
if (selection.isCollapsed) {
// Draw cursor line
const pos = this.selectionManager.documentPositionToScreen(selection.focus);
this.drawCursor(pos.x, pos.y, 20, color);
// Draw name label
this.drawNameLabel(pos.x, pos.y - 24, userName, color);
} else {
// Draw selection highlight
this.drawSelection(selection, color);
// Draw cursor at focus
const pos = this.selectionManager.documentPositionToScreen(selection.focus);
this.drawCursor(pos.x, pos.y, 20, color);
this.drawNameLabel(pos.x, pos.y - 24, userName, color);
}
}
private drawCursor(x: number, y: number, height: number, color: string): void {
this.ctx.fillStyle = color;
this.ctx.fillRect(x - 1, y, 2, height);
}
private drawNameLabel(x: number, y: number, name: string, color: string): void {
this.ctx.font = '12px Arial';
const metrics = this.ctx.measureText(name);
const padding = 4;
// Background
this.ctx.fillStyle = color;
this.ctx.fillRect(
x,
y,
metrics.width + padding * 2,
16
);
// Text
this.ctx.fillStyle = '#ffffff';
this.ctx.fillText(name, x + padding, y + 12);
}
private drawSelection(selection: Selection, color: string): void {
// Get all line ranges that the selection covers
const lineRanges = this.getSelectionLineRanges(selection);
this.ctx.fillStyle = color + '40'; // 25% opacity
for (const range of lineRanges) {
this.ctx.fillRect(range.x, range.y, range.width, range.height);
}
}
}
8. Input Handling
Handling input across different browsers, languages, and input methods:
// Comprehensive input handling for document editing
class InputHandler {
private documentModel: DocumentModel;
private selectionManager: SelectionManager;
private hiddenTextArea: HTMLTextAreaElement;
private compositionState: CompositionState | null = null;
constructor(
documentModel: DocumentModel,
selectionManager: SelectionManager
) {
this.documentModel = documentModel;
this.selectionManager = selectionManager;
// Create hidden textarea for IME and clipboard
this.hiddenTextArea = this.createHiddenTextArea();
this.setupEventListeners();
}
private createHiddenTextArea(): HTMLTextAreaElement {
const textarea = document.createElement('textarea');
textarea.style.cssText = `
position: absolute;
left: -9999px;
top: 0;
width: 1px;
height: 1px;
opacity: 0;
pointer-events: none;
`;
document.body.appendChild(textarea);
return textarea;
}
private setupEventListeners(): void {
// Keyboard events
document.addEventListener('keydown', this.handleKeyDown.bind(this));
// IME composition events
this.hiddenTextArea.addEventListener('compositionstart', this.handleCompositionStart.bind(this));
this.hiddenTextArea.addEventListener('compositionupdate', this.handleCompositionUpdate.bind(this));
this.hiddenTextArea.addEventListener('compositionend', this.handleCompositionEnd.bind(this));
// Input events (for IME final input)
this.hiddenTextArea.addEventListener('input', this.handleInput.bind(this));
// Clipboard events
document.addEventListener('copy', this.handleCopy.bind(this));
document.addEventListener('cut', this.handleCut.bind(this));
document.addEventListener('paste', this.handlePaste.bind(this));
// Focus management
document.addEventListener('click', this.handleClick.bind(this));
}
private handleKeyDown(event: KeyboardEvent): void {
// Handle special keys
if (event.key === 'Backspace') {
event.preventDefault();
this.handleBackspace();
return;
}
if (event.key === 'Delete') {
event.preventDefault();
this.handleDelete();
return;
}
if (event.key === 'Enter') {
event.preventDefault();
this.handleEnter(event.shiftKey);
return;
}
if (event.key === 'Tab') {
event.preventDefault();
this.handleTab(event.shiftKey);
return;
}
// Handle keyboard shortcuts
if (event.metaKey || event.ctrlKey) {
if (this.handleShortcut(event)) {
event.preventDefault();
return;
}
}
// Arrow keys for navigation
if (event.key.startsWith('Arrow')) {
event.preventDefault();
this.handleArrowKey(event);
return;
}
// Regular character input - let it go through IME/hidden textarea
if (event.key.length === 1 && !event.metaKey && !event.ctrlKey) {
// Focus hidden textarea to capture input
this.hiddenTextArea.focus();
}
}
private handleShortcut(event: KeyboardEvent): boolean {
const key = event.key.toLowerCase();
switch (key) {
case 'b':
this.toggleFormat('bold');
return true;
case 'i':
this.toggleFormat('italic');
return true;
case 'u':
this.toggleFormat('underline');
return true;
case 'z':
if (event.shiftKey) {
this.documentModel.redo();
} else {
this.documentModel.undo();
}
return true;
case 'y':
this.documentModel.redo();
return true;
case 'a':
this.selectAll();
return true;
default:
return false;
}
}
// IME handling for Asian languages, etc.
private handleCompositionStart(event: CompositionEvent): void {
const selection = this.selectionManager.getSelection();
if (!selection) return;
this.compositionState = {
startPosition: selection.anchor.offset,
currentText: '',
};
}
private handleCompositionUpdate(event: CompositionEvent): void {
if (!this.compositionState) return;
// Show composition text inline (with underline)
this.showCompositionPreview(event.data);
}
private handleCompositionEnd(event: CompositionEvent): void {
if (!this.compositionState) return;
// Remove preview and insert final text
this.clearCompositionPreview();
this.insertText(event.data);
this.compositionState = null;
this.hiddenTextArea.value = '';
}
private handleInput(event: Event): void {
// Handle non-composition input
if (this.compositionState) return;
const target = event.target as HTMLTextAreaElement;
const text = target.value;
if (text) {
this.insertText(text);
target.value = '';
}
}
private handleBackspace(): void {
const selection = this.selectionManager.getSelection();
if (!selection) return;
if (selection.isCollapsed) {
// Delete character before cursor
if (selection.anchor.offset > 0) {
this.documentModel.applyOperation({
type: 'delete',
position: selection.anchor.offset - 1,
length: 1,
});
this.selectionManager.setSelection({
anchor: { offset: selection.anchor.offset - 1, path: [] },
focus: { offset: selection.anchor.offset - 1, path: [] },
isCollapsed: true,
direction: 'none',
});
}
} else {
// Delete selection
this.deleteSelection(selection);
}
}
private handleDelete(): void {
const selection = this.selectionManager.getSelection();
if (!selection) return;
if (selection.isCollapsed) {
// Delete character after cursor
this.documentModel.applyOperation({
type: 'delete',
position: selection.anchor.offset,
length: 1,
});
} else {
this.deleteSelection(selection);
}
}
private handleEnter(shiftKey: boolean): void {
if (shiftKey) {
// Soft line break
this.insertText('\n');
} else {
// New paragraph
this.insertParagraphBreak();
}
}
private insertText(text: string): void {
const selection = this.selectionManager.getSelection();
if (!selection) return;
// Delete selection first if not collapsed
if (!selection.isCollapsed) {
this.deleteSelection(selection);
}
// Insert text
this.documentModel.applyOperation({
type: 'insert',
position: selection.anchor.offset,
content: text,
});
// Move cursor after inserted text
this.selectionManager.setSelection({
anchor: { offset: selection.anchor.offset + text.length, path: [] },
focus: { offset: selection.anchor.offset + text.length, path: [] },
isCollapsed: true,
direction: 'none',
});
}
private deleteSelection(selection: Selection): void {
const start = Math.min(selection.anchor.offset, selection.focus.offset);
const end = Math.max(selection.anchor.offset, selection.focus.offset);
this.documentModel.applyOperation({
type: 'delete',
position: start,
length: end - start,
});
this.selectionManager.setSelection({
anchor: { offset: start, path: [] },
focus: { offset: start, path: [] },
isCollapsed: true,
direction: 'none',
});
}
private toggleFormat(format: 'bold' | 'italic' | 'underline'): void {
const selection = this.selectionManager.getSelection();
if (!selection || selection.isCollapsed) return;
const start = Math.min(selection.anchor.offset, selection.focus.offset);
const end = Math.max(selection.anchor.offset, selection.focus.offset);
// Check current format state
const currentState = this.documentModel.getFormatAt(start, format);
this.documentModel.applyOperation({
type: 'format',
position: start,
length: end - start,
attributes: { [format]: !currentState },
});
}
// Clipboard handling
private handleCopy(event: ClipboardEvent): void {
const selection = this.selectionManager.getSelection();
if (!selection || selection.isCollapsed) return;
event.preventDefault();
const start = Math.min(selection.anchor.offset, selection.focus.offset);
const end = Math.max(selection.anchor.offset, selection.focus.offset);
// Get content with formatting
const content = this.documentModel.getContentRange(start, end);
// Set both plain text and rich text formats
event.clipboardData?.setData('text/plain', content.plainText);
event.clipboardData?.setData('text/html', content.html);
event.clipboardData?.setData('application/x-google-docs-document-slice', JSON.stringify(content.native));
}
private handleCut(event: ClipboardEvent): void {
this.handleCopy(event);
const selection = this.selectionManager.getSelection();
if (selection && !selection.isCollapsed) {
this.deleteSelection(selection);
}
}
private async handlePaste(event: ClipboardEvent): Promise<void> {
event.preventDefault();
const clipboardData = event.clipboardData;
if (!clipboardData) return;
// Try to get Google Docs native format first
const nativeData = clipboardData.getData('application/x-google-docs-document-slice');
if (nativeData) {
const content = JSON.parse(nativeData);
this.insertNativeContent(content);
return;
}
// Try HTML
const htmlData = clipboardData.getData('text/html');
if (htmlData) {
const content = this.parseHtmlToDocContent(htmlData);
this.insertNativeContent(content);
return;
}
// Fall back to plain text
const textData = clipboardData.getData('text/plain');
if (textData) {
this.insertText(textData);
}
// Handle images
const items = clipboardData.items;
for (const item of items) {
if (item.type.startsWith('image/')) {
const blob = item.getAsFile();
if (blob) {
await this.insertImage(blob);
}
}
}
}
}
9. Offline Support
Google Docs works offline with automatic sync when reconnected:
// Offline-first architecture
class OfflineManager {
private db: IDBDatabase;
private operationQueue: Operation[] = [];
private isOnline: boolean = navigator.onLine;
private otEngine: OTEngine;
private documentId: string;
constructor(documentId: string, otEngine: OTEngine) {
this.documentId = documentId;
this.otEngine = otEngine;
this.initIndexedDB();
this.setupNetworkListeners();
}
private async initIndexedDB(): Promise<void> {
return new Promise((resolve, reject) => {
const request = indexedDB.open('google-docs-offline', 1);
request.onerror = () => reject(request.error);
request.onsuccess = () => {
this.db = request.result;
resolve();
};
request.onupgradeneeded = (event) => {
const db = (event.target as IDBOpenDBRequest).result;
// Store for document snapshots
if (!db.objectStoreNames.contains('documents')) {
const docStore = db.createObjectStore('documents', { keyPath: 'id' });
docStore.createIndex('lastModified', 'lastModified');
}
// Store for pending operations
if (!db.objectStoreNames.contains('operations')) {
const opStore = db.createObjectStore('operations', {
keyPath: 'id',
autoIncrement: true,
});
opStore.createIndex('documentId', 'documentId');
opStore.createIndex('timestamp', 'timestamp');
}
};
});
}
private setupNetworkListeners(): void {
window.addEventListener('online', () => {
this.isOnline = true;
this.syncPendingOperations();
});
window.addEventListener('offline', () => {
this.isOnline = false;
});
}
// Save document snapshot for offline access
async saveDocumentSnapshot(document: Document): Promise<void> {
const transaction = this.db.transaction('documents', 'readwrite');
const store = transaction.objectStore('documents');
await new Promise<void>((resolve, reject) => {
const request = store.put({
id: this.documentId,
document,
lastModified: Date.now(),
revision: document.revision,
});
request.onsuccess = () => resolve();
request.onerror = () => reject(request.error);
});
}
// Load document from offline cache
async loadDocumentSnapshot(): Promise<Document | null> {
const transaction = this.db.transaction('documents', 'readonly');
const store = transaction.objectStore('documents');
return new Promise((resolve, reject) => {
const request = store.get(this.documentId);
request.onsuccess = () => {
resolve(request.result?.document || null);
};
request.onerror = () => reject(request.error);
});
}
// Queue operation for offline storage
async queueOperation(operation: Operation): Promise<void> {
const transaction = this.db.transaction('operations', 'readwrite');
const store = transaction.objectStore('operations');
await new Promise<void>((resolve, reject) => {
const request = store.add({
documentId: this.documentId,
operation,
timestamp: Date.now(),
});
request.onsuccess = () => resolve();
request.onerror = () => reject(request.error);
});
}
// Sync pending operations when back online
async syncPendingOperations(): Promise<void> {
const transaction = this.db.transaction('operations', 'readonly');
const store = transaction.objectStore('operations');
const index = store.index('documentId');
const pendingOps = await new Promise<StoredOperation[]>((resolve, reject) => {
const request = index.getAll(IDBKeyRange.only(this.documentId));
request.onsuccess = () => resolve(request.result);
request.onerror = () => reject(request.error);
});
if (pendingOps.length === 0) return;
// Sort by timestamp
pendingOps.sort((a, b) => a.timestamp - b.timestamp);
// Compose all pending operations
const composed = composeOperations(pendingOps.map(p => p.operation));
try {
// Send to server
await this.otEngine.sendOperation(composed);
// Clear pending operations on success
await this.clearPendingOperations();
} catch (error) {
console.error('Failed to sync pending operations:', error);
// Keep operations for retry
}
}
private async clearPendingOperations(): Promise<void> {
const transaction = this.db.transaction('operations', 'readwrite');
const store = transaction.objectStore('operations');
const index = store.index('documentId');
const cursor = index.openCursor(IDBKeyRange.only(this.documentId));
await new Promise<void>((resolve, reject) => {
cursor.onsuccess = (event) => {
const result = (event.target as IDBRequest).result;
if (result) {
result.delete();
result.continue();
} else {
resolve();
}
};
cursor.onerror = () => reject(cursor.error);
});
}
// Handle operation when offline
async handleOfflineOperation(operation: Operation): Promise<void> {
// Store operation
await this.queueOperation(operation);
// Apply locally (optimistic)
this.otEngine.applyLocal(operation);
// Update local snapshot
const currentDoc = this.otEngine.getDocument();
await this.saveDocumentSnapshot(currentDoc);
}
}
// Service Worker for offline caching
// sw.js
const CACHE_NAME = 'google-docs-v1';
const STATIC_ASSETS = [
'/',
'/document',
'/static/js/main.js',
'/static/css/main.css',
'/static/fonts/roboto.woff2',
];
self.addEventListener('install', (event: ExtendableEvent) => {
event.waitUntil(
caches.open(CACHE_NAME).then(cache => {
return cache.addAll(STATIC_ASSETS);
})
);
});
self.addEventListener('fetch', (event: FetchEvent) => {
const url = new URL(event.request.url);
// API requests - network first, then cache
if (url.pathname.startsWith('/api/')) {
event.respondWith(
fetch(event.request)
.then(response => {
// Clone and cache successful responses
if (response.ok) {
const responseClone = response.clone();
caches.open(CACHE_NAME).then(cache => {
cache.put(event.request, responseClone);
});
}
return response;
})
.catch(() => {
// Return cached response if network fails
return caches.match(event.request);
})
);
return;
}
// Static assets - cache first
event.respondWith(
caches.match(event.request).then(response => {
return response || fetch(event.request);
})
);
});
10. Comments & Suggestions System
The comments system allows threaded discussions anchored to document positions:
// Comment and suggestion system
interface Comment {
id: string;
content: string;
author: User;
createdTime: string;
modifiedTime: string;
resolved: boolean;
replies: Reply[];
anchor: CommentAnchor;
quotedContent?: string;
}
interface CommentAnchor {
// Range in document this comment refers to
startOffset: number;
endOffset: number;
// For robustness - text content at time of commenting
quotedText: string;
}
interface Suggestion {
id: string;
author: User;
createdTime: string;
// The change being suggested
originalContent: string;
suggestedContent: string;
anchor: SuggestionAnchor;
status: 'pending' | 'accepted' | 'rejected';
}
interface SuggestionAnchor {
startOffset: number;
endOffset: number;
}
class CommentManager {
private comments: Map<string, Comment> = new Map();
private suggestions: Map<string, Suggestion> = new Map();
private documentModel: DocumentModel;
constructor(documentModel: DocumentModel) {
this.documentModel = documentModel;
// Listen for document changes to update anchors
documentModel.on('operation', this.handleDocumentOperation.bind(this));
}
// Add a new comment
async addComment(
startOffset: number,
endOffset: number,
content: string
): Promise<Comment> {
const quotedText = this.documentModel.getTextRange(startOffset, endOffset);
const comment: Comment = {
id: generateId(),
content,
author: getCurrentUser(),
createdTime: new Date().toISOString(),
modifiedTime: new Date().toISOString(),
resolved: false,
replies: [],
anchor: {
startOffset,
endOffset,
quotedText,
},
quotedContent: quotedText,
};
this.comments.set(comment.id, comment);
// Save to server
await this.saveComment(comment);
return comment;
}
// Add a suggestion (track changes)
async addSuggestion(
startOffset: number,
endOffset: number,
suggestedContent: string
): Promise<Suggestion> {
const originalContent = this.documentModel.getTextRange(startOffset, endOffset);
const suggestion: Suggestion = {
id: generateId(),
author: getCurrentUser(),
createdTime: new Date().toISOString(),
originalContent,
suggestedContent,
anchor: { startOffset, endOffset },
status: 'pending',
};
this.suggestions.set(suggestion.id, suggestion);
// Apply suggestion as a tracked change in document
this.documentModel.applyOperation({
type: 'suggest',
suggestionId: suggestion.id,
position: startOffset,
length: endOffset - startOffset,
replacement: suggestedContent,
});
await this.saveSuggestion(suggestion);
return suggestion;
}
// Accept suggestion
async acceptSuggestion(suggestionId: string): Promise<void> {
const suggestion = this.suggestions.get(suggestionId);
if (!suggestion) return;
// Apply the suggested change permanently
this.documentModel.applyOperation({
type: 'acceptSuggestion',
suggestionId,
});
suggestion.status = 'accepted';
await this.saveSuggestion(suggestion);
}
// Reject suggestion
async rejectSuggestion(suggestionId: string): Promise<void> {
const suggestion = this.suggestions.get(suggestionId);
if (!suggestion) return;
// Revert to original content
this.documentModel.applyOperation({
type: 'rejectSuggestion',
suggestionId,
});
suggestion.status = 'rejected';
await this.saveSuggestion(suggestion);
}
// Update comment/suggestion anchors when document changes
private handleDocumentOperation(operation: Operation): void {
// Update comment anchors
for (const [id, comment] of this.comments) {
comment.anchor = this.transformAnchor(comment.anchor, operation);
}
// Update suggestion anchors
for (const [id, suggestion] of this.suggestions) {
if (suggestion.status === 'pending') {
suggestion.anchor = this.transformAnchor(suggestion.anchor, operation);
}
}
}
private transformAnchor(
anchor: { startOffset: number; endOffset: number },
operation: Operation
): { startOffset: number; endOffset: number } {
let { startOffset, endOffset } = anchor;
switch (operation.type) {
case 'insert':
if (operation.position <= startOffset) {
startOffset += operation.content.length;
endOffset += operation.content.length;
} else if (operation.position < endOffset) {
endOffset += operation.content.length;
}
break;
case 'delete':
const deleteEnd = operation.position + operation.length;
if (deleteEnd <= startOffset) {
// Delete is before anchor
startOffset -= operation.length;
endOffset -= operation.length;
} else if (operation.position >= endOffset) {
// Delete is after anchor - no change
} else {
// Delete overlaps anchor - adjust
if (operation.position < startOffset) {
startOffset = operation.position;
}
if (deleteEnd < endOffset) {
endOffset -= operation.length;
} else {
endOffset = startOffset;
}
}
break;
}
return { startOffset: Math.max(0, startOffset), endOffset: Math.max(0, endOffset) };
}
}
// Comment sidebar component
function CommentSidebar({ documentId }: { documentId: string }) {
const { comments, loading } = useComments(documentId);
const [activeComment, setActiveComment] = useState<string | null>(null);
// Group comments by their vertical position in document
const positionedComments = useMemo(() => {
return comments
.filter(c => !c.resolved)
.map(comment => ({
...comment,
// Calculate Y position based on anchor
yPosition: getYPositionForOffset(comment.anchor.startOffset),
}))
.sort((a, b) => a.yPosition - b.yPosition);
}, [comments]);
// Resolve overlapping comment positions
const layoutComments = useMemo(() => {
const minGap = 100; // Minimum pixels between comments
const result: PositionedComment[] = [];
let lastY = 0;
for (const comment of positionedComments) {
const y = Math.max(comment.yPosition, lastY + minGap);
result.push({ ...comment, displayY: y });
lastY = y;
}
return result;
}, [positionedComments]);
return (
<aside className="comment-sidebar">
{layoutComments.map(comment => (
<CommentThread
key={comment.id}
comment={comment}
style={{ top: comment.displayY }}
isActive={activeComment === comment.id}
onActivate={() => setActiveComment(comment.id)}
onDeactivate={() => setActiveComment(null)}
/>
))}
</aside>
);
}
function CommentThread({
comment,
style,
isActive,
onActivate,
onDeactivate,
}: CommentThreadProps) {
const [replyText, setReplyText] = useState('');
const { addReply, resolveComment } = useCommentActions(comment.id);
return (
<div
className={`comment-thread ${isActive ? 'active' : ''}`}
style={style}
onClick={onActivate}
>
{/* Connection line to document */}
<div className="comment-connector" />
{/* Main comment */}
<div className="comment-main">
<div className="comment-header">
<Avatar user={comment.author} size="small" />
<span className="author-name">{comment.author.name}</span>
<span className="timestamp">{formatRelativeTime(comment.createdTime)}</span>
</div>
{comment.quotedContent && (
<blockquote className="quoted-content">
"{comment.quotedContent}"
</blockquote>
)}
<p className="comment-content">{comment.content}</p>
</div>
{/* Replies */}
{comment.replies.map(reply => (
<div key={reply.id} className="comment-reply">
<Avatar user={reply.author} size="small" />
<div>
<span className="author-name">{reply.author.name}</span>
<p>{reply.content}</p>
</div>
</div>
))}
{/* Reply input (shown when active) */}
{isActive && (
<div className="reply-input">
<input
type="text"
placeholder="Reply..."
value={replyText}
onChange={(e) => setReplyText(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter' && replyText.trim()) {
addReply(replyText);
setReplyText('');
}
}}
/>
<button onClick={() => resolveComment()}>Resolve</button>
</div>
)}
</div>
);
}
11. Version History
Complete revision history with the ability to restore any previous version:
// Version history system
interface Revision {
id: string;
documentId: string;
timestamp: string;
author: User;
// Snapshot or delta from previous revision
type: 'snapshot' | 'delta';
data: RevisionData;
// Summary of changes
summary: RevisionSummary;
}
interface RevisionSummary {
addedCharacters: number;
deletedCharacters: number;
formattingChanges: number;
imageChanges: number;
}
class RevisionHistoryManager {
private revisions: Revision[] = [];
private documentId: string;
private currentDocument: Document;
// Automatic snapshot every N operations or M minutes
private operationsSinceSnapshot: number = 0;
private lastSnapshotTime: number = Date.now();
private snapshotThreshold = 100; // operations
private timeThreshold = 5 * 60 * 1000; // 5 minutes
constructor(documentId: string, document: Document) {
this.documentId = documentId;
this.currentDocument = document;
}
// Record an operation
recordOperation(operation: Operation, author: User): void {
this.operationsSinceSnapshot++;
// Check if we should create a snapshot
const timeSinceSnapshot = Date.now() - this.lastSnapshotTime;
if (
this.operationsSinceSnapshot >= this.snapshotThreshold ||
timeSinceSnapshot >= this.timeThreshold
) {
this.createSnapshot(author);
}
}
// Create a snapshot revision
private async createSnapshot(author: User): Promise<void> {
const revision: Revision = {
id: generateId(),
documentId: this.documentId,
timestamp: new Date().toISOString(),
author,
type: 'snapshot',
data: {
document: this.currentDocument,
},
summary: this.calculateSummary(),
};
this.revisions.push(revision);
this.operationsSinceSnapshot = 0;
this.lastSnapshotTime = Date.now();
await this.saveRevision(revision);
}
// Get revision list (grouped by session/time)
getRevisionList(): RevisionGroup[] {
const groups: RevisionGroup[] = [];
let currentGroup: RevisionGroup | null = null;
for (const revision of this.revisions) {
const revisionTime = new Date(revision.timestamp);
// Group revisions by author and time proximity
if (
!currentGroup ||
currentGroup.author.id !== revision.author.id ||
revisionTime.getTime() - currentGroup.endTime.getTime() > 30 * 60 * 1000 // 30 min gap
) {
currentGroup = {
id: revision.id,
author: revision.author,
startTime: revisionTime,
endTime: revisionTime,
revisions: [],
summary: { addedCharacters: 0, deletedCharacters: 0, formattingChanges: 0, imageChanges: 0 },
};
groups.push(currentGroup);
}
currentGroup.endTime = revisionTime;
currentGroup.revisions.push(revision);
// Aggregate summary
currentGroup.summary.addedCharacters += revision.summary.addedCharacters;
currentGroup.summary.deletedCharacters += revision.summary.deletedCharacters;
currentGroup.summary.formattingChanges += revision.summary.formattingChanges;
currentGroup.summary.imageChanges += revision.summary.imageChanges;
}
return groups.reverse(); // Most recent first
}
// Get document at specific revision
async getDocumentAtRevision(revisionId: string): Promise<Document> {
const revisionIndex = this.revisions.findIndex(r => r.id === revisionId);
if (revisionIndex === -1) {
throw new Error('Revision not found');
}
// Find nearest snapshot at or before this revision
let snapshotIndex = revisionIndex;
while (snapshotIndex >= 0 && this.revisions[snapshotIndex].type !== 'snapshot') {
snapshotIndex--;
}
if (snapshotIndex < 0) {
throw new Error('No snapshot found');
}
// Start with snapshot
let document = this.revisions[snapshotIndex].data.document;
// Apply deltas to reach target revision
for (let i = snapshotIndex + 1; i <= revisionIndex; i++) {
const revision = this.revisions[i];
if (revision.type === 'delta') {
document = applyDelta(document, revision.data.delta);
} else {
document = revision.data.document;
}
}
return document;
}
// Restore document to specific revision
async restoreToRevision(revisionId: string): Promise<void> {
const document = await this.getDocumentAtRevision(revisionId);
// Create restore operation
const restoreOperation: Operation = {
type: 'restore',
revisionId,
document,
};
// Apply through OT engine (will be synced to other clients)
this.otEngine.applyLocal(restoreOperation);
// Create new snapshot
await this.createSnapshot(getCurrentUser());
}
// Named versions (manual saves)
async createNamedVersion(name: string): Promise<Revision> {
const revision: Revision = {
id: generateId(),
documentId: this.documentId,
timestamp: new Date().toISOString(),
author: getCurrentUser(),
type: 'snapshot',
data: {
document: this.currentDocument,
name, // Named version
},
summary: this.calculateSummary(),
};
this.revisions.push(revision);
await this.saveRevision(revision);
return revision;
}
}
// Version history UI
function VersionHistoryPanel({ documentId }: { documentId: string }) {
const { revisionGroups, loading } = useRevisionHistory(documentId);
const [selectedRevision, setSelectedRevision] = useState<string | null>(null);
const [previewDocument, setPreviewDocument] = useState<Document | null>(null);
const handleSelectRevision = async (revisionId: string) => {
setSelectedRevision(revisionId);
const doc = await revisionHistoryManager.getDocumentAtRevision(revisionId);
setPreviewDocument(doc);
};
return (
<div className="version-history-panel">
<header>
<h2>Version history</h2>
<button onClick={() => restoreToRevision(selectedRevision)}>
Restore this version
</button>
</header>
<div className="revision-list">
{revisionGroups.map(group => (
<div key={group.id} className="revision-group">
<div className="group-header">
<span className="date">
{formatDate(group.startTime)}
</span>
</div>
<div className="group-content">
<Avatar user={group.author} />
<div className="group-details">
<span className="author">{group.author.name}</span>
<span className="time-range">
{formatTimeRange(group.startTime, group.endTime)}
</span>
<span className="summary">
{formatSummary(group.summary)}
</span>
</div>
{/* Expandable list of individual revisions */}
<RevisionList
revisions={group.revisions}
selectedId={selectedRevision}
onSelect={handleSelectRevision}
/>
</div>
</div>
))}
</div>
{/* Preview pane */}
{previewDocument && (
<div className="revision-preview">
<DocumentPreview document={previewDocument} />
</div>
)}
</div>
);
}
12. Accessibility
Making a canvas-based editor accessible requires extensive work:
// Accessibility layer for canvas-based rendering
class AccessibilityManager {
private accessibilityTree: AccessibilityNode;
private liveRegion: HTMLElement;
private documentModel: DocumentModel;
private selectionManager: SelectionManager;
constructor(documentModel: DocumentModel, selectionManager: SelectionManager) {
this.documentModel = documentModel;
this.selectionManager = selectionManager;
this.createAccessibilityDOM();
this.setupKeyboardNavigation();
this.setupScreenReaderSupport();
}
// Create hidden accessible DOM that mirrors document structure
private createAccessibilityDOM(): void {
// Create container that's visually hidden but accessible
const container = document.createElement('div');
container.id = 'docs-accessibility-tree';
container.setAttribute('role', 'document');
container.setAttribute('aria-label', 'Document content');
container.style.cssText = `
position: absolute;
left: -10000px;
top: 0;
width: 1px;
height: 1px;
overflow: hidden;
`;
document.body.appendChild(container);
this.accessibilityTree = { element: container, children: [] };
// Create live region for announcements
this.liveRegion = document.createElement('div');
this.liveRegion.setAttribute('aria-live', 'polite');
this.liveRegion.setAttribute('aria-atomic', 'true');
this.liveRegion.className = 'sr-only';
document.body.appendChild(this.liveRegion);
// Build initial tree
this.rebuildAccessibilityTree();
// Listen for document changes
this.documentModel.on('change', () => this.updateAccessibilityTree());
}
// Build accessible DOM tree from document model
private rebuildAccessibilityTree(): void {
const container = this.accessibilityTree.element;
container.innerHTML = '';
const document = this.documentModel.getDocument();
for (const element of document.body.content) {
const node = this.createAccessibleNode(element);
if (node) {
container.appendChild(node);
}
}
}
private createAccessibleNode(element: StructuralElement): HTMLElement | null {
switch (element.type) {
case 'paragraph':
return this.createParagraphNode(element);
case 'table':
return this.createTableNode(element);
case 'sectionBreak':
return this.createSectionBreakNode(element);
default:
return null;
}
}
private createParagraphNode(paragraph: Paragraph): HTMLElement {
// Determine appropriate element based on style
let element: HTMLElement;
if (paragraph.paragraphStyle.namedStyleType?.startsWith('HEADING')) {
const level = paragraph.paragraphStyle.namedStyleType.slice(-1);
element = document.createElement(`h${level}`);
} else if (paragraph.bullet) {
element = document.createElement('li');
} else {
element = document.createElement('p');
}
// Build text content with accessible formatting
for (const run of paragraph.elements) {
if (run.type === 'textRun') {
const span = this.createTextRunNode(run);
element.appendChild(span);
} else if (run.type === 'inlineObjectElement') {
const img = this.createImageNode(run);
element.appendChild(img);
}
}
return element;
}
private createTextRunNode(textRun: TextRun): HTMLElement {
let element: HTMLElement = document.createElement('span');
element.textContent = textRun.content;
// Wrap with formatting elements
if (textRun.textStyle.bold) {
const strong = document.createElement('strong');
strong.appendChild(element);
element = strong;
}
if (textRun.textStyle.italic) {
const em = document.createElement('em');
em.appendChild(element);
element = em;
}
if (textRun.textStyle.link) {
const a = document.createElement('a');
a.href = textRun.textStyle.link.url;
a.appendChild(element);
element = a;
}
return element;
}
private createTableNode(table: Table): HTMLElement {
const tableElement = document.createElement('table');
tableElement.setAttribute('role', 'table');
for (let rowIndex = 0; rowIndex < table.rows; rowIndex++) {
const tr = document.createElement('tr');
for (let colIndex = 0; colIndex < table.columns; colIndex++) {
const cell = table.cells[rowIndex][colIndex];
const td = document.createElement(rowIndex === 0 ? 'th' : 'td');
// Set cell content
for (const element of cell.content) {
const node = this.createAccessibleNode(element);
if (node) td.appendChild(node);
}
tr.appendChild(td);
}
tableElement.appendChild(tr);
}
return tableElement;
}
// Announce changes to screen readers
announce(message: string, priority: 'polite' | 'assertive' = 'polite'): void {
this.liveRegion.setAttribute('aria-live', priority);
this.liveRegion.textContent = message;
// Clear after announcement
setTimeout(() => {
this.liveRegion.textContent = '';
}, 1000);
}
// Announce selection changes
private announceSelectionChange(selection: Selection): void {
if (selection.isCollapsed) {
// Announce cursor position
const context = this.getContextAtPosition(selection.focus.offset);
this.announce(`Cursor at ${context}`);
} else {
// Announce selected text
const selectedText = this.documentModel.getTextRange(
Math.min(selection.anchor.offset, selection.focus.offset),
Math.max(selection.anchor.offset, selection.focus.offset)
);
if (selectedText.length < 100) {
this.announce(`Selected: ${selectedText}`);
} else {
this.announce(`Selected ${selectedText.length} characters`);
}
}
}
private getContextAtPosition(offset: number): string {
const content = this.documentModel.getContentAt(offset);
if (content.type === 'text') {
// Get surrounding context
const before = this.documentModel.getTextRange(
Math.max(0, offset - 10),
offset
);
const after = this.documentModel.getTextRange(
offset,
Math.min(this.documentModel.getLength(), offset + 10)
);
return `"${before}" cursor "${after}"`;
}
return `${content.type} element`;
}
// Keyboard navigation
private setupKeyboardNavigation(): void {
document.addEventListener('keydown', (event) => {
// Ctrl+Home: Go to beginning
if (event.ctrlKey && event.key === 'Home') {
this.selectionManager.setSelection({
anchor: { offset: 0, path: [] },
focus: { offset: 0, path: [] },
isCollapsed: true,
direction: 'none',
});
this.announce('Beginning of document');
event.preventDefault();
}
// Ctrl+End: Go to end
if (event.ctrlKey && event.key === 'End') {
const length = this.documentModel.getLength();
this.selectionManager.setSelection({
anchor: { offset: length, path: [] },
focus: { offset: length, path: [] },
isCollapsed: true,
direction: 'none',
});
this.announce('End of document');
event.preventDefault();
}
// F7: Toggle caret browsing (read-only navigation)
if (event.key === 'F7') {
this.toggleCaretBrowsing();
event.preventDefault();
}
});
}
}
13. Performance Optimization
Handling large documents requires extensive optimization:
// Performance optimizations for large documents
class PerformanceOptimizer {
private documentModel: DocumentModel;
private layoutEngine: LayoutEngine;
private renderEngine: RenderEngine;
// Virtualization - only layout/render visible content
private visibleRange: { start: number; end: number } = { start: 0, end: 0 };
private layoutCache: Map<string, LayoutResult> = new Map();
private renderCache: Map<string, ImageBitmap> = new Map();
// Incremental updates
private dirtyRegions: Set<string> = new Set();
private pendingLayout: boolean = false;
private pendingRender: boolean = false;
constructor(
documentModel: DocumentModel,
layoutEngine: LayoutEngine,
renderEngine: RenderEngine
) {
this.documentModel = documentModel;
this.layoutEngine = layoutEngine;
this.renderEngine = renderEngine;
this.setupIncrementalUpdates();
}
// Handle scroll - update visible range
handleScroll(scrollTop: number, viewportHeight: number): void {
const pageHeight = 1056; // Standard page height in pixels
const buffer = pageHeight; // Render one page above/below
const newStart = Math.max(0, scrollTop - buffer);
const newEnd = scrollTop + viewportHeight + buffer;
if (newStart !== this.visibleRange.start || newEnd !== this.visibleRange.end) {
this.visibleRange = { start: newStart, end: newEnd };
this.scheduleRender();
}
}
// Mark region as needing re-layout
invalidateLayout(startOffset: number, endOffset: number): void {
// Find affected paragraphs/pages
const affectedPages = this.getAffectedPages(startOffset, endOffset);
for (const pageId of affectedPages) {
this.layoutCache.delete(pageId);
this.renderCache.delete(pageId);
this.dirtyRegions.add(pageId);
}
this.scheduleLayout();
}
// Incremental layout - only re-layout dirty regions
private async performIncrementalLayout(): Promise<void> {
if (this.dirtyRegions.size === 0) return;
const startTime = performance.now();
const budget = 16; // 16ms budget for 60fps
for (const pageId of this.dirtyRegions) {
// Check if we've exceeded time budget
if (performance.now() - startTime > budget) {
// Schedule continuation
this.scheduleLayout();
break;
}
// Layout this page
const pageLayout = await this.layoutEngine.layoutPage(pageId);
this.layoutCache.set(pageId, pageLayout);
this.dirtyRegions.delete(pageId);
}
this.scheduleRender();
}
// Render visible pages
private async performRender(): Promise<void> {
const visiblePages = this.getVisiblePages();
for (const pageId of visiblePages) {
let bitmap = this.renderCache.get(pageId);
if (!bitmap) {
// Render to offscreen canvas
const layout = this.layoutCache.get(pageId);
if (layout) {
bitmap = await this.renderPageTobitmap(layout);
this.renderCache.set(pageId, bitmap);
}
}
if (bitmap) {
this.renderEngine.drawBitmap(bitmap, this.getPagePosition(pageId));
}
}
}
// Render page to ImageBitmap for caching
private async renderPageTobitmap(layout: LayoutResult): Promise<ImageBitmap> {
const offscreen = new OffscreenCanvas(layout.width, layout.height);
const ctx = offscreen.getContext('2d')!;
// Render page content
this.renderEngine.renderToContext(ctx, layout);
// Create bitmap for efficient reuse
return createImageBitmap(offscreen);
}
// Memory management - evict old cache entries
manageMemory(): void {
const maxCacheSize = 50; // Max pages to cache
// Evict pages far from visible range
if (this.layoutCache.size > maxCacheSize) {
const visiblePages = new Set(this.getVisiblePages());
for (const [pageId, _] of this.layoutCache) {
if (!visiblePages.has(pageId)) {
this.layoutCache.delete(pageId);
this.renderCache.get(pageId)?.close(); // Free ImageBitmap memory
this.renderCache.delete(pageId);
}
if (this.layoutCache.size <= maxCacheSize) break;
}
}
}
// Debounced layout scheduling
private scheduleLayout(): void {
if (this.pendingLayout) return;
this.pendingLayout = true;
requestIdleCallback(() => {
this.pendingLayout = false;
this.performIncrementalLayout();
});
}
// RAF-based render scheduling
private scheduleRender(): void {
if (this.pendingRender) return;
this.pendingRender = true;
requestAnimationFrame(() => {
this.pendingRender = false;
this.performRender();
});
}
private setupIncrementalUpdates(): void {
this.documentModel.on('operation', (operation: Operation) => {
// Determine affected range
let start = operation.position;
let end = operation.position;
switch (operation.type) {
case 'insert':
end = operation.position + operation.content.length;
break;
case 'delete':
end = operation.position + operation.length;
break;
case 'format':
end = operation.position + operation.length;
break;
}
this.invalidateLayout(start, end);
});
}
}
// Web Worker for heavy computations
// layout-worker.ts
self.onmessage = async (event: MessageEvent) => {
const { type, payload } = event.data;
switch (type) {
case 'LAYOUT_PAGE': {
const { pageId, content, styles } = payload;
const layout = performLayout(content, styles);
self.postMessage({ type: 'LAYOUT_RESULT', pageId, layout });
break;
}
case 'SPELL_CHECK': {
const { text, language } = payload;
const errors = performSpellCheck(text, language);
self.postMessage({ type: 'SPELL_CHECK_RESULT', errors });
break;
}
case 'SEARCH': {
const { query, document } = payload;
const results = searchDocument(query, document);
self.postMessage({ type: 'SEARCH_RESULT', results });
break;
}
}
};
14. Architecture Evolution
┌─────────────────────────────────────────────────────────────────────────────┐
│ GOOGLE DOCS ARCHITECTURE EVOLUTION │
├─────────────────────────────────────────────────────────────────────────────┤
│ │
│ 2006 2010 2014 2018 2021 2024 │
│ │ │ │ │ │ │ │
│ ▼ ▼ ▼ ▼ ▼ ▼ │
│ ┌─────┐ ┌─────┐ ┌─────┐ ┌─────┐ ┌─────┐ ┌─────┐ │
│ │Writely│ │ OT │ │ Real│ │Offline│ │Canvas│ │ AI │ │
│ │Acqui │ │Engine│ │-time│ │First │ │Render│ │Integ│ │
│ │sition│ │ │ │Collab│ │ │ │ │ │ation│ │
│ └─────┘ └─────┘ └─────┘ └─────┘ └─────┘ └─────┘ │
│ │
│ KEY MILESTONES: │
│ ─────────────── │
│ 2006: Google acquires Writely, basic web editor │
│ 2010: Operational Transformation enables real-time collaboration │
│ 2014: 60+ concurrent editors supported │
│ 2018: Offline-first architecture, Service Workers │
│ 2021: Canvas-based rendering replaces DOM │
│ 2024: AI-powered writing assistance integrated │
│ │
│ RENDERING EVOLUTION: │
│ ──────────────────── │
│ v1: contenteditable + DOM manipulation │
│ v2: Custom DOM with synthetic selection │
│ v3: Canvas rendering with accessibility tree │
│ │
│ PERFORMANCE IMPROVEMENTS: │
│ ───────────────────────── │
│ Document open time: 5s → 1s → 500ms │
│ Max document size: 50 pages → 500 pages → 1500+ pages │
│ Typing latency: 150ms → 50ms → 16ms │
│ Sync latency: 500ms → 100ms → 50ms │
│ │
└─────────────────────────────────────────────────────────────────────────────┘
15. Lessons & Tradeoffs
Key Architectural Decisions
1. Canvas vs DOM Rendering
// TRADEOFF: Control vs Browser Features
// DOM Rendering (Pre-2021)
// Pros:
// - Native accessibility
// - Native text selection
// - Browser handles complex typography
// - Simpler implementation
// Cons:
// - Performance degrades with document size
// - Inconsistent rendering across browsers
// - Limited control over layout
// Canvas Rendering (2021+)
// Pros:
// - Consistent cross-browser rendering
// - Better performance for large documents
// - Full control over typography
// - Pixel-perfect printing
// Cons:
// - Must implement accessibility layer
// - Must implement text selection
// - Must implement text shaping
// - Significant engineering investment
// Decision: Canvas is worth the investment at Google's scale
2. Operational Transformation vs CRDTs
// TRADEOFF: Complexity vs Offline Support
// OT (Google's choice)
// - Requires server for transformation
// - Simpler conflict resolution
// - Proven at scale
// - Better for real-time sync
// CRDTs
// - True offline-first
// - No server required for merging
// - More complex implementation
// - Can grow unbounded
// Decision: OT with offline queue works well for
// document editing where users are usually online
3. Custom vs Browser APIs
// Google Docs implements nearly everything custom:
// - Text rendering (canvas)
// - Selection handling
// - Clipboard operations
// - Input method handling
// - Spell checking
// - Accessibility
// Why? Full control over behavior across all browsers
// and ability to implement features browsers don't support
Conclusion
Google Docs represents one of the most sophisticated frontend applications ever built. Key takeaways:
-
OT is Hard but Essential: Operational Transformation enables the magical real-time collaboration experience, but requires mathematical rigor.
-
Canvas Enables Control: Moving from DOM to canvas rendering was a massive undertaking but enables consistent, performant rendering.
-
Accessibility Requires Dedication: Custom rendering means rebuilding accessibility from scratch—a significant but necessary investment.
-
Offline is a Feature: Users expect documents to work offline, requiring sophisticated sync and conflict resolution.
-
Performance at Scale: Documents can be 1000+ pages; virtualization, caching, and incremental updates are essential.
Google Docs proves that browser-based applications can match or exceed native application capabilities with sufficient engineering investment.
Current production metrics:
- Typing Latency: ~16ms (one frame)
- Sync Latency: ~50ms (p50)
- Max Concurrent Editors: 100+
- Max Document Size: 1.5M characters
- Offline Support: Full editing capability
- Accessibility: WCAG 2.1 AA compliant
What did you think?