@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
package/src/selection-handler.ts
CHANGED
|
@@ -19,6 +19,13 @@ export class ReactSelectionHandler {
|
|
|
19
19
|
private editor: Editor;
|
|
20
20
|
private getContentEditableElement: () => HTMLElement | null;
|
|
21
21
|
private _isProgrammaticChange = false;
|
|
22
|
+
private _getScopeRoot(): ParentNode {
|
|
23
|
+
const contentEditableElement = this.getContentEditableElement();
|
|
24
|
+
if (contentEditableElement && contentEditableElement.querySelector) {
|
|
25
|
+
return contentEditableElement;
|
|
26
|
+
}
|
|
27
|
+
return document;
|
|
28
|
+
}
|
|
22
29
|
|
|
23
30
|
constructor(
|
|
24
31
|
editor: Editor,
|
|
@@ -241,7 +248,7 @@ export class ReactSelectionHandler {
|
|
|
241
248
|
private ensureRuns(containerEl: Element, containerId: string): ContainerRuns {
|
|
242
249
|
return buildTextRunIndex(containerEl, containerId, {
|
|
243
250
|
buildReverseMap: true,
|
|
244
|
-
excludePredicate: (el) =>
|
|
251
|
+
excludePredicate: (el) => this.isDecoratorElement(el),
|
|
245
252
|
});
|
|
246
253
|
}
|
|
247
254
|
|
|
@@ -359,8 +366,9 @@ export class ReactSelectionHandler {
|
|
|
359
366
|
}): void {
|
|
360
367
|
const { startNodeId, startOffset, endNodeId, endOffset } = rangeSelection;
|
|
361
368
|
|
|
362
|
-
const
|
|
363
|
-
const
|
|
369
|
+
const scopeRoot = this._getScopeRoot();
|
|
370
|
+
const startElementRaw = scopeRoot.querySelector(`[data-bc-sid="${startNodeId}"]`);
|
|
371
|
+
const endElementRaw = scopeRoot.querySelector(`[data-bc-sid="${endNodeId}"]`);
|
|
364
372
|
if (!startElementRaw || !endElementRaw) return;
|
|
365
373
|
|
|
366
374
|
const startElement = this.findBestContainer(startElementRaw);
|
|
@@ -395,7 +403,8 @@ export class ReactSelectionHandler {
|
|
|
395
403
|
}
|
|
396
404
|
|
|
397
405
|
private convertNodeSelectionToDOM(nodeSelection: { nodeId: string }): void {
|
|
398
|
-
const
|
|
406
|
+
const scopeRoot = this._getScopeRoot();
|
|
407
|
+
const element = scopeRoot.querySelector(`[data-bc-sid="${nodeSelection.nodeId}"]`);
|
|
399
408
|
if (!element) return;
|
|
400
409
|
|
|
401
410
|
const selection = window.getSelection();
|
|
@@ -413,9 +422,7 @@ export class ReactSelectionHandler {
|
|
|
413
422
|
return buildTextRunIndex(container, containerId ?? undefined, {
|
|
414
423
|
buildReverseMap: true,
|
|
415
424
|
excludePredicate: (el) =>
|
|
416
|
-
|
|
417
|
-
el.hasAttribute('data-bc-decorator') ||
|
|
418
|
-
el.hasAttribute('data-decorator-category'),
|
|
425
|
+
this.isDecoratorElement(el),
|
|
419
426
|
normalizeWhitespace: false,
|
|
420
427
|
});
|
|
421
428
|
} catch {
|
|
@@ -423,6 +430,15 @@ export class ReactSelectionHandler {
|
|
|
423
430
|
}
|
|
424
431
|
}
|
|
425
432
|
|
|
433
|
+
private isDecoratorElement(el: Element): boolean {
|
|
434
|
+
return (
|
|
435
|
+
el.hasAttribute('data-decorator-sid') ||
|
|
436
|
+
el.hasAttribute('data-bc-decorator-sid') ||
|
|
437
|
+
el.hasAttribute('data-bc-decorator') ||
|
|
438
|
+
el.hasAttribute('data-decorator-category')
|
|
439
|
+
);
|
|
440
|
+
}
|
|
441
|
+
|
|
426
442
|
private findDOMRangeFromModelOffset(
|
|
427
443
|
runs: ContainerRuns,
|
|
428
444
|
modelOffset: number
|
|
@@ -437,8 +453,26 @@ export class ReactSelectionHandler {
|
|
|
437
453
|
};
|
|
438
454
|
}
|
|
439
455
|
|
|
440
|
-
const
|
|
441
|
-
if (
|
|
456
|
+
const totalRuns = runs.runs.length;
|
|
457
|
+
if (totalRuns === 0) return null;
|
|
458
|
+
|
|
459
|
+
let runIndex = binarySearchRun(runs.runs, modelOffset);
|
|
460
|
+
if (runIndex === -1) {
|
|
461
|
+
let fallbackIndex = -1;
|
|
462
|
+
for (let i = 0; i < totalRuns; i += 1) {
|
|
463
|
+
const run = runs.runs[i];
|
|
464
|
+
if (modelOffset < run.start) {
|
|
465
|
+
fallbackIndex = i;
|
|
466
|
+
break;
|
|
467
|
+
}
|
|
468
|
+
if (modelOffset === run.end && i + 1 < totalRuns) {
|
|
469
|
+
fallbackIndex = i + 1;
|
|
470
|
+
break;
|
|
471
|
+
}
|
|
472
|
+
}
|
|
473
|
+
if (fallbackIndex === -1) return null;
|
|
474
|
+
runIndex = fallbackIndex;
|
|
475
|
+
}
|
|
442
476
|
|
|
443
477
|
const run = runs.runs[runIndex];
|
|
444
478
|
const localOffset = modelOffset - run.start;
|
package/test/EditorView.test.tsx
CHANGED
|
@@ -1,8 +1,9 @@
|
|
|
1
|
-
import { describe, it, expect } from 'vitest';
|
|
1
|
+
import { describe, it, expect, vi } from 'vitest';
|
|
2
2
|
import { render, screen, act } from '@testing-library/react';
|
|
3
3
|
import { createRef } from 'react';
|
|
4
4
|
import {
|
|
5
5
|
EditorView,
|
|
6
|
+
EditorViewContentLayer,
|
|
6
7
|
EditorViewLayer,
|
|
7
8
|
EditorViewContextProvider,
|
|
8
9
|
useEditorViewContext,
|
|
@@ -20,6 +21,25 @@ function mockEditor() {
|
|
|
20
21
|
} as any;
|
|
21
22
|
}
|
|
22
23
|
|
|
24
|
+
function createMockEditorWithEventBus() {
|
|
25
|
+
const listeners = new Map<string, Set<Function>>();
|
|
26
|
+
const editor = mockEditor();
|
|
27
|
+
|
|
28
|
+
return {
|
|
29
|
+
...editor,
|
|
30
|
+
on(event: string, callback: Function) {
|
|
31
|
+
if (!listeners.has(event)) listeners.set(event, new Set());
|
|
32
|
+
listeners.get(event)!.add(callback);
|
|
33
|
+
},
|
|
34
|
+
off(event: string, callback: Function) {
|
|
35
|
+
listeners.get(event)?.delete(callback);
|
|
36
|
+
},
|
|
37
|
+
emit(event: string, data?: unknown) {
|
|
38
|
+
listeners.get(event)?.forEach((handler) => handler(data));
|
|
39
|
+
},
|
|
40
|
+
} as any;
|
|
41
|
+
}
|
|
42
|
+
|
|
23
43
|
describe('EditorView', () => {
|
|
24
44
|
it('renders root div with data-editor-view="true" and position relative', () => {
|
|
25
45
|
const editor = mockEditor();
|
|
@@ -215,4 +235,270 @@ describe('EditorViewContext', () => {
|
|
|
215
235
|
expect(el.getAttribute('data-has-mutation-manager')).toBe('true');
|
|
216
236
|
expect(el.getAttribute('data-has-set-content-editable')).toBe('true');
|
|
217
237
|
});
|
|
238
|
+
|
|
239
|
+
it('EditorViewContentLayer applies model selection when skip flag is false', () => {
|
|
240
|
+
const editor = createMockEditorWithEventBus();
|
|
241
|
+
let capturedCtx: any = null;
|
|
242
|
+
|
|
243
|
+
function Capture() {
|
|
244
|
+
const ctx = useEditorViewContext();
|
|
245
|
+
capturedCtx = ctx;
|
|
246
|
+
return <span data-testid="capture" />;
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
render(
|
|
250
|
+
<EditorViewContextProvider editor={editor}>
|
|
251
|
+
<EditorViewContentLayer />
|
|
252
|
+
<Capture />
|
|
253
|
+
</EditorViewContextProvider>
|
|
254
|
+
);
|
|
255
|
+
|
|
256
|
+
const convertSpy = vi.spyOn(capturedCtx.selectionHandler, 'convertModelSelectionToDOM');
|
|
257
|
+
|
|
258
|
+
vi.useFakeTimers();
|
|
259
|
+
try {
|
|
260
|
+
editor.emit('editor:selection.model', {
|
|
261
|
+
type: 'range',
|
|
262
|
+
startNodeId: 't1',
|
|
263
|
+
startOffset: 0,
|
|
264
|
+
endNodeId: 't1',
|
|
265
|
+
endOffset: 0,
|
|
266
|
+
});
|
|
267
|
+
|
|
268
|
+
vi.advanceTimersByTime(32);
|
|
269
|
+
expect(convertSpy).toHaveBeenCalledTimes(1);
|
|
270
|
+
} finally {
|
|
271
|
+
vi.useRealTimers();
|
|
272
|
+
}
|
|
273
|
+
});
|
|
274
|
+
|
|
275
|
+
it('EditorViewContentLayer skips model selection when skip flag is true', () => {
|
|
276
|
+
const editor = createMockEditorWithEventBus();
|
|
277
|
+
let capturedCtx: any = null;
|
|
278
|
+
|
|
279
|
+
function Capture() {
|
|
280
|
+
const ctx = useEditorViewContext();
|
|
281
|
+
capturedCtx = ctx;
|
|
282
|
+
return <span data-testid="capture" />;
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
render(
|
|
286
|
+
<EditorViewContextProvider editor={editor}>
|
|
287
|
+
<EditorViewContentLayer />
|
|
288
|
+
<Capture />
|
|
289
|
+
</EditorViewContextProvider>
|
|
290
|
+
);
|
|
291
|
+
|
|
292
|
+
const convertSpy = vi.spyOn(capturedCtx.selectionHandler, 'convertModelSelectionToDOM');
|
|
293
|
+
capturedCtx.viewStateRef.current.skipApplyModelSelectionToDOM = true;
|
|
294
|
+
|
|
295
|
+
vi.useFakeTimers();
|
|
296
|
+
try {
|
|
297
|
+
editor.emit('editor:selection.model', {
|
|
298
|
+
type: 'range',
|
|
299
|
+
startNodeId: 't1',
|
|
300
|
+
startOffset: 0,
|
|
301
|
+
endNodeId: 't1',
|
|
302
|
+
endOffset: 0,
|
|
303
|
+
});
|
|
304
|
+
|
|
305
|
+
vi.advanceTimersByTime(32);
|
|
306
|
+
expect(convertSpy).toHaveBeenCalledTimes(0);
|
|
307
|
+
} finally {
|
|
308
|
+
vi.useRealTimers();
|
|
309
|
+
}
|
|
310
|
+
});
|
|
311
|
+
|
|
312
|
+
it('EditorViewContentLayer skips model selection when applySelectionToView is false', () => {
|
|
313
|
+
const editor = createMockEditorWithEventBus();
|
|
314
|
+
let capturedCtx: any = null;
|
|
315
|
+
|
|
316
|
+
function Capture() {
|
|
317
|
+
const ctx = useEditorViewContext();
|
|
318
|
+
capturedCtx = ctx;
|
|
319
|
+
return <span data-testid="capture" />;
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
render(
|
|
323
|
+
<EditorViewContextProvider editor={editor}>
|
|
324
|
+
<EditorViewContentLayer />
|
|
325
|
+
<Capture />
|
|
326
|
+
</EditorViewContextProvider>
|
|
327
|
+
);
|
|
328
|
+
|
|
329
|
+
const convertSpy = vi.spyOn(capturedCtx.selectionHandler, 'convertModelSelectionToDOM');
|
|
330
|
+
|
|
331
|
+
vi.useFakeTimers();
|
|
332
|
+
try {
|
|
333
|
+
editor.emit('editor:selection.model', {
|
|
334
|
+
selection: {
|
|
335
|
+
type: 'range',
|
|
336
|
+
startNodeId: 't3',
|
|
337
|
+
startOffset: 0,
|
|
338
|
+
endNodeId: 't3',
|
|
339
|
+
endOffset: 0,
|
|
340
|
+
},
|
|
341
|
+
applySelectionToView: false,
|
|
342
|
+
});
|
|
343
|
+
|
|
344
|
+
vi.advanceTimersByTime(32);
|
|
345
|
+
expect(convertSpy).toHaveBeenCalledTimes(0);
|
|
346
|
+
} finally {
|
|
347
|
+
vi.useRealTimers();
|
|
348
|
+
}
|
|
349
|
+
});
|
|
350
|
+
|
|
351
|
+
it('EditorViewContentLayer skips model selection when source is remote', () => {
|
|
352
|
+
const editor = createMockEditorWithEventBus();
|
|
353
|
+
let capturedCtx: any = null;
|
|
354
|
+
|
|
355
|
+
function Capture() {
|
|
356
|
+
const ctx = useEditorViewContext();
|
|
357
|
+
capturedCtx = ctx;
|
|
358
|
+
return <span data-testid="capture" />;
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
render(
|
|
362
|
+
<EditorViewContextProvider editor={editor}>
|
|
363
|
+
<EditorViewContentLayer />
|
|
364
|
+
<Capture />
|
|
365
|
+
</EditorViewContextProvider>
|
|
366
|
+
);
|
|
367
|
+
|
|
368
|
+
const convertSpy = vi.spyOn(capturedCtx.selectionHandler, 'convertModelSelectionToDOM');
|
|
369
|
+
|
|
370
|
+
vi.useFakeTimers();
|
|
371
|
+
try {
|
|
372
|
+
editor.emit('editor:selection.model', {
|
|
373
|
+
selection: {
|
|
374
|
+
type: 'range',
|
|
375
|
+
startNodeId: 't-remote',
|
|
376
|
+
startOffset: 0,
|
|
377
|
+
endNodeId: 't-remote',
|
|
378
|
+
endOffset: 0,
|
|
379
|
+
},
|
|
380
|
+
source: 'remote',
|
|
381
|
+
});
|
|
382
|
+
|
|
383
|
+
vi.advanceTimersByTime(32);
|
|
384
|
+
expect(convertSpy).toHaveBeenCalledTimes(0);
|
|
385
|
+
} finally {
|
|
386
|
+
vi.useRealTimers();
|
|
387
|
+
}
|
|
388
|
+
});
|
|
389
|
+
|
|
390
|
+
it('EditorViewContentLayer applies node selection when source is local', () => {
|
|
391
|
+
const editor = createMockEditorWithEventBus();
|
|
392
|
+
let capturedCtx: any = null;
|
|
393
|
+
|
|
394
|
+
function Capture() {
|
|
395
|
+
const ctx = useEditorViewContext();
|
|
396
|
+
capturedCtx = ctx;
|
|
397
|
+
return <span data-testid="capture" />;
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
render(
|
|
401
|
+
<EditorViewContextProvider editor={editor}>
|
|
402
|
+
<EditorViewContentLayer />
|
|
403
|
+
<Capture />
|
|
404
|
+
</EditorViewContextProvider>
|
|
405
|
+
);
|
|
406
|
+
|
|
407
|
+
const convertSpy = vi.spyOn(capturedCtx.selectionHandler, 'convertModelSelectionToDOM');
|
|
408
|
+
|
|
409
|
+
vi.useFakeTimers();
|
|
410
|
+
try {
|
|
411
|
+
editor.emit('editor:selection.model', {
|
|
412
|
+
type: 'node',
|
|
413
|
+
nodeId: 'node-local',
|
|
414
|
+
startNodeId: 'node-local',
|
|
415
|
+
startOffset: 0,
|
|
416
|
+
endNodeId: 'node-local',
|
|
417
|
+
endOffset: 3,
|
|
418
|
+
});
|
|
419
|
+
|
|
420
|
+
vi.advanceTimersByTime(32);
|
|
421
|
+
expect(convertSpy).toHaveBeenCalledTimes(1);
|
|
422
|
+
expect(convertSpy).toHaveBeenCalledWith(
|
|
423
|
+
expect.objectContaining({
|
|
424
|
+
type: 'node',
|
|
425
|
+
nodeId: 'node-local',
|
|
426
|
+
startNodeId: 'node-local',
|
|
427
|
+
startOffset: 0,
|
|
428
|
+
endNodeId: 'node-local',
|
|
429
|
+
endOffset: 3,
|
|
430
|
+
})
|
|
431
|
+
);
|
|
432
|
+
} finally {
|
|
433
|
+
vi.useRealTimers();
|
|
434
|
+
}
|
|
435
|
+
});
|
|
436
|
+
|
|
437
|
+
it('EditorViewContentLayer skips node selection when source is remote', () => {
|
|
438
|
+
const editor = createMockEditorWithEventBus();
|
|
439
|
+
let capturedCtx: any = null;
|
|
440
|
+
|
|
441
|
+
function Capture() {
|
|
442
|
+
const ctx = useEditorViewContext();
|
|
443
|
+
capturedCtx = ctx;
|
|
444
|
+
return <span data-testid="capture" />;
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
render(
|
|
448
|
+
<EditorViewContextProvider editor={editor}>
|
|
449
|
+
<EditorViewContentLayer />
|
|
450
|
+
<Capture />
|
|
451
|
+
</EditorViewContextProvider>
|
|
452
|
+
);
|
|
453
|
+
|
|
454
|
+
const convertSpy = vi.spyOn(capturedCtx.selectionHandler, 'convertModelSelectionToDOM');
|
|
455
|
+
|
|
456
|
+
vi.useFakeTimers();
|
|
457
|
+
try {
|
|
458
|
+
editor.emit('editor:selection.model', {
|
|
459
|
+
type: 'node',
|
|
460
|
+
nodeId: 'node-remote',
|
|
461
|
+
startNodeId: 'node-remote',
|
|
462
|
+
startOffset: 0,
|
|
463
|
+
endNodeId: 'node-remote',
|
|
464
|
+
endOffset: 2,
|
|
465
|
+
source: 'remote',
|
|
466
|
+
});
|
|
467
|
+
|
|
468
|
+
vi.advanceTimersByTime(32);
|
|
469
|
+
expect(convertSpy).toHaveBeenCalledTimes(0);
|
|
470
|
+
} finally {
|
|
471
|
+
vi.useRealTimers();
|
|
472
|
+
}
|
|
473
|
+
});
|
|
474
|
+
});
|
|
475
|
+
|
|
476
|
+
describe('EditorViewContentLayer composition 이벤트 비의존성', () => {
|
|
477
|
+
it('beforeinput 의 isComposing 정보만으로 입력이 처리되어야 함', () => {
|
|
478
|
+
const editor = mockEditor();
|
|
479
|
+
let capturedCtx: any = null;
|
|
480
|
+
|
|
481
|
+
function Capture() {
|
|
482
|
+
const ctx = useEditorViewContext();
|
|
483
|
+
capturedCtx = ctx;
|
|
484
|
+
return <span data-testid="capture" />;
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
render(
|
|
488
|
+
<EditorViewContextProvider editor={editor}>
|
|
489
|
+
<EditorViewContentLayer />
|
|
490
|
+
<Capture />
|
|
491
|
+
</EditorViewContextProvider>
|
|
492
|
+
);
|
|
493
|
+
|
|
494
|
+
const beforeInputHandler = vi.spyOn(capturedCtx.inputHandler, 'handleBeforeInput');
|
|
495
|
+
capturedCtx.inputHandler.handleBeforeInput({
|
|
496
|
+
inputType: 'insertText',
|
|
497
|
+
isComposing: true,
|
|
498
|
+
data: '가',
|
|
499
|
+
preventDefault: vi.fn(),
|
|
500
|
+
} as unknown as InputEvent);
|
|
501
|
+
|
|
502
|
+
expect(beforeInputHandler).toHaveBeenCalled();
|
|
503
|
+
});
|
|
218
504
|
});
|