This commit is contained in:
Juergen Kunz
2025-06-24 15:17:37 +00:00
parent 75637c7793
commit 83f153f654
3 changed files with 323 additions and 306 deletions

View File

@ -6,7 +6,6 @@ import {
type TemplateResult,
cssManager,
css,
query,
} from '@design.estate/dees-element';
import { type IBlock } from './wysiwyg.types.js';
@ -38,11 +37,14 @@ export class DeesWysiwygBlock extends DeesElement {
onMouseUp?: (e: MouseEvent) => void;
};
@query('.block')
private blockElement: HTMLDivElement;
// Reference to the editable block element
private blockElement: HTMLDivElement | null = null;
// Track if we've initialized the content
private contentInitialized: boolean = false;
// Track cursor position
private lastKnownCursorPosition: number = 0;
public static styles = [
cssManager.defaultStyles,
@ -270,10 +272,16 @@ export class DeesWysiwygBlock extends DeesElement {
// Mark that content has been initialized
this.contentInitialized = true;
// For code blocks, the actual contenteditable block is nested
// First, populate the container with the rendered content
const container = this.shadowRoot?.querySelector('.wysiwyg-block-container') as HTMLDivElement;
if (container && this.block) {
container.innerHTML = this.renderBlockContent();
}
// Now find the actual editable block element
const editableBlock = this.block.type === 'code'
? this.shadowRoot?.querySelector('.block.code') as HTMLDivElement
: this.blockElement;
: this.shadowRoot?.querySelector('.block') as HTMLDivElement;
// Ensure the block element maintains its content
if (editableBlock) {
@ -282,16 +290,31 @@ export class DeesWysiwygBlock extends DeesElement {
// Set up all event handlers manually to avoid Lit re-renders
editableBlock.addEventListener('input', (e) => {
this.logCursorPosition('input');
this.handlers?.onInput?.(e as InputEvent);
// Track cursor position after input
const pos = this.getCursorPosition(editableBlock);
if (pos !== null) {
this.lastKnownCursorPosition = pos;
}
});
editableBlock.addEventListener('keydown', (e) => {
// Track cursor position before keydown
const pos = this.getCursorPosition(editableBlock);
if (pos !== null) {
this.lastKnownCursorPosition = pos;
}
this.handlers?.onKeyDown?.(e);
});
editableBlock.addEventListener('keyup', (e) => {
this.logCursorPosition('keyup', e);
// Track cursor position after key release
const pos = this.getCursorPosition(editableBlock);
if (pos !== null) {
this.lastKnownCursorPosition = pos;
}
});
editableBlock.addEventListener('focus', () => {
@ -311,13 +334,28 @@ export class DeesWysiwygBlock extends DeesElement {
});
editableBlock.addEventListener('mouseup', (e) => {
this.logCursorPosition('mouseup');
// Small delay to let browser set cursor position
setTimeout(() => {
const pos = this.getCursorPosition(editableBlock);
if (pos !== null) {
this.lastKnownCursorPosition = pos;
console.log('Cursor position after mouseup:', pos);
}
}, 0);
this.handleMouseUp(e);
this.handlers?.onMouseUp?.(e);
});
editableBlock.addEventListener('click', () => {
this.logCursorPosition('click');
editableBlock.addEventListener('click', (e: MouseEvent) => {
// Small delay to let browser set cursor position
setTimeout(() => {
const pos = this.getCursorPosition(editableBlock);
if (pos !== null) {
this.lastKnownCursorPosition = pos;
console.log('Cursor position after click:', pos);
}
}, 0);
});
// Set initial content if needed
@ -332,15 +370,23 @@ export class DeesWysiwygBlock extends DeesElement {
}
}
// For code blocks, we use the nested editableBlock
// The blockElement getter will automatically find the right element
// Store reference to the block element for quick access
this.blockElement = editableBlock;
}
render(): TemplateResult {
if (!this.block) return html``;
// Since we need dynamic content, we'll render an empty container
// and set the innerHTML in firstUpdated
return html`<div class="wysiwyg-block-container"></div>`;
}
private renderBlockContent(): string {
if (!this.block) return '';
if (this.block.type === 'divider') {
return html`
return `
<div class="block divider" data-block-id="${this.block.id}" data-block-type="${this.block.type}">
<hr>
</div>
@ -349,11 +395,12 @@ export class DeesWysiwygBlock extends DeesElement {
if (this.block.type === 'code') {
const language = this.block.metadata?.language || 'plain text';
return html`
const selectedClass = this.isSelected ? ' selected' : '';
return `
<div class="code-block-container">
<div class="code-language">${language}</div>
<div
class="block code ${this.isSelected ? 'selected' : ''}"
class="block code${selectedClass}"
contenteditable="true"
data-block-type="${this.block.type}"
></div>
@ -362,11 +409,10 @@ export class DeesWysiwygBlock extends DeesElement {
}
const placeholder = this.getPlaceholder();
// Return static HTML without event bindings
return html`
const selectedClass = this.isSelected ? ' selected' : '';
return `
<div
class="block ${this.block.type} ${this.isSelected ? 'selected' : ''}"
class="block ${this.block.type}${selectedClass}"
contenteditable="true"
data-placeholder="${placeholder}"
></div>
@ -395,7 +441,7 @@ export class DeesWysiwygBlock extends DeesElement {
// Get the actual editable element (might be nested for code blocks)
const editableElement = this.block?.type === 'code'
? this.shadowRoot?.querySelector('.block.code') as HTMLDivElement
: this.blockElement;
: this.shadowRoot?.querySelector('.block') as HTMLDivElement;
if (!editableElement) return;
@ -418,7 +464,7 @@ export class DeesWysiwygBlock extends DeesElement {
// Get the actual editable element (might be nested for code blocks)
const editableElement = this.block?.type === 'code'
? this.shadowRoot?.querySelector('.block.code') as HTMLDivElement
: this.blockElement;
: this.shadowRoot?.querySelector('.block') as HTMLDivElement;
if (!editableElement) return;
@ -455,24 +501,66 @@ export class DeesWysiwygBlock extends DeesElement {
}
}
private getFirstTextNode(node: Node): Text | null {
if (node.nodeType === Node.TEXT_NODE) {
return node as Text;
/**
* Get cursor position in the editable element
*/
private getCursorPosition(element: HTMLElement): number | null {
// Get parent wysiwyg component's shadow root
const parentComponent = this.closest('dees-input-wysiwyg');
const parentShadowRoot = parentComponent?.shadowRoot;
// Get selection info with both shadow roots for proper traversal
const shadowRoots: ShadowRoot[] = [];
if (parentShadowRoot) shadowRoots.push(parentShadowRoot);
if (this.shadowRoot) shadowRoots.push(this.shadowRoot);
const selectionInfo = WysiwygSelection.getSelectionInfo(...shadowRoots);
console.log('getCursorPosition: Selection info from shadow DOMs:', {
selectionInfo,
shadowRootsCount: shadowRoots.length
});
if (!selectionInfo) {
console.log('getCursorPosition: No selection found');
return null;
}
for (let i = 0; i < node.childNodes.length; i++) {
const textNode = this.getFirstTextNode(node.childNodes[i]);
if (textNode) return textNode;
console.log('getCursorPosition: Range info:', {
startContainer: selectionInfo.startContainer,
startOffset: selectionInfo.startOffset,
collapsed: selectionInfo.collapsed,
startContainerText: selectionInfo.startContainer.textContent
});
if (!element.contains(selectionInfo.startContainer)) {
console.log('getCursorPosition: Range not in element');
return null;
}
return null;
// Create a range from start of element to cursor position
const preCaretRange = document.createRange();
preCaretRange.selectNodeContents(element);
preCaretRange.setEnd(selectionInfo.startContainer, selectionInfo.startOffset);
// Get the text content length up to cursor
const position = preCaretRange.toString().length;
console.log('getCursorPosition: Calculated position:', {
position,
preCaretText: preCaretRange.toString(),
elementText: element.textContent,
elementTextLength: element.textContent?.length
});
return position;
}
public getContent(): string {
// Get the actual editable element (might be nested for code blocks)
const editableElement = this.block?.type === 'code'
? this.shadowRoot?.querySelector('.block.code') as HTMLDivElement
: this.blockElement;
: this.shadowRoot?.querySelector('.block') as HTMLDivElement;
if (!editableElement) return '';
@ -490,7 +578,7 @@ export class DeesWysiwygBlock extends DeesElement {
// Get the actual editable element (might be nested for code blocks)
const editableElement = this.block?.type === 'code'
? this.shadowRoot?.querySelector('.block.code') as HTMLDivElement
: this.blockElement;
: this.shadowRoot?.querySelector('.block') as HTMLDivElement;
if (!editableElement) return;
@ -531,7 +619,10 @@ export class DeesWysiwygBlock extends DeesElement {
public focusListItem(): void {
if (this.block.type === 'list') {
WysiwygBlocks.focusListItem(this.blockElement);
const editableElement = this.shadowRoot?.querySelector('.block') as HTMLDivElement;
if (editableElement) {
WysiwygBlocks.focusListItem(editableElement);
}
}
}
@ -539,179 +630,149 @@ export class DeesWysiwygBlock extends DeesElement {
* Gets content split at cursor position
*/
public getSplitContent(): { before: string; after: string } | null {
if (!this.blockElement) return null;
console.log('getSplitContent: Starting...');
// Get the full content first
const fullContent = this.getContent();
console.log('getSplitContent: Full content:', {
content: fullContent,
length: fullContent.length,
blockType: this.block.type
});
// Direct approach: Get selection from window
const selection = window.getSelection();
if (!selection || selection.rangeCount === 0) {
console.log('getSplitContent: No selection found');
return {
before: fullContent,
after: ''
};
}
const range = selection.getRangeAt(0);
console.log('getSplitContent: Range info:', {
startContainer: range.startContainer,
startOffset: range.startOffset,
collapsed: range.collapsed,
startContainerType: range.startContainer.nodeType,
startContainerText: range.startContainer.textContent?.substring(0, 50)
});
// Check if this block element has focus or contains the selection
const activeElement = this.shadowRoot?.activeElement || document.activeElement;
const hasFocus = this.blockElement === activeElement || this.blockElement?.contains(activeElement as Node);
// For contenteditable, check if selection is in our shadow DOM
let selectionInThisBlock = false;
try {
// Walk up from the selection to see if we reach our block element
let node: Node | null = range.startContainer;
while (node) {
if (node === this.blockElement || node === this.shadowRoot) {
selectionInThisBlock = true;
break;
}
node = node.parentNode || (node as any).host; // Check shadow host too
}
} catch (e) {
console.log('Error checking selection ancestry:', e);
}
console.log('getSplitContent: Focus check:', {
hasFocus,
selectionInThisBlock,
activeElement,
blockElement: this.blockElement
});
if (!hasFocus && !selectionInThisBlock) {
console.log('getSplitContent: Block does not have focus/selection');
return null;
}
// Get the actual editable element (might be nested for code blocks)
const editableElement = this.block.type === 'code'
// Get the actual editable element first
const editableElement = this.block?.type === 'code'
? this.shadowRoot?.querySelector('.block.code') as HTMLDivElement
: this.blockElement;
: this.shadowRoot?.querySelector('.block') as HTMLDivElement;
if (!editableElement) {
console.log('getSplitContent: No editable element found');
return null;
}
// Handle special cases for different block types
if (this.block.type === 'code') {
// For code blocks, split text content
const fullText = editableElement.textContent || '';
const textNode = this.getFirstTextNode(editableElement);
if (textNode && range.startContainer === textNode) {
const before = fullText.substring(0, range.startOffset);
const after = fullText.substring(range.startOffset);
console.log('getSplitContent: Code block split result:', {
contentLength: fullText.length,
beforeContent: before,
beforeLength: before.length,
afterContent: after,
afterLength: after.length,
startOffset: range.startOffset
});
return { before, after };
}
}
console.log('getSplitContent: Element info:', {
blockType: this.block.type,
innerHTML: editableElement.innerHTML,
textContent: editableElement.textContent,
textLength: editableElement.textContent?.length
});
// For other block types, extract HTML content
try {
// If selection is not directly in our element, try to find cursor position by text
if (!editableElement.contains(range.startContainer)) {
// Simple approach: split at cursor position in text
const textContent = editableElement.textContent || '';
const cursorPos = range.startOffset; // Simplified cursor position
const beforeText = textContent.substring(0, cursorPos);
const afterText = textContent.substring(cursorPos);
console.log('Splitting by text position (fallback):', {
cursorPos,
beforeText,
afterText,
totalLength: textContent.length
// Get parent wysiwyg component's shadow root
const parentComponent = this.closest('dees-input-wysiwyg');
const parentShadowRoot = parentComponent?.shadowRoot;
// Get selection info with both shadow roots for proper traversal
const shadowRoots: ShadowRoot[] = [];
if (parentShadowRoot) shadowRoots.push(parentShadowRoot);
if (this.shadowRoot) shadowRoots.push(this.shadowRoot);
const selectionInfo = WysiwygSelection.getSelectionInfo(...shadowRoots);
console.log('getSplitContent: Selection info from shadow DOMs:', {
selectionInfo,
shadowRootsCount: shadowRoots.length
});
if (!selectionInfo) {
console.log('getSplitContent: No selection, using last known position:', this.lastKnownCursorPosition);
// Try using last known cursor position
if (this.lastKnownCursorPosition !== null) {
const fullText = editableElement.textContent || '';
const pos = Math.min(this.lastKnownCursorPosition, fullText.length);
console.log('getSplitContent: Splitting with last known position:', {
pos,
fullTextLength: fullText.length,
before: fullText.substring(0, pos),
after: fullText.substring(pos)
});
// For now, return text-based split
return {
before: beforeText,
after: afterText
before: fullText.substring(0, pos),
after: fullText.substring(pos)
};
}
// Create a temporary range to get content before cursor
const beforeRange = document.createRange();
beforeRange.selectNodeContents(editableElement);
beforeRange.setEnd(range.startContainer, range.startOffset);
// Create a temporary range to get content after cursor
const afterRange = document.createRange();
afterRange.selectNodeContents(editableElement);
afterRange.setStart(range.startContainer, range.startOffset);
// Clone HTML content (not extract, to avoid modifying the DOM)
const beforeContents = beforeRange.cloneContents();
const afterContents = afterRange.cloneContents();
// Convert to HTML strings
const tempDiv = document.createElement('div');
tempDiv.appendChild(beforeContents);
const beforeHtml = tempDiv.innerHTML;
tempDiv.innerHTML = '';
tempDiv.appendChild(afterContents);
const afterHtml = tempDiv.innerHTML;
const result = {
before: beforeHtml,
after: afterHtml
};
console.log('getSplitContent: Split result:', {
contentLength: fullContent.length,
beforeContent: result.before,
beforeLength: result.before.length,
afterContent: result.after,
afterLength: result.after.length
});
return result;
} catch (error) {
console.error('Error splitting content:', error);
// Fallback: return all content as "before"
const fallbackResult = {
before: this.getContent(),
after: ''
};
console.log('getSplitContent: Fallback result:', {
beforeContent: fallbackResult.before,
beforeLength: fallbackResult.before.length,
afterContent: fallbackResult.after,
afterLength: fallbackResult.after.length
});
return fallbackResult;
return null;
}
console.log('getSplitContent: Selection range:', {
startContainer: selectionInfo.startContainer,
startOffset: selectionInfo.startOffset,
startContainerInElement: editableElement.contains(selectionInfo.startContainer)
});
// Make sure the selection is within this block
if (!editableElement.contains(selectionInfo.startContainer)) {
console.log('getSplitContent: Selection not in this block, using last known position:', this.lastKnownCursorPosition);
// Try using last known cursor position
if (this.lastKnownCursorPosition !== null) {
const fullText = editableElement.textContent || '';
const pos = Math.min(this.lastKnownCursorPosition, fullText.length);
return {
before: fullText.substring(0, pos),
after: fullText.substring(pos)
};
}
return null;
}
// For code blocks, use simple text splitting
if (this.block.type === 'code') {
const cursorPos = this.getCursorPosition(editableElement) || 0;
const fullText = editableElement.textContent || '';
console.log('getSplitContent: Code block split:', {
cursorPos,
fullTextLength: fullText.length,
before: fullText.substring(0, cursorPos),
after: fullText.substring(cursorPos)
});
return {
before: fullText.substring(0, cursorPos),
after: fullText.substring(cursorPos)
};
}
// For HTML content, get cursor position first
const cursorPos = this.getCursorPosition(editableElement);
console.log('getSplitContent: Cursor position for HTML split:', cursorPos);
if (cursorPos === null || cursorPos === 0) {
// If cursor is at start or can't determine position, move all content
console.log('getSplitContent: Cursor at start or null, moving all content');
return {
before: '',
after: editableElement.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(editableElement, 0);
beforeRange.setEnd(selectionInfo.startContainer, selectionInfo.startOffset);
// After range: from cursor to end of element
afterRange.setStart(selectionInfo.startContainer, selectionInfo.startOffset);
afterRange.setEnd(editableElement, editableElement.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;
console.log('getSplitContent: Final split result:', {
cursorPos,
beforeHtml,
beforeLength: beforeHtml.length,
afterHtml,
afterLength: afterHtml.length
});
return {
before: beforeHtml,
after: afterHtml
};
}
private handleMouseUp(_e: MouseEvent): void {
@ -722,7 +783,10 @@ export class DeesWysiwygBlock extends DeesElement {
const range = selection.getRangeAt(0);
// Check if selection is within this block
if (this.blockElement && this.blockElement.contains(range.commonAncestorContainer)) {
const editableElement = this.block?.type === 'code'
? this.shadowRoot?.querySelector('.block.code') as HTMLDivElement
: this.shadowRoot?.querySelector('.block') as HTMLDivElement;
if (editableElement && editableElement.contains(range.commonAncestorContainer)) {
const selectedText = selection.toString();
if (selectedText.length > 0) {
// Dispatch a custom event that can cross shadow DOM boundaries
@ -740,117 +804,4 @@ export class DeesWysiwygBlock extends DeesElement {
}
}, 10);
}
/**
* Logs cursor position for debugging
*/
private logCursorPosition(eventType: string, event?: KeyboardEvent): void {
console.log(`[CursorLog] Event triggered: ${eventType} in block ${this.block.id}`);
// Get the actual active element considering shadow DOM
const activeElement = this.shadowRoot?.activeElement;
console.log(`[CursorLog] Active element:`, activeElement, 'Block element:', this.blockElement);
// Only log if this block is focused
if (activeElement !== this.blockElement) {
console.log(`[CursorLog] Block not focused, skipping detailed logging`);
return;
}
// Get selection info using the new utility that handles Shadow DOM
const selectionInfo = WysiwygSelection.getSelectionInfo(this.shadowRoot!);
if (!selectionInfo) {
console.log(`[${eventType}] No selection available`);
return;
}
const isInThisBlock = WysiwygSelection.isSelectionInElement(this.blockElement, this.shadowRoot!);
if (!isInThisBlock) {
return;
}
// Get cursor position details
const details: any = {
event: eventType,
blockId: this.block.id,
blockType: this.block.type,
collapsed: selectionInfo.collapsed,
startContainer: {
nodeType: selectionInfo.startContainer.nodeType,
nodeName: selectionInfo.startContainer.nodeName,
textContent: selectionInfo.startContainer.textContent?.substring(0, 50) + '...',
},
startOffset: selectionInfo.startOffset,
};
// Add key info if it's a keyboard event
if (event) {
details.key = event.key;
details.shiftKey = event.shiftKey;
details.ctrlKey = event.ctrlKey;
details.metaKey = event.metaKey;
}
// Try to get the actual cursor position in the text
if (selectionInfo.startContainer.nodeType === Node.TEXT_NODE) {
const textNode = selectionInfo.startContainer as Text;
const textBefore = textNode.textContent?.substring(0, selectionInfo.startOffset) || '';
const textAfter = textNode.textContent?.substring(selectionInfo.startOffset) || '';
details.cursorPosition = {
textBefore: textBefore.slice(-20), // Last 20 chars before cursor
textAfter: textAfter.slice(0, 20), // First 20 chars after cursor
totalLength: textNode.textContent?.length || 0,
offset: selectionInfo.startOffset
};
}
// Check if we're at boundaries
details.boundaries = {
atStart: this.isCursorAtStart(selectionInfo),
atEnd: this.isCursorAtEnd(selectionInfo)
};
console.log('Cursor Position:', details);
}
/**
* Check if cursor is at the start of the block
*/
private isCursorAtStart(selectionInfo: { startContainer: Node; startOffset: number; collapsed: boolean }): boolean {
if (!selectionInfo.collapsed || selectionInfo.startOffset !== 0) return false;
const firstNode = this.getFirstTextNode(this.blockElement);
return !firstNode || selectionInfo.startContainer === firstNode || selectionInfo.startContainer === this.blockElement;
}
/**
* Check if cursor is at the end of the block
*/
private isCursorAtEnd(selectionInfo: { endContainer: Node; endOffset: number; collapsed: boolean }): boolean {
if (!selectionInfo.collapsed) return false;
const lastNode = this.getLastTextNode(this.blockElement);
if (!lastNode) return true;
return selectionInfo.endContainer === lastNode &&
selectionInfo.endOffset === (lastNode.textContent?.length || 0);
}
/**
* Get the last text node in the element
*/
private getLastTextNode(node: Node): Text | null {
if (node.nodeType === Node.TEXT_NODE) {
return node as Text;
}
for (let i = node.childNodes.length - 1; i >= 0; i--) {
const lastText = this.getLastTextNode(node.childNodes[i]);
if (lastText) return lastText;
}
return null;
}
}