@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,330 @@
|
|
|
1
|
+
import { describe, it, expect, vi, afterEach } from 'vitest';
|
|
2
|
+
import { ReactInputHandler } from '../src/input-handler';
|
|
3
|
+
|
|
4
|
+
function createSelectionHandler() {
|
|
5
|
+
return {
|
|
6
|
+
convertDOMSelectionToModel: vi.fn(() => ({
|
|
7
|
+
type: 'range',
|
|
8
|
+
startNodeId: 't1',
|
|
9
|
+
startOffset: 1,
|
|
10
|
+
endNodeId: 't1',
|
|
11
|
+
endOffset: 1,
|
|
12
|
+
})),
|
|
13
|
+
convertStaticRangeToModel: vi.fn(() => ({
|
|
14
|
+
type: 'range',
|
|
15
|
+
startNodeId: 't1',
|
|
16
|
+
startOffset: 0,
|
|
17
|
+
endNodeId: 't1',
|
|
18
|
+
endOffset: 0,
|
|
19
|
+
})),
|
|
20
|
+
isSelectionInsideEditableText: vi.fn(() => true),
|
|
21
|
+
convertModelSelectionToDOM: vi.fn(),
|
|
22
|
+
};
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function createDomTextFixture(initialText: string) {
|
|
26
|
+
const root = document.createElement('div');
|
|
27
|
+
root.setAttribute('contenteditable', 'true');
|
|
28
|
+
const inline = document.createElement('span');
|
|
29
|
+
inline.setAttribute('data-bc-sid', 't1');
|
|
30
|
+
const textNode = document.createTextNode(initialText);
|
|
31
|
+
inline.appendChild(textNode);
|
|
32
|
+
root.appendChild(inline);
|
|
33
|
+
document.body.appendChild(root);
|
|
34
|
+
return { root, inline, textNode };
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function createInputHandler() {
|
|
38
|
+
const executeCommand = vi.fn(async () => true);
|
|
39
|
+
const emit = vi.fn();
|
|
40
|
+
const dataStore = {
|
|
41
|
+
getNode: (sid: string) => (sid === 't1' ? { stype: 'inline-text', text: 'hello' } : null),
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
const selectionHandler = createSelectionHandler();
|
|
45
|
+
const viewStateRef = {
|
|
46
|
+
current: {
|
|
47
|
+
isModelDrivenChange: false,
|
|
48
|
+
isRendering: false,
|
|
49
|
+
isComposing: false,
|
|
50
|
+
compositionWindowUntil: 0,
|
|
51
|
+
skipNextRenderFromMO: false,
|
|
52
|
+
skipApplyModelSelectionToDOM: false,
|
|
53
|
+
},
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
const editor = {
|
|
57
|
+
dataStore,
|
|
58
|
+
executeCommand,
|
|
59
|
+
emit,
|
|
60
|
+
updateSelection: vi.fn(),
|
|
61
|
+
on: vi.fn(),
|
|
62
|
+
off: vi.fn(),
|
|
63
|
+
getDocumentProxy: () => ({ stype: 'document' }),
|
|
64
|
+
keybindings: { resolve: vi.fn(() => []) },
|
|
65
|
+
};
|
|
66
|
+
|
|
67
|
+
const inputHandler = new ReactInputHandler(editor as any, selectionHandler as any, viewStateRef as any);
|
|
68
|
+
|
|
69
|
+
return { inputHandler, executeCommand, emit, editor, viewStateRef, selectionHandler };
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function createCharacterMutation(target: Node) {
|
|
73
|
+
return [{ type: 'characterData', target } as unknown as MutationRecord];
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
describe('ReactInputHandler IME/Composition stability', () => {
|
|
77
|
+
afterEach(() => {
|
|
78
|
+
vi.clearAllMocks();
|
|
79
|
+
document.body.innerHTML = '';
|
|
80
|
+
const sel = window.getSelection();
|
|
81
|
+
sel?.removeAllRanges();
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
it('skips C1 DOM sync while composing, and re-syncs after composition window', async () => {
|
|
85
|
+
const { root, textNode } = createDomTextFixture('hello');
|
|
86
|
+
const { inputHandler, executeCommand } = createInputHandler();
|
|
87
|
+
|
|
88
|
+
textNode.textContent = 'hella';
|
|
89
|
+
await inputHandler.handleDomMutations(createCharacterMutation(textNode));
|
|
90
|
+
expect(executeCommand).toHaveBeenCalledTimes(1);
|
|
91
|
+
|
|
92
|
+
executeCommand.mockClear();
|
|
93
|
+
inputHandler.setComposing(true);
|
|
94
|
+
textNode.textContent = 'helli';
|
|
95
|
+
await inputHandler.handleDomMutations(createCharacterMutation(textNode));
|
|
96
|
+
expect(executeCommand).toHaveBeenCalledTimes(0);
|
|
97
|
+
|
|
98
|
+
inputHandler.setComposing(false);
|
|
99
|
+
textNode.textContent = 'hello';
|
|
100
|
+
await inputHandler.handleDomMutations(createCharacterMutation(textNode));
|
|
101
|
+
expect(executeCommand).toHaveBeenCalledTimes(0);
|
|
102
|
+
|
|
103
|
+
await new Promise((resolve) => setTimeout(resolve, 140));
|
|
104
|
+
textNode.textContent = 'hellu';
|
|
105
|
+
await inputHandler.handleDomMutations(createCharacterMutation(textNode));
|
|
106
|
+
expect(executeCommand).toHaveBeenCalledTimes(1);
|
|
107
|
+
|
|
108
|
+
document.body.removeChild(root);
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
it('defers syncFocusedTextNodeAfterComposition until post-composition window expiry and applies once', async () => {
|
|
112
|
+
const { root, inline, textNode } = createDomTextFixture('hello');
|
|
113
|
+
const { inputHandler, executeCommand } = createInputHandler();
|
|
114
|
+
|
|
115
|
+
const sel = window.getSelection();
|
|
116
|
+
const range = document.createRange();
|
|
117
|
+
range.setStart(textNode, 0);
|
|
118
|
+
range.setEnd(textNode, 0);
|
|
119
|
+
sel?.removeAllRanges();
|
|
120
|
+
sel?.addRange(range);
|
|
121
|
+
|
|
122
|
+
textNode.textContent = 'world';
|
|
123
|
+
inputHandler.setComposing(true);
|
|
124
|
+
inputHandler.setComposing(false);
|
|
125
|
+
|
|
126
|
+
// First attempt should defer while composition window is open.
|
|
127
|
+
await inputHandler.syncFocusedTextNodeAfterComposition();
|
|
128
|
+
expect(executeCommand).toHaveBeenCalledTimes(0);
|
|
129
|
+
|
|
130
|
+
await new Promise((resolve) => setTimeout(resolve, 140));
|
|
131
|
+
await inputHandler.syncFocusedTextNodeAfterComposition();
|
|
132
|
+
expect(executeCommand).toHaveBeenCalledTimes(1);
|
|
133
|
+
|
|
134
|
+
// Additional retries must not duplicate (composition generation changed or scheduling cleared)
|
|
135
|
+
await inputHandler.syncFocusedTextNodeAfterComposition();
|
|
136
|
+
expect(executeCommand).toHaveBeenCalledTimes(1);
|
|
137
|
+
|
|
138
|
+
expect(executeCommand).toHaveBeenLastCalledWith('replaceText', {
|
|
139
|
+
range: {
|
|
140
|
+
type: 'range',
|
|
141
|
+
startNodeId: 't1',
|
|
142
|
+
startOffset: 0,
|
|
143
|
+
endNodeId: 't1',
|
|
144
|
+
endOffset: 5,
|
|
145
|
+
},
|
|
146
|
+
text: 'world',
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
inline.remove();
|
|
150
|
+
document.body.removeChild(root);
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
it('supports consecutive composition cycles and keeps sync to one per cycle', async () => {
|
|
154
|
+
const { root, inline, textNode } = createDomTextFixture('hello');
|
|
155
|
+
const { inputHandler, executeCommand } = createInputHandler();
|
|
156
|
+
|
|
157
|
+
const sel = window.getSelection();
|
|
158
|
+
const range = document.createRange();
|
|
159
|
+
range.setStart(textNode, 0);
|
|
160
|
+
range.setEnd(textNode, 0);
|
|
161
|
+
sel?.removeAllRanges();
|
|
162
|
+
sel?.addRange(range);
|
|
163
|
+
|
|
164
|
+
textNode.textContent = 'first';
|
|
165
|
+
inputHandler.setComposing(true);
|
|
166
|
+
inputHandler.setComposing(false);
|
|
167
|
+
await new Promise((resolve) => setTimeout(resolve, 140));
|
|
168
|
+
await inputHandler.syncFocusedTextNodeAfterComposition();
|
|
169
|
+
expect(executeCommand).toHaveBeenCalledTimes(1);
|
|
170
|
+
|
|
171
|
+
textNode.textContent = 'second';
|
|
172
|
+
inputHandler.setComposing(true);
|
|
173
|
+
inputHandler.setComposing(false);
|
|
174
|
+
await new Promise((resolve) => setTimeout(resolve, 140));
|
|
175
|
+
await inputHandler.syncFocusedTextNodeAfterComposition();
|
|
176
|
+
expect(executeCommand).toHaveBeenCalledTimes(2);
|
|
177
|
+
|
|
178
|
+
await inputHandler.syncFocusedTextNodeAfterComposition();
|
|
179
|
+
expect(executeCommand).toHaveBeenCalledTimes(2);
|
|
180
|
+
|
|
181
|
+
inline.remove();
|
|
182
|
+
document.body.removeChild(root);
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
it('beforeInput isComposing 값으로 조합 상태 진입/종료를 추적해야 함', async () => {
|
|
186
|
+
const { root, inline, textNode } = createDomTextFixture('hello');
|
|
187
|
+
const { inputHandler, executeCommand } = createInputHandler();
|
|
188
|
+
|
|
189
|
+
const beginComposition = {
|
|
190
|
+
inputType: 'insertText',
|
|
191
|
+
isComposing: true,
|
|
192
|
+
data: '한',
|
|
193
|
+
preventDefault: vi.fn(),
|
|
194
|
+
} as unknown as InputEvent;
|
|
195
|
+
|
|
196
|
+
const endComposition = {
|
|
197
|
+
inputType: 'insertText',
|
|
198
|
+
isComposing: false,
|
|
199
|
+
data: '안',
|
|
200
|
+
preventDefault: vi.fn(),
|
|
201
|
+
} as unknown as InputEvent;
|
|
202
|
+
|
|
203
|
+
const inlineSelection = window.getSelection();
|
|
204
|
+
const range = document.createRange();
|
|
205
|
+
range.setStart(textNode, 0);
|
|
206
|
+
range.setEnd(textNode, 0);
|
|
207
|
+
inlineSelection?.removeAllRanges();
|
|
208
|
+
inlineSelection?.addRange(range);
|
|
209
|
+
|
|
210
|
+
textNode.textContent = 'hella';
|
|
211
|
+
inputHandler.handleBeforeInput(beginComposition);
|
|
212
|
+
await inputHandler.handleDomMutations(createCharacterMutation(textNode));
|
|
213
|
+
expect(executeCommand).toHaveBeenCalledTimes(0);
|
|
214
|
+
|
|
215
|
+
inputHandler.handleBeforeInput(endComposition);
|
|
216
|
+
inputHandler.handleInput({
|
|
217
|
+
isComposing: false,
|
|
218
|
+
} as unknown as InputEvent);
|
|
219
|
+
|
|
220
|
+
await new Promise((resolve) => setTimeout(resolve, 140));
|
|
221
|
+
await inputHandler.syncFocusedTextNodeAfterComposition();
|
|
222
|
+
expect(executeCommand).toHaveBeenCalledTimes(1);
|
|
223
|
+
|
|
224
|
+
inline.remove();
|
|
225
|
+
document.body.removeChild(root);
|
|
226
|
+
});
|
|
227
|
+
|
|
228
|
+
it('composition에서 inline-text 밖이면 beforeinput에서 기본 동작을 막아야 함', () => {
|
|
229
|
+
const { inputHandler, selectionHandler } = createInputHandler();
|
|
230
|
+
(selectionHandler as any).isSelectionInsideEditableText = vi.fn(() => false);
|
|
231
|
+
|
|
232
|
+
const preventDefault = vi.fn();
|
|
233
|
+
const beforeInputEvent = {
|
|
234
|
+
inputType: 'insertText',
|
|
235
|
+
isComposing: true,
|
|
236
|
+
data: '한',
|
|
237
|
+
preventDefault,
|
|
238
|
+
} as unknown as InputEvent;
|
|
239
|
+
|
|
240
|
+
inputHandler.handleBeforeInput(beforeInputEvent);
|
|
241
|
+
expect((selectionHandler as any).isSelectionInsideEditableText).toHaveBeenCalledTimes(1);
|
|
242
|
+
expect(preventDefault).toHaveBeenCalled();
|
|
243
|
+
});
|
|
244
|
+
|
|
245
|
+
it('sets post-composition window on keydown 229 and blocks immediate processing through keydown path', () => {
|
|
246
|
+
const { inputHandler, viewStateRef, executeCommand } = createInputHandler();
|
|
247
|
+
|
|
248
|
+
const keydownEvent = Object.create(null) as KeyboardEvent;
|
|
249
|
+
Object.defineProperty(keydownEvent, 'keyCode', { value: 229 });
|
|
250
|
+
Object.defineProperty(keydownEvent, 'key', { value: 'Process' });
|
|
251
|
+
Object.defineProperty(keydownEvent, 'ctrlKey', { value: false });
|
|
252
|
+
Object.defineProperty(keydownEvent, 'metaKey', { value: false });
|
|
253
|
+
Object.defineProperty(keydownEvent, 'altKey', { value: false });
|
|
254
|
+
Object.defineProperty(keydownEvent, 'shiftKey', { value: false });
|
|
255
|
+
const before = Date.now();
|
|
256
|
+
|
|
257
|
+
inputHandler.handleKeydown(keydownEvent);
|
|
258
|
+
|
|
259
|
+
expect(viewStateRef.current.compositionWindowUntil).toBeGreaterThan(before);
|
|
260
|
+
expect(executeCommand).not.toHaveBeenCalled();
|
|
261
|
+
});
|
|
262
|
+
|
|
263
|
+
it('sets post-composition window on keydown 229 even without key fields', () => {
|
|
264
|
+
const { inputHandler, viewStateRef, executeCommand } = createInputHandler();
|
|
265
|
+
|
|
266
|
+
const keydownEvent = {
|
|
267
|
+
metaKey: false,
|
|
268
|
+
keyCode: 229,
|
|
269
|
+
key: 'Process',
|
|
270
|
+
} as unknown as KeyboardEvent;
|
|
271
|
+
|
|
272
|
+
inputHandler.handleKeydown(keydownEvent);
|
|
273
|
+
const before = Date.now();
|
|
274
|
+
expect(viewStateRef.current.compositionWindowUntil).toBeGreaterThan(Date.now());
|
|
275
|
+
expect(viewStateRef.current.compositionWindowUntil).toBeGreaterThan(before);
|
|
276
|
+
expect(executeCommand).not.toHaveBeenCalled();
|
|
277
|
+
});
|
|
278
|
+
|
|
279
|
+
it('prevents paste handling during IME phase', async () => {
|
|
280
|
+
const { root, textNode } = createDomTextFixture('hello');
|
|
281
|
+
const { inputHandler, executeCommand } = createInputHandler();
|
|
282
|
+
|
|
283
|
+
const preventDefault = vi.fn();
|
|
284
|
+
const getData = vi.fn(() => 'world');
|
|
285
|
+
const pasteEvent = {
|
|
286
|
+
clipboardData: { getData },
|
|
287
|
+
preventDefault,
|
|
288
|
+
} as unknown as ClipboardEvent;
|
|
289
|
+
|
|
290
|
+
inputHandler.setComposing(true);
|
|
291
|
+
inputHandler.handlePaste(pasteEvent);
|
|
292
|
+
expect(preventDefault).not.toHaveBeenCalled();
|
|
293
|
+
expect(executeCommand).not.toHaveBeenCalled();
|
|
294
|
+
|
|
295
|
+
inputHandler.setComposing(false);
|
|
296
|
+
// Wait until composition window expires and paste can be applied.
|
|
297
|
+
await new Promise((resolve) => setTimeout(resolve, 140));
|
|
298
|
+
|
|
299
|
+
// ensure selection exists; insertTextAtSelection uses selection conversion via selectionHandler mock
|
|
300
|
+
const sel = window.getSelection();
|
|
301
|
+
const range = document.createRange();
|
|
302
|
+
range.setStart(textNode, 1);
|
|
303
|
+
range.setEnd(textNode, 1);
|
|
304
|
+
sel?.removeAllRanges();
|
|
305
|
+
sel?.addRange(range);
|
|
306
|
+
|
|
307
|
+
inputHandler.handlePaste(pasteEvent);
|
|
308
|
+
expect(getData).toHaveBeenCalledWith('text/plain');
|
|
309
|
+
expect(executeCommand).toHaveBeenCalledTimes(1);
|
|
310
|
+
|
|
311
|
+
document.body.removeChild(root);
|
|
312
|
+
});
|
|
313
|
+
|
|
314
|
+
it('ignore composition path in beforeinput insertFromPaste and skip command if IME active', () => {
|
|
315
|
+
const { inputHandler, executeCommand } = createInputHandler();
|
|
316
|
+
|
|
317
|
+
const inputEvent = {
|
|
318
|
+
inputType: 'insertFromPaste',
|
|
319
|
+
isComposing: true,
|
|
320
|
+
data: 'x',
|
|
321
|
+
getTargetRanges: vi.fn(),
|
|
322
|
+
preventDefault: vi.fn(),
|
|
323
|
+
} as unknown as InputEvent;
|
|
324
|
+
|
|
325
|
+
inputHandler.setComposing(false);
|
|
326
|
+
inputHandler.handleBeforeInput(inputEvent);
|
|
327
|
+
expect(executeCommand).not.toHaveBeenCalled();
|
|
328
|
+
expect((inputEvent.getTargetRanges as any)).not.toHaveBeenCalled();
|
|
329
|
+
});
|
|
330
|
+
});
|
|
@@ -83,4 +83,206 @@ describe('ReactSelectionHandler', () => {
|
|
|
83
83
|
|
|
84
84
|
document.body.removeChild(root);
|
|
85
85
|
});
|
|
86
|
+
|
|
87
|
+
it('convertDOMSelectionToModel ignores decorator text when mapping offsets', () => {
|
|
88
|
+
const root = document.createElement('div');
|
|
89
|
+
root.setAttribute('contenteditable', 'true');
|
|
90
|
+
const inline = document.createElement('span');
|
|
91
|
+
inline.setAttribute('data-bc-sid', 't1');
|
|
92
|
+
inline.setAttribute('data-text-container', 'true');
|
|
93
|
+
|
|
94
|
+
const beforeDecor = document.createElement('span');
|
|
95
|
+
beforeDecor.setAttribute('data-bc-decorator-sid', 'dec');
|
|
96
|
+
beforeDecor.textContent = 'XX';
|
|
97
|
+
inline.appendChild(beforeDecor);
|
|
98
|
+
|
|
99
|
+
const textA = document.createTextNode('ab');
|
|
100
|
+
const textB = document.createTextNode('cd');
|
|
101
|
+
inline.appendChild(textA);
|
|
102
|
+
inline.appendChild(document.createElement('span')); // wrapper edge
|
|
103
|
+
inline.appendChild(textB);
|
|
104
|
+
root.appendChild(inline);
|
|
105
|
+
document.body.appendChild(root);
|
|
106
|
+
|
|
107
|
+
const editor = createMockEditor((id) => (id === 't1' ? { stype: 'inline-text', text: 'abcd' } : null));
|
|
108
|
+
const handler = new ReactSelectionHandler(editor, () => root);
|
|
109
|
+
|
|
110
|
+
const range = document.createRange();
|
|
111
|
+
range.setStart(textB, 0);
|
|
112
|
+
range.setEnd(textB, 0);
|
|
113
|
+
const sel = window.getSelection();
|
|
114
|
+
sel?.removeAllRanges();
|
|
115
|
+
sel?.addRange(range);
|
|
116
|
+
|
|
117
|
+
const modelSelection = handler.convertDOMSelectionToModel(sel!);
|
|
118
|
+
expect(modelSelection).toMatchObject({
|
|
119
|
+
type: 'range',
|
|
120
|
+
startNodeId: 't1',
|
|
121
|
+
startOffset: 2,
|
|
122
|
+
endNodeId: 't1',
|
|
123
|
+
endOffset: 2,
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
document.body.removeChild(root);
|
|
127
|
+
sel?.removeAllRanges();
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
it('convertModelSelectionToDOM repositions collapsed selection to the corresponding DOM boundary', () => {
|
|
131
|
+
const root = document.createElement('div');
|
|
132
|
+
root.setAttribute('contenteditable', 'true');
|
|
133
|
+
const inline = document.createElement('span');
|
|
134
|
+
inline.setAttribute('data-bc-sid', 't1');
|
|
135
|
+
inline.setAttribute('data-text-container', 'true');
|
|
136
|
+
|
|
137
|
+
const t1 = document.createTextNode('ab');
|
|
138
|
+
const dec = document.createElement('span');
|
|
139
|
+
dec.setAttribute('data-bc-decorator-sid', 'dec');
|
|
140
|
+
dec.textContent = 'D';
|
|
141
|
+
const t2 = document.createTextNode('cd');
|
|
142
|
+
inline.appendChild(t1);
|
|
143
|
+
inline.appendChild(dec);
|
|
144
|
+
inline.appendChild(t2);
|
|
145
|
+
root.appendChild(inline);
|
|
146
|
+
document.body.appendChild(root);
|
|
147
|
+
|
|
148
|
+
const editor = createMockEditor((id) => (id === 't1' ? { stype: 'inline-text', text: 'abcd' } : null));
|
|
149
|
+
const handler = new ReactSelectionHandler(editor, () => root);
|
|
150
|
+
handler.convertModelSelectionToDOM({
|
|
151
|
+
type: 'range',
|
|
152
|
+
startNodeId: 't1',
|
|
153
|
+
startOffset: 2,
|
|
154
|
+
endNodeId: 't1',
|
|
155
|
+
endOffset: 2,
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
const sel = window.getSelection();
|
|
159
|
+
expect(sel?.rangeCount).toBe(1);
|
|
160
|
+
const r = sel?.getRangeAt(0);
|
|
161
|
+
expect(r?.startContainer).toBe(t2);
|
|
162
|
+
expect(r?.startOffset).toBe(0);
|
|
163
|
+
expect(r?.endContainer).toBe(t2);
|
|
164
|
+
expect(r?.endOffset).toBe(0);
|
|
165
|
+
|
|
166
|
+
document.body.removeChild(root);
|
|
167
|
+
sel?.removeAllRanges();
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
it('convertModelSelectionToDOM maps model end offset to final text node boundary', () => {
|
|
171
|
+
const root = document.createElement('div');
|
|
172
|
+
root.setAttribute('contenteditable', 'true');
|
|
173
|
+
const inline = document.createElement('span');
|
|
174
|
+
inline.setAttribute('data-bc-sid', 't1');
|
|
175
|
+
inline.setAttribute('data-text-container', 'true');
|
|
176
|
+
|
|
177
|
+
const t1 = document.createTextNode('ab');
|
|
178
|
+
const dec = document.createElement('span');
|
|
179
|
+
dec.setAttribute('data-bc-decorator-sid', 'dec');
|
|
180
|
+
dec.textContent = 'D';
|
|
181
|
+
const t2 = document.createTextNode('cd');
|
|
182
|
+
inline.appendChild(t1);
|
|
183
|
+
inline.appendChild(dec);
|
|
184
|
+
inline.appendChild(t2);
|
|
185
|
+
root.appendChild(inline);
|
|
186
|
+
document.body.appendChild(root);
|
|
187
|
+
|
|
188
|
+
const editor = createMockEditor((id) => (id === 't1' ? { stype: 'inline-text', text: 'abcd' } : null));
|
|
189
|
+
const handler = new ReactSelectionHandler(editor, () => root);
|
|
190
|
+
handler.convertModelSelectionToDOM({
|
|
191
|
+
type: 'range',
|
|
192
|
+
startNodeId: 't1',
|
|
193
|
+
startOffset: 4,
|
|
194
|
+
endNodeId: 't1',
|
|
195
|
+
endOffset: 4,
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
const sel = window.getSelection();
|
|
199
|
+
expect(sel?.rangeCount).toBe(1);
|
|
200
|
+
const r = sel?.getRangeAt(0);
|
|
201
|
+
expect(r?.startContainer).toBe(t2);
|
|
202
|
+
expect(r?.startOffset).toBe(2);
|
|
203
|
+
expect(r?.endContainer).toBe(t2);
|
|
204
|
+
expect(r?.endOffset).toBe(2);
|
|
205
|
+
|
|
206
|
+
document.body.removeChild(root);
|
|
207
|
+
sel?.removeAllRanges();
|
|
208
|
+
});
|
|
209
|
+
|
|
210
|
+
it('convertModelSelectionToDOM는 contentEditable 루트 내에서 중복 data-bc-sid를 구분해야 함', () => {
|
|
211
|
+
const otherRoot = document.createElement('div');
|
|
212
|
+
const root = document.createElement('div');
|
|
213
|
+
root.setAttribute('contenteditable', 'true');
|
|
214
|
+
|
|
215
|
+
const targetA = document.createElement('span');
|
|
216
|
+
targetA.setAttribute('data-bc-sid', 'shared-node');
|
|
217
|
+
targetA.setAttribute('data-text-container', 'true');
|
|
218
|
+
targetA.textContent = 'A';
|
|
219
|
+
|
|
220
|
+
const targetB = document.createElement('span');
|
|
221
|
+
targetB.setAttribute('data-bc-sid', 'shared-node');
|
|
222
|
+
targetB.setAttribute('data-text-container', 'true');
|
|
223
|
+
targetB.textContent = 'B';
|
|
224
|
+
|
|
225
|
+
otherRoot.appendChild(targetA);
|
|
226
|
+
root.appendChild(targetB);
|
|
227
|
+
document.body.appendChild(otherRoot);
|
|
228
|
+
document.body.appendChild(root);
|
|
229
|
+
|
|
230
|
+
const editor = createMockEditor((id) => {
|
|
231
|
+
if (id === 'shared-node') {
|
|
232
|
+
return { stype: 'inline-text', text: 'B' };
|
|
233
|
+
}
|
|
234
|
+
return null;
|
|
235
|
+
});
|
|
236
|
+
const handler = new ReactSelectionHandler(editor, () => root);
|
|
237
|
+
|
|
238
|
+
handler.convertModelSelectionToDOM({
|
|
239
|
+
type: 'range',
|
|
240
|
+
startNodeId: 'shared-node',
|
|
241
|
+
startOffset: 0,
|
|
242
|
+
endNodeId: 'shared-node',
|
|
243
|
+
endOffset: 1,
|
|
244
|
+
});
|
|
245
|
+
|
|
246
|
+
expect(window.getSelection()?.toString()).toBe('B');
|
|
247
|
+
|
|
248
|
+
document.body.removeChild(otherRoot);
|
|
249
|
+
document.body.removeChild(root);
|
|
250
|
+
window.getSelection()?.removeAllRanges();
|
|
251
|
+
});
|
|
252
|
+
|
|
253
|
+
it('convertModelSelectionToDOM는 node 선택일 때 해당 data-bc-sid 컨테이너 전체를 선택해야 함', () => {
|
|
254
|
+
const root = document.createElement('div');
|
|
255
|
+
root.setAttribute('contenteditable', 'true');
|
|
256
|
+
const nodeElement = document.createElement('span');
|
|
257
|
+
nodeElement.setAttribute('data-bc-sid', 'node-1');
|
|
258
|
+
nodeElement.setAttribute('data-text-container', 'true');
|
|
259
|
+
nodeElement.textContent = 'node selection';
|
|
260
|
+
root.appendChild(nodeElement);
|
|
261
|
+
document.body.appendChild(root);
|
|
262
|
+
|
|
263
|
+
const editor = createMockEditor((id) => {
|
|
264
|
+
if (id === 'node-1') {
|
|
265
|
+
return { stype: 'inline-text', text: 'node selection' };
|
|
266
|
+
}
|
|
267
|
+
return null;
|
|
268
|
+
});
|
|
269
|
+
const handler = new ReactSelectionHandler(editor, () => root);
|
|
270
|
+
|
|
271
|
+
handler.convertModelSelectionToDOM({
|
|
272
|
+
type: 'node',
|
|
273
|
+
nodeId: 'node-1',
|
|
274
|
+
startNodeId: 'node-1',
|
|
275
|
+
startOffset: 0,
|
|
276
|
+
endNodeId: 'node-1',
|
|
277
|
+
endOffset: 4
|
|
278
|
+
});
|
|
279
|
+
|
|
280
|
+
const selection = window.getSelection();
|
|
281
|
+
expect(selection).not.toBeNull();
|
|
282
|
+
expect(selection!.rangeCount).toBe(1);
|
|
283
|
+
expect(selection!.toString()).toBe('node selection');
|
|
284
|
+
|
|
285
|
+
document.body.removeChild(root);
|
|
286
|
+
selection?.removeAllRanges();
|
|
287
|
+
});
|
|
86
288
|
});
|