@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.
@@ -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) => el.hasAttribute('data-bc-decorator'),
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 startElementRaw = document.querySelector(`[data-bc-sid="${startNodeId}"]`);
363
- const endElementRaw = document.querySelector(`[data-bc-sid="${endNodeId}"]`);
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 element = document.querySelector(`[data-bc-sid="${nodeSelection.nodeId}"]`);
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
- el.hasAttribute('data-decorator-sid') ||
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 runIndex = binarySearchRun(runs.runs, modelOffset);
441
- if (runIndex === -1) return null;
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;
@@ -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
  });