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

View File

@@ -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>`;
}

View File

@@ -311,7 +311,6 @@ export class MindmapElementModel extends GfxGroupLikeElementModel<MindmapElement
id = this.surface.addElement({
type,
xywh: '[0,0,100,30]',
maxWidth: false,
...props,
...style.node,
});
@@ -345,7 +344,6 @@ export class MindmapElementModel extends GfxGroupLikeElementModel<MindmapElement
id = this.surface.addElement({
type,
xywh: '[0,0,113,41]',
maxWidth: false,
...props,
...rootStyle,
});
@@ -834,7 +832,12 @@ export class MindmapElementModel extends GfxGroupLikeElementModel<MindmapElement
}
const stashed = new Set<GfxPrimitiveElementModel>();
const stashedNodeIds = new Set<string>();
const traverse = (node: MindmapNode) => {
if (this._stashedNode.has(node.id)) return;
this._stashedNode.add(node.id);
stashedNodeIds.add(node.id);
node.element.stash('xywh');
stashed.add(node.element);
@@ -846,7 +849,9 @@ export class MindmapElementModel extends GfxGroupLikeElementModel<MindmapElement
traverse(mindNode);
return () => {
this._stashedNode.delete(mindNode.id);
stashedNodeIds.forEach(id => {
this._stashedNode.delete(id);
});
stashed.forEach(el => {
el.pop('xywh');
});

View File

@@ -35,6 +35,7 @@ export type NodeStyle = {
strokeColor: Color;
textResizing: TextResizing;
maxWidth: false | number;
fontSize: number;
fontFamily: string;
@@ -62,6 +63,8 @@ export type ConnectorStyle = {
mode: ConnectorMode;
};
export const MINDMAP_NODE_MAX_WIDTH = 512;
export abstract class MindmapStyleGetter {
abstract readonly root: NodeStyle;
@@ -90,6 +93,7 @@ export class StyleOne extends MindmapStyleGetter {
radius: 8,
textResizing: TextResizing.AUTO_WIDTH_AND_HEIGHT,
maxWidth: MINDMAP_NODE_MAX_WIDTH,
strokeWidth: 4,
strokeColor: '#53b2ef',
@@ -161,6 +165,7 @@ export class StyleOne extends MindmapStyleGetter {
radius: 8,
textResizing: TextResizing.AUTO_WIDTH_AND_HEIGHT,
maxWidth: MINDMAP_NODE_MAX_WIDTH,
strokeWidth: 3,
strokeColor: color,
@@ -198,6 +203,7 @@ export class StyleTwo extends MindmapStyleGetter {
radius: 3,
textResizing: TextResizing.AUTO_WIDTH_AND_HEIGHT,
maxWidth: MINDMAP_NODE_MAX_WIDTH,
strokeWidth: 3,
strokeColor: DefaultTheme.black,
@@ -271,6 +277,7 @@ export class StyleTwo extends MindmapStyleGetter {
radius: 3,
textResizing: TextResizing.AUTO_WIDTH_AND_HEIGHT,
maxWidth: MINDMAP_NODE_MAX_WIDTH,
strokeWidth: 3,
strokeColor: DefaultTheme.black,
@@ -308,6 +315,7 @@ export class StyleThree extends MindmapStyleGetter {
radius: 10,
textResizing: TextResizing.AUTO_WIDTH_AND_HEIGHT,
maxWidth: MINDMAP_NODE_MAX_WIDTH,
strokeWidth: 0,
strokeColor: 'transparent',
@@ -343,6 +351,7 @@ export class StyleThree extends MindmapStyleGetter {
radius: 10,
textResizing: TextResizing.AUTO_WIDTH_AND_HEIGHT,
maxWidth: MINDMAP_NODE_MAX_WIDTH,
strokeWidth: 2,
strokeColor,
@@ -420,6 +429,7 @@ export class StyleFour extends MindmapStyleGetter {
radius: 0,
textResizing: TextResizing.AUTO_WIDTH_AND_HEIGHT,
maxWidth: MINDMAP_NODE_MAX_WIDTH,
strokeWidth: 0,
strokeColor: 'transparent',

View File

@@ -1,8 +1,13 @@
import type { MindMapView } from '@blocksuite/affine/gfx/mindmap';
import { LayoutType, type MindmapElementModel } from '@blocksuite/affine-model';
import { Bound } from '@blocksuite/global/gfx';
import { mountShapeTextEditor } from '@blocksuite/affine/gfx/shape';
import {
LayoutType,
type MindmapElementModel,
type ShapeElementModel,
} from '@blocksuite/affine-model';
import { Bound, deserializeXYWH } from '@blocksuite/global/gfx';
import type { GfxController } from '@blocksuite/std/gfx';
import { beforeEach, describe, expect, test } from 'vitest';
import { beforeEach, describe, expect, test, vi } from 'vitest';
import { click, pointermove, wait } from '../utils/common.js';
import { getDocRootBlock } from '../utils/edgeless.js';
@@ -36,6 +41,148 @@ describe('mindmap', () => {
return cleanup;
});
test('should update mindmap node size during IME composition', async () => {
const mindmapId = gfx.surface!.addElement({
type: 'mindmap',
children: { text: 'root', children: [{ text: 'leaf1' }] },
});
const mindmap = () => gfx.getElementById(mindmapId) as MindmapElementModel;
const root = getDocRootBlock(window.doc, window.editor, 'edgeless');
doc.captureSync();
await wait();
const rootNode = mindmap().tree.element as ShapeElementModel;
const updateSpy = vi.spyOn(gfx.surface!, 'updateElement');
mountShapeTextEditor(rootNode, root);
await wait();
const shapeEditor = root.querySelector('edgeless-shape-text-editor') as
| (HTMLElement & {
inlineEditorContainer?: HTMLElement;
richText?: HTMLElement;
})
| null;
expect(shapeEditor).not.toBeNull();
expect(shapeEditor?.richText).toBeTruthy();
expect(shapeEditor?.inlineEditorContainer).toBeTruthy();
expect(rootNode.maxWidth).toBe(512);
const initialWidth = mindmap().tree.element.w;
const initialHeight = mindmap().tree.element.h;
const exaggeratedScrollWidth = initialWidth + 1000;
const exaggeratedScrollHeight = initialHeight + 1000;
updateSpy.mockClear();
const preedit = shapeEditor!.inlineEditorContainer!.querySelector(
'[data-v-text="true"]'
);
expect(preedit).toBeTruthy();
shapeEditor!.inlineEditorContainer?.dispatchEvent(
new CompositionEvent('compositionstart', {
data: '',
bubbles: true,
})
);
preedit!.textContent =
'测试测试测试测试测试测试测试测试测试测试测试测试测试测试测试测试';
Object.defineProperty(shapeEditor!.inlineEditorContainer!, 'scrollWidth', {
configurable: true,
get: () => exaggeratedScrollWidth,
});
Object.defineProperty(shapeEditor!.inlineEditorContainer!, 'scrollHeight', {
configurable: true,
get: () => exaggeratedScrollHeight,
});
const compositionUpdate = new CompositionEvent('compositionupdate', {
data: '测试测试测试测试测试测试测试测试测试测试测试测试测试测试测试测试',
bubbles: true,
});
shapeEditor!.inlineEditorContainer?.dispatchEvent(compositionUpdate);
await wait();
await wait();
const compositionWidths = updateSpy.mock.calls
.filter(
(call): call is [string, { xywh: string }] =>
call[0] === rootNode.id &&
typeof call[1] === 'object' &&
call[1] !== null &&
'xywh' in call[1] &&
typeof call[1].xywh === 'string'
)
.map(([, payload]) => deserializeXYWH(payload.xywh)[2]);
const compositionHeights = updateSpy.mock.calls
.filter(
(call): call is [string, { xywh: string }] =>
call[0] === rootNode.id &&
typeof call[1] === 'object' &&
call[1] !== null &&
'xywh' in call[1] &&
typeof call[1].xywh === 'string'
)
.map(([, payload]) => deserializeXYWH(payload.xywh)[3]);
expect(compositionWidths.length).toBeGreaterThan(0);
expect(Math.max(...compositionWidths)).toBeGreaterThan(initialWidth);
expect(Math.max(...compositionWidths)).toBeLessThanOrEqual(512);
expect(Math.max(...compositionWidths)).toBeLessThan(exaggeratedScrollWidth);
expect(Math.max(...compositionHeights)).toBeGreaterThan(initialHeight);
expect(Math.max(...compositionHeights)).toBeLessThan(
exaggeratedScrollHeight
);
const compositionEnd = new CompositionEvent('compositionend', {
data: '测试测试测试测试测试测试测试测试测试测试测试测试测试测试测试测试',
bubbles: true,
});
shapeEditor!.inlineEditorContainer?.dispatchEvent(compositionEnd);
await wait();
});
test('should wrap long mindmap editor content by max width instead of viewport', async () => {
const text = 'abcdefghijklmnopqrstuvwxyz'.repeat(20);
const mindmapId = gfx.surface!.addElement({
type: 'mindmap',
children: { text, children: [{ text: 'leaf1' }] },
});
const mindmap = () => gfx.getElementById(mindmapId) as MindmapElementModel;
const root = getDocRootBlock(window.doc, window.editor, 'edgeless');
doc.captureSync();
await wait();
const rootNode = mindmap().tree.element as ShapeElementModel;
gfx.viewport.setViewport(1, [
-gfx.viewport.width / 2 + 48,
gfx.viewport.height / 2,
]);
await wait();
mountShapeTextEditor(rootNode, root);
await wait();
const shapeEditor = root.querySelector('edgeless-shape-text-editor') as
| (HTMLElement & {
richText?: HTMLElement;
})
| null;
expect(shapeEditor?.richText).toBeTruthy();
const richText = shapeEditor!.richText!;
const viewLeft = gfx.viewport.toViewCoord(rootNode.x, rootNode.y)[0];
expect(viewLeft).toBeGreaterThan(gfx.viewport.width - 64);
expect(rootNode.maxWidth).toBe(512);
expect(rootNode.w).toBeLessThanOrEqual(512);
expect(richText.clientWidth).toBeGreaterThan(400);
expect(richText.clientWidth).toBeLessThanOrEqual(512);
expect(richText.scrollWidth).toBeLessThanOrEqual(richText.clientWidth + 1);
});
test('delete the root node should remove all children', async () => {
const tree = {
text: 'root',