mirror of
https://github.com/toeverything/AFFiNE.git
synced 2026-08-22 04:21:40 +08:00
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:
@@ -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>`;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user