feat: UI输入框IME支持和编辑器Inspector重构 (#310)
UI系统改进: - 添加 IMEHelper 支持中文/日文/韩文输入法 - UIInputFieldComponent 添加组合输入状态管理 - UIInputSystem 添加 IME 事件处理 - UIInputFieldRenderSystem 优化渲染逻辑 - UIRenderCollector 增强纹理处理 引擎改进: - EngineBridge 添加新的渲染接口 - EngineRenderSystem 优化渲染流程 - Rust 引擎添加新的渲染功能 编辑器改进: - 新增模块化 Inspector 组件架构 - EntityRefField 增强实体引用选择 - 优化 FlexLayoutDock 和 SceneHierarchy 样式 - 添加国际化文本
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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 key(null/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 });
|
||||
}
|
||||
|
||||
248
packages/ui/src/utils/IMEHelper.ts
Normal file
248
packages/ui/src/utils/IMEHelper.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -38,3 +38,10 @@ export {
|
||||
type CharacterPosition,
|
||||
type LineInfo
|
||||
} from './TextMeasureService';
|
||||
|
||||
export {
|
||||
// IME utilities | IME 输入法工具
|
||||
IMEHelper,
|
||||
getIMEHelper,
|
||||
disposeIMEHelper
|
||||
} from './IMEHelper';
|
||||
|
||||
Reference in New Issue
Block a user