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
@@ -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',