- Implemented WysiwygModalManager for managing modals related to code blocks and block settings. - Created WysiwygSelection for handling text selection across Shadow DOM boundaries. - Introduced WysiwygShortcuts for managing keyboard shortcuts and slash menu items. - Developed wysiwygStyles for consistent styling of the WYSIWYG editor. - Defined types for blocks, slash menu items, and shortcut patterns in wysiwyg.types.ts.
		
			
				
	
	
		
			533 lines
		
	
	
		
			18 KiB
		
	
	
	
		
			TypeScript
		
	
	
	
	
	
			
		
		
	
	
			533 lines
		
	
	
		
			18 KiB
		
	
	
	
		
			TypeScript
		
	
	
	
	
	
| import { BaseBlockHandler, type IBlockEventHandlers } from '../block.base.js';
 | |
| import type { IBlock } from '../../wysiwyg.types.js';
 | |
| import { cssManager } from '@design.estate/dees-element';
 | |
| import { WysiwygBlocks } from '../../wysiwyg.blocks.js';
 | |
| import { WysiwygSelection } from '../../wysiwyg.selection.js';
 | |
| 
 | |
| export class HeadingBlockHandler extends BaseBlockHandler {
 | |
|   type: string;
 | |
|   private level: 1 | 2 | 3;
 | |
|   
 | |
|   // Track cursor position
 | |
|   private lastKnownCursorPosition: number = 0;
 | |
|   private lastSelectedText: string = '';
 | |
|   private selectionHandler: (() => void) | null = null;
 | |
|   
 | |
|   constructor(type: 'heading-1' | 'heading-2' | 'heading-3') {
 | |
|     super();
 | |
|     this.type = type;
 | |
|     this.level = parseInt(type.split('-')[1]) as 1 | 2 | 3;
 | |
|   }
 | |
|   
 | |
|   render(block: IBlock, isSelected: boolean): string {
 | |
|     const selectedClass = isSelected ? ' selected' : '';
 | |
|     const placeholder = this.getPlaceholder();
 | |
|     
 | |
|     
 | |
|     return `
 | |
|       <div
 | |
|         class="block heading-${this.level}${selectedClass}"
 | |
|         contenteditable="true"
 | |
|         data-placeholder="${placeholder}"
 | |
|         data-block-id="${block.id}"
 | |
|         data-block-type="${block.type}"
 | |
|       ></div>
 | |
|     `;
 | |
|   }
 | |
|   
 | |
|   setup(element: HTMLElement, block: IBlock, handlers: IBlockEventHandlers): void {
 | |
|     const headingBlock = element.querySelector(`.block.heading-${this.level}`) as HTMLDivElement;
 | |
|     if (!headingBlock) {
 | |
|       console.error('HeadingBlockHandler.setup: No heading block element found');
 | |
|       return;
 | |
|     }
 | |
|     
 | |
|     
 | |
|     // Set initial content if needed
 | |
|     if (block.content && !headingBlock.innerHTML) {
 | |
|       headingBlock.innerHTML = block.content;
 | |
|     }
 | |
|     
 | |
|     // Input handler with cursor tracking
 | |
|     headingBlock.addEventListener('input', (e) => {
 | |
|       handlers.onInput(e as InputEvent);
 | |
|       
 | |
|       // Track cursor position after input
 | |
|       const pos = this.getCursorPosition(element);
 | |
|       if (pos !== null) {
 | |
|         this.lastKnownCursorPosition = pos;
 | |
|       }
 | |
|     });
 | |
|     
 | |
|     // Keydown handler with cursor tracking
 | |
|     headingBlock.addEventListener('keydown', (e) => {
 | |
|       // Track cursor position before keydown
 | |
|       const pos = this.getCursorPosition(element);
 | |
|       if (pos !== null) {
 | |
|         this.lastKnownCursorPosition = pos;
 | |
|       }
 | |
|       
 | |
|       handlers.onKeyDown(e);
 | |
|     });
 | |
|     
 | |
|     // Focus handler
 | |
|     headingBlock.addEventListener('focus', () => {
 | |
|       handlers.onFocus();
 | |
|     });
 | |
|     
 | |
|     // Blur handler
 | |
|     headingBlock.addEventListener('blur', () => {
 | |
|       handlers.onBlur();
 | |
|     });
 | |
|     
 | |
|     // Composition handlers for IME support
 | |
|     headingBlock.addEventListener('compositionstart', () => {
 | |
|       handlers.onCompositionStart();
 | |
|     });
 | |
|     
 | |
|     headingBlock.addEventListener('compositionend', () => {
 | |
|       handlers.onCompositionEnd();
 | |
|     });
 | |
|     
 | |
|     // Mouse up handler
 | |
|     headingBlock.addEventListener('mouseup', (e) => {
 | |
|       const pos = this.getCursorPosition(element);
 | |
|       if (pos !== null) {
 | |
|         this.lastKnownCursorPosition = pos;
 | |
|       }
 | |
|       
 | |
|       // Selection will be handled by selectionchange event
 | |
|       handlers.onMouseUp?.(e);
 | |
|     });
 | |
|     
 | |
|     // Click handler with delayed cursor tracking
 | |
|     headingBlock.addEventListener('click', (e: MouseEvent) => {
 | |
|       // Small delay to let browser set cursor position
 | |
|       setTimeout(() => {
 | |
|         const pos = this.getCursorPosition(element);
 | |
|         if (pos !== null) {
 | |
|           this.lastKnownCursorPosition = pos;
 | |
|         }
 | |
|       }, 0);
 | |
|     });
 | |
|     
 | |
|     // Keyup handler for additional cursor tracking
 | |
|     headingBlock.addEventListener('keyup', (e) => {
 | |
|       const pos = this.getCursorPosition(element);
 | |
|       if (pos !== null) {
 | |
|         this.lastKnownCursorPosition = pos;
 | |
|       }
 | |
|     });
 | |
|     
 | |
|     // Set up selection change handler
 | |
|     this.setupSelectionHandler(element, headingBlock, block);
 | |
|   }
 | |
|   
 | |
|   private setupSelectionHandler(element: HTMLElement, headingBlock: HTMLDivElement, block: IBlock): void {
 | |
|     // Add selection change handler
 | |
|     const checkSelection = () => {
 | |
|       const selection = window.getSelection();
 | |
|       if (!selection || selection.rangeCount === 0) return;
 | |
|       
 | |
|       const selectedText = selection.toString();
 | |
|       if (selectedText.length === 0) {
 | |
|         // Clear selection if no text
 | |
|         if (this.lastSelectedText) {
 | |
|           this.lastSelectedText = '';
 | |
|           this.dispatchSelectionEvent(element, {
 | |
|             text: '',
 | |
|             blockId: block.id,
 | |
|             hasSelection: false
 | |
|           });
 | |
|         }
 | |
|         return;
 | |
|       }
 | |
|       
 | |
|       // Get parent wysiwyg component's shadow root - in setup, we need to traverse
 | |
|       const wysiwygBlock = (headingBlock.getRootNode() as ShadowRoot).host as any;
 | |
|       const parentComponent = wysiwygBlock?.closest('dees-input-wysiwyg');
 | |
|       const parentShadowRoot = parentComponent?.shadowRoot;
 | |
|       const blockShadowRoot = wysiwygBlock?.shadowRoot;
 | |
|       
 | |
|       // Use getComposedRanges with shadow roots as per MDN docs
 | |
|       const shadowRoots: ShadowRoot[] = [];
 | |
|       if (parentShadowRoot) shadowRoots.push(parentShadowRoot);
 | |
|       if (blockShadowRoot) shadowRoots.push(blockShadowRoot);
 | |
|       
 | |
|       // Get selection info using our Shadow DOM-aware utility
 | |
|       const selectionInfo = WysiwygSelection.getSelectionInfo(...shadowRoots);
 | |
|       if (!selectionInfo) return;
 | |
|       
 | |
|       // Check if selection is within this block
 | |
|       const startInBlock = WysiwygSelection.containsAcrossShadowDOM(headingBlock, selectionInfo.startContainer);
 | |
|       const endInBlock = WysiwygSelection.containsAcrossShadowDOM(headingBlock, selectionInfo.endContainer);
 | |
|       
 | |
|       if (startInBlock || endInBlock) {
 | |
|         if (selectedText !== this.lastSelectedText) {
 | |
|           this.lastSelectedText = selectedText;
 | |
|           
 | |
|           // Create range and get rect
 | |
|           const range = WysiwygSelection.createRangeFromInfo(selectionInfo);
 | |
|           const rect = range.getBoundingClientRect();
 | |
|           
 | |
|           // Dispatch event
 | |
|           this.dispatchSelectionEvent(element, {
 | |
|             text: selectedText.trim(),
 | |
|             blockId: block.id,
 | |
|             range: range,
 | |
|             rect: rect,
 | |
|             hasSelection: true
 | |
|           });
 | |
|         }
 | |
|       } else if (this.lastSelectedText) {
 | |
|         // Clear selection if no longer in this block
 | |
|         this.lastSelectedText = '';
 | |
|         this.dispatchSelectionEvent(element, {
 | |
|           text: '',
 | |
|           blockId: block.id,
 | |
|           hasSelection: false
 | |
|         });
 | |
|       }
 | |
|     };
 | |
|     
 | |
|     // Listen for selection changes
 | |
|     document.addEventListener('selectionchange', checkSelection);
 | |
|     
 | |
|     // Store the handler for cleanup
 | |
|     this.selectionHandler = checkSelection;
 | |
|     
 | |
|     // Clean up on disconnect (will be called by dees-wysiwyg-block)
 | |
|     const wysiwygBlock = (headingBlock.getRootNode() as ShadowRoot).host as any;
 | |
|     if (wysiwygBlock) {
 | |
|       const originalDisconnectedCallback = (wysiwygBlock as any).disconnectedCallback;
 | |
|       (wysiwygBlock as any).disconnectedCallback = async function() {
 | |
|         if (this.selectionHandler) {
 | |
|           document.removeEventListener('selectionchange', this.selectionHandler);
 | |
|           this.selectionHandler = null;
 | |
|         }
 | |
|         if (originalDisconnectedCallback) {
 | |
|           await originalDisconnectedCallback.call(wysiwygBlock);
 | |
|         }
 | |
|       }.bind(this);
 | |
|     }
 | |
|   }
 | |
|   
 | |
|   private dispatchSelectionEvent(element: HTMLElement, detail: any): void {
 | |
|     const event = new CustomEvent('block-text-selected', {
 | |
|       detail,
 | |
|       bubbles: true,
 | |
|       composed: true
 | |
|     });
 | |
|     element.dispatchEvent(event);
 | |
|   }
 | |
|   
 | |
|   getStyles(): string {
 | |
|     // Return styles for all heading levels
 | |
|     return `
 | |
|       .block.heading-1 {
 | |
|         font-size: 32px;
 | |
|         font-weight: 700;
 | |
|         line-height: 1.2;
 | |
|         margin: 24px 0 8px 0;
 | |
|         color: ${cssManager.bdTheme('#000000', '#ffffff')};
 | |
|       }
 | |
| 
 | |
|       .block.heading-2 {
 | |
|         font-size: 24px;
 | |
|         font-weight: 600;
 | |
|         line-height: 1.3;
 | |
|         margin: 20px 0 6px 0;
 | |
|         color: ${cssManager.bdTheme('#000000', '#ffffff')};
 | |
|       }
 | |
| 
 | |
|       .block.heading-3 {
 | |
|         font-size: 20px;
 | |
|         font-weight: 600;
 | |
|         line-height: 1.4;
 | |
|         margin: 16px 0 4px 0;
 | |
|         color: ${cssManager.bdTheme('#000000', '#ffffff')};
 | |
|       }
 | |
|     `;
 | |
|   }
 | |
|   
 | |
|   getPlaceholder(): string {
 | |
|     switch(this.level) {
 | |
|       case 1:
 | |
|         return 'Heading 1';
 | |
|       case 2:
 | |
|         return 'Heading 2';
 | |
|       case 3:
 | |
|         return 'Heading 3';
 | |
|       default:
 | |
|         return 'Heading';
 | |
|     }
 | |
|   }
 | |
|   
 | |
|   /**
 | |
|    * Helper to get the last text node in an element
 | |
|    */
 | |
|   private getLastTextNode(element: Node): Text | null {
 | |
|     if (element.nodeType === Node.TEXT_NODE) {
 | |
|       return element as Text;
 | |
|     }
 | |
|     
 | |
|     for (let i = element.childNodes.length - 1; i >= 0; i--) {
 | |
|       const lastText = this.getLastTextNode(element.childNodes[i]);
 | |
|       if (lastText) return lastText;
 | |
|     }
 | |
|     
 | |
|     return null;
 | |
|   }
 | |
|   
 | |
|   // Helper methods for heading functionality (mostly the same as paragraph)
 | |
|   
 | |
|   getCursorPosition(element: HTMLElement, context?: any): number | null {
 | |
|     // Get the actual heading element
 | |
|     const headingBlock = element.querySelector(`.block.heading-${this.level}`) as HTMLDivElement;
 | |
|     if (!headingBlock) {
 | |
|       return null;
 | |
|     }
 | |
|     
 | |
|     // Get shadow roots from context
 | |
|     const wysiwygBlock = context?.component;
 | |
|     const parentComponent = wysiwygBlock?.closest('dees-input-wysiwyg');
 | |
|     const parentShadowRoot = parentComponent?.shadowRoot;
 | |
|     const blockShadowRoot = context?.shadowRoot;
 | |
|     
 | |
|     // Get selection info with both shadow roots for proper traversal
 | |
|     const shadowRoots: ShadowRoot[] = [];
 | |
|     if (parentShadowRoot) shadowRoots.push(parentShadowRoot);
 | |
|     if (blockShadowRoot) shadowRoots.push(blockShadowRoot);
 | |
|     
 | |
|     const selectionInfo = WysiwygSelection.getSelectionInfo(...shadowRoots);
 | |
|     
 | |
|     if (!selectionInfo) {
 | |
|       return null;
 | |
|     }
 | |
|     
 | |
|     if (!WysiwygSelection.containsAcrossShadowDOM(headingBlock, selectionInfo.startContainer)) {
 | |
|       return null;
 | |
|     }
 | |
|     
 | |
|     // Create a range from start of element to cursor position
 | |
|     const preCaretRange = document.createRange();
 | |
|     preCaretRange.selectNodeContents(headingBlock);
 | |
|     preCaretRange.setEnd(selectionInfo.startContainer, selectionInfo.startOffset);
 | |
|     
 | |
|     // Get the text content length up to cursor
 | |
|     const position = preCaretRange.toString().length;
 | |
|     
 | |
|     return position;
 | |
|   }
 | |
|   
 | |
|   getContent(element: HTMLElement, context?: any): string {
 | |
|     const headingBlock = element.querySelector(`.block.heading-${this.level}`) as HTMLDivElement;
 | |
|     if (!headingBlock) return '';
 | |
|     
 | |
|     // For headings, get the innerHTML which includes formatting tags
 | |
|     const content = headingBlock.innerHTML || '';
 | |
|     return content;
 | |
|   }
 | |
|   
 | |
|   setContent(element: HTMLElement, content: string, context?: any): void {
 | |
|     const headingBlock = element.querySelector(`.block.heading-${this.level}`) as HTMLDivElement;
 | |
|     if (!headingBlock) return;
 | |
|     
 | |
|     // Store if we have focus
 | |
|     const hadFocus = document.activeElement === headingBlock || 
 | |
|                      element.shadowRoot?.activeElement === headingBlock;
 | |
|     
 | |
|     headingBlock.innerHTML = content;
 | |
|     
 | |
|     // Restore focus if we had it
 | |
|     if (hadFocus) {
 | |
|       headingBlock.focus();
 | |
|     }
 | |
|   }
 | |
|   
 | |
|   setCursorToStart(element: HTMLElement, context?: any): void {
 | |
|     const headingBlock = element.querySelector(`.block.heading-${this.level}`) as HTMLDivElement;
 | |
|     if (headingBlock) {
 | |
|       WysiwygBlocks.setCursorToStart(headingBlock);
 | |
|     }
 | |
|   }
 | |
|   
 | |
|   setCursorToEnd(element: HTMLElement, context?: any): void {
 | |
|     const headingBlock = element.querySelector(`.block.heading-${this.level}`) as HTMLDivElement;
 | |
|     if (headingBlock) {
 | |
|       WysiwygBlocks.setCursorToEnd(headingBlock);
 | |
|     }
 | |
|   }
 | |
|   
 | |
|   focus(element: HTMLElement, context?: any): void {
 | |
|     const headingBlock = element.querySelector(`.block.heading-${this.level}`) as HTMLDivElement;
 | |
|     if (!headingBlock) return;
 | |
|     
 | |
|     // Ensure the element is focusable
 | |
|     if (!headingBlock.hasAttribute('contenteditable')) {
 | |
|       headingBlock.setAttribute('contenteditable', 'true');
 | |
|     }
 | |
|     
 | |
|     headingBlock.focus();
 | |
|     
 | |
|     // If focus failed, try again after a microtask
 | |
|     if (document.activeElement !== headingBlock && element.shadowRoot?.activeElement !== headingBlock) {
 | |
|       Promise.resolve().then(() => {
 | |
|         headingBlock.focus();
 | |
|       });
 | |
|     }
 | |
|   }
 | |
|   
 | |
|   focusWithCursor(element: HTMLElement, position: 'start' | 'end' | number = 'end', context?: any): void {
 | |
|     const headingBlock = element.querySelector(`.block.heading-${this.level}`) as HTMLDivElement;
 | |
|     if (!headingBlock) return;
 | |
|     
 | |
|     
 | |
|     // Ensure element is focusable first
 | |
|     if (!headingBlock.hasAttribute('contenteditable')) {
 | |
|       headingBlock.setAttribute('contenteditable', 'true');
 | |
|     }
 | |
|     
 | |
|     // For 'end' position, we need to set up selection before focus to prevent browser default
 | |
|     if (position === 'end' && headingBlock.textContent && headingBlock.textContent.length > 0) {
 | |
|       // Set up the selection first
 | |
|       const sel = window.getSelection();
 | |
|       if (sel) {
 | |
|         const range = document.createRange();
 | |
|         const lastNode = this.getLastTextNode(headingBlock) || headingBlock;
 | |
|         if (lastNode.nodeType === Node.TEXT_NODE) {
 | |
|           range.setStart(lastNode, lastNode.textContent?.length || 0);
 | |
|           range.setEnd(lastNode, lastNode.textContent?.length || 0);
 | |
|         } else {
 | |
|           range.selectNodeContents(lastNode);
 | |
|           range.collapse(false);
 | |
|         }
 | |
|         sel.removeAllRanges();
 | |
|         sel.addRange(range);
 | |
|       }
 | |
|     }
 | |
|     
 | |
|     // Now focus the element
 | |
|     headingBlock.focus();
 | |
|     
 | |
|     // Set cursor position after focus is established (for non-end positions)
 | |
|     const setCursor = () => {
 | |
|       if (position === 'start') {
 | |
|         this.setCursorToStart(element, context);
 | |
|       } else if (position === 'end' && (!headingBlock.textContent || headingBlock.textContent.length === 0)) {
 | |
|         // Only call setCursorToEnd for empty blocks
 | |
|         this.setCursorToEnd(element, context);
 | |
|       } else if (typeof position === 'number') {
 | |
|         // Use the selection utility to set cursor position
 | |
|         WysiwygSelection.setCursorPosition(headingBlock, position);
 | |
|       }
 | |
|     };
 | |
|     
 | |
|     // Ensure cursor is set after focus
 | |
|     if (document.activeElement === headingBlock || element.shadowRoot?.activeElement === headingBlock) {
 | |
|       setCursor();
 | |
|     } else {
 | |
|       // Wait for focus to be established
 | |
|       Promise.resolve().then(() => {
 | |
|         if (document.activeElement === headingBlock || element.shadowRoot?.activeElement === headingBlock) {
 | |
|           setCursor();
 | |
|         } else {
 | |
|           // Try again with a small delay - sometimes focus needs more time
 | |
|           setTimeout(() => {
 | |
|             if (document.activeElement === headingBlock || element.shadowRoot?.activeElement === headingBlock) {
 | |
|               setCursor();
 | |
|             }
 | |
|           }, 10);
 | |
|         }
 | |
|       });
 | |
|     }
 | |
|   }
 | |
|   
 | |
|   getSplitContent(element: HTMLElement, context?: any): { before: string; after: string } | null {
 | |
|     const headingBlock = element.querySelector(`.block.heading-${this.level}`) as HTMLDivElement;
 | |
|     if (!headingBlock) {
 | |
|       return null;
 | |
|     }
 | |
|     
 | |
|     // Get shadow roots from context
 | |
|     const wysiwygBlock = context?.component;
 | |
|     const parentComponent = wysiwygBlock?.closest('dees-input-wysiwyg');
 | |
|     const parentShadowRoot = parentComponent?.shadowRoot;
 | |
|     const blockShadowRoot = context?.shadowRoot;
 | |
|     
 | |
|     // Get selection info with both shadow roots for proper traversal
 | |
|     const shadowRoots: ShadowRoot[] = [];
 | |
|     if (parentShadowRoot) shadowRoots.push(parentShadowRoot);
 | |
|     if (blockShadowRoot) shadowRoots.push(blockShadowRoot);
 | |
|     
 | |
|     const selectionInfo = WysiwygSelection.getSelectionInfo(...shadowRoots);
 | |
|     
 | |
|     if (!selectionInfo) {
 | |
|       // Try using last known cursor position
 | |
|       if (this.lastKnownCursorPosition !== null) {
 | |
|         const fullText = headingBlock.textContent || '';
 | |
|         const pos = Math.min(this.lastKnownCursorPosition, fullText.length);
 | |
|         return {
 | |
|           before: fullText.substring(0, pos),
 | |
|           after: fullText.substring(pos)
 | |
|         };
 | |
|       }
 | |
|       return null;
 | |
|     }
 | |
|     
 | |
|     // Make sure the selection is within this block
 | |
|     if (!WysiwygSelection.containsAcrossShadowDOM(headingBlock, selectionInfo.startContainer)) {
 | |
|       // Try using last known cursor position
 | |
|       if (this.lastKnownCursorPosition !== null) {
 | |
|         const fullText = headingBlock.textContent || '';
 | |
|         const pos = Math.min(this.lastKnownCursorPosition, fullText.length);
 | |
|         return {
 | |
|           before: fullText.substring(0, pos),
 | |
|           after: fullText.substring(pos)
 | |
|         };
 | |
|       }
 | |
|       return null;
 | |
|     }
 | |
|     
 | |
|     // Get cursor position first
 | |
|     const cursorPos = this.getCursorPosition(element, context);
 | |
|     
 | |
|     if (cursorPos === null || cursorPos === 0) {
 | |
|       // If cursor is at start or can't determine position, move all content
 | |
|       return {
 | |
|         before: '',
 | |
|         after: headingBlock.innerHTML
 | |
|       };
 | |
|     }
 | |
|     
 | |
|     // For HTML content, split using ranges to preserve formatting
 | |
|     const beforeRange = document.createRange();
 | |
|     const afterRange = document.createRange();
 | |
|     
 | |
|     // Before range: from start of element to cursor
 | |
|     beforeRange.setStart(headingBlock, 0);
 | |
|     beforeRange.setEnd(selectionInfo.startContainer, selectionInfo.startOffset);
 | |
|     
 | |
|     // After range: from cursor to end of element
 | |
|     afterRange.setStart(selectionInfo.startContainer, selectionInfo.startOffset);
 | |
|     afterRange.setEnd(headingBlock, headingBlock.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 
 | |
|     };
 | |
|   }
 | |
| } |