Files
dees-catalog/ts_web/elements/wysiwyg/blocks/text/quote.block.ts
Juergen Kunz 4a26307e1b update
2025-06-25 05:30:20 +00:00

457 lines
15 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 QuoteBlockHandler extends BaseBlockHandler {
type = 'quote';
// Track cursor position
private lastKnownCursorPosition: number = 0;
private lastSelectedText: string = '';
private selectionHandler: (() => void) | null = null;
render(block: IBlock, isSelected: boolean): string {
const selectedClass = isSelected ? ' selected' : '';
const placeholder = this.getPlaceholder();
return `
<div
class="block quote${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 quoteBlock = element.querySelector('.block.quote') as HTMLDivElement;
if (!quoteBlock) {
console.error('QuoteBlockHandler.setup: No quote block element found');
return;
}
// Set initial content if needed
if (block.content && !quoteBlock.innerHTML) {
quoteBlock.innerHTML = block.content;
}
// Input handler with cursor tracking
quoteBlock.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
quoteBlock.addEventListener('keydown', (e) => {
// Track cursor position before keydown
const pos = this.getCursorPosition(element);
if (pos !== null) {
this.lastKnownCursorPosition = pos;
}
handlers.onKeyDown(e);
});
// Focus handler
quoteBlock.addEventListener('focus', () => {
handlers.onFocus();
});
// Blur handler
quoteBlock.addEventListener('blur', () => {
handlers.onBlur();
});
// Composition handlers for IME support
quoteBlock.addEventListener('compositionstart', () => {
handlers.onCompositionStart();
});
quoteBlock.addEventListener('compositionend', () => {
handlers.onCompositionEnd();
});
// Mouse up handler
quoteBlock.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
quoteBlock.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
quoteBlock.addEventListener('keyup', (e) => {
const pos = this.getCursorPosition(element);
if (pos !== null) {
this.lastKnownCursorPosition = pos;
}
});
// Set up selection change handler
this.setupSelectionHandler(element, quoteBlock, block);
}
private setupSelectionHandler(element: HTMLElement, quoteBlock: 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 - traverse from shadow root
const wysiwygBlock = (quoteBlock.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(quoteBlock, selectionInfo.startContainer);
const endInBlock = WysiwygSelection.containsAcrossShadowDOM(quoteBlock, 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 = (quoteBlock.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 `
/* Quote specific styles */
.block.quote {
border-left: 3px solid ${cssManager.bdTheme('#0066cc', '#4d94ff')};
padding-left: 20px;
color: ${cssManager.bdTheme('#555', '#b0b0b0')};
font-style: italic;
line-height: 1.6;
margin: 16px 0;
}
`;
}
getPlaceholder(): string {
return 'Add a quote...';
}
// Helper methods for quote functionality
getCursorPosition(element: HTMLElement, context?: any): number | null {
// Get the actual quote element
const quoteBlock = element.querySelector('.block.quote') as HTMLDivElement;
if (!quoteBlock) {
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(quoteBlock, selectionInfo.startContainer)) {
return null;
}
// Create a range from start of element to cursor position
const preCaretRange = document.createRange();
preCaretRange.selectNodeContents(quoteBlock);
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 quoteBlock = element.querySelector('.block.quote') as HTMLDivElement;
if (!quoteBlock) return '';
// For quotes, get the innerHTML which includes formatting tags
const content = quoteBlock.innerHTML || '';
return content;
}
setContent(element: HTMLElement, content: string, context?: any): void {
const quoteBlock = element.querySelector('.block.quote') as HTMLDivElement;
if (!quoteBlock) return;
// Store if we have focus
const hadFocus = document.activeElement === quoteBlock ||
element.shadowRoot?.activeElement === quoteBlock;
quoteBlock.innerHTML = content;
// Restore focus if we had it
if (hadFocus) {
quoteBlock.focus();
}
}
setCursorToStart(element: HTMLElement, context?: any): void {
const quoteBlock = element.querySelector('.block.quote') as HTMLDivElement;
if (quoteBlock) {
WysiwygBlocks.setCursorToStart(quoteBlock);
}
}
setCursorToEnd(element: HTMLElement, context?: any): void {
const quoteBlock = element.querySelector('.block.quote') as HTMLDivElement;
if (quoteBlock) {
WysiwygBlocks.setCursorToEnd(quoteBlock);
}
}
focus(element: HTMLElement, context?: any): void {
const quoteBlock = element.querySelector('.block.quote') as HTMLDivElement;
if (!quoteBlock) return;
// Ensure the element is focusable
if (!quoteBlock.hasAttribute('contenteditable')) {
quoteBlock.setAttribute('contenteditable', 'true');
}
quoteBlock.focus();
// If focus failed, try again after a microtask
if (document.activeElement !== quoteBlock && element.shadowRoot?.activeElement !== quoteBlock) {
Promise.resolve().then(() => {
quoteBlock.focus();
});
}
}
focusWithCursor(element: HTMLElement, position: 'start' | 'end' | number = 'end', context?: any): void {
const quoteBlock = element.querySelector('.block.quote') as HTMLDivElement;
if (!quoteBlock) return;
// Ensure element is focusable first
if (!quoteBlock.hasAttribute('contenteditable')) {
quoteBlock.setAttribute('contenteditable', 'true');
}
// Focus the element
quoteBlock.focus();
// Set cursor position after focus is established
const setCursor = () => {
if (position === 'start') {
this.setCursorToStart(element, context);
} else if (position === 'end') {
this.setCursorToEnd(element, context);
} else if (typeof position === 'number') {
// Use the selection utility to set cursor position
WysiwygSelection.setCursorPosition(quoteBlock, position);
}
};
// Ensure cursor is set after focus
if (document.activeElement === quoteBlock || element.shadowRoot?.activeElement === quoteBlock) {
setCursor();
} else {
// Wait for focus to be established
Promise.resolve().then(() => {
if (document.activeElement === quoteBlock || element.shadowRoot?.activeElement === quoteBlock) {
setCursor();
}
});
}
}
getSplitContent(element: HTMLElement, context?: any): { before: string; after: string } | null {
const quoteBlock = element.querySelector('.block.quote') as HTMLDivElement;
if (!quoteBlock) {
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 = quoteBlock.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(quoteBlock, selectionInfo.startContainer)) {
// Try using last known cursor position
if (this.lastKnownCursorPosition !== null) {
const fullText = quoteBlock.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: quoteBlock.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(quoteBlock, 0);
beforeRange.setEnd(selectionInfo.startContainer, selectionInfo.startOffset);
// After range: from cursor to end of element
afterRange.setStart(selectionInfo.startContainer, selectionInfo.startOffset);
afterRange.setEnd(quoteBlock, quoteBlock.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
};
}
}