From 1c76ade15019c0449f00bac6a15db1d967d3518c Mon Sep 17 00:00:00 2001 From: Juergen Kunz Date: Tue, 24 Jun 2025 11:06:02 +0000 Subject: [PATCH] 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 --- readme.hints.md | 59 +++- ts_web/elements/wysiwyg/dees-input-wysiwyg.ts | 308 +++++++++++++----- ts_web/elements/wysiwyg/dees-wysiwyg-block.ts | 26 +- ts_web/elements/wysiwyg/instructions.md | 3 + .../wysiwyg/wysiwyg.blockoperations.ts | 33 +- .../elements/wysiwyg/wysiwyg.inputhandler.ts | 67 +++- 6 files changed, 401 insertions(+), 95 deletions(-) create mode 100644 ts_web/elements/wysiwyg/instructions.md diff --git a/readme.hints.md b/readme.hints.md index 044bfdc..4d1a36d 100644 --- a/readme.hints.md +++ b/readme.hints.md @@ -409,4 +409,61 @@ Created `wysiwyg.interfaces.ts` with proper typing: 4. Arrow keys handle block navigation without focus loss 5. All timing is handled via proper async/await patterns -The refactoring eliminates race conditions and timing issues that were causing focus loss and content duplication problems. \ No newline at end of file +The refactoring eliminates race conditions and timing issues that were causing focus loss and content duplication problems. + +### Programmatic Rendering Solution (2025-06-24 - Part 10) + +Fixed persistent focus loss issue by implementing fully programmatic rendering: + +#### The Problem: +- User would click in a block, type text, then press arrow keys and lose focus +- Root cause: Lit was re-rendering components when block content was mutated +- Even with shouldUpdate() preventing re-renders, parent re-evaluation caused focus loss + +#### The Solution: + +1. **Static Parent Rendering**: + - Parent component renders only once with empty editor content div + - All blocks are created and managed programmatically via DOM manipulation + - No Lit re-renders triggered by state changes + +2. **Manual Block Management**: + - `renderBlocksProgrammatically()` creates all block elements manually + - `createBlockElement()` builds block wrapper with all event handlers + - `updateBlockElement()` replaces individual blocks when needed + - No reactive properties trigger parent re-renders + +3. **Content Update Strategy**: + - During typing, content is NOT immediately synced to data model + - Auto-save delayed to 2 seconds to avoid interference + - Content synced from DOM only on blur or before save + - `syncAllBlockContent()` reads from DOM when needed + +4. **Focus Preservation**: + - Block components prevent re-renders with `shouldUpdate()` + - Parent never re-renders after initial load + - Focus remains stable during all editing operations + - Arrow key navigation works without focus loss + +5. **Implementation Details**: + ```typescript + // Parent render method - static after first render + render(): TemplateResult { + return html` +
+ +
+ `; + } + + // All block operations use DOM manipulation + private renderBlocksProgrammatically() { + this.editorContentRef.innerHTML = ''; + this.blocks.forEach(block => { + const blockWrapper = this.createBlockElement(block); + this.editorContentRef.appendChild(blockWrapper); + }); + } + ``` + +This approach completely eliminates focus loss by taking full control of the DOM and preventing any framework-induced re-renders during editing. \ No newline at end of file diff --git a/ts_web/elements/wysiwyg/dees-input-wysiwyg.ts b/ts_web/elements/wysiwyg/dees-input-wysiwyg.ts index 29d74cb..71384a6 100644 --- a/ts_web/elements/wysiwyg/dees-input-wysiwyg.ts +++ b/ts_web/elements/wysiwyg/dees-input-wysiwyg.ts @@ -54,7 +54,7 @@ export class DeesInputWysiwyg extends DeesInputBase { } ]; - @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 { private inputHandler: WysiwygInputHandler; private keyboardHandler: WysiwygKeyboardHandler; private dragDropHandler: WysiwygDragDropHandler; + + // Content cache to avoid triggering re-renders during typing + private contentCache: Map = new Map(); public static styles = [ ...DeesInputBase.baseStyles, @@ -110,6 +113,11 @@ export class DeesInputWysiwyg extends DeesInputBase { 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 { 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 = ` + + + + + + `; + 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 {
- ${this.blocks.map(block => this.renderBlock(block))} +
`; } - 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` -
- ${block.type !== 'divider' ? html` -
- ` : ''} - - ${block.type !== 'divider' ? html` -
- - - - - -
- ` : ''} -
- `; - } + // Old renderBlock method removed - using programmatic rendering instead @@ -292,19 +343,73 @@ export class DeesInputWysiwyg extends DeesInputBase { } 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 { 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 { 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 { } 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 { 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 { 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 { 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 { // 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 { }); } + // Modal methods moved to WysiwygModalManager private async showLanguageSelectionModal(): Promise { return new Promise((resolve) => { let selectedLanguage: string | null = null; diff --git a/ts_web/elements/wysiwyg/dees-wysiwyg-block.ts b/ts_web/elements/wysiwyg/dees-wysiwyg-block.ts index c42ce18..30502ca 100644 --- a/ts_web/elements/wysiwyg/dees-wysiwyg-block.ts +++ b/ts_web/elements/wysiwyg/dees-wysiwyg-block.ts @@ -41,6 +41,9 @@ export class DeesWysiwygBlock extends DeesElement { @query('.block') private blockElement: HTMLDivElement; + + // Track if we've initialized the content + private contentInitialized: boolean = false; public static styles = [ cssManager.defaultStyles, @@ -250,11 +253,30 @@ export class DeesWysiwygBlock extends DeesElement { `, ]; - protected shouldUpdate(): boolean { + protected shouldUpdate(changedProperties: Map): boolean { + // Never update if only the block content changed + if (changedProperties.has('block') && this.block) { + const oldBlock = changedProperties.get('block'); + if (oldBlock && oldBlock.id === this.block.id && oldBlock.type === this.block.type) { + // Only content or metadata changed, don't re-render + return false; + } + } + // Only update if the block type or id changes - // Content changes are handled directly in the DOM return !this.blockElement || this.block?.type !== this.blockElement.dataset.blockType; } + + public firstUpdated(): void { + // Mark that content has been initialized + this.contentInitialized = true; + + // 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); + } + } render(): TemplateResult { if (!this.block) return html``; diff --git a/ts_web/elements/wysiwyg/instructions.md b/ts_web/elements/wysiwyg/instructions.md new file mode 100644 index 0000000..209a26a --- /dev/null +++ b/ts_web/elements/wysiwyg/instructions.md @@ -0,0 +1,3 @@ +* We don't use lit template logic, but use static`` here to handle dom operations ourselves +* We try to have separated concerns in different classes +* We try to have clean concise and managable code diff --git a/ts_web/elements/wysiwyg/wysiwyg.blockoperations.ts b/ts_web/elements/wysiwyg/wysiwyg.blockoperations.ts index 12a2bb2..1846e30 100644 --- a/ts_web/elements/wysiwyg/wysiwyg.blockoperations.ts +++ b/ts_web/elements/wysiwyg/wysiwyg.blockoperations.ts @@ -34,12 +34,20 @@ export class WysiwygBlockOperations { ...blocks.slice(blockIndex + 1) ]; + // Insert the new block element programmatically if we have the editor + if (this.component.editorContentRef) { + const afterWrapper = this.component.editorContentRef.querySelector(`[data-block-id="${afterBlock.id}"]`); + if (afterWrapper) { + const newWrapper = this.component.createBlockElement(newBlock); + afterWrapper.insertAdjacentElement('afterend', newWrapper); + } + } + this.component.updateValue(); - this.component.requestUpdate(); if (focusNewBlock && newBlock.type !== 'divider') { - // Wait for the component to update - await this.component.updateComplete; + // Give DOM time to settle + await new Promise(resolve => setTimeout(resolve, 0)); // Focus the new block await this.focusBlock(newBlock.id, 'start'); @@ -51,6 +59,15 @@ export class WysiwygBlockOperations { */ removeBlock(blockId: string): void { this.component.blocks = this.component.blocks.filter((b: IBlock) => b.id !== blockId); + + // Remove the block element programmatically if we have the editor + if (this.component.editorContentRef) { + const wrapper = this.component.editorContentRef.querySelector(`[data-block-id="${blockId}"]`); + if (wrapper) { + wrapper.remove(); + } + } + this.component.updateValue(); } @@ -72,9 +89,6 @@ export class WysiwygBlockOperations { * Focuses a specific block */ async focusBlock(blockId: string, cursorPosition: 'start' | 'end' | number = 'start'): Promise { - // First ensure the component is updated - await this.component.updateComplete; - const wrapperElement = this.component.shadowRoot!.querySelector(`[data-block-id="${blockId}"]`); if (wrapperElement) { const blockComponent = wrapperElement.querySelector('dees-wysiwyg-block') as any; @@ -110,8 +124,13 @@ export class WysiwygBlockOperations { if (metadata) { block.metadata = metadata; } + + // Update the block element programmatically if we have the editor + if (this.component.editorContentRef) { + this.component.updateBlockElement(blockId); + } + this.component.updateValue(); - this.component.requestUpdate(); } } diff --git a/ts_web/elements/wysiwyg/wysiwyg.inputhandler.ts b/ts_web/elements/wysiwyg/wysiwyg.inputhandler.ts index 6d7cb6e..b3c0fcc 100644 --- a/ts_web/elements/wysiwyg/wysiwyg.inputhandler.ts +++ b/ts_web/elements/wysiwyg/wysiwyg.inputhandler.ts @@ -2,6 +2,7 @@ import { type IBlock } from './wysiwyg.types.js'; import { WysiwygShortcuts } from './wysiwyg.shortcuts.js'; import { WysiwygBlocks } from './wysiwyg.blocks.js'; import { WysiwygBlockOperations } from './wysiwyg.blockoperations.js'; +import { WysiwygModalManager } from './wysiwyg.modalmanager.js'; export class WysiwygInputHandler { private component: any; @@ -20,10 +21,7 @@ export class WysiwygInputHandler { const target = e.target as HTMLDivElement; const textContent = target.textContent || ''; - // Update block content based on type - this.updateBlockContent(block, target); - - // Check for block type transformations + // Check for block type transformations BEFORE updating content const detectedType = this.detectBlockTypeIntent(textContent); if (detectedType && detectedType.type !== block.type) { e.preventDefault(); @@ -34,7 +32,10 @@ export class WysiwygInputHandler { // Handle slash commands this.handleSlashCommand(textContent, target); - // Schedule auto-save + // Don't update block content immediately - let the block handle its own content + // This prevents re-renders during typing + + // Schedule auto-save (which will sync content later) this.scheduleAutoSave(); } @@ -48,7 +49,11 @@ export class WysiwygInputHandler { if (blockComponent) { // Use the block component's getContent method for consistency - block.content = blockComponent.getContent(); + const newContent = blockComponent.getContent(); + // Only update if content actually changed to avoid unnecessary updates + if (block.content !== newContent) { + block.content = newContent; + } // Update list metadata if needed if (block.type === 'list') { @@ -133,7 +138,11 @@ export class WysiwygInputHandler { target.innerHTML = `<${listTag}>
  • `; this.component.updateValue(); - this.component.requestUpdate(); + + // Update the block element programmatically + if (this.component.editorContentRef) { + this.component.updateBlockElement(block.id); + } setTimeout(() => { WysiwygBlocks.focusListItem(target); @@ -142,13 +151,17 @@ export class WysiwygInputHandler { block.type = 'divider'; block.content = ' '; + // Update the block element programmatically + if (this.component.editorContentRef) { + this.component.updateBlockElement(block.id); + } + const newBlock = blockOps.createBlock(); blockOps.insertBlockAfter(block, newBlock); this.component.updateValue(); - this.component.requestUpdate(); } else if (detectedType.type === 'code') { - const language = await this.component.showLanguageSelectionModal(); + const language = await WysiwygModalManager.showLanguageSelectionModal(); if (language) { block.type = 'code'; block.content = ''; @@ -156,7 +169,11 @@ export class WysiwygInputHandler { target.textContent = ''; this.component.updateValue(); - this.component.requestUpdate(); + + // Update the block element programmatically + if (this.component.editorContentRef) { + this.component.updateBlockElement(block.id); + } } } else { block.type = detectedType.type; @@ -164,7 +181,11 @@ export class WysiwygInputHandler { target.textContent = ''; this.component.updateValue(); - this.component.requestUpdate(); + + // Update the block element programmatically + if (this.component.editorContentRef) { + this.component.updateBlockElement(block.id); + } } } @@ -233,8 +254,30 @@ export class WysiwygInputHandler { return; } this.saveTimeout = setTimeout(() => { + // Sync all block content from DOM before saving + this.syncAllBlockContent(); + // Only update value, don't trigger any re-renders this.component.updateValue(); - }, 1000); + // Don't call requestUpdate() as it's not needed + }, 2000); // Increased delay to reduce interference with typing + } + + /** + * Syncs content from all block DOMs to the data model + */ + private syncAllBlockContent(): void { + this.component.blocks.forEach((block: IBlock) => { + const wrapperElement = this.component.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; + } + } + }); } /**