Overview
The core challenge in model-based editors is maintaining bidirectional synchronization: converting user DOM edits to model operations, and rendering model changes back to DOM. Special cases like contenteditable="false", browser extensions, and framework re-renders complicate this significantly.
⚠️ Why This is Difficult
contenteditable="false"behavior is inconsistent across browsers- Selection can be lost during DOM updates
- External DOM manipulation (translations, extensions) breaks assumptions
- Framework re-renders can reset caret position
- Model operations must account for read-only regions
contenteditable=false Impact on Model
When contenteditable="false" elements exist in your document, they create read-only regions that must be represented in your model and handled carefully during operations.
Model Representation
Represent read-only nodes in your model:
interface Node {
type: string;
attrs?: Record<string, any>;
content?: Node[];
// Mark nodes as non-editable
marks?: Mark[];
}
interface Mark {
type: 'readonly' | 'locked' | 'protected';
attrs?: Record<string, any>;
}
// Example: Image node that should not be editable
const imageNode = {
type: 'image',
attrs: {
src: 'image.jpg',
alt: 'Description',
contenteditable: false // Store in model
},
marks: [{ type: 'readonly' }] // Mark as read-only
};
// Or use a separate flag
interface DocumentNode {
type: string;
editable: boolean; // Explicit flag
attrs?: Record<string, any>;
content?: DocumentNode[];
}Key considerations:
- Store
contenteditable="false"state in model attributes - Use marks or flags to indicate read-only regions
- Validate operations don't modify read-only nodes
- Preserve read-only state during transformations
Rendering Considerations
When rendering model to DOM, preserve contenteditable="false":
class ModelRenderer {
renderNode(node) {
const element = document.createElement(this.getTagName(node.type));
// Preserve contenteditable=false from model
if (node.attrs?.contenteditable === false) {
element.setAttribute('contenteditable', 'false');
}
// Add data attributes for model tracking
element.setAttribute('data-node-id', node.id);
element.setAttribute('data-node-type', node.type);
// Render children
if (node.content) {
node.content.forEach(child => {
element.appendChild(this.renderNode(child));
});
}
return element;
}
// Prevent editing in read-only nodes
setupReadonlyHandlers(element) {
if (element.getAttribute('contenteditable') === 'false') {
element.addEventListener('beforeinput', (e) => {
e.preventDefault();
e.stopPropagation();
});
element.addEventListener('input', (e) => {
e.preventDefault();
e.stopPropagation();
});
}
}
}⚠️ Browser Inconsistency
Some browsers (especially Chrome) may allow editing within contenteditable="false" elements. Always add programmatic protection using beforeinput event handlers.
Selection Boundaries
Selection that spans across editable and non-editable boundaries requires special handling:
class SelectionManager {
normalizeSelection(range) {
// Check if selection spans contenteditable=false
const startEditable = this.isEditable(range.startContainer);
const endEditable = this.isEditable(range.endContainer);
if (!startEditable || !endEditable) {
// Selection crosses boundary
// Option 1: Expand to nearest editable boundary
return this.expandToEditableBoundary(range);
// Option 2: Collapse to nearest editable position
// return this.collapseToEditablePosition(range);
}
return range;
}
isEditable(node) {
let current = node;
while (current && current !== this.editor) {
if (current.getAttribute?.('contenteditable') === 'false') {
return false;
}
current = current.parentElement;
}
return true;
}
expandToEditableBoundary(range) {
// Find nearest editable ancestor
let start = range.startContainer;
while (start && !this.isEditable(start)) {
start = start.parentElement;
}
let end = range.endContainer;
while (end && !this.isEditable(end)) {
end = end.parentElement;
}
if (start && end) {
range.setStart(start, 0);
range.setEnd(end, end.textContent?.length || 0);
}
return range;
}
}DOM to Model Conversion
Converting DOM changes back to model operations requires detecting contenteditable="false" and handling nested structures correctly.
Detecting contenteditable=false
When parsing DOM to model, detect and preserve read-only state:
class DOMParser {
parseElement(element) {
const node = {
type: this.getElementType(element),
attrs: this.parseAttributes(element),
content: []
};
// Detect contenteditable=false
const contenteditable = element.getAttribute('contenteditable');
if (contenteditable === 'false') {
node.attrs.contenteditable = false;
node.marks = [{ type: 'readonly' }];
}
// Parse children
for (const child of element.childNodes) {
if (child.nodeType === Node.ELEMENT_NODE) {
node.content.push(this.parseElement(child));
} else if (child.nodeType === Node.TEXT_NODE) {
node.content.push({
type: 'text',
text: child.textContent
});
}
}
return node;
}
parseAttributes(element) {
const attrs = {};
// Copy relevant attributes
for (const attr of element.attributes) {
if (this.isRelevantAttribute(attr.name)) {
attrs[attr.name] = attr.value;
}
}
return attrs;
}
}Nested Structures
Handle nested contenteditable attributes correctly:
class DOMParser {
isEditable(element) {
// Walk up the tree to determine editability
let current = element;
while (current && current !== this.editor) {
const ce = current.getAttribute('contenteditable');
if (ce === 'false') {
return false;
}
if (ce === 'true') {
return true;
}
// If inherit or not set, check parent
current = current.parentElement;
}
// Default: editor is editable
return true;
}
parseWithInheritance(element) {
const node = this.parseElement(element);
// Determine actual editability based on inheritance
const actuallyEditable = this.isEditable(element);
if (!actuallyEditable) {
node.attrs.contenteditable = false;
node.marks = [{ type: 'readonly' }];
}
return node;
}
}Browser Inconsistencies
Different browsers handle contenteditable="false" differently:
- Chrome/Edge: May allow editing within
contenteditable="false"elements - Firefox: Generally respects
contenteditable="false"but cursor may disappear - Safari: Generally respects
contenteditable="false"
Always validate DOM changes against your model's read-only constraints, regardless of browser behavior:
class OperationValidator {
validateOperation(operation, model) {
const targetNode = this.getNodeAtPath(model, operation.path);
// Check if target is read-only
if (targetNode?.attrs?.contenteditable === false) {
throw new Error('Cannot modify read-only node');
}
// Check if operation would affect read-only nodes
const affectedNodes = this.getAffectedNodes(operation, model);
for (const node of affectedNodes) {
if (node.attrs?.contenteditable === false) {
throw new Error('Operation would affect read-only node');
}
}
return true;
}
}Model to DOM Conversion
When rendering model to DOM, ensure contenteditable="false" attributes are correctly applied and preserved.
Preserving Read-only State
Always restore contenteditable="false" when updating DOM:
class ModelRenderer {
updateDOM(model, previousModel) {
const diff = this.diffModels(previousModel, model);
diff.forEach(change => {
switch (change.type) {
case 'update':
const element = this.findDOMElement(change.path);
// Always restore contenteditable attribute
if (change.node.attrs?.contenteditable === false) {
element.setAttribute('contenteditable', 'false');
this.setupReadonlyHandlers(element);
} else {
element.removeAttribute('contenteditable');
}
break;
case 'insert':
const newElement = this.renderNode(change.node);
// contenteditable=false is set in renderNode
this.insertElement(newElement, change.path);
break;
}
});
}
renderNode(node) {
const element = document.createElement(this.getTagName(node.type));
// Copy all attributes including contenteditable
if (node.attrs) {
Object.entries(node.attrs).forEach(([key, value]) => {
if (value !== undefined && value !== null) {
element.setAttribute(key, String(value));
}
});
}
// Setup readonly handlers if needed
if (node.attrs?.contenteditable === false) {
this.setupReadonlyHandlers(element);
}
return element;
}
}Attribute Synchronization
Keep model attributes and DOM attributes in sync:
class AttributeSync {
syncAttributes(modelNode, domElement) {
// Model -> DOM
if (modelNode.attrs) {
Object.entries(modelNode.attrs).forEach(([key, value]) => {
if (key === 'contenteditable' && value === false) {
domElement.setAttribute('contenteditable', 'false');
} else if (value !== undefined && value !== null) {
domElement.setAttribute(key, String(value));
}
});
}
// DOM -> Model (for external changes)
const domAttrs = {};
for (const attr of domElement.attributes) {
domAttrs[attr.name] = attr.value;
}
// Detect if contenteditable was changed externally
if (domAttrs.contenteditable === 'false' &&
modelNode.attrs?.contenteditable !== false) {
// External change detected, update model
this.updateModelAttribute(modelNode, 'contenteditable', false);
}
}
}Selection Synchronization
Selection must be preserved when converting between DOM and model, especially when contenteditable="false" elements are involved.
Selection Loss Prevention
Save and restore selection during DOM updates:
class SelectionManager {
saveSelection() {
const selection = window.getSelection();
if (selection.rangeCount === 0) return null;
const range = selection.getRangeAt(0);
// Convert DOM selection to model selection
return {
anchor: this.domPositionToModelPath(range.startContainer, range.startOffset),
focus: this.domPositionToModelPath(range.endContainer, range.endOffset),
collapsed: range.collapsed
};
}
restoreSelection(modelSelection) {
if (!modelSelection) return;
// Convert model selection to DOM selection
const anchorPos = this.modelPathToDOMPosition(modelSelection.anchor);
const focusPos = this.modelPathToDOMPosition(modelSelection.focus);
if (!anchorPos || !focusPos) return;
const range = document.createRange();
range.setStart(anchorPos.node, anchorPos.offset);
range.setEnd(focusPos.node, focusPos.offset);
const selection = window.getSelection();
selection.removeAllRanges();
selection.addRange(range);
}
// Update selection after model operations
updateSelectionAfterOperation(operation, previousSelection) {
// Adjust selection based on operation
const newSelection = this.adjustSelectionForOperation(
previousSelection,
operation
);
// Restore in next frame to ensure DOM is updated
requestAnimationFrame(() => {
this.restoreSelection(newSelection);
});
}
}Boundary Handling
Handle selection boundaries at editable/non-editable junctions:
class BoundaryHandler {
normalizeSelectionAtBoundary(range) {
const startEditable = this.isEditable(range.startContainer);
const endEditable = this.isEditable(range.endContainer);
// If selection starts in non-editable, move to next editable
if (!startEditable) {
const nextEditable = this.findNextEditable(range.startContainer);
if (nextEditable) {
range.setStart(nextEditable.node, nextEditable.offset);
} else {
// No editable found, collapse selection
range.collapse(true);
}
}
// If selection ends in non-editable, move to previous editable
if (!endEditable) {
const prevEditable = this.findPreviousEditable(range.endContainer);
if (prevEditable) {
range.setEnd(prevEditable.node, prevEditable.offset);
} else {
range.collapse(false);
}
}
return range;
}
findNextEditable(node) {
// Walk forward in DOM tree to find next editable position
let current = node;
while (current) {
if (this.isEditable(current)) {
return { node: current, offset: 0 };
}
current = this.getNextSibling(current) || this.getNextSibling(current?.parentElement);
}
return null;
}
}External DOM Manipulation
External factors like browser translation, extensions, and framework re-renders can manipulate the DOM, breaking your model synchronization.
Browser Translation
Browser translation features manipulate DOM, potentially breaking your model:
class TranslationHandler {
preventTranslation(element) {
// Disable translation for editor
element.setAttribute('translate', 'no');
// Monitor for translation changes
const observer = new MutationObserver((mutations) => {
mutations.forEach(mutation => {
// Detect if translation injected elements
if (this.isTranslationElement(mutation.target)) {
// Revert translation changes
this.revertTranslation(mutation.target);
}
});
});
observer.observe(element, {
childList: true,
subtree: true,
characterData: true
});
}
isTranslationElement(element) {
// Translation often adds specific classes or attributes
return element.classList?.contains('translated') ||
element.hasAttribute('data-translation-id');
}
revertTranslation(element) {
// Restore original content from model
const nodeId = element.getAttribute('data-node-id');
if (nodeId) {
const modelNode = this.getNodeById(nodeId);
this.renderNode(modelNode, element);
}
}
}⚠️ Translation Impact
Browser translation can inject <span> elements, modify text content, and break contenteditable="false" attributes. Always use translate="no" and monitor for changes.
Browser Extensions
Browser extensions can inject scripts and modify DOM:
class ExtensionHandler {
detectExternalChanges() {
// Use MutationObserver to detect unexpected changes
const observer = new MutationObserver((mutations) => {
mutations.forEach(mutation => {
// Check if change matches our model
if (!this.isExpectedChange(mutation)) {
// External change detected
this.revertToModel(mutation.target);
}
});
});
observer.observe(this.editor, {
childList: true,
subtree: true,
attributes: true,
attributeFilter: ['contenteditable', 'data-node-id']
});
}
isExpectedChange(mutation) {
// Check if this change was initiated by our code
return mutation.target.hasAttribute('data-expected-change') ||
this.isOurOperation(mutation);
}
revertToModel(element) {
// Restore from model
const nodeId = element.getAttribute('data-node-id');
if (nodeId) {
const modelNode = this.getNodeById(nodeId);
this.renderNode(modelNode, element.parentElement);
}
}
}Framework Re-renders
Framework re-renders can reset DOM, losing selection and breaking synchronization:
class FrameworkSync {
handleReRender() {
// Save state before framework re-render
const selection = this.saveSelection();
const model = this.getModel();
// Mark that we're about to update
this.isUpdating = true;
// Framework re-renders DOM
// ... framework code ...
// After re-render, restore
requestAnimationFrame(() => {
this.restoreModel(model);
this.restoreSelection(selection);
this.isUpdating = false;
});
}
preventCaretJump() {
// Save caret position before any DOM update
const selection = this.saveSelection();
// Perform update
this.updateDOM();
// Restore in next frame
requestAnimationFrame(() => {
this.restoreSelection(selection);
});
}
// Use MutationObserver to detect framework changes
watchForFrameworkChanges() {
const observer = new MutationObserver(() => {
if (!this.isUpdating) {
// Unexpected change, sync from DOM to model
this.syncFromDOM();
}
});
observer.observe(this.editor, {
childList: true,
subtree: true,
attributes: true
});
}
}Undo/Redo Synchronization
Browser's native undo/redo stack doesn't include programmatic DOM changes. You need to implement custom undo/redo that synchronizes with your model.
Programmatic Changes in History
Track all model operations for undo/redo, including programmatic changes:
class HistoryManager {
constructor(model) {
this.model = model;
this.undoStack = [];
this.redoStack = [];
this.maxSize = 50;
}
recordOperation(operation, beforeState, afterState) {
const historyEntry = {
operation,
beforeModel: beforeState,
afterModel: afterState,
beforeSelection: this.saveSelection(),
timestamp: Date.now()
};
this.undoStack.push(historyEntry);
if (this.undoStack.length > this.maxSize) {
this.undoStack.shift();
}
this.redoStack = [];
}
undo() {
if (this.undoStack.length === 0) return false;
const entry = this.undoStack.pop();
this.model = entry.beforeModel;
this.renderModelToDOM(this.model);
requestAnimationFrame(() => {
this.restoreSelection(entry.beforeSelection);
});
this.redoStack.push(entry);
return true;
}
}⚠️ Browser History vs Custom History
Browser's native undo/redo (Ctrl+Z/Cmd+Z) may conflict with your custom implementation. Consider preventing default and handling keyboard shortcuts yourself.
Selection in History
Always save and restore selection with each history entry:
class HistoryManager {
saveSelection() {
const selection = window.getSelection();
if (selection.rangeCount === 0) return null;
const range = selection.getRangeAt(0);
return {
anchor: this.domPositionToModelPath(range.startContainer, range.startOffset),
focus: this.domPositionToModelPath(range.endContainer, range.endOffset)
};
}
restoreSelection(modelSelection) {
if (!modelSelection) return;
const anchorPos = this.modelPathToDOMPosition(modelSelection.anchor);
const focusPos = this.modelPathToDOMPosition(modelSelection.focus);
if (!anchorPos || !focusPos) return;
const range = document.createRange();
range.setStart(anchorPos.node, anchorPos.offset);
range.setEnd(focusPos.node, focusPos.offset);
const selection = window.getSelection();
selection.removeAllRanges();
selection.addRange(range);
}
}Browser History Integration
Prevent browser's native undo/redo from interfering:
class HistoryManager {
preventBrowserUndoRedo() {
this.editor.addEventListener('beforeinput', (e) => {
if (e.inputType === 'historyUndo' || e.inputType === 'historyRedo') {
e.preventDefault();
if (e.inputType === 'historyUndo') {
this.undo();
} else {
this.redo();
}
}
});
}
}IME Composition Synchronization
IME composition creates temporary DOM changes that must be handled carefully to avoid corrupting your model.
Composition State Tracking
Track composition state to prevent model updates during composition:
class CompositionHandler {
constructor() {
this.isComposing = false;
this.compositionText = '';
}
init(editor) {
editor.addEventListener('compositionstart', () => {
this.isComposing = true;
this.compositionText = '';
});
editor.addEventListener('compositionend', (e) => {
this.finalizeComposition(e.data || this.compositionText);
this.isComposing = false;
});
}
finalizeComposition(text) {
const operation = {
type: 'insertText',
path: this.saveSelection().anchor,
text: text
};
this.applyToModel(operation);
this.renderModelToDOM();
}
}Handling Duplicate Events
Some browsers (especially iOS Safari with Korean IME) fire duplicate events:
class CompositionHandler {
handleBeforeInput(e) {
if (!e.isComposing) return;
if (e.inputType === 'deleteContentBackward' && this.isComposing) {
this.pendingDelete = e;
e.preventDefault();
return;
}
if (e.inputType === 'insertText' && this.isComposing && this.pendingDelete) {
this.pendingDelete = null;
this.handleCompositionUpdate(e.data);
e.preventDefault();
return;
}
}
}Composition to Model Conversion
Convert composition result to model operation only after composition ends:
class CompositionHandler {
compositionend(e) {
const finalText = e.data || this.compositionText;
const selection = this.saveSelection();
const operation = {
type: 'insertText',
path: selection.anchor,
text: finalText
};
const newModel = this.applyOperation(this.model, operation);
this.renderModelToDOM(newModel);
this.isComposing = false;
}
}Paste Operation Synchronization
Paste operations insert DOM content that must be converted to model operations while preserving formatting.
Paste DOM to Model
Convert pasted DOM content to model operations:
class PasteHandler {
handlePaste(e) {
e.preventDefault();
const clipboardData = e.clipboardData;
const html = clipboardData.getData('text/html');
const text = clipboardData.getData('text/plain');
const selection = this.saveSelection();
if (!selection.collapsed) {
this.applyOperation({ type: 'delete', path: selection.anchor });
}
const operations = html ? this.htmlToModelOperations(html) : this.textToModelOperations(text);
operations.forEach(op => this.applyOperation(op));
this.renderModelToDOM();
requestAnimationFrame(() => {
this.restoreSelection(this.calculateSelectionAfterPaste(selection, operations));
});
}
}Formatting Preservation
Preserve or filter formatting based on your schema:
class PasteHandler {
sanitizePastedHTML(html) {
const parser = new DOMParser();
const doc = parser.parseFromString(html, 'text/html');
const allowedTags = ['p', 'br', 'strong', 'em', 'u', 'a'];
this.filterElements(doc.body, allowedTags);
return doc.body.innerHTML;
}
}Paste During Composition
Handle paste operations that occur during IME composition:
class PasteHandler {
handlePaste(e) {
if (this.compositionHandler.isComposing) {
this.compositionHandler.cancelComposition();
}
this.processPaste(e);
}
}Drag & Drop Synchronization
Drag & drop operations modify DOM directly. You need to detect these changes and convert them to model operations.
DOM Changes During Drag
Monitor DOM changes during drag operations:
class DragDropHandler {
init(editor) {
let draggedContent = null;
editor.addEventListener('dragstart', (e) => {
draggedContent = this.getSelectedContent();
e.dataTransfer.effectAllowed = 'move';
});
editor.addEventListener('drop', (e) => {
e.preventDefault();
const dropPosition = this.getDropPosition(e);
if (e.dataTransfer.effectAllowed === 'move') {
this.removeDraggedContent();
}
this.insertAtPosition(dropPosition, draggedContent);
const operations = this.dragDropToModelOperations(dropPosition, draggedContent);
operations.forEach(op => this.applyOperation(op));
this.renderModelToDOM();
});
}
}Drop to Model Conversion
Convert drag & drop to model operations:
class DragDropHandler {
dragDropToModelOperations(dropPosition, content) {
const operations = [];
const modelNodes = this.contentToModelNodes(content);
modelNodes.forEach((node, index) => {
operations.push({
type: 'insert',
path: { ...dropPosition, offset: dropPosition.offset + index },
node: node
});
});
return operations;
}
}Duplicate Prevention
Prevent duplicate elements (especially in Firefox):
class DragDropHandler {
preventDuplicates() {
this.editor.addEventListener('drop', (e) => {
e.preventDefault();
if (this.draggedElement && this.draggedElement.parentNode) {
this.draggedElement.parentNode.removeChild(this.draggedElement);
}
this.insertAtDropPosition(e);
});
}
}Transaction and DOM Updates
Transactions group multiple operations. DOM updates must be atomic and support rollback.
Atomic DOM Updates
Update DOM atomically for transactions:
class TransactionManager {
applyTransaction(transaction) {
const beforeDOM = this.snapshotDOM();
const beforeSelection = this.saveSelection();
try {
transaction.operations.forEach(op => this.applyOperation(op));
this.renderModelToDOM();
requestAnimationFrame(() => {
this.restoreSelection(beforeSelection);
});
this.commitTransaction(transaction);
} catch (error) {
this.rollbackDOM(beforeDOM);
throw error;
}
}
}DOM Rollback
Rollback DOM to previous state:
class TransactionManager {
rollbackDOM(beforeDOM) {
const parent = this.editor.parentNode;
const newEditor = beforeDOM.cloneNode(true);
Array.from(this.editor.attributes).forEach(attr => {
newEditor.setAttribute(attr.name, attr.value);
});
parent.replaceChild(newEditor, this.editor);
this.editor = newEditor;
this.setupEventHandlers();
}
}Consistency Validation & Recovery
Periodically validate that model and DOM are in sync, and recover from inconsistencies.
Model-DOM Validation
Validate model and DOM match:
class ConsistencyValidator {
validate() {
const domModel = this.parseDOMToModel(this.editor);
const differences = this.diffModels(this.model, domModel);
if (differences.length > 0) {
this.handleInconsistency(differences);
}
return differences.length === 0;
}
}Inconsistency Detection
Use MutationObserver to detect unexpected DOM changes:
class ConsistencyValidator {
watchForInconsistencies() {
const observer = new MutationObserver((mutations) => {
mutations.forEach(mutation => {
if (!this.isExpectedChange(mutation)) {
this.handleUnexpectedChange(mutation);
}
});
});
observer.observe(this.editor, {
childList: true,
subtree: true,
attributes: true
});
}
}Recovery Strategies
Strategies for recovering from inconsistencies:
class ConsistencyValidator {
recoverFromInconsistency(differences) {
if (this.shouldRevertToModel(differences)) {
this.revertDOMToModel();
} else if (this.shouldSyncFromDOM(differences)) {
this.syncModelFromDOM();
} else {
this.mergeDifferences(differences);
}
}
}Best Practices
Best practices for maintaining model-DOM synchronization:
- Always save selection before DOM updates: Use
requestAnimationFrameto restore after updates - Store contenteditable state in model: Don't rely solely on DOM attributes
- Validate operations against model: Check read-only constraints before applying
- Use MutationObserver for external changes: Detect and revert unexpected DOM modifications
- Preserve data attributes: Use
data-node-idto track model-DOM mapping - Handle boundaries explicitly: Normalize selections that cross editable/non-editable boundaries
- Test across browsers:
contenteditable="false"behavior varies significantly - Use programmatic protection: Add
beforeinputhandlers even if browser respects attribute
class ModelDOMSync {
// Complete synchronization workflow
syncModelToDOM(model) {
// 1. Save current selection
const selection = this.saveSelection();
// 2. Update DOM from model
this.updateDOM(model);
// 3. Restore selection in next frame
requestAnimationFrame(() => {
this.restoreSelection(selection);
});
}
syncDOMToModel() {
// 1. Parse DOM to model
const newModel = this.parseDOM();
// 2. Validate against constraints
this.validateModel(newModel);
// 3. Update model
this.setModel(newModel);
// 4. Preserve selection
const selection = this.saveSelection();
requestAnimationFrame(() => {
this.restoreSelection(selection);
});
}
// Handle contenteditable=false nodes
handleReadonlyNode(node, element) {
// Set attribute
element.setAttribute('contenteditable', 'false');
// Add programmatic protection
element.addEventListener('beforeinput', (e) => {
e.preventDefault();
e.stopPropagation();
}, { capture: true });
// Mark in model
node.marks = [{ type: 'readonly' }];
}
}