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. |