fix(wysiwyg): Implement programmatic rendering to eliminate focus loss during typing
- Convert parent component to use static rendering with programmatic DOM manipulation - Remove all reactive state that could trigger re-renders during editing - Delay content sync to avoid interference with typing (2s auto-save) - Update all block operations to use manual DOM updates instead of Lit re-renders - Fix specific issue where typing + arrow keys caused focus loss - Add comprehensive focus management documentation
This commit is contained in:
@@ -54,7 +54,7 @@ export class DeesInputWysiwyg extends DeesInputBase<string> {
|
||||
}
|
||||
];
|
||||
|
||||
@state()
|
||||
// Not using @state to avoid re-renders when selection changes
|
||||
private selectedBlockId: string | null = null;
|
||||
|
||||
// Slash menu is now globally rendered
|
||||
@@ -84,6 +84,9 @@ export class DeesInputWysiwyg extends DeesInputBase<string> {
|
||||
private inputHandler: WysiwygInputHandler;
|
||||
private keyboardHandler: WysiwygKeyboardHandler;
|
||||
private dragDropHandler: WysiwygDragDropHandler;
|
||||
|
||||
// Content cache to avoid triggering re-renders during typing
|
||||
private contentCache: Map<string, string> = new Map();
|
||||
|
||||
public static styles = [
|
||||
...DeesInputBase.baseStyles,
|
||||
@@ -110,6 +113,11 @@ export class DeesInputWysiwyg extends DeesInputBase<string> {
|
||||
document.removeEventListener('selectionchange', this.selectionChangeHandler);
|
||||
// Clean up handlers
|
||||
this.inputHandler?.destroy();
|
||||
// Clean up blur timeout
|
||||
if (this.blurTimeout) {
|
||||
clearTimeout(this.blurTimeout);
|
||||
this.blurTimeout = null;
|
||||
}
|
||||
}
|
||||
|
||||
async firstUpdated() {
|
||||
@@ -127,6 +135,104 @@ export class DeesInputWysiwyg extends DeesInputBase<string> {
|
||||
this.updateFormattingMenuPosition();
|
||||
}
|
||||
});
|
||||
|
||||
// Render blocks programmatically
|
||||
this.renderBlocksProgrammatically();
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders all blocks programmatically without triggering re-renders
|
||||
*/
|
||||
private 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);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a block element programmatically
|
||||
*/
|
||||
private createBlockElement(block: IBlock): HTMLElement {
|
||||
const wrapper = document.createElement('div');
|
||||
wrapper.className = 'block-wrapper';
|
||||
wrapper.setAttribute('data-block-id', block.id);
|
||||
|
||||
// Add drag handle for non-divider blocks
|
||||
if (block.type !== 'divider') {
|
||||
const dragHandle = document.createElement('div');
|
||||
dragHandle.className = 'drag-handle';
|
||||
dragHandle.draggable = true;
|
||||
dragHandle.addEventListener('dragstart', (e) => this.dragDropHandler.handleDragStart(e, block));
|
||||
dragHandle.addEventListener('dragend', () => this.dragDropHandler.handleDragEnd());
|
||||
wrapper.appendChild(dragHandle);
|
||||
}
|
||||
|
||||
// Create the block component
|
||||
const blockComponent = document.createElement('dees-wysiwyg-block') as any;
|
||||
blockComponent.block = block;
|
||||
blockComponent.isSelected = this.selectedBlockId === block.id;
|
||||
blockComponent.handlers = {
|
||||
onInput: (e: InputEvent) => this.inputHandler.handleBlockInput(e, block),
|
||||
onKeyDown: (e: KeyboardEvent) => this.keyboardHandler.handleBlockKeyDown(e, block),
|
||||
onFocus: () => this.handleBlockFocus(block),
|
||||
onBlur: () => this.handleBlockBlur(block),
|
||||
onCompositionStart: () => this.isComposing = true,
|
||||
onCompositionEnd: () => this.isComposing = false,
|
||||
onMouseUp: (e: MouseEvent) => this.handleTextSelection(e),
|
||||
};
|
||||
wrapper.appendChild(blockComponent);
|
||||
|
||||
// Add settings button for non-divider blocks
|
||||
if (block.type !== 'divider') {
|
||||
const settings = document.createElement('div');
|
||||
settings.className = 'block-settings';
|
||||
settings.innerHTML = `
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor">
|
||||
<circle cx="12" cy="5" r="2"></circle>
|
||||
<circle cx="12" cy="12" r="2"></circle>
|
||||
<circle cx="12" cy="19" r="2"></circle>
|
||||
</svg>
|
||||
`;
|
||||
settings.addEventListener('click', (e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
WysiwygModalManager.showBlockSettingsModal(block, (updatedBlock) => {
|
||||
this.updateValue();
|
||||
// Re-render only the updated block
|
||||
this.updateBlockElement(block.id);
|
||||
});
|
||||
});
|
||||
wrapper.appendChild(settings);
|
||||
}
|
||||
|
||||
// Add drag event listeners
|
||||
wrapper.addEventListener('dragover', (e) => this.dragDropHandler.handleDragOver(e, block));
|
||||
wrapper.addEventListener('drop', (e) => this.dragDropHandler.handleDrop(e, block));
|
||||
wrapper.addEventListener('dragleave', () => this.dragDropHandler.handleDragLeave(block));
|
||||
|
||||
return wrapper;
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates a specific block element
|
||||
*/
|
||||
private updateBlockElement(blockId: string) {
|
||||
const block = this.blocks.find(b => b.id === blockId);
|
||||
if (!block) return;
|
||||
|
||||
const wrapper = this.editorContentRef.querySelector(`[data-block-id="${blockId}"]`);
|
||||
if (!wrapper) return;
|
||||
|
||||
// Replace with new element
|
||||
const newWrapper = this.createBlockElement(block);
|
||||
wrapper.replaceWith(newWrapper);
|
||||
}
|
||||
|
||||
|
||||
@@ -141,70 +247,15 @@ export class DeesInputWysiwyg extends DeesInputBase<string> {
|
||||
<div
|
||||
class="editor-content ${this.draggedBlockId ? 'dragging' : ''}"
|
||||
@click="${this.handleEditorClick}"
|
||||
id="editor-content"
|
||||
>
|
||||
${this.blocks.map(block => this.renderBlock(block))}
|
||||
<!-- Blocks will be rendered programmatically -->
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
private renderBlock(block: IBlock): TemplateResult {
|
||||
const isSelected = this.selectedBlockId === block.id;
|
||||
const isDragging = this.dragDropHandler.isDragging(block.id);
|
||||
const isDragOver = this.dragDropHandler.isDragOver(block.id);
|
||||
const dragOverClasses = this.dragDropHandler.getDragOverClasses(block.id);
|
||||
|
||||
return html`
|
||||
<div
|
||||
class="block-wrapper ${isDragging ? 'dragging' : ''} ${dragOverClasses}"
|
||||
data-block-id="${block.id}"
|
||||
@dragover="${(e: DragEvent) => this.dragDropHandler.handleDragOver(e, block)}"
|
||||
@drop="${(e: DragEvent) => this.dragDropHandler.handleDrop(e, block)}"
|
||||
@dragleave="${() => this.dragDropHandler.handleDragLeave(block)}"
|
||||
>
|
||||
${block.type !== 'divider' ? html`
|
||||
<div
|
||||
class="drag-handle"
|
||||
draggable="true"
|
||||
@dragstart="${(e: DragEvent) => this.dragDropHandler.handleDragStart(e, block)}"
|
||||
@dragend="${() => this.dragDropHandler.handleDragEnd()}"
|
||||
></div>
|
||||
` : ''}
|
||||
<dees-wysiwyg-block
|
||||
.block="${block}"
|
||||
.isSelected="${isSelected}"
|
||||
.handlers="${{
|
||||
onInput: (e: InputEvent) => this.inputHandler.handleBlockInput(e, block),
|
||||
onKeyDown: (e: KeyboardEvent) => this.keyboardHandler.handleBlockKeyDown(e, block),
|
||||
onFocus: () => this.handleBlockFocus(block),
|
||||
onBlur: () => this.handleBlockBlur(block),
|
||||
onCompositionStart: () => this.isComposing = true,
|
||||
onCompositionEnd: () => this.isComposing = false,
|
||||
onMouseUp: (e: MouseEvent) => this.handleTextSelection(e),
|
||||
}}"
|
||||
></dees-wysiwyg-block>
|
||||
${block.type !== 'divider' ? html`
|
||||
<div
|
||||
class="block-settings"
|
||||
@click="${(e: MouseEvent) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
WysiwygModalManager.showBlockSettingsModal(block, (updatedBlock) => {
|
||||
this.updateValue();
|
||||
this.requestUpdate();
|
||||
});
|
||||
}}"
|
||||
>
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor">
|
||||
<circle cx="12" cy="5" r="2"></circle>
|
||||
<circle cx="12" cy="12" r="2"></circle>
|
||||
<circle cx="12" cy="19" r="2"></circle>
|
||||
</svg>
|
||||
</div>
|
||||
` : ''}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
// Old renderBlock method removed - using programmatic rendering instead
|
||||
|
||||
|
||||
|
||||
@@ -292,19 +343,73 @@ export class DeesInputWysiwyg extends DeesInputBase<string> {
|
||||
}
|
||||
|
||||
private handleBlockFocus(block: IBlock) {
|
||||
// Clear any pending blur timeout when focusing
|
||||
if (this.blurTimeout) {
|
||||
clearTimeout(this.blurTimeout);
|
||||
this.blurTimeout = null;
|
||||
}
|
||||
|
||||
if (block.type !== 'divider') {
|
||||
const prevSelectedId = this.selectedBlockId;
|
||||
this.selectedBlockId = block.id;
|
||||
|
||||
// Only update selection UI if it changed
|
||||
if (prevSelectedId !== block.id) {
|
||||
// Update the previous block's selection state
|
||||
if (prevSelectedId) {
|
||||
const prevWrapper = this.shadowRoot?.querySelector(`[data-block-id="${prevSelectedId}"]`);
|
||||
const prevBlockComponent = prevWrapper?.querySelector('dees-wysiwyg-block') as any;
|
||||
if (prevBlockComponent) {
|
||||
prevBlockComponent.isSelected = false;
|
||||
}
|
||||
}
|
||||
|
||||
// Update the new block's selection state
|
||||
const wrapper = this.shadowRoot?.querySelector(`[data-block-id="${block.id}"]`);
|
||||
const blockComponent = wrapper?.querySelector('dees-wysiwyg-block') as any;
|
||||
if (blockComponent) {
|
||||
blockComponent.isSelected = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private blurTimeout: any = null;
|
||||
|
||||
private handleBlockBlur(block: IBlock) {
|
||||
// Clear any existing blur timeout
|
||||
if (this.blurTimeout) {
|
||||
clearTimeout(this.blurTimeout);
|
||||
}
|
||||
|
||||
// Don't update value if slash menu is visible
|
||||
if (this.slashMenu.visible) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Update value on blur to ensure it's saved
|
||||
this.updateValue();
|
||||
// Sync content from the block that's losing focus
|
||||
const wrapperElement = this.shadowRoot?.querySelector(`[data-block-id="${block.id}"]`);
|
||||
const blockComponent = wrapperElement?.querySelector('dees-wysiwyg-block') as any;
|
||||
|
||||
if (blockComponent && blockComponent.getContent) {
|
||||
const newContent = blockComponent.getContent();
|
||||
// Only update if content actually changed
|
||||
if (block.content !== newContent) {
|
||||
block.content = newContent;
|
||||
}
|
||||
}
|
||||
|
||||
// Delay the blur handling to avoid interfering with typing
|
||||
this.blurTimeout = setTimeout(() => {
|
||||
// Check if we've refocused on another block
|
||||
const activeElement = this.shadowRoot?.activeElement;
|
||||
const isBlockFocused = activeElement?.classList.contains('block');
|
||||
|
||||
if (!isBlockFocused) {
|
||||
// Only update value if we're truly blurring away from all blocks
|
||||
this.updateValue();
|
||||
}
|
||||
}, 100);
|
||||
|
||||
// Don't immediately clear selectedBlockId or close menus
|
||||
// Let click handlers decide what to do
|
||||
@@ -340,10 +445,18 @@ export class DeesInputWysiwyg extends DeesInputBase<string> {
|
||||
const blockIndex = this.blocks.findIndex(b => b.id === afterBlock.id);
|
||||
this.blocks = [...this.blocks.slice(0, blockIndex + 1), newBlock, ...this.blocks.slice(blockIndex + 1)];
|
||||
|
||||
// Insert the new block element programmatically
|
||||
const afterWrapper = this.editorContentRef.querySelector(`[data-block-id="${afterBlock.id}"]`);
|
||||
if (afterWrapper) {
|
||||
const newWrapper = this.createBlockElement(newBlock);
|
||||
afterWrapper.insertAdjacentElement('afterend', newWrapper);
|
||||
}
|
||||
|
||||
this.updateValue();
|
||||
this.requestUpdate();
|
||||
|
||||
if (focusNewBlock && newBlock.type !== 'divider') {
|
||||
// Give DOM time to settle
|
||||
await new Promise(resolve => setTimeout(resolve, 0));
|
||||
await this.blockOperations.focusBlock(newBlock.id, 'start');
|
||||
}
|
||||
}
|
||||
@@ -400,12 +513,12 @@ export class DeesInputWysiwyg extends DeesInputBase<string> {
|
||||
currentBlock.content = currentBlock.content || '';
|
||||
}
|
||||
|
||||
// Update and refocus
|
||||
// Update the block element programmatically
|
||||
this.updateBlockElement(currentBlock.id);
|
||||
this.updateValue();
|
||||
this.requestUpdate();
|
||||
|
||||
// Wait for update to complete before focusing
|
||||
await this.updateComplete;
|
||||
// Give DOM time to settle
|
||||
await new Promise(resolve => setTimeout(resolve, 0));
|
||||
|
||||
// Focus the block after rendering
|
||||
if (type === 'list') {
|
||||
@@ -454,7 +567,11 @@ export class DeesInputWysiwyg extends DeesInputBase<string> {
|
||||
}
|
||||
|
||||
this.changeSubject.next(this.value);
|
||||
this.requestUpdate();
|
||||
|
||||
// Re-render blocks programmatically if we have the editor
|
||||
if (this.editorContentRef) {
|
||||
this.renderBlocksProgrammatically();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -470,7 +587,11 @@ export class DeesInputWysiwyg extends DeesInputBase<string> {
|
||||
public importBlocks(blocks: IBlock[]): void {
|
||||
this.blocks = JSON.parse(JSON.stringify(blocks));
|
||||
this.updateValue();
|
||||
this.requestUpdate();
|
||||
|
||||
// Re-render blocks programmatically if we have the editor
|
||||
if (this.editorContentRef) {
|
||||
this.renderBlocksProgrammatically();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -515,17 +636,39 @@ export class DeesInputWysiwyg extends DeesInputBase<string> {
|
||||
e.dataTransfer.effectAllowed = 'move';
|
||||
e.dataTransfer.setData('text/plain', block.id);
|
||||
|
||||
// Add a slight delay to show the dragging state
|
||||
// Add dragging class to the wrapper
|
||||
setTimeout(() => {
|
||||
this.requestUpdate();
|
||||
const wrapper = this.editorContentRef.querySelector(`[data-block-id="${block.id}"]`);
|
||||
if (wrapper) {
|
||||
wrapper.classList.add('dragging');
|
||||
}
|
||||
|
||||
// Add dragging class to editor content
|
||||
this.editorContentRef.classList.add('dragging');
|
||||
}, 10);
|
||||
}
|
||||
|
||||
private handleDragEnd(): void {
|
||||
// Remove all drag-related classes
|
||||
if (this.draggedBlockId) {
|
||||
const wrapper = this.editorContentRef.querySelector(`[data-block-id="${this.draggedBlockId}"]`);
|
||||
if (wrapper) {
|
||||
wrapper.classList.remove('dragging');
|
||||
}
|
||||
}
|
||||
|
||||
// Remove all drag-over classes
|
||||
const allWrappers = this.editorContentRef.querySelectorAll('.block-wrapper');
|
||||
allWrappers.forEach(wrapper => {
|
||||
wrapper.classList.remove('drag-over-before', 'drag-over-after');
|
||||
});
|
||||
|
||||
// Remove dragging class from editor content
|
||||
this.editorContentRef.classList.remove('dragging');
|
||||
|
||||
this.draggedBlockId = null;
|
||||
this.dragOverBlockId = null;
|
||||
this.dragOverPosition = null;
|
||||
this.requestUpdate();
|
||||
}
|
||||
|
||||
private handleDragOver(e: DragEvent, block: IBlock): void {
|
||||
@@ -537,16 +680,31 @@ export class DeesInputWysiwyg extends DeesInputBase<string> {
|
||||
const rect = (e.currentTarget as HTMLElement).getBoundingClientRect();
|
||||
const midpoint = rect.top + rect.height / 2;
|
||||
|
||||
// Remove previous drag-over classes
|
||||
if (this.dragOverBlockId) {
|
||||
const prevWrapper = this.editorContentRef.querySelector(`[data-block-id="${this.dragOverBlockId}"]`);
|
||||
if (prevWrapper) {
|
||||
prevWrapper.classList.remove('drag-over-before', 'drag-over-after');
|
||||
}
|
||||
}
|
||||
|
||||
this.dragOverBlockId = block.id;
|
||||
this.dragOverPosition = e.clientY < midpoint ? 'before' : 'after';
|
||||
this.requestUpdate();
|
||||
|
||||
// Add new drag-over class
|
||||
const wrapper = e.currentTarget as HTMLElement;
|
||||
wrapper.classList.add(`drag-over-${this.dragOverPosition}`);
|
||||
}
|
||||
|
||||
private handleDragLeave(block: IBlock): void {
|
||||
if (this.dragOverBlockId === block.id) {
|
||||
const wrapper = this.editorContentRef.querySelector(`[data-block-id="${block.id}"]`);
|
||||
if (wrapper) {
|
||||
wrapper.classList.remove('drag-over-before', 'drag-over-after');
|
||||
}
|
||||
|
||||
this.dragOverBlockId = null;
|
||||
this.dragOverPosition = null;
|
||||
this.requestUpdate();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -574,6 +732,9 @@ export class DeesInputWysiwyg extends DeesInputBase<string> {
|
||||
// Insert at new position
|
||||
this.blocks.splice(newIndex, 0, draggedBlock);
|
||||
|
||||
// Re-render blocks programmatically to reflect the new order
|
||||
this.renderBlocksProgrammatically();
|
||||
|
||||
// Update state
|
||||
this.updateValue();
|
||||
this.handleDragEnd();
|
||||
@@ -803,6 +964,7 @@ export class DeesInputWysiwyg extends DeesInputBase<string> {
|
||||
});
|
||||
}
|
||||
|
||||
// Modal methods moved to WysiwygModalManager
|
||||
private async showLanguageSelectionModal(): Promise<string | null> {
|
||||
return new Promise((resolve) => {
|
||||
let selectedLanguage: string | null = null;
|
||||
|
Reference in New Issue
Block a user