fix(wysiwyg):Improve Wysiwyg editor
This commit is contained in:
@ -1,18 +1,17 @@
|
||||
import {
|
||||
customElement,
|
||||
property,
|
||||
html,
|
||||
static as html,
|
||||
DeesElement,
|
||||
type TemplateResult,
|
||||
cssManager,
|
||||
css,
|
||||
query,
|
||||
unsafeStatic,
|
||||
static as staticHtml,
|
||||
} from '@design.estate/dees-element';
|
||||
|
||||
import { type IBlock } from './wysiwyg.types.js';
|
||||
import { WysiwygBlocks } from './wysiwyg.blocks.js';
|
||||
import { WysiwygSelection } from './wysiwyg.selection.js';
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
@ -271,10 +270,71 @@ export class DeesWysiwygBlock extends DeesElement {
|
||||
// Mark that content has been initialized
|
||||
this.contentInitialized = true;
|
||||
|
||||
// For code blocks, the actual contenteditable block is nested
|
||||
const editableBlock = this.block.type === 'code'
|
||||
? this.shadowRoot?.querySelector('.block.code') as HTMLDivElement
|
||||
: this.blockElement;
|
||||
|
||||
// Ensure the block element maintains its content
|
||||
if (this.blockElement) {
|
||||
this.blockElement.setAttribute('data-block-id', this.block.id);
|
||||
this.blockElement.setAttribute('data-block-type', this.block.type);
|
||||
if (editableBlock) {
|
||||
editableBlock.setAttribute('data-block-id', this.block.id);
|
||||
editableBlock.setAttribute('data-block-type', this.block.type);
|
||||
|
||||
// Set up all event handlers manually to avoid Lit re-renders
|
||||
editableBlock.addEventListener('input', (e) => {
|
||||
this.logCursorPosition('input');
|
||||
this.handlers?.onInput?.(e as InputEvent);
|
||||
});
|
||||
|
||||
editableBlock.addEventListener('keydown', (e) => {
|
||||
this.handlers?.onKeyDown?.(e);
|
||||
});
|
||||
|
||||
editableBlock.addEventListener('keyup', (e) => {
|
||||
this.logCursorPosition('keyup', e);
|
||||
});
|
||||
|
||||
editableBlock.addEventListener('focus', () => {
|
||||
this.handlers?.onFocus?.();
|
||||
});
|
||||
|
||||
editableBlock.addEventListener('blur', () => {
|
||||
this.handlers?.onBlur?.();
|
||||
});
|
||||
|
||||
editableBlock.addEventListener('compositionstart', () => {
|
||||
this.handlers?.onCompositionStart?.();
|
||||
});
|
||||
|
||||
editableBlock.addEventListener('compositionend', () => {
|
||||
this.handlers?.onCompositionEnd?.();
|
||||
});
|
||||
|
||||
editableBlock.addEventListener('mouseup', (e) => {
|
||||
this.logCursorPosition('mouseup');
|
||||
this.handleMouseUp(e);
|
||||
this.handlers?.onMouseUp?.(e);
|
||||
});
|
||||
|
||||
editableBlock.addEventListener('click', () => {
|
||||
this.logCursorPosition('click');
|
||||
});
|
||||
|
||||
// Set initial content if needed
|
||||
if (this.block.content) {
|
||||
if (this.block.type === 'code') {
|
||||
editableBlock.textContent = this.block.content;
|
||||
} else if (this.block.type === 'list') {
|
||||
editableBlock.innerHTML = WysiwygBlocks.renderListContent(this.block.content, this.block.metadata);
|
||||
} else {
|
||||
editableBlock.innerHTML = this.block.content;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Update blockElement reference for code blocks
|
||||
if (this.block.type === 'code') {
|
||||
this.blockElement = editableBlock;
|
||||
}
|
||||
}
|
||||
|
||||
@ -298,41 +358,20 @@ export class DeesWysiwygBlock extends DeesElement {
|
||||
class="block code ${this.isSelected ? 'selected' : ''}"
|
||||
contenteditable="true"
|
||||
data-block-type="${this.block.type}"
|
||||
@input="${this.handlers?.onInput}"
|
||||
@keydown="${this.handlers?.onKeyDown}"
|
||||
@focus="${this.handlers?.onFocus}"
|
||||
@blur="${this.handlers?.onBlur}"
|
||||
@compositionstart="${this.handlers?.onCompositionStart}"
|
||||
@compositionend="${this.handlers?.onCompositionEnd}"
|
||||
@mouseup="${(e: MouseEvent) => {
|
||||
this.handleMouseUp(e);
|
||||
this.handlers?.onMouseUp?.(e);
|
||||
}}"
|
||||
.textContent="${this.block.content || ''}"
|
||||
></div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
const placeholder = this.getPlaceholder();
|
||||
const initialContent = this.getInitialContent();
|
||||
|
||||
return staticHtml`
|
||||
// Return static HTML without event bindings
|
||||
return html`
|
||||
<div
|
||||
class="block ${this.block.type} ${this.isSelected ? 'selected' : ''}"
|
||||
contenteditable="true"
|
||||
data-placeholder="${placeholder}"
|
||||
@input="${this.handlers?.onInput}"
|
||||
@keydown="${this.handlers?.onKeyDown}"
|
||||
@focus="${this.handlers?.onFocus}"
|
||||
@blur="${this.handlers?.onBlur}"
|
||||
@compositionstart="${this.handlers?.onCompositionStart}"
|
||||
@compositionend="${this.handlers?.onCompositionEnd}"
|
||||
@mouseup="${(e: MouseEvent) => {
|
||||
this.handleMouseUp(e);
|
||||
this.handlers?.onMouseUp?.(e);
|
||||
}}"
|
||||
>${unsafeStatic(initialContent)}</div>
|
||||
></div>
|
||||
`;
|
||||
}
|
||||
|
||||
@ -353,12 +392,6 @@ export class DeesWysiwygBlock extends DeesElement {
|
||||
}
|
||||
}
|
||||
|
||||
private getInitialContent(): string {
|
||||
if (this.block.type === 'list') {
|
||||
return WysiwygBlocks.renderListContent(this.block.content, this.block.metadata);
|
||||
}
|
||||
return this.block.content || '';
|
||||
}
|
||||
|
||||
public focus(): void {
|
||||
if (!this.blockElement) return;
|
||||
@ -391,34 +424,13 @@ export class DeesWysiwygBlock extends DeesElement {
|
||||
|
||||
// Set cursor position after focus is established
|
||||
const setCursor = () => {
|
||||
const selection = window.getSelection();
|
||||
if (!selection) return;
|
||||
|
||||
if (position === 'start') {
|
||||
this.setCursorToStart();
|
||||
} else if (position === 'end') {
|
||||
this.setCursorToEnd();
|
||||
} else if (typeof position === 'number') {
|
||||
// Set cursor at specific position
|
||||
const range = document.createRange();
|
||||
const textNode = this.getFirstTextNode(this.blockElement);
|
||||
|
||||
if (textNode) {
|
||||
const length = textNode.textContent?.length || 0;
|
||||
const safePosition = Math.min(position, length);
|
||||
range.setStart(textNode, safePosition);
|
||||
range.collapse(true);
|
||||
selection.removeAllRanges();
|
||||
selection.addRange(range);
|
||||
} else if (this.blockElement.childNodes.length === 0) {
|
||||
// Empty block - create a text node
|
||||
const emptyText = document.createTextNode('');
|
||||
this.blockElement.appendChild(emptyText);
|
||||
range.setStart(emptyText, 0);
|
||||
range.collapse(true);
|
||||
selection.removeAllRanges();
|
||||
selection.addRange(range);
|
||||
}
|
||||
// Use the new selection utility to set cursor position
|
||||
WysiwygSelection.setCursorPosition(this.blockElement, position);
|
||||
}
|
||||
};
|
||||
|
||||
@ -501,47 +513,121 @@ export class DeesWysiwygBlock extends DeesElement {
|
||||
public getSplitContent(): { before: string; after: string } | null {
|
||||
if (!this.blockElement) return null;
|
||||
|
||||
const selection = window.getSelection();
|
||||
if (!selection || selection.rangeCount === 0) {
|
||||
// Get the full content first
|
||||
const fullContent = this.getContent();
|
||||
console.log('getSplitContent: Full content:', {
|
||||
content: fullContent,
|
||||
length: fullContent.length,
|
||||
blockType: this.block.type
|
||||
});
|
||||
|
||||
// Get selection info using the new utility that handles Shadow DOM
|
||||
const selectionInfo = WysiwygSelection.getSelectionInfo(this.shadowRoot!);
|
||||
if (!selectionInfo) {
|
||||
console.log('getSplitContent: No selection, returning all content as before');
|
||||
return {
|
||||
before: this.getContent(),
|
||||
before: fullContent,
|
||||
after: ''
|
||||
};
|
||||
}
|
||||
|
||||
const range = selection.getRangeAt(0);
|
||||
|
||||
// Check if selection is within this block
|
||||
if (!this.blockElement.contains(range.commonAncestorContainer)) {
|
||||
if (!WysiwygSelection.isSelectionInElement(this.blockElement, this.shadowRoot!)) {
|
||||
console.log('getSplitContent: Selection not in this block');
|
||||
return null;
|
||||
}
|
||||
|
||||
// Clone the range to extract content before and after cursor
|
||||
const beforeRange = range.cloneRange();
|
||||
beforeRange.selectNodeContents(this.blockElement);
|
||||
beforeRange.setEnd(range.startContainer, range.startOffset);
|
||||
// Get cursor position as a number
|
||||
const cursorPosition = WysiwygSelection.getCursorPositionInElement(this.blockElement, this.shadowRoot!);
|
||||
console.log('getSplitContent: Cursor position:', {
|
||||
cursorPosition,
|
||||
contentLength: fullContent.length,
|
||||
startContainer: selectionInfo.startContainer,
|
||||
startOffset: selectionInfo.startOffset,
|
||||
collapsed: selectionInfo.collapsed
|
||||
});
|
||||
|
||||
const afterRange = range.cloneRange();
|
||||
afterRange.selectNodeContents(this.blockElement);
|
||||
afterRange.setStart(range.endContainer, range.endOffset);
|
||||
// Handle special cases for different block types
|
||||
if (this.block.type === 'code') {
|
||||
// For code blocks, split text content
|
||||
const fullText = this.blockElement.textContent || '';
|
||||
const textNode = this.getFirstTextNode(this.blockElement);
|
||||
|
||||
if (textNode && selectionInfo.startContainer === textNode) {
|
||||
const before = fullText.substring(0, selectionInfo.startOffset);
|
||||
const after = fullText.substring(selectionInfo.startOffset);
|
||||
|
||||
console.log('getSplitContent: Code block split result:', {
|
||||
cursorPosition,
|
||||
contentLength: fullText.length,
|
||||
beforeContent: before,
|
||||
beforeLength: before.length,
|
||||
afterContent: after,
|
||||
afterLength: after.length,
|
||||
startOffset: selectionInfo.startOffset
|
||||
});
|
||||
|
||||
return { before, after };
|
||||
}
|
||||
}
|
||||
|
||||
// Extract content
|
||||
const beforeFragment = beforeRange.cloneContents();
|
||||
const afterFragment = afterRange.cloneContents();
|
||||
|
||||
// Convert to HTML
|
||||
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
|
||||
};
|
||||
// For other block types, extract HTML content
|
||||
try {
|
||||
// Create a temporary range to get content before cursor
|
||||
const beforeRange = document.createRange();
|
||||
beforeRange.selectNodeContents(this.blockElement);
|
||||
beforeRange.setEnd(selectionInfo.startContainer, selectionInfo.startOffset);
|
||||
|
||||
// Create a temporary range to get content after cursor
|
||||
const afterRange = document.createRange();
|
||||
afterRange.selectNodeContents(this.blockElement);
|
||||
afterRange.setStart(selectionInfo.startContainer, selectionInfo.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:', {
|
||||
cursorPosition,
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
private handleMouseUp(_e: MouseEvent): void {
|
||||
@ -570,4 +656,117 @@ 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;
|
||||
}
|
||||
}
|
Reference in New Issue
Block a user