@barocss/editor-view-react 0.1.0 → 0.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,40 @@
1
+ # editor-view-react IME/입력 안정성 체크리스트
2
+
3
+ 목표: `editor-view-dom`과 동일한 방향으로, React 기반 contenteditable 경로에서도 입력/IME/렌더 타이밍을 안정화한다.
4
+
5
+ ## A. 즉시 적용 체크리스트 (현재 구현 완료)
6
+
7
+ - [x] `compositionstart`에서 입력 가능 영역 검사 + 필요 시 block
8
+ - [x] `compositionupdate`에서 composing flag 유지
9
+ - [x] `compositionend` 직후 DOM 동기화 타이밍을 `rAF × 2`로 지연
10
+ - [x] `compositionstart/end` 이벤트 기반의 IME 플래그에 더해 `keyCode === 229`/조합 직후 윈도우(`compositionWindowUntil`) 보조 플래그 추가
11
+ - [x] `keydown`, `beforeinput`, `paste`, `drop`, `handleDomMutations`가 보조 IME 윈도우 상태에서 model 렌더/변환을 보수적으로 처리
12
+ - [x] `editor:content.change`에서 `skipRender` 및 `skipNextRenderFromMO` 경로 유지
13
+
14
+ ## B. 추가 점검 항목 (추천)
15
+
16
+ - [ ] `beforeinput.getTargetRanges()` 사용률:
17
+ - `insertText`/`insertReplacementText`/`insertFromPaste`에서 대상 범위를 model range로 변환해 `preventDefault + replaceText`로 처리 가능한지 검증
18
+ - 범위가 `inline-text`를 벗어날 때 입력을 차단했을 때의 UX 확인
19
+ - [ ] Safari/macOS + Chrome/Android IME(한글/일본어/중국어)에서 다음 순서 시나리오 검증:
20
+ - `compositionstart` 바로 뒤 첫 글자 입력
21
+ - `compositionupdate` 연속, `compositionend` 뒤 `beforeinput/input` 순서 역전
22
+ - `keydown`이 `keyCode 229`를 내뿜는 경우
23
+ - [ ] Composition 중 구조 변경(엔터/붙여넣기/드래그) 경로:
24
+ - 즉시 렌더를 건너뛸지/완전 동기화할지 정책 일관성 확인
25
+ - IME 중 selection 유지 안정성(커서 점프/리셋)
26
+ - [ ] React reconciliation side-effect 점검:
27
+ - 동일 sid 재사용이 유지되는지
28
+ - composition window 종료 직후 `skipApplyModelSelectionToDOM` 동작이 불필요한 selection 재적용을 막는지
29
+
30
+ ## C. 실패/회귀 대응 체크
31
+
32
+ - [ ] IME 입력 중 `skipNextRenderFromMO`가 과도하게 남아 다음 편집까지 남는지
33
+ - [ ] `compositionWindowUntil` 타임아웃이 너무 길거나 짧을 때 발생하는 오탐/미탐 비율 확인
34
+ - [ ] `paste/drop`이 IME 직후에 오독되어 무시되는 케이스 분리(텍스트 입력 vs 비 IME 입력)
35
+
36
+ ## D. 모니터링 이벤트(로그 또는 테스트 hook)
37
+
38
+ - `editor:input.*`/`editor:content.change` payload에 `from`이 `getTargetRanges|compositionend-sync|MutationObserver-C1` 등으로 충분히 구분되는지
39
+ - `selectionchange` 경로에서 `convertDOMSelectionToModel` 실패 빈도 추적
40
+ - composition window 내/직후 입력을 `skip`/`guard`한 횟수 집계 (안정화 정도 판단 지표)
@@ -90,7 +90,7 @@ Possible improvements to align behavior and reduce drift:
90
90
 
91
91
  | Item | Description |
92
92
  |------|-------------|
93
- | **Decorator range adjustment on text edit** | When the user types, inline decorator ranges (startOffset/endOffset) should be adjusted so they stay correct. editor-view-dom computes `adjustedDecorators` in handleEfficientEdit (via dataStore.decorators.adjustRanges or edit-position-converter) but applying them back to DecoratorManager is still TODO. editor-view-react does not yet run any adjust step after replaceText. Adding this in both views would keep decorators in sync with content. |
93
+ | **Decorator range adjustment on text edit** | When the user types, inline decorator ranges (startOffset/endOffset) should be adjusted so they stay correct. editor-view-dom computes `adjustedDecorators` in handleEfficientEdit (via dataStore.decorators.adjustRanges or edit-position-converter), but applying back to `DecoratorManager` is not yet implemented in either view. Add this in both views to keep decorators in sync with content. |
94
94
  | **Single Decorator type source** | Decorator is defined in editor-view-dom, shared, and renderer-react. Unifying on shared (and re-exporting or extending in the other packages) would avoid drift. |
95
95
  | **ref.updateDecorator(id, updates)** | editor-view-dom exposes updateDecorator(id, updates). editor-view-react ref currently has addDecorator, removeDecorator, getDecorators; adding updateDecorator would call DecoratorManager.update for parity. |
96
96
  | **Optional DecoratorRegistry in editor-view-react** | editor-view-dom uses DecoratorRegistry for validation and default values when adding decorators. editor-view-react uses DecoratorManager without a validator; an optional registry would allow the same validation/defaults. |
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@barocss/editor-view-react",
3
- "version": "0.1.0",
3
+ "version": "0.1.1",
4
4
  "description": "React view layer for Barocss Editor (renderer-react + Editor)",
5
5
  "type": "module",
6
6
  "main": "./src/index.ts",
@@ -17,9 +17,9 @@
17
17
  "react-dom": ">=18.0.0"
18
18
  },
19
19
  "dependencies": {
20
- "@barocss/dsl": "1.0.0",
21
- "@barocss/editor-core": "1.0.0",
22
- "@barocss/renderer-react": "0.1.0",
20
+ "@barocss/dsl": "1.0.1",
21
+ "@barocss/editor-core": "1.0.1",
22
+ "@barocss/renderer-react": "0.1.1",
23
23
  "@barocss/shared": "1.0.0",
24
24
  "@barocss/text-analyzer": "0.1.0"
25
25
  },
@@ -1,4 +1,11 @@
1
1
  import { useEffect, useMemo, useRef, useState } from 'react';
2
+ import type {
3
+ ClipboardEventHandler,
4
+ DragEventHandler,
5
+ FormEvent,
6
+ FormEventHandler,
7
+ KeyboardEventHandler,
8
+ } from 'react';
2
9
  import { getGlobalRegistry } from '@barocss/dsl';
3
10
  import { ReactRenderer } from '@barocss/renderer-react';
4
11
  import { useEditorViewContext } from './EditorViewContext';
@@ -12,9 +19,10 @@ import type { EditorViewContentLayerProps } from './types';
12
19
  export function EditorViewContentLayer({ options = {} }: EditorViewContentLayerProps) {
13
20
  const {
14
21
  editor,
15
- selectionHandler,
22
+ inputHandler,
16
23
  viewStateRef,
17
24
  setContentEditableElement,
25
+ selectionHandler,
18
26
  getMergedDecorators,
19
27
  decoratorVersion,
20
28
  } = useEditorViewContext();
@@ -22,20 +30,48 @@ export function EditorViewContentLayer({ options = {} }: EditorViewContentLayerP
22
30
 
23
31
  const [documentSnapshot, setDocumentSnapshot] = useState<unknown>(() => editor.getDocumentProxy?.() ?? null);
24
32
  const contentRef = useRef<HTMLDivElement | null>(null);
33
+ const modelRenderGuardFrameRef = useRef<number | null>(null);
25
34
 
26
35
  useEffect(() => {
27
- const onContentChange = (e: { content?: unknown }) => {
36
+ const onContentChange = (e: { content?: unknown; skipRender?: boolean }) => {
37
+ if (e?.skipRender) return;
38
+
28
39
  if (viewStateRef?.current?.skipNextRenderFromMO) {
29
40
  viewStateRef.current.skipNextRenderFromMO = false;
30
41
  return;
31
42
  }
43
+
44
+ if (modelRenderGuardFrameRef.current !== null) {
45
+ window.cancelAnimationFrame(modelRenderGuardFrameRef.current);
46
+ }
47
+
48
+ if (viewStateRef.current) {
49
+ viewStateRef.current.isModelDrivenChange = true;
50
+ viewStateRef.current.isRendering = true;
51
+ }
52
+
32
53
  const next = e?.content ?? editor.getDocumentProxy?.() ?? null;
33
54
  setDocumentSnapshot(next);
55
+
56
+ modelRenderGuardFrameRef.current = window.requestAnimationFrame(() => {
57
+ modelRenderGuardFrameRef.current = null;
58
+ if (viewStateRef.current) {
59
+ viewStateRef.current.isModelDrivenChange = false;
60
+ viewStateRef.current.isRendering = false;
61
+ }
62
+ });
34
63
  };
35
64
  editor.on?.('editor:content.change', onContentChange);
36
65
  setDocumentSnapshot(editor.getDocumentProxy?.() ?? null);
37
66
  return () => {
38
67
  editor.off?.('editor:content.change', onContentChange);
68
+ if (modelRenderGuardFrameRef.current !== null) {
69
+ window.cancelAnimationFrame(modelRenderGuardFrameRef.current);
70
+ }
71
+ if (viewStateRef.current) {
72
+ viewStateRef.current.isModelDrivenChange = false;
73
+ viewStateRef.current.isRendering = false;
74
+ }
39
75
  };
40
76
  }, [editor, viewStateRef]);
41
77
 
@@ -46,11 +82,32 @@ export function EditorViewContentLayer({ options = {} }: EditorViewContentLayerP
46
82
  }, [setContentEditableElement]);
47
83
 
48
84
  useEffect(() => {
49
- const onModelSelection = (sel: unknown) => {
85
+ const onModelSelection = (eventPayload: unknown) => {
86
+ const hasSelectionField =
87
+ typeof eventPayload === 'object' &&
88
+ eventPayload !== null &&
89
+ Object.prototype.hasOwnProperty.call(eventPayload, 'selection');
90
+ const source = typeof eventPayload === 'object' && eventPayload !== null && Object.prototype.hasOwnProperty.call(eventPayload, 'source')
91
+ ? (eventPayload as { source?: string }).source
92
+ : undefined;
93
+ const selectionFromEvent = hasSelectionField
94
+ ? (eventPayload as { selection: unknown }).selection
95
+ : eventPayload;
96
+ const applySelectionToView = hasSelectionField
97
+ ? source === 'remote'
98
+ ? false
99
+ : (eventPayload as { applySelectionToView?: boolean }).applySelectionToView !== false
100
+ : true;
101
+ const shouldApplySelectionToView = source === 'remote' ? false : applySelectionToView;
102
+
103
+ if (!shouldApplySelectionToView) return;
104
+
50
105
  if (viewStateRef?.current?.skipApplyModelSelectionToDOM) return;
51
106
  requestAnimationFrame(() => {
52
107
  requestAnimationFrame(() => {
53
- selectionHandler.convertModelSelectionToDOM(sel as Parameters<typeof selectionHandler.convertModelSelectionToDOM>[0]);
108
+ selectionHandler.convertModelSelectionToDOM(
109
+ selectionFromEvent as Parameters<typeof selectionHandler.convertModelSelectionToDOM>[0]
110
+ );
54
111
  });
55
112
  });
56
113
  };
@@ -75,6 +132,30 @@ export function EditorViewContentLayer({ options = {} }: EditorViewContentLayerP
75
132
  return renderer.build(model, decorators);
76
133
  }, [documentSnapshot, renderer, decorators]);
77
134
 
135
+ const handleBeforeInput: FormEventHandler<HTMLDivElement> = (event: FormEvent<HTMLDivElement>) => {
136
+ const inputEvent = event.nativeEvent as InputEvent;
137
+ inputHandler.handleBeforeInput(inputEvent);
138
+ };
139
+
140
+ const handleInput: FormEventHandler<HTMLDivElement> = (event: FormEvent<HTMLDivElement>) => {
141
+ const inputEvent = event.nativeEvent as InputEvent;
142
+ inputHandler.handleInput(inputEvent);
143
+ };
144
+
145
+ const handleKeyDown: KeyboardEventHandler<HTMLDivElement> = (event) => {
146
+ inputHandler.handleKeydown(event.nativeEvent);
147
+ };
148
+
149
+ const handlePaste: ClipboardEventHandler<HTMLDivElement> = (event) => {
150
+ const clipboardEvent = event.nativeEvent as ClipboardEvent;
151
+ inputHandler.handlePaste(clipboardEvent);
152
+ };
153
+
154
+ const handleDrop: DragEventHandler<HTMLDivElement> = (event) => {
155
+ const dropEvent = event.nativeEvent as DragEvent;
156
+ inputHandler.handleDrop(dropEvent);
157
+ };
158
+
78
159
  return (
79
160
  <div
80
161
  ref={contentRef}
@@ -83,6 +164,11 @@ export function EditorViewContentLayer({ options = {} }: EditorViewContentLayerP
83
164
  suppressContentEditableWarning
84
165
  data-bc-layer="content"
85
166
  data-testid="editor-content"
167
+ onInput={handleInput}
168
+ onBeforeInput={handleBeforeInput}
169
+ onKeyDown={handleKeyDown}
170
+ onPaste={handlePaste}
171
+ onDrop={handleDrop}
86
172
  >
87
173
  {content}
88
174
  </div>
@@ -20,6 +20,8 @@ export interface EditorViewViewState {
20
20
  isModelDrivenChange: boolean;
21
21
  isRendering: boolean;
22
22
  isComposing: boolean;
23
+ /** Tracks IME-related events shortly after compositionend or keyCode 229 keydown. */
24
+ compositionWindowUntil: number;
23
25
  /** When true, next editor:content.change (from model commit during MO C1) must not trigger refresh (data-only update). */
24
26
  skipNextRenderFromMO: boolean;
25
27
  /** When true, editor:selection.model must not call convertModelSelectionToDOM (selection came from DOM input; leave DOM selection as-is). */
@@ -64,6 +66,7 @@ export function EditorViewContextProvider({ editor, children }: { editor: Editor
64
66
  isModelDrivenChange: false,
65
67
  isRendering: false,
66
68
  isComposing: false,
69
+ compositionWindowUntil: 0,
67
70
  skipNextRenderFromMO: false,
68
71
  skipApplyModelSelectionToDOM: false,
69
72
  });
@@ -49,6 +49,68 @@ export class ReactInputHandler {
49
49
  private viewStateRef: MutableRefObject<EditorViewViewState>;
50
50
  private _isComposing = false;
51
51
  private _pendingInsertHint: InputHint | null = null;
52
+ private readonly _compositionWindowMs = 120;
53
+ private _compositionGeneration = 0;
54
+ private _compositionSyncToken = 0;
55
+ private _isCompositionSyncScheduled = false;
56
+ private _contentChangeTxSeq = 0;
57
+
58
+ private _buildDebugTransaction(operations: any[] = [], description?: string) {
59
+ return {
60
+ sid: `tx-viewreact-${Date.now()}-${++this._contentChangeTxSeq}`,
61
+ timestamp: new Date(),
62
+ operations,
63
+ description
64
+ };
65
+ }
66
+
67
+ private markPostCompositionWindow(): void {
68
+ this.viewStateRef.current.compositionWindowUntil = Date.now() + this._compositionWindowMs;
69
+ }
70
+
71
+ private clearPostCompositionWindow(): void {
72
+ this.viewStateRef.current.compositionWindowUntil = 0;
73
+ }
74
+
75
+ private isPostCompositionWindowActive(): boolean {
76
+ return this.viewStateRef.current.compositionWindowUntil > Date.now();
77
+ }
78
+
79
+ private isImePhase(isComposing?: boolean): boolean {
80
+ if (this._isComposing || this.viewStateRef.current.isComposing) return true;
81
+ if (isComposing) return true;
82
+ return this.isPostCompositionWindowActive();
83
+ }
84
+
85
+ private setImeComposingState(isComposing: boolean, clearWindow = true): void {
86
+ const wasComposing = this._isComposing;
87
+
88
+ if (wasComposing !== isComposing) {
89
+ this._compositionGeneration += 1;
90
+ this._compositionSyncToken = this._compositionGeneration;
91
+
92
+ if (isComposing) {
93
+ this._isCompositionSyncScheduled = false;
94
+ if (clearWindow) {
95
+ this.clearPostCompositionWindow();
96
+ }
97
+ } else {
98
+ this._isCompositionSyncScheduled = true;
99
+ this.markPostCompositionWindow();
100
+ }
101
+ }
102
+
103
+ this._isComposing = isComposing;
104
+ this.viewStateRef.current.isComposing = isComposing;
105
+ }
106
+
107
+ private requestCompositionEndSync(): void {
108
+ requestAnimationFrame(() => {
109
+ requestAnimationFrame(() => {
110
+ void this.syncFocusedTextNodeAfterComposition();
111
+ });
112
+ });
113
+ }
52
114
 
53
115
  constructor(
54
116
  editor: Editor,
@@ -60,53 +122,124 @@ export class ReactInputHandler {
60
122
  this.viewStateRef = viewStateRef;
61
123
  }
62
124
 
63
- /** Set IME composition state. Called from compositionstart/compositionend so keydown/beforeinput see it early. */
125
+ /** Set IME composition state. Called from beforeinput and compatibility keydown 229. */
64
126
  setComposing(isComposing: boolean): void {
65
- this._isComposing = isComposing;
66
- this.viewStateRef.current.isComposing = isComposing;
127
+ const wasComposing = this._isComposing;
128
+ this.setImeComposingState(isComposing);
129
+
130
+ if (wasComposing && !isComposing) {
131
+ this.requestCompositionEndSync();
132
+ }
133
+ }
134
+
135
+ handleInput(event: InputEvent): void {
136
+ const wasComposing = this._isComposing;
137
+
138
+ if (event.isComposing !== undefined) {
139
+ this.setImeComposingState(event.isComposing);
140
+ if (wasComposing && !event.isComposing) {
141
+ this.requestCompositionEndSync();
142
+ }
143
+ }
144
+ }
145
+
146
+ handlePaste(event: ClipboardEvent): void {
147
+ if (this.isImePhase()) return;
148
+
149
+ event.preventDefault();
150
+ const text = event.clipboardData?.getData('text/plain') ?? '';
151
+ if (!text) return;
152
+
153
+ this.insertTextAtSelection(text);
154
+ }
155
+
156
+ handleDrop(event: DragEvent): void {
157
+ if (this.isImePhase()) return;
158
+
159
+ event.preventDefault();
160
+ const text = event.dataTransfer?.getData('text/plain') ?? '';
161
+ if (!text) return;
162
+
163
+ this.insertTextAtSelection(text);
67
164
  }
68
165
 
69
166
  /**
70
167
  * Sync model to DOM for the focused inline-text node. Call once after compositionend so the final composed text is applied (no intermediate C1).
71
168
  */
72
169
  async syncFocusedTextNodeAfterComposition(): Promise<void> {
170
+ const syncToken = this._compositionSyncToken;
171
+ const generation = this._compositionGeneration;
172
+ const completeSync = (): void => {
173
+ if (syncToken === this._compositionSyncToken && generation === this._compositionGeneration) {
174
+ this._isCompositionSyncScheduled = false;
175
+ this._compositionSyncToken += 1;
176
+ }
177
+ };
178
+
179
+ if (!this._isCompositionSyncScheduled) return;
180
+ if (this._isComposing || this.viewStateRef.current.isComposing) return;
73
181
  const view = this.viewStateRef.current;
182
+ if (syncToken !== this._compositionSyncToken || generation !== this._compositionGeneration) return;
74
183
  if (view.isModelDrivenChange || view.isRendering) return;
75
184
 
185
+ if (this.isPostCompositionWindowActive()) {
186
+ setTimeout(() => {
187
+ void this.syncFocusedTextNodeAfterComposition();
188
+ }, Math.max(0, view.compositionWindowUntil - Date.now()));
189
+ return;
190
+ }
191
+
76
192
  const selection = window.getSelection();
77
- if (!selection?.rangeCount || !selection.anchorNode) return;
193
+ if (!selection?.rangeCount || !selection.anchorNode) {
194
+ completeSync();
195
+ return;
196
+ }
78
197
 
79
198
  const inlineEl = findClosestInlineTextNode(selection.anchorNode);
80
199
  if (!inlineEl) return;
81
200
 
82
201
  const nodeId = inlineEl.getAttribute('data-bc-sid');
83
- if (!nodeId) return;
202
+ if (!nodeId) {
203
+ completeSync();
204
+ return;
205
+ }
84
206
 
85
207
  const dataStore = this.editor.dataStore;
86
208
  const modelNode = dataStore?.getNode?.(nodeId) as { stype?: string; text?: string } | undefined;
87
- if (!modelNode || modelNode.stype !== 'inline-text') return;
209
+ if (!modelNode || modelNode.stype !== 'inline-text') {
210
+ completeSync();
211
+ return;
212
+ }
88
213
 
89
214
  const prevText = modelNode.text ?? '';
90
215
  const newText = reconstructModelTextFromDOM(inlineEl);
91
- if (prevText === newText) return;
216
+ if (prevText === newText) {
217
+ completeSync();
218
+ return;
219
+ }
220
+
221
+ const contentRange: ContentRange = {
222
+ type: 'range',
223
+ startNodeId: nodeId,
224
+ startOffset: 0,
225
+ endNodeId: nodeId,
226
+ endOffset: prevText.length,
227
+ };
92
228
 
93
229
  this.viewStateRef.current.skipNextRenderFromMO = true;
94
230
  let success = false;
95
231
  try {
96
232
  success = await this.editor.executeCommand('replaceText', {
97
- range: {
98
- type: 'range',
99
- startNodeId: nodeId,
100
- startOffset: 0,
101
- endNodeId: nodeId,
102
- endOffset: prevText.length,
103
- },
104
- text: newText,
233
+ range: contentRange,
234
+ text: newText,
105
235
  });
106
236
  } finally {
107
237
  this.viewStateRef.current.skipNextRenderFromMO = false;
108
238
  }
109
- if (!success) return;
239
+ if (!success) {
240
+ completeSync();
241
+ return;
242
+ }
110
243
 
111
244
  const selAfter = window.getSelection();
112
245
  if (selAfter?.rangeCount) {
@@ -137,8 +270,18 @@ export class ReactInputHandler {
137
270
  skipRender: true,
138
271
  from: 'compositionend-sync',
139
272
  content: (this.editor as { document?: unknown }).document,
140
- transaction: { type: 'text_replace', nodeId },
273
+ transaction: this._buildDebugTransaction([
274
+ {
275
+ type: 'replaceText',
276
+ payload: {
277
+ nodeId,
278
+ range: contentRange
279
+ }
280
+ }
281
+ ], 'compositionend-sync')
141
282
  });
283
+
284
+ completeSync();
142
285
  }
143
286
 
144
287
  /**
@@ -150,7 +293,7 @@ export class ReactInputHandler {
150
293
  async handleDomMutations(mutations: MutationRecord[]): Promise<void> {
151
294
  const view = this.viewStateRef.current;
152
295
  if (view.isModelDrivenChange || view.isRendering) return;
153
- if (view.isComposing) return;
296
+ if (this.isImePhase()) return;
154
297
 
155
298
  this.viewStateRef.current.skipNextRenderFromMO = true;
156
299
  try {
@@ -180,7 +323,7 @@ export class ReactInputHandler {
180
323
  }
181
324
  }
182
325
 
183
- const inputHint = this.getValidInsertHint(view.isComposing);
326
+ const inputHint = this.getValidInsertHint(view.isComposing || this.isPostCompositionWindowActive());
184
327
  const classified = classifyDomChangeC1(mutations, {
185
328
  editor: this.editor,
186
329
  selection: selection ?? undefined,
@@ -269,16 +412,38 @@ export class ReactInputHandler {
269
412
  skipRender: true,
270
413
  from: 'MutationObserver-C1',
271
414
  content: (this.editor as { document?: unknown }).document,
272
- transaction: { type: 'text_replace', nodeId: classified.nodeId },
415
+ transaction: this._buildDebugTransaction([
416
+ {
417
+ type: 'replaceText',
418
+ payload: {
419
+ nodeId: classified.nodeId,
420
+ range: classified.contentRange
421
+ }
422
+ }
423
+ ], 'MutationObserver-C1')
273
424
  });
274
425
  this._pendingInsertHint = null;
275
426
  }
276
427
 
277
428
  handleBeforeInput(event: InputEvent): void {
278
- if (event.isComposing !== undefined) {
279
- this._isComposing = event.isComposing;
280
- this.viewStateRef.current.isComposing = event.isComposing;
429
+ const isComposing = event.isComposing;
430
+ const wasComposing = this._isComposing;
431
+
432
+ if (isComposing !== undefined) {
433
+ this.setImeComposingState(isComposing);
434
+
435
+ if (isComposing && !this.selectionHandler.isSelectionInsideEditableText()) {
436
+ event.preventDefault();
437
+ return;
438
+ }
439
+
440
+ if (wasComposing && !isComposing) {
441
+ this.clearPostCompositionWindow();
442
+ this.requestCompositionEndSync();
443
+ }
281
444
  }
445
+
446
+ const isIme = this.isImePhase(isComposing);
282
447
  const inputType = event.inputType;
283
448
 
284
449
  if (shouldPreventDefaultStructural(inputType)) {
@@ -293,7 +458,7 @@ export class ReactInputHandler {
293
458
  return;
294
459
  }
295
460
 
296
- if (shouldHandleDelete(inputType) && !event.isComposing) {
461
+ if (shouldHandleDelete(inputType) && !isIme) {
297
462
  event.preventDefault();
298
463
  this.handleDelete(event);
299
464
  return;
@@ -312,6 +477,8 @@ export class ReactInputHandler {
312
477
  }
313
478
 
314
479
  private updateInsertHintFromBeforeInput(event: InputEvent): void {
480
+ if (this.isImePhase(event.isComposing)) return;
481
+
315
482
  const selection = window.getSelection();
316
483
  if (!selection?.rangeCount) {
317
484
  this._pendingInsertHint = null;
@@ -343,6 +510,13 @@ export class ReactInputHandler {
343
510
  */
344
511
  handleKeydown(event: KeyboardEvent): void {
345
512
  if (this._isComposing) return;
513
+ if (event.keyCode === 229) {
514
+ this.markPostCompositionWindow();
515
+ this.setImeComposingState(true, false);
516
+ return;
517
+ }
518
+
519
+ if (this.isImePhase()) return;
346
520
 
347
521
  const isCharacterKey =
348
522
  event.key.length === 1 &&
@@ -407,7 +581,7 @@ export class ReactInputHandler {
407
581
  private tryHandleInsertViaGetTargetRanges(event: InputEvent): boolean {
408
582
  const inputType = event.inputType;
409
583
  if (!['insertText', 'insertFromPaste', 'insertReplacementText'].includes(inputType)) return false;
410
- if (event.isComposing) return false;
584
+ if (this.isImePhase(event.isComposing)) return false;
411
585
 
412
586
  const getTargetRanges = (event as InputEvent & { getTargetRanges?: () => StaticRange[] }).getTargetRanges;
413
587
  if (typeof getTargetRanges !== 'function') return false;
@@ -459,7 +633,14 @@ export class ReactInputHandler {
459
633
  skipRender: false,
460
634
  from: 'getTargetRanges',
461
635
  content: (this.editor as { document?: unknown }).document,
462
- transaction: { type: 'text_replace', range: rangeForReplace },
636
+ transaction: this._buildDebugTransaction([
637
+ {
638
+ type: 'replaceText',
639
+ payload: {
640
+ ...rangeForReplace
641
+ }
642
+ }
643
+ ], 'getTargetRanges')
463
644
  });
464
645
  this.applyModelSelectionAfterRender(newCaret);
465
646
  }).catch(() => {});
@@ -526,7 +707,14 @@ export class ReactInputHandler {
526
707
  skipRender: false,
527
708
  from: 'beforeinput-delete',
528
709
  content: (this.editor as { document?: unknown }).document,
529
- transaction: { type: 'delete', contentRange },
710
+ transaction: this._buildDebugTransaction([
711
+ {
712
+ type: 'deleteText',
713
+ payload: {
714
+ range: contentRange
715
+ }
716
+ }
717
+ ], 'beforeinput-delete')
530
718
  });
531
719
 
532
720
  this.applyModelSelectionAfterRender(newModelSelection);