From 77c0b2ef4732615d138b2d4271da251bd7a10f04 Mon Sep 17 00:00:00 2001 From: Cats Juice Date: Thu, 9 Apr 2026 11:25:55 +0800 Subject: [PATCH] fix: prevent IME preedit overflow in mind map node editor (#14520) ## Summary Update the edgeless shape text editor to resize mind map node text bounds while IME composition is in progress. ## Changes - listen to `compositionupdate` on the inline editor container - trigger `_updateElementWH()` on `compositionupdate` and `compositionend` - keep text box dimensions in sync before composition is committed ## Testing - Not run locally: `pnpm` is not available in this environment, so package build/tests could not be executed here. Fixes #11515 ## Summary by CodeRabbit * **Bug Fixes** * Editor mounting tolerates missing/null elements and validates input to avoid errors. * Text creation/update consistently targets the refreshed element to prevent mismatches. * Inline editor listens for IME composition events and schedules layout/size recalculation (with proper cleanup) so sizing stays in sync. * **Tests** * Added an integration test verifying layout/size updates during IME composition events. --------- Co-authored-by: DarkSky <25152247+darkskygit@users.noreply.github.com> Co-authored-by: DarkSky --- .../src/text/edgeless-shape-text-editor.ts | 181 +++++++++++++++--- .../model/src/elements/mindmap/mindmap.ts | 11 +- .../model/src/elements/mindmap/style.ts | 10 + .../src/__tests__/edgeless/mindmap.spec.ts | 153 ++++++++++++++- 4 files changed, 325 insertions(+), 30 deletions(-) diff --git a/blocksuite/affine/gfx/shape/src/text/edgeless-shape-text-editor.ts b/blocksuite/affine/gfx/shape/src/text/edgeless-shape-text-editor.ts index a4501f8cd..786b082d4 100644 --- a/blocksuite/affine/gfx/shape/src/text/edgeless-shape-text-editor.ts +++ b/blocksuite/affine/gfx/shape/src/text/edgeless-shape-text-editor.ts @@ -3,11 +3,8 @@ import { EdgelessCRUDIdentifier, TextUtils, } from '@blocksuite/affine-block-surface'; -import { - MindmapElementModel, - ShapeElementModel, - TextResizing, -} from '@blocksuite/affine-model'; +import type { ShapeElementModel } from '@blocksuite/affine-model'; +import { MindmapElementModel, TextResizing } from '@blocksuite/affine-model'; import type { RichText } from '@blocksuite/affine-rich-text'; import { ThemeProvider } from '@blocksuite/affine-shared/services'; import { getSelectedRect } from '@blocksuite/affine-shared/utils'; @@ -21,15 +18,24 @@ import { stdContext, } from '@blocksuite/std'; import { GfxControllerIdentifier } from '@blocksuite/std/gfx'; -import { RANGE_SYNC_EXCLUDE_ATTR } from '@blocksuite/std/inline'; +import { InlineEditor, RANGE_SYNC_EXCLUDE_ATTR } from '@blocksuite/std/inline'; import { consume } from '@lit/context'; import { html, nothing } from 'lit'; import { property, query } from 'lit/decorators.js'; import { styleMap } from 'lit/directives/style-map.js'; import * as Y from 'yjs'; +function isShapeElement(element: unknown): element is ShapeElementModel { + return ( + !!element && + typeof element === 'object' && + 'type' in element && + element.type === 'shape' + ); +} + export function mountShapeTextEditor( - shapeElement: ShapeElementModel, + shapeElement: { id: string } | null | undefined, edgeless: BlockComponent ) { const mountElm = edgeless.querySelector('.edgeless-mount-point'); @@ -43,24 +49,27 @@ export function mountShapeTextEditor( const gfx = edgeless.std.get(GfxControllerIdentifier); const crud = edgeless.std.get(EdgelessCRUDIdentifier); + if (!shapeElement?.id) { + console.error('Cannot mount text editor on an invalid shape element'); + return; + } + const updatedElement = crud.getElementById(shapeElement.id); - if (!(updatedElement instanceof ShapeElementModel)) { + if (!isShapeElement(updatedElement)) { console.error('Cannot mount text editor on a non-shape element'); return; } gfx.tool.setTool(DefaultTool); gfx.selection.set({ - elements: [shapeElement.id], + elements: [updatedElement.id], editing: true, }); - if (!shapeElement.text) { + if (!updatedElement.text) { const text = new Y.Text(); - edgeless.std - .get(EdgelessCRUDIdentifier) - .updateElement(shapeElement.id, { text }); + crud.updateElement(updatedElement.id, { text }); } const shapeEditor = new EdgelessShapeTextEditor(); @@ -70,6 +79,8 @@ export function mountShapeTextEditor( } export class EdgelessShapeTextEditor extends WithDisposable(ShadowlessElement) { + private _compositionUpdateRaf: number | null = null; + private _keeping = false; private _lastXYWH = ''; @@ -148,6 +159,11 @@ export class EdgelessShapeTextEditor extends WithDisposable(ShadowlessElement) { } private _unmount() { + if (this._compositionUpdateRaf !== null) { + cancelAnimationFrame(this._compositionUpdateRaf); + this._compositionUpdateRaf = null; + } + this._resizeObserver?.disconnect(); this._resizeObserver = null; @@ -171,10 +187,96 @@ export class EdgelessShapeTextEditor extends WithDisposable(ShadowlessElement) { }); } + private _scheduleElementWHUpdate(flush = false) { + if (flush) { + if (this._compositionUpdateRaf !== null) { + cancelAnimationFrame(this._compositionUpdateRaf); + this._compositionUpdateRaf = null; + } + this._updateElementWH(); + return; + } + + if (this._compositionUpdateRaf !== null) { + return; + } + + this._compositionUpdateRaf = requestAnimationFrame(() => { + this._compositionUpdateRaf = null; + this._updateElementWH(); + }); + } + + private _getInlineEditorContentRect() { + if (!this.inlineEditorContainer) { + return null; + } + + const textNodes = InlineEditor.getTextNodesFromElement( + this.inlineEditorContainer + ); + const firstText = textNodes[0]; + const lastText = textNodes.at(-1); + + if (!firstText || !lastText) { + return null; + } + + const range = this.ownerDocument.createRange(); + range.setStart(firstText, 0); + range.setEnd(lastText, lastText.length); + const rect = range.getBoundingClientRect(); + + return rect.width > 0 || rect.height > 0 ? rect : null; + } + private _updateElementWH() { const bcr = this.richText.getBoundingClientRect(); - const containerHeight = this.richText.offsetHeight; - const containerWidth = this.richText.offsetWidth; + const [verticalPadding, horizontalPadding] = this.element.padding; + const contentRect = this._getInlineEditorContentRect(); + const autoWidth = + this.element.textResizing === TextResizing.AUTO_WIDTH_AND_HEIGHT; + const constrainedAutoWidth = autoWidth && !!this.element.maxWidth; + const maxAutoWidth = + constrainedAutoWidth && typeof this.element.maxWidth === 'number' + ? this.element.maxWidth + : Number.POSITIVE_INFINITY; + const nativeRangeRect = this.inlineEditor + ?.getNativeRange() + ?.getBoundingClientRect(); + const nativeRangeHeight = + nativeRangeRect != null + ? Math.max(0, nativeRangeRect.bottom - bcr.top + verticalPadding) + : 0; + const nativeRangeWidth = + nativeRangeRect != null + ? Math.max(0, nativeRangeRect.right - bcr.left + horizontalPadding) + : 0; + const editorContentHeight = + this.inlineEditorContainer?.scrollHeight != null + ? this.inlineEditorContainer.scrollHeight + verticalPadding * 2 + : 0; + const editorContentWidth = + this.inlineEditorContainer?.scrollWidth != null + ? this.inlineEditorContainer.scrollWidth + horizontalPadding * 2 + : 0; + const contentRectHeight = + contentRect != null ? contentRect.height + verticalPadding * 2 : 0; + const contentRectWidth = + contentRect != null ? contentRect.width + horizontalPadding * 2 : 0; + const containerHeight = Math.max( + this.richText.offsetHeight, + contentRectHeight, + constrainedAutoWidth ? nativeRangeHeight : 0, + autoWidth ? 0 : editorContentHeight + ); + const containerWidth = Math.max( + Math.min(this.richText.offsetWidth, maxAutoWidth), + Math.min(contentRectWidth, maxAutoWidth), + Math.min(nativeRangeWidth, maxAutoWidth), + autoWidth ? 0 : editorContentWidth + ); + const textResizing = this.element.textResizing; if ( @@ -213,7 +315,7 @@ export class EdgelessShapeTextEditor extends WithDisposable(ShadowlessElement) { if (this.isMindMapNode) { const mindmap = this.element.group as MindmapElementModel; - mindmap.layout(); + mindmap.layout(mindmap.tree, { applyStyle: false }); } this.richText.style.minHeight = `${containerHeight}px`; @@ -259,6 +361,7 @@ export class EdgelessShapeTextEditor extends WithDisposable(ShadowlessElement) { this.updateComplete .then(() => { if (!this.inlineEditor) return; + if (this.element.group instanceof MindmapElementModel) { this.inlineEditor.selectAll(); } else { @@ -280,6 +383,21 @@ export class EdgelessShapeTextEditor extends WithDisposable(ShadowlessElement) { this._unmount(); } ); + + this.disposables.addFromEvent( + this.inlineEditorContainer, + 'compositionupdate', + () => { + this._scheduleElementWHUpdate(); + } + ); + this.disposables.addFromEvent( + this.inlineEditorContainer, + 'compositionend', + () => { + this._scheduleElementWHUpdate(true); + } + ); }) .catch(console.error); @@ -325,6 +443,12 @@ export class EdgelessShapeTextEditor extends WithDisposable(ShadowlessElement) { ); const [x, y] = this.gfx.viewport.toViewCoord(leftTopX, leftTopY); const autoWidth = textResizing === TextResizing.AUTO_WIDTH_AND_HEIGHT; + const constrainedAutoWidth = autoWidth && !!this.element.maxWidth; + const editorWidth = constrainedAutoWidth + ? 'max-content' + : textResizing === TextResizing.AUTO_HEIGHT + ? rect.width + 'px' + : 'fit-content'; const color = this.std .get(ThemeProvider) .generateColorProperty(this.element.color, '#000000'); @@ -333,10 +457,7 @@ export class EdgelessShapeTextEditor extends WithDisposable(ShadowlessElement) { position: 'absolute', left: x + 'px', top: y + 'px', - width: - textResizing === TextResizing.AUTO_HEIGHT - ? rect.width + 'px' - : 'fit-content', + width: editorWidth, // override rich-text style (height: 100%) height: 'initial', minHeight: @@ -377,9 +498,21 @@ export class EdgelessShapeTextEditor extends WithDisposable(ShadowlessElement) { return html`