8.5 KiB
Critical WYSIWYG Knowledge - DO NOT LOSE
This document captures all the hard-won knowledge from our WYSIWYG editor development. These patterns and solutions took significant effort to discover and MUST be preserved during refactoring.
1. Static Rendering to Prevent Focus Loss
Problem
When using Lit's reactive rendering, every state change would cause a re-render, which would:
- Lose cursor position
- Lose focus state
- Interrupt typing
- Break IME (Input Method Editor) support
Solution
We render blocks statically and manage updates imperatively:
// In dees-wysiwyg-block.ts
render(): TemplateResult {
if (!this.block) return html``;
// Render empty container - content set in firstUpdated
return html`<div class="wysiwyg-block-container"></div>`;
}
firstUpdated(): void {
const container = this.shadowRoot?.querySelector('.wysiwyg-block-container');
if (container && this.block) {
container.innerHTML = this.renderBlockContent();
}
}
Critical Pattern
- NEVER use reactive properties that would trigger re-renders during typing
- Use
shouldUpdate()
to prevent unnecessary renders - Manage content updates imperatively through event handlers
2. Shadow DOM Selection Handling
Problem
The Web Selection API doesn't work across Shadow DOM boundaries by default. This broke:
- Text selection
- Cursor position tracking
- Formatting detection
- Content splitting for Enter key
Solution
Use the getComposedRanges
API with all relevant shadow roots:
// From paragraph.block.ts
const wysiwygBlock = element.closest('dees-wysiwyg-block');
const parentComponent = wysiwygBlock?.closest('dees-input-wysiwyg');
const parentShadowRoot = parentComponent?.shadowRoot;
const blockShadowRoot = (wysiwygBlock as any)?.shadowRoot;
const shadowRoots: ShadowRoot[] = [];
if (parentShadowRoot) shadowRoots.push(parentShadowRoot);
if (blockShadowRoot) shadowRoots.push(blockShadowRoot);
const selectionInfo = WysiwygSelection.getSelectionInfo(...shadowRoots);
Critical Pattern
- ALWAYS collect all shadow roots in the hierarchy
- Use
WysiwygSelection
utility methods that handle shadow DOM - Never use raw
window.getSelection()
without shadow root context
3. Cursor Position Tracking
Problem
Cursor position would be lost during various operations, making it impossible to:
- Split content at the right position for Enter key
- Restore cursor after operations
- Track position for formatting
Solution
Track cursor position through multiple events and maintain lastKnownCursorPosition
:
// Track on every relevant event
private lastKnownCursorPosition: number = 0;
// In event handlers:
const pos = this.getCursorPosition(element);
if (pos !== null) {
this.lastKnownCursorPosition = pos;
}
// Fallback when selection not available:
if (!selectionInfo && this.lastKnownCursorPosition !== null) {
// Use last known position
}
Critical Events to Track
input
- After text changeskeydown
- Before key presskeyup
- After key pressmouseup
- After mouse selectionclick
- With setTimeout(0) for browser to set cursor
4. Content Splitting for Enter Key
Problem
Splitting content at cursor position while preserving HTML formatting was complex due to:
- Need to preserve formatting tags
- Shadow DOM complications
- Cursor position accuracy
Solution
Use Range API to split content while preserving HTML:
getSplitContent(): { before: string; after: string } | null {
// Create ranges for before and after cursor
const beforeRange = document.createRange();
const afterRange = document.createRange();
beforeRange.setStart(element, 0);
beforeRange.setEnd(selectionInfo.startContainer, selectionInfo.startOffset);
afterRange.setStart(selectionInfo.startContainer, selectionInfo.startOffset);
afterRange.setEnd(element, element.childNodes.length);
// Extract HTML content
const beforeFragment = beforeRange.cloneContents();
const afterFragment = afterRange.cloneContents();
// Convert to HTML strings
const tempDiv = document.createElement('div');
tempDiv.appendChild(beforeFragment);
const beforeHtml = tempDiv.innerHTML;
tempDiv.innerHTML = '';
tempDiv.appendChild(afterFragment);
const afterHtml = tempDiv.innerHTML;
return { before: beforeHtml, after: afterHtml };
}
5. Focus Management
Problem
Focus would be lost or not properly set due to:
- Timing issues with DOM updates
- Shadow DOM complications
- Browser inconsistencies
Solution
Use defensive focus management with fallbacks:
focus(element: HTMLElement): void {
const block = element.querySelector('.block');
if (!block) return;
// Ensure focusable
if (!block.hasAttribute('contenteditable')) {
block.setAttribute('contenteditable', 'true');
}
block.focus();
// Fallback with microtask if focus failed
if (document.activeElement !== block && element.shadowRoot?.activeElement !== block) {
Promise.resolve().then(() => {
block.focus();
});
}
}
6. Selection Event Handling for Formatting
Problem
Need to show formatting menu when text is selected, but selection events don't bubble across Shadow DOM.
Solution
Dispatch custom events with selection information:
// Listen for selection changes
document.addEventListener('selectionchange', () => {
const selectionInfo = WysiwygSelection.getSelectionInfo(...shadowRoots);
if (selectedText !== this.lastSelectedText) {
const range = WysiwygSelection.createRangeFromInfo(selectionInfo);
const rect = range.getBoundingClientRect();
// Dispatch custom event
this.dispatchSelectionEvent(element, {
text: selectedText,
blockId: block.id,
range: range,
rect: rect,
hasSelection: true
});
}
});
// Custom event bubbles through Shadow DOM
const event = new CustomEvent('block-text-selected', {
detail,
bubbles: true,
composed: true
});
7. IME (Input Method Editor) Support
Problem
Composition events for non-Latin input methods would break without proper handling.
Solution
Track composition state and handle events:
// In dees-input-wysiwyg.ts
public isComposing: boolean = false;
// In block handlers
element.addEventListener('compositionstart', () => {
handlers.onCompositionStart(); // Sets isComposing = true
});
element.addEventListener('compositionend', () => {
handlers.onCompositionEnd(); // Sets isComposing = false
});
// Don't process certain operations during composition
if (this.isComposing) return;
8. Programmatic Rendering
Problem
Lit's declarative rendering would cause focus loss and performance issues with many blocks.
Solution
Render blocks programmatically:
public renderBlocksProgrammatically() {
if (!this.editorContentRef) return;
// Clear existing blocks
this.editorContentRef.innerHTML = '';
// Create and append block elements
this.blocks.forEach(block => {
const blockWrapper = this.createBlockElement(block);
this.editorContentRef.appendChild(blockWrapper);
});
}
9. Block Handler Architecture Requirements
When creating new block handlers, they MUST:
- Implement all cursor/selection methods even if not applicable
- Use Shadow DOM-aware selection utilities
- Track cursor position through events
- Handle focus with fallbacks
- Preserve HTML content when getting/setting
- Dispatch selection events for formatting
- Support IME composition events
- Clean up event listeners on disconnect
10. Testing Considerations
webhelpers.fixture() Issue
The test helper webhelpers.fixture()
triggers property changes during initialization that can cause null reference errors. Always:
- Check for null/undefined before accessing nested properties
- Set required properties in specific order when testing
- Consider manual element creation for complex test scenarios
Summary
These patterns represent hours of debugging and problem-solving. When refactoring:
- NEVER remove static rendering approach
- ALWAYS use Shadow DOM-aware selection utilities
- MAINTAIN cursor position tracking through all events
- PRESERVE the complex content splitting logic
- KEEP all focus management fallbacks
- ENSURE selection events bubble through Shadow DOM
- SUPPORT IME composition events
- TEST thoroughly with actual typing, not just unit tests
Any changes that break these patterns will result in a degraded user experience that took significant effort to achieve.