294 lines
8.5 KiB
Markdown
294 lines
8.5 KiB
Markdown
![]() |
# 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:
|
||
|
|
||
|
```typescript
|
||
|
// 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:
|
||
|
|
||
|
```typescript
|
||
|
// 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`:
|
||
|
|
||
|
```typescript
|
||
|
// 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 changes
|
||
|
- `keydown` - Before key press
|
||
|
- `keyup` - After key press
|
||
|
- `mouseup` - After mouse selection
|
||
|
- `click` - 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:
|
||
|
|
||
|
```typescript
|
||
|
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:
|
||
|
|
||
|
```typescript
|
||
|
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:
|
||
|
|
||
|
```typescript
|
||
|
// 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:
|
||
|
|
||
|
```typescript
|
||
|
// 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:
|
||
|
|
||
|
```typescript
|
||
|
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:
|
||
|
|
||
|
1. **Implement all cursor/selection methods** even if not applicable
|
||
|
2. **Use Shadow DOM-aware selection utilities**
|
||
|
3. **Track cursor position through events**
|
||
|
4. **Handle focus with fallbacks**
|
||
|
5. **Preserve HTML content when getting/setting**
|
||
|
6. **Dispatch selection events for formatting**
|
||
|
7. **Support IME composition events**
|
||
|
8. **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:
|
||
|
|
||
|
1. Check for null/undefined before accessing nested properties
|
||
|
2. Set required properties in specific order when testing
|
||
|
3. Consider manual element creation for complex test scenarios
|
||
|
|
||
|
## Summary
|
||
|
|
||
|
These patterns represent hours of debugging and problem-solving. When refactoring:
|
||
|
|
||
|
1. **NEVER** remove static rendering approach
|
||
|
2. **ALWAYS** use Shadow DOM-aware selection utilities
|
||
|
3. **MAINTAIN** cursor position tracking through all events
|
||
|
4. **PRESERVE** the complex content splitting logic
|
||
|
5. **KEEP** all focus management fallbacks
|
||
|
6. **ENSURE** selection events bubble through Shadow DOM
|
||
|
7. **SUPPORT** IME composition events
|
||
|
8. **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.
|