@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.
- package/CHANGELOG.md +17 -0
- package/dist/editor-view-react/src/EditorViewContentLayer.d.ts.map +1 -1
- package/dist/editor-view-react/src/EditorViewContext.d.ts +2 -0
- package/dist/editor-view-react/src/EditorViewContext.d.ts.map +1 -1
- package/dist/editor-view-react/src/input-handler.d.ts +16 -1
- package/dist/editor-view-react/src/input-handler.d.ts.map +1 -1
- package/dist/editor-view-react/src/selection-handler.d.ts +2 -0
- package/dist/editor-view-react/src/selection-handler.d.ts.map +1 -1
- package/dist/index.cjs +10 -4
- package/dist/index.js +5453 -1713
- package/docs/ime-composition-stability-checklist.md +40 -0
- package/docs/layers-spec.md +1 -1
- package/package.json +4 -4
- package/src/EditorViewContentLayer.tsx +90 -4
- package/src/EditorViewContext.tsx +3 -0
- package/src/input-handler.ts +215 -27
- package/src/selection-handler.ts +43 -9
- package/test/EditorView.test.tsx +287 -1
- package/test/input-handler-ims.test.ts +330 -0
- package/test/selection-handler.test.ts +202 -0
|
@@ -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`한 횟수 집계 (안정화 정도 판단 지표)
|
package/docs/layers-spec.md
CHANGED
|
@@ -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
|
|
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.
|
|
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.
|
|
21
|
-
"@barocss/editor-core": "1.0.
|
|
22
|
-
"@barocss/renderer-react": "0.1.
|
|
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
|
-
|
|
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 = (
|
|
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(
|
|
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
|
});
|
package/src/input-handler.ts
CHANGED
|
@@ -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
|
|
125
|
+
/** Set IME composition state. Called from beforeinput and compatibility keydown 229. */
|
|
64
126
|
setComposing(isComposing: boolean): void {
|
|
65
|
-
this._isComposing
|
|
66
|
-
this.
|
|
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)
|
|
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)
|
|
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')
|
|
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)
|
|
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
|
-
|
|
98
|
-
|
|
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)
|
|
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:
|
|
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 (
|
|
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:
|
|
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
|
-
|
|
279
|
-
|
|
280
|
-
|
|
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) && !
|
|
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:
|
|
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:
|
|
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);
|