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


<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## 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.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->

---------

Co-authored-by: DarkSky <25152247+darkskygit@users.noreply.github.com>
Co-authored-by: DarkSky <darksky2048@gmail.com>
This commit is contained in:
Cats Juice
2026-04-09 11:25:55 +08:00
committed by GitHub
parent 7138fea9db
commit 77c0b2ef47
4 changed files with 325 additions and 30 deletions
@@ -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` <style>
edgeless-shape-text-editor v-text [data-v-text] {
overflow-wrap: ${autoWidth ? 'normal' : 'anywhere'};
word-break: ${autoWidth ? 'normal' : 'break-word'} !important;
white-space: ${autoWidth ? 'pre' : 'pre-wrap'} !important;
overflow-wrap: ${constrainedAutoWidth
? 'anywhere'
: autoWidth
? 'normal'
: 'anywhere'};
word-break: ${constrainedAutoWidth
? 'break-word'
: autoWidth
? 'normal'
: 'break-word'} !important;
white-space: ${constrainedAutoWidth
? 'pre-wrap'
: autoWidth
? 'pre'
: 'pre-wrap'} !important;
}
edgeless-shape-text-editor .inline-editor {
@@ -389,7 +522,7 @@ export class EdgelessShapeTextEditor extends WithDisposable(ShadowlessElement) {
<rich-text
.yText=${this.element.text}
.enableFormat=${false}
.enableAutoScrollHorizontally=${false}
.enableAutoScrollHorizontally=${autoWidth && !this.isMindMapNode}
style=${inlineEditorStyle}
></rich-text>`;
}