feat: UI输入框IME支持和编辑器Inspector重构 (#310)

UI系统改进:
- 添加 IMEHelper 支持中文/日文/韩文输入法
- UIInputFieldComponent 添加组合输入状态管理
- UIInputSystem 添加 IME 事件处理
- UIInputFieldRenderSystem 优化渲染逻辑
- UIRenderCollector 增强纹理处理

引擎改进:
- EngineBridge 添加新的渲染接口
- EngineRenderSystem 优化渲染流程
- Rust 引擎添加新的渲染功能

编辑器改进:
- 新增模块化 Inspector 组件架构
- EntityRefField 增强实体引用选择
- 优化 FlexLayoutDock 和 SceneHierarchy 样式
- 添加国际化文本
This commit is contained in:
YHH
2025-12-19 15:45:14 +08:00
committed by GitHub
parent 536c4c5593
commit ecdb8f2021
46 changed files with 5825 additions and 257 deletions

View File

@@ -262,6 +262,26 @@ export class UIInputFieldComponent extends Component {
*/
public scrollOffset: number = 0;
// ===== IME 组合状态 IME Composition State =====
/**
* 是否正在进行 IME 组合输入
* Whether IME composition is in progress
*/
public isComposing: boolean = false;
/**
* IME 组合中的文本(如拼音 "zhong"
* Text being composed in IME (e.g., pinyin "zhong")
*/
public compositionText: string = '';
/**
* 组合开始时的光标位置
* Caret position when composition started
*/
public compositionStart: number = 0;
// ===== 回调 Callbacks =====
/**
@@ -496,6 +516,37 @@ export class UIInputFieldComponent extends Component {
return this.text;
}
/**
* 获取带 IME 组合文本的显示文本
* Get display text with IME composition text
*
* 组合文本会插入到光标位置,用于实时预览输入法输入。
* Composition text is inserted at caret position for real-time IME input preview.
*/
public getDisplayTextWithComposition(): string {
if (!this.isComposing || !this.compositionText) {
return this.getDisplayText();
}
const displayText = this.getDisplayText();
// 在组合开始位置插入组合文本
// Insert composition text at composition start position
const before = displayText.substring(0, this.compositionStart);
const after = displayText.substring(this.compositionStart);
return before + this.compositionText + after;
}
/**
* 获取组合文本的结束位置(用于光标定位)
* Get composition text end position (for caret positioning)
*/
public getCompositionEndPosition(): number {
if (!this.isComposing || !this.compositionText) {
return this.caretPosition;
}
return this.compositionStart + this.compositionText.length;
}
/**
* 验证单个字符是否可以输入
* Validate if a single character can be input

View File

@@ -8,6 +8,7 @@ import { UIScrollViewComponent } from '../components/widgets/UIScrollViewCompone
import { UIToggleComponent } from '../components/widgets/UIToggleComponent';
import { UIInputFieldComponent } from '../components/widgets/UIInputFieldComponent';
import { UIDropdownComponent } from '../components/widgets/UIDropdownComponent';
import { IMEHelper } from '../utils/IMEHelper';
import type { UILayoutSystem } from './UILayoutSystem';
// Re-export MouseButton for backward compatibility
@@ -98,6 +99,9 @@ export class UIInputSystem extends EntitySystem {
// Used to get UI canvas size for coordinate conversion
private layoutSystem: UILayoutSystem | null = null;
// ===== IME 输入法支持 IME Support =====
private imeHelper: IMEHelper | null = null;
constructor() {
super(Matcher.empty().all(UITransformComponent, UIInteractableComponent));
@@ -142,6 +146,15 @@ export class UIInputSystem extends EntitySystem {
// 阻止右键菜单
canvas.addEventListener('contextmenu', (e) => e.preventDefault());
// 初始化 IME 辅助服务
// Initialize IME helper service
this.imeHelper = new IMEHelper();
this.imeHelper.setCanvas(canvas);
this.imeHelper.onCompositionStart = () => this.handleCompositionStart();
this.imeHelper.onCompositionUpdate = (text) => this.handleCompositionUpdate(text);
this.imeHelper.onCompositionEnd = (text) => this.handleCompositionEnd(text);
this.imeHelper.onInput = (text) => this.handleIMEInput(text);
}
/**
@@ -162,6 +175,13 @@ export class UIInputSystem extends EntitySystem {
document.removeEventListener('keydown', this.boundKeyDown);
document.removeEventListener('keyup', this.boundKeyUp);
document.removeEventListener('keypress', this.boundKeyPress);
// 销毁 IME 辅助服务
// Dispose IME helper service
if (this.imeHelper) {
this.imeHelper.dispose();
this.imeHelper = null;
}
}
/**
@@ -751,6 +771,14 @@ export class UIInputSystem extends EntitySystem {
oldInteractable.focused = false;
oldInteractable.onBlur?.();
}
// 清除旧 InputField 的 IME 组合状态
// Clear IME composition state for old InputField
const oldInputField = this.focusedEntity.getComponent(UIInputFieldComponent);
if (oldInputField) {
oldInputField.isComposing = false;
oldInputField.compositionText = '';
}
}
this.focusedEntity = entity;
@@ -762,6 +790,18 @@ export class UIInputSystem extends EntitySystem {
interactable.focused = true;
interactable.onFocus?.();
}
// 如果是 InputField激活 IME
// If it's an InputField, activate IME
const inputField = entity.getComponent(UIInputFieldComponent);
if (inputField && this.imeHelper) {
this.imeHelper.focus();
this.updateIMEPosition(entity);
}
} else {
// 失去焦点时关闭 IME
// Blur IME when focus is lost
this.imeHelper?.blur();
}
}
@@ -1266,6 +1306,153 @@ export class UIInputSystem extends EntitySystem {
return -1;
}
// ===== IME 事件处理 IME Event Handlers =====
/**
* 更新 IME 隐藏 input 的位置
* Update IME hidden input position
*
* 将 IME 候选窗口定位到光标附近
* Position IME candidate window near the caret
*/
private updateIMEPosition(entity: Entity): void {
if (!this.imeHelper || !this.canvas) return;
const transform = entity.getComponent(UITransformComponent);
const inputField = entity.getComponent(UIInputFieldComponent);
if (!transform || !inputField) return;
// 获取 UI 世界坐标
const worldX = transform.worldX ?? transform.x;
const worldY = transform.worldY ?? transform.y;
const width = transform.computedWidth ?? transform.width;
const height = transform.computedHeight ?? transform.height;
const pivotX = transform.pivotX;
const pivotY = transform.pivotY;
// 计算光标在 UI 世界坐标中的位置
const textAreaStartX = worldX - width * pivotX + inputField.padding;
const caretX = textAreaStartX + inputField.getCaretX() - inputField.scrollOffset;
const caretY = worldY - height * pivotY + height / 2;
// 转换为屏幕坐标
const canvasRect = this.canvas.getBoundingClientRect();
const uiCanvasSize = this.layoutSystem?.getCanvasSize() ?? { width: 1920, height: 1080 };
// UI 坐标 -> 归一化坐标 -> 屏幕坐标
const normalizedX = (caretX / uiCanvasSize.width) + 0.5;
const normalizedY = 0.5 - (caretY / uiCanvasSize.height);
const screenX = canvasRect.left + normalizedX * canvasRect.width;
const screenY = canvasRect.top + normalizedY * canvasRect.height;
this.imeHelper.updatePosition(screenX, screenY);
}
/**
* 处理 IME 组合开始
* Handle IME composition start
*/
private handleCompositionStart(): void {
if (!this.focusedEntity) return;
const inputField = this.focusedEntity.getComponent(UIInputFieldComponent);
if (!inputField || inputField.readOnly || inputField.disabled) return;
// 如果有选中文本,先删除
// Delete selection if any
if (inputField.hasSelection()) {
inputField.deleteSelection();
}
inputField.isComposing = true;
inputField.compositionStart = inputField.caretPosition;
inputField.compositionText = '';
}
/**
* 处理 IME 组合更新
* Handle IME composition update
*/
private handleCompositionUpdate(text: string): void {
if (!this.focusedEntity) return;
const inputField = this.focusedEntity.getComponent(UIInputFieldComponent);
if (!inputField || !inputField.isComposing) return;
inputField.compositionText = text;
// 更新 IME 位置(组合文本可能改变光标位置)
// Update IME position (composition text may change caret position)
this.updateIMEPosition(this.focusedEntity);
}
/**
* 处理 IME 组合结束
* Handle IME composition end
*/
private handleCompositionEnd(text: string): void {
if (!this.focusedEntity) return;
const inputField = this.focusedEntity.getComponent(UIInputFieldComponent);
if (!inputField) return;
inputField.isComposing = false;
inputField.compositionText = '';
// 插入最终文本
// Insert final text
if (text && !inputField.readOnly && !inputField.disabled) {
inputField.insertText(text);
// 确保光标可见
// Ensure caret is visible
const transform = this.focusedEntity.getComponent(UITransformComponent);
if (transform) {
const width = transform.computedWidth ?? transform.width;
const textAreaWidth = width - inputField.padding * 2;
inputField.ensureCaretVisible(textAreaWidth);
}
}
}
/**
* 处理 IME 直接输入(非组合输入)
* Handle IME direct input (non-composition input)
*/
private handleIMEInput(text: string): void {
if (!this.focusedEntity) return;
const inputField = this.focusedEntity.getComponent(UIInputFieldComponent);
if (!inputField || inputField.readOnly || inputField.disabled) return;
// 组合过程中不处理直接输入
// Don't handle direct input during composition
if (inputField.isComposing) return;
// 验证并插入文本
// Validate and insert text
let validText = '';
for (const char of text) {
if (inputField.validateInput(char)) {
validText += char;
}
}
if (validText) {
inputField.insertText(validText);
// 确保光标可见
// Ensure caret is visible
const transform = this.focusedEntity.getComponent(UITransformComponent);
if (transform) {
const width = transform.computedWidth ?? transform.width;
const textAreaWidth = width - inputField.padding * 2;
inputField.ensureCaretVisible(textAreaWidth);
}
}
}
protected onDestroy(): void {
this.unbind();
}

View File

@@ -12,6 +12,7 @@ import { UITransformComponent } from '../../components/UITransformComponent';
import { UIInputFieldComponent } from '../../components/widgets/UIInputFieldComponent';
import { getUIRenderCollector, registerCacheInvalidationCallback, unregisterCacheInvalidationCallback } from './UIRenderCollector';
import { getUIRenderTransform } from './UIRenderUtils';
import { getTextMeasureService } from '../../utils/TextMeasureService';
/**
* Text texture cache entry
@@ -152,16 +153,16 @@ export class UIInputFieldRenderSystem extends EntitySystem {
// 2. Render text or placeholder (above background)
this.renderText(collector, input, rt, textX, textY, textWidth, textHeight, entityId);
// 3. 渲染选中高亮
// 3. Render selection highlight
// 3. 渲染选中高亮
if (input.focused && input.hasSelection()) {
this.renderSelection(collector, input, rt, textX, textY, textHeight, entityId);
this.renderSelection(collector, input, rt, textX, textY, textWidth, textHeight, entityId);
}
// 4. 渲染光标
// 4. Render caret
// 4. 渲染光标
if (input.focused && input.caretVisible && !input.hasSelection()) {
this.renderCaret(collector, input, rt, textX, textY, textHeight, entityId);
this.renderCaret(collector, input, rt, textX, textY, textWidth, textHeight, entityId);
}
}
}
@@ -184,8 +185,13 @@ export class UIInputFieldRenderSystem extends EntitySystem {
// 确定要显示的文本和颜色
// Determine text to display and color
const isPlaceholder = input.text.length === 0;
const displayText = isPlaceholder ? input.placeholder : input.getDisplayText();
const isPlaceholder = input.text.length === 0 && !input.isComposing;
// 使用带 IME 组合文本的显示文本
// Use display text with IME composition
const displayText = isPlaceholder
? input.placeholder
: input.getDisplayTextWithComposition();
// 如果没有文本可显示,跳过渲染
// Skip rendering if no text to display
@@ -207,22 +213,99 @@ export class UIInputFieldRenderSystem extends EntitySystem {
if (textureId === null) return;
// 提交文本渲染原语(在背景之上)
// Calculate clip rect for text viewport
// 计算文本视窗的裁剪矩形
const clipRect = {
x: textX,
y: textY,
width: textWidth,
height: textHeight
};
// Submit text render primitive (above background)
// 提交文本渲染原语(在背景之上)
collector.addRect(
textX + textWidth / 2, // 中心点 | Center point
textX + textWidth / 2, // Center point | 中心点
textY + textHeight / 2,
textWidth,
textHeight,
0xFFFFFF, // 白色着色(颜色已烘焙到纹理中) | White tint (color is baked into texture)
0xFFFFFF, // White tint (color is baked into texture) | 白色着色(颜色已烘焙到纹理中)
rt.alpha,
rt.sortingLayer,
rt.orderInLayer + 1, // 在背景之上 | Above background
rt.orderInLayer + 1, // Above background | 在背景之上
{
pivotX: 0.5,
pivotY: 0.5,
textureId,
entityId
entityId,
clipRect
}
);
// Render IME composition text underline
// 渲染 IME 组合文本下划线
if (input.isComposing && input.compositionText) {
this.renderCompositionUnderline(collector, input, rt, textX, textY, textWidth, textHeight, entityId);
}
}
/**
* Render IME composition text underline
* 渲染 IME 组合文本下划线
*/
private renderCompositionUnderline(
collector: ReturnType<typeof getUIRenderCollector>,
input: UIInputFieldComponent,
rt: ReturnType<typeof getUIRenderTransform>,
textX: number,
textY: number,
textWidth: number,
textHeight: number,
entityId: number
): void {
if (!rt) return;
const font = input.getFontConfig();
const displayText = input.getDisplayTextWithComposition();
// Calculate composition text start and end X position
// 计算组合文本的起始和结束 X 位置
const service = getTextMeasureService();
const compositionStartX = service.getXForCharIndex(displayText, font, input.compositionStart);
const compositionEndX = service.getXForCharIndex(displayText, font, input.compositionStart + input.compositionText.length);
const underlineWidth = compositionEndX - compositionStartX;
if (underlineWidth <= 0) return;
const underlineX = textX + compositionStartX - input.scrollOffset;
const underlineY = textY + textHeight - 2; // Bottom position | 底部位置
const underlineHeight = 1;
// Clip rect for text viewport
// 文本视窗的裁剪矩形
const clipRect = {
x: textX,
y: textY,
width: textWidth,
height: textHeight
};
// Render underline
// 渲染下划线
collector.addRect(
underlineX + underlineWidth / 2,
underlineY + underlineHeight / 2,
underlineWidth,
underlineHeight,
input.textColor,
rt.alpha,
rt.sortingLayer,
rt.orderInLayer + 2, // Above text | 在文本之上
{
pivotX: 0.5,
pivotY: 0.5,
entityId,
clipRect
}
);
}
@@ -350,8 +433,8 @@ export class UIInputFieldRenderSystem extends EntitySystem {
}
/**
* 渲染选中高亮
* Render selection highlight
* 渲染选中高亮
*/
private renderSelection(
collector: ReturnType<typeof getUIRenderCollector>,
@@ -359,6 +442,7 @@ export class UIInputFieldRenderSystem extends EntitySystem {
rt: ReturnType<typeof getUIRenderTransform>,
textX: number,
textY: number,
textWidth: number,
textHeight: number,
entityId: number
): void {
@@ -370,8 +454,17 @@ export class UIInputFieldRenderSystem extends EntitySystem {
if (selWidth <= 0) return;
// Clip rect for text viewport
// 文本视窗的裁剪矩形
const clipRect = {
x: textX,
y: textY,
width: textWidth,
height: textHeight
};
collector.addRect(
selX + selWidth / 2, // 中心点 | Center point
selX + selWidth / 2, // Center point | 中心点
textY + textHeight / 2,
selWidth,
textHeight,
@@ -382,14 +475,15 @@ export class UIInputFieldRenderSystem extends EntitySystem {
{
pivotX: 0.5,
pivotY: 0.5,
entityId
entityId,
clipRect
}
);
}
/**
* 渲染光标
* Render caret
* 渲染光标
*/
private renderCaret(
collector: ReturnType<typeof getUIRenderCollector>,
@@ -397,6 +491,7 @@ export class UIInputFieldRenderSystem extends EntitySystem {
rt: ReturnType<typeof getUIRenderTransform>,
textX: number,
textY: number,
textWidth: number,
textHeight: number,
entityId: number
): void {
@@ -405,6 +500,15 @@ export class UIInputFieldRenderSystem extends EntitySystem {
const caretXOffset = input.getCaretX();
const caretX = textX + caretXOffset - input.scrollOffset;
// Clip rect for text viewport
// 文本视窗的裁剪矩形
const clipRect = {
x: textX,
y: textY,
width: textWidth,
height: textHeight
};
collector.addRect(
caretX + input.caretWidth / 2,
textY + textHeight / 2,
@@ -417,7 +521,8 @@ export class UIInputFieldRenderSystem extends EntitySystem {
{
pivotX: 0.5,
pivotY: 0.5,
entityId
entityId,
clipRect
}
);
}

View File

@@ -47,7 +47,8 @@ export type BatchBreakReason =
| 'first' // 第一个批次 | First batch
| 'sortingLayer' // 排序层不同 | Different sorting layer
| 'texture' // 纹理不同 | Different texture
| 'material'; // 材质不同 | Different material
| 'material' // 材质不同 | Different material
| 'clipRect'; // 裁剪区域不同 | Different clip rect
/**
* 合批调试信息
@@ -150,6 +151,13 @@ export interface UIRenderPrimitive {
materialOverrides?: UIMaterialOverrides;
/** Source entity ID (for debugging). | 来源实体 ID用于调试。 */
entityId?: number;
/**
* Clip rectangle for scissor test (screen coordinates).
* Content outside this rect will be clipped.
* 裁剪矩形用于 scissor test屏幕坐标
* 此矩形外的内容将被裁剪。
*/
clipRect?: { x: number; y: number; width: number; height: number };
}
/**
@@ -172,6 +180,13 @@ export interface ProviderRenderData {
materialIds?: Uint32Array;
/** Material overrides (per-group). | 材质覆盖(按组)。 */
materialOverrides?: UIMaterialOverrides;
/**
* Clip rectangle for scissor test (screen coordinates).
* All primitives in this batch will be clipped to this rect.
* 裁剪矩形用于 scissor test屏幕坐标
* 此批次中的所有原语将被裁剪到此矩形。
*/
clipRect?: { x: number; y: number; width: number; height: number };
}
/**
@@ -234,6 +249,8 @@ export class UIRenderCollector {
materialOverrides?: UIMaterialOverrides;
/** 来源实体 ID用于调试| Source entity ID (for debugging) */
entityId?: number;
/** 裁剪矩形(屏幕坐标)| Clip rectangle (screen coordinates) */
clipRect?: { x: number; y: number; width: number; height: number };
}
): void {
// Pack color with alpha: 0xAABBGGRR
@@ -261,7 +278,8 @@ export class UIRenderCollector {
uv: options?.uv,
materialId: options?.materialId,
materialOverrides: options?.materialOverrides,
entityId: options?.entityId
entityId: options?.entityId,
clipRect: options?.clipRect
};
this.primitives.push(primitive);
@@ -537,14 +555,16 @@ export class UIRenderCollector {
// 每个批次的 entityId 集合 | Entity ID set per batch
const batchEntityIds = new Map<string, Set<number>>();
// 追踪上一个原语的属性以检测打断原因 | Track previous primitive's properties to detect break reason
// 合批条件:连续的原语如果有相同的 sortingLayer + texture + material 就可以合批
// orderInLayer 只决定渲染顺序,不影响能否合批
// Batching condition: consecutive primitives with same sortingLayer + texture + material can be batched
// Track previous primitive's properties to detect break reason
// Batching condition: consecutive primitives with same sortingLayer + texture + material + clipRect can be batched
// orderInLayer only determines render order, doesn't affect batching
// 追踪上一个原语的属性以检测打断原因
// 合批条件:连续的原语如果有相同的 sortingLayer + texture + material + clipRect 就可以合批
// orderInLayer 只决定渲染顺序,不影响能否合批
let prevSortingLayer: string | null = null;
let prevTextureKey: string | null = null;
let prevMaterialKey: number | null = null;
let prevClipRectKey: string | null = null;
let batchIndex = 0;
let currentGroup: UIRenderPrimitive[] | null = null;
let currentBatchKey: string | null = null;
@@ -572,9 +592,14 @@ export class UIRenderCollector {
}
const materialKey = prim.materialId ?? 0;
// 合批 key 必须包含 orderInLayer否则不同深度的元素会被错误合并
// Batch key must include orderInLayer, otherwise elements at different depths will be incorrectly merged
const batchKey = `${prim.sortingLayer}:${prim.orderInLayer}:${textureKey}:${materialKey}`;
// Generate clipRect key (null/undefined = no clipping)
// 生成 clipRect keynull/undefined = 无裁剪)
const clipRectKey = prim.clipRect
? `${prim.clipRect.x},${prim.clipRect.y},${prim.clipRect.width},${prim.clipRect.height}`
: 'none';
// Batch key must include orderInLayer and clipRect
// 合批 key 必须包含 orderInLayer 和 clipRect
const batchKey = `${prim.sortingLayer}:${prim.orderInLayer}:${textureKey}:${materialKey}:${clipRectKey}`;
// 检查是否需要新批次sortingLayer、orderInLayer、texture 或 material 变化
// Check if new batch needed: sortingLayer, orderInLayer, texture or material changed
@@ -595,6 +620,9 @@ export class UIRenderCollector {
} else if (materialKey !== prevMaterialKey) {
reason = 'material';
detail = `Material changed: ${prevMaterialKey}${materialKey}`;
} else if (clipRectKey !== prevClipRectKey) {
reason = 'clipRect';
detail = `ClipRect changed: ${prevClipRectKey}${clipRectKey}`;
}
}
@@ -634,6 +662,7 @@ export class UIRenderCollector {
prevSortingLayer = prim.sortingLayer;
prevTextureKey = textureKey;
prevMaterialKey = materialKey;
prevClipRectKey = clipRectKey;
}
// 更新每个批次的原语数量和 entityIds | Update primitive count and entityIds for each batch
@@ -769,6 +798,11 @@ export class UIRenderCollector {
if (firstPrim.materialOverrides && Object.keys(firstPrim.materialOverrides).length > 0) {
renderData.materialOverrides = firstPrim.materialOverrides;
}
// Use the first primitive's clipRect (all in group share same clipRect)
// 使用第一个原语的 clipRect组内所有原语共享相同 clipRect
if (firstPrim.clipRect) {
renderData.clipRect = firstPrim.clipRect;
}
result.push({ data: renderData, addIndex: firstPrim.addIndex });
}

View File

@@ -0,0 +1,248 @@
/**
* IME 输入法辅助服务
* IME (Input Method Editor) Helper Service
*
* 使用隐藏的 <input> 元素接收 IME 输入,支持中文/日文/韩文等需要输入法的语言。
* Uses a hidden <input> element to receive IME input, supporting Chinese/Japanese/Korean
* and other languages that require input methods.
*
* @example
* ```typescript
* const imeHelper = new IMEHelper();
* imeHelper.onCompositionEnd = (text) => {
* inputField.insertText(text);
* };
* imeHelper.focus();
* ```
*/
export class IMEHelper {
private hiddenInput: HTMLInputElement;
private canvas: HTMLCanvasElement | null = null;
// ===== 状态 State =====
/**
* 是否正在进行 IME 组合
* Whether IME composition is in progress
*/
public isComposing: boolean = false;
/**
* 当前组合中的文本
* Current composition text
*/
public compositionText: string = '';
// ===== 回调 Callbacks =====
/**
* 组合开始回调
* Composition start callback
*/
onCompositionStart?: () => void;
/**
* 组合更新回调(用户输入拼音等)
* Composition update callback (user typing pinyin, etc.)
*/
onCompositionUpdate?: (text: string) => void;
/**
* 组合结束回调(用户选择了候选字)
* Composition end callback (user selected a candidate)
*/
onCompositionEnd?: (text: string) => void;
/**
* 直接输入回调(非 IME 输入)
* Direct input callback (non-IME input)
*/
onInput?: (text: string) => void;
constructor() {
this.hiddenInput = this.createHiddenInput();
this.bindEvents();
}
/**
* 创建隐藏的 input 元素
* Create hidden input element
*/
private createHiddenInput(): HTMLInputElement {
const input = document.createElement('input');
input.type = 'text';
input.id = '__esengine_ime_input__';
input.autocomplete = 'off';
input.autocapitalize = 'off';
input.spellcheck = false;
// 使用样式隐藏但保持可聚焦
// Hide but keep focusable
input.style.cssText = `
position: absolute;
left: 0px;
top: 0px;
width: 1px;
height: 1px;
opacity: 0;
pointer-events: none;
z-index: -1;
border: none;
outline: none;
padding: 0;
margin: 0;
background: transparent;
font-size: 16px;
`;
document.body.appendChild(input);
return input;
}
/**
* 绑定事件
* Bind events
*/
private bindEvents(): void {
// 组合开始 | Composition start
this.hiddenInput.addEventListener('compositionstart', (_e) => {
this.isComposing = true;
this.compositionText = '';
this.onCompositionStart?.();
});
// 组合更新 | Composition update
this.hiddenInput.addEventListener('compositionupdate', (e) => {
this.compositionText = e.data || '';
this.onCompositionUpdate?.(this.compositionText);
});
// 组合结束 | Composition end
this.hiddenInput.addEventListener('compositionend', (e) => {
this.isComposing = false;
const text = e.data || '';
this.compositionText = '';
this.onCompositionEnd?.(text);
// 清空 input 值以便下次输入
// Clear input value for next input
this.hiddenInput.value = '';
});
// 直接输入(非 IME| Direct input (non-IME)
this.hiddenInput.addEventListener('input', (e) => {
// 组合过程中不处理 input 事件
// Don't handle input event during composition
if (this.isComposing) return;
const input = e.target as HTMLInputElement;
if (input.value) {
this.onInput?.(input.value);
// 清空以便下次输入
// Clear for next input
input.value = '';
}
});
// 阻止默认键盘行为(由 UIInputSystem 处理)
// Prevent default keyboard behavior (handled by UIInputSystem)
this.hiddenInput.addEventListener('keydown', (e) => {
// 允许 IME 相关的键
// Allow IME-related keys
if (this.isComposing) return;
// 阻止非 IME 的默认行为(如 Backspace、Enter 等)
// Prevent non-IME default behavior (like Backspace, Enter, etc.)
const specialKeys = ['Backspace', 'Delete', 'Enter', 'Tab', 'Escape', 'ArrowLeft', 'ArrowRight', 'ArrowUp', 'ArrowDown', 'Home', 'End'];
if (specialKeys.includes(e.key) || e.ctrlKey || e.metaKey) {
// 这些键由 UIInputSystem 处理,不需要在这里处理
// These keys are handled by UIInputSystem, no need to handle here
return;
}
});
}
/**
* 设置关联的 Canvas 元素
* Set associated canvas element
*/
setCanvas(canvas: HTMLCanvasElement): void {
this.canvas = canvas;
}
/**
* 更新隐藏 input 的位置(让 IME 候选窗口出现在正确位置)
* Update hidden input position (so IME candidate window appears in correct position)
*
* @param screenX - 屏幕 X 坐标 | Screen X coordinate
* @param screenY - 屏幕 Y 坐标 | Screen Y coordinate
*/
updatePosition(screenX: number, screenY: number): void {
this.hiddenInput.style.left = `${screenX}px`;
this.hiddenInput.style.top = `${screenY}px`;
}
/**
* 聚焦隐藏的 input 元素
* Focus the hidden input element
*/
focus(): void {
this.hiddenInput.value = '';
this.isComposing = false;
this.compositionText = '';
this.hiddenInput.focus();
}
/**
* 取消聚焦
* Blur the hidden input element
*/
blur(): void {
this.hiddenInput.blur();
this.isComposing = false;
this.compositionText = '';
}
/**
* 检查是否已聚焦
* Check if focused
*/
isFocused(): boolean {
return document.activeElement === this.hiddenInput;
}
/**
* 释放资源
* Dispose resources
*/
dispose(): void {
this.hiddenInput.remove();
this.onCompositionStart = undefined;
this.onCompositionUpdate = undefined;
this.onCompositionEnd = undefined;
this.onInput = undefined;
}
}
// ===== 全局单例 Global Singleton =====
let globalIMEHelper: IMEHelper | null = null;
/**
* 获取全局 IME 辅助服务实例
* Get global IME helper instance
*/
export function getIMEHelper(): IMEHelper {
if (!globalIMEHelper) {
globalIMEHelper = new IMEHelper();
}
return globalIMEHelper;
}
/**
* 销毁全局 IME 辅助服务实例
* Dispose global IME helper instance
*/
export function disposeIMEHelper(): void {
if (globalIMEHelper) {
globalIMEHelper.dispose();
globalIMEHelper = null;
}
}

View File

@@ -38,3 +38,10 @@ export {
type CharacterPosition,
type LineInfo
} from './TextMeasureService';
export {
// IME utilities | IME 输入法工具
IMEHelper,
getIMEHelper,
disposeIMEHelper
} from './IMEHelper';