• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

worktile / slate-angular / fb24f851-a421-4e5f-aa65-dcf1feef254b

17 Dec 2025 02:07AM UTC coverage: 36.837% (-0.5%) from 37.323%
fb24f851-a421-4e5f-aa65-dcf1feef254b

Pull #325

circleci

web-flow
Merge branch 'master' into xws/#WIK-19623
Pull Request #325: feat(virtual-scroll): #WIK-19623 support scrolling to specified node key

380 of 1240 branches covered (30.65%)

Branch coverage included in aggregate %.

1 of 38 new or added lines in 1 file covered. (2.63%)

410 existing lines in 1 file now uncovered.

1071 of 2699 relevant lines covered (39.68%)

24.13 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

21.66
/packages/src/components/editable/editable.component.ts
1
import {
2
    Component,
3
    OnInit,
4
    Input,
5
    HostBinding,
6
    Renderer2,
7
    ElementRef,
8
    ChangeDetectionStrategy,
9
    OnDestroy,
10
    ChangeDetectorRef,
11
    NgZone,
12
    Injector,
13
    forwardRef,
14
    OnChanges,
15
    SimpleChanges,
16
    AfterViewChecked,
17
    DoCheck,
18
    inject,
19
    ViewContainerRef
20
} from '@angular/core';
21
import { Text as SlateText, Element, Transforms, Editor, Range, Path, NodeEntry, Node, Selection } from 'slate';
22
import { direction } from 'direction';
23
import scrollIntoView from 'scroll-into-view-if-needed';
24
import { AngularEditor } from '../../plugins/angular-editor';
25
import {
26
    DOMElement,
27
    isDOMNode,
28
    DOMStaticRange,
29
    DOMRange,
30
    isDOMElement,
31
    isPlainTextOnlyPaste,
32
    DOMSelection,
33
    getDefaultView,
34
    EDITOR_TO_WINDOW,
35
    EDITOR_TO_ELEMENT,
36
    NODE_TO_ELEMENT,
37
    ELEMENT_TO_NODE,
38
    IS_FOCUSED,
39
    IS_READ_ONLY
40
} from 'slate-dom';
41
import { Subject } from 'rxjs';
42
import {
43
    IS_FIREFOX,
44
    IS_SAFARI,
45
    IS_CHROME,
46
    HAS_BEFORE_INPUT_SUPPORT,
47
    IS_ANDROID,
48
    VIRTUAL_SCROLL_DEFAULT_BLOCK_HEIGHT,
49
    SLATE_DEBUG_KEY
50
} from '../../utils/environment';
51
import Hotkeys from '../../utils/hotkeys';
52
import { BeforeInputEvent, extractBeforeInputEvent } from '../../custom-event/BeforeInputEventPlugin';
53
import { BEFORE_INPUT_EVENTS } from '../../custom-event/before-input-polyfill';
54
import { SlateErrorCode } from '../../types/error';
55
import { NG_VALUE_ACCESSOR } from '@angular/forms';
56
import { SlateChildrenContext, SlateViewContext } from '../../view/context';
57
import { ViewType } from '../../types/view';
58
import { HistoryEditor } from 'slate-history';
59
import {
60
    EDITOR_TO_VIRTUAL_SCROLL_SELECTION,
61
    ELEMENT_KEY_TO_HEIGHTS,
62
    ELEMENT_TO_COMPONENT,
63
    IS_ENABLED_VIRTUAL_SCROLL,
64
    isDecoratorRangeListEqual
65
} from '../../utils';
66
import { SlatePlaceholder } from '../../types/feature';
67
import { restoreDom } from '../../utils/restore-dom';
68
import { ListRender } from '../../view/render/list-render';
69
import { TRIPLE_CLICK, EDITOR_TO_ON_CHANGE } from 'slate-dom';
70
import { BaseElementComponent } from '../../view/base';
71
import { BaseElementFlavour } from '../../view/flavour/element';
72
import { SlateVirtualScrollConfig, SlateVirtualScrollToAnchorConfig, VirtualViewResult } from '../../types';
73
import { isKeyHotkey } from 'is-hotkey';
74
import { VirtualScrollDebugOverlay } from './debug';
75

76
// not correctly clipboardData on beforeinput
77
const forceOnDOMPaste = IS_SAFARI;
1✔
78

79
const isDebug = localStorage.getItem(SLATE_DEBUG_KEY) === 'true';
1✔
80

81
@Component({
82
    selector: 'slate-editable',
83
    host: {
84
        class: 'slate-editable-container',
85
        '[attr.contenteditable]': 'readonly ? undefined : true',
86
        '[attr.role]': `readonly ? undefined : 'textbox'`,
87
        '[attr.spellCheck]': `!hasBeforeInputSupport ? false : spellCheck`,
88
        '[attr.autoCorrect]': `!hasBeforeInputSupport ? 'false' : autoCorrect`,
89
        '[attr.autoCapitalize]': `!hasBeforeInputSupport ? 'false' : autoCapitalize`
90
    },
91
    template: '',
92
    changeDetection: ChangeDetectionStrategy.OnPush,
93
    providers: [
94
        {
95
            provide: NG_VALUE_ACCESSOR,
96
            useExisting: forwardRef(() => SlateEditable),
23✔
97
            multi: true
98
        }
99
    ],
100
    imports: []
101
})
102
export class SlateEditable implements OnInit, OnChanges, OnDestroy, AfterViewChecked, DoCheck {
1✔
103
    viewContext: SlateViewContext;
104
    context: SlateChildrenContext;
105

106
    private destroy$ = new Subject();
23✔
107

108
    isComposing = false;
23✔
109
    isDraggingInternally = false;
23✔
110
    isUpdatingSelection = false;
23✔
111
    latestElement = null as DOMElement | null;
23✔
112

113
    protected manualListeners: (() => void)[] = [];
23✔
114

115
    private initialized: boolean;
116

117
    private onTouchedCallback: () => void = () => {};
23✔
118

119
    private onChangeCallback: (_: any) => void = () => {};
23✔
120

121
    @Input() editor: AngularEditor;
122

123
    @Input() renderElement: (element: Element) => ViewType | null;
124

125
    @Input() renderLeaf: (text: SlateText) => ViewType | null;
126

127
    @Input() renderText: (text: SlateText) => ViewType | null;
128

129
    @Input() decorate: (entry: NodeEntry) => Range[] = () => [];
228✔
130

131
    @Input() placeholderDecorate: (editor: Editor) => SlatePlaceholder[];
132

133
    @Input() scrollSelectionIntoView: (editor: AngularEditor, domRange: DOMRange) => void = defaultScrollSelectionIntoView;
23✔
134

135
    @Input() isStrictDecorate: boolean = true;
23✔
136

137
    @Input() trackBy: (node: Element) => any = () => null;
206✔
138

139
    @Input() readonly = false;
23✔
140

141
    @Input() placeholder: string;
142

143
    @Input()
144
    set virtualScroll(config: SlateVirtualScrollConfig) {
UNCOV
145
        this.virtualScrollConfig = config;
×
UNCOV
146
        IS_ENABLED_VIRTUAL_SCROLL.set(this.editor, config.enabled);
×
UNCOV
147
        if (this.isEnabledVirtualScroll()) {
×
UNCOV
148
            this.tryUpdateVirtualViewport();
×
149
        }
150
    }
151

152
    @Input()
153
    set virtualScrollToAnchor(config: SlateVirtualScrollToAnchorConfig) {
NEW
UNCOV
154
        this.virtualToAnchorConfig = config;
×
NEW
UNCOV
155
        if (this.isEnabledVirtualScroll()) {
×
NEW
UNCOV
156
            this.tryAnchorScroll();
×
157
        }
158
    }
159

160
    //#region input event handler
161
    @Input() beforeInput: (event: Event) => void;
162
    @Input() blur: (event: Event) => void;
163
    @Input() click: (event: MouseEvent) => void;
164
    @Input() compositionEnd: (event: CompositionEvent) => void;
165
    @Input() compositionUpdate: (event: CompositionEvent) => void;
166
    @Input() compositionStart: (event: CompositionEvent) => void;
167
    @Input() copy: (event: ClipboardEvent) => void;
168
    @Input() cut: (event: ClipboardEvent) => void;
169
    @Input() dragOver: (event: DragEvent) => void;
170
    @Input() dragStart: (event: DragEvent) => void;
171
    @Input() dragEnd: (event: DragEvent) => void;
172
    @Input() drop: (event: DragEvent) => void;
173
    @Input() focus: (event: Event) => void;
174
    @Input() keydown: (event: KeyboardEvent) => void;
175
    @Input() paste: (event: ClipboardEvent) => void;
176
    //#endregion
177

178
    //#region DOM attr
179
    @Input() spellCheck = false;
23✔
180
    @Input() autoCorrect = false;
23✔
181
    @Input() autoCapitalize = false;
23✔
182

183
    @HostBinding('attr.data-slate-editor') dataSlateEditor = true;
23✔
184
    @HostBinding('attr.data-slate-node') dataSlateNode = 'value';
23✔
185
    @HostBinding('attr.data-gramm') dataGramm = false;
23✔
186

187
    get hasBeforeInputSupport() {
188
        return HAS_BEFORE_INPUT_SUPPORT;
456✔
189
    }
190
    //#endregion
191

192
    viewContainerRef = inject(ViewContainerRef);
23✔
193

194
    getOutletParent = () => {
23✔
195
        return this.elementRef.nativeElement;
43✔
196
    };
197

198
    getOutletElement = () => {
23✔
199
        if (this.virtualScrollInitialized) {
23!
UNCOV
200
            return this.virtualCenterOutlet;
×
201
        } else {
202
            return null;
23✔
203
        }
204
    };
205

206
    listRender: ListRender;
207

208
    private virtualScrollConfig: SlateVirtualScrollConfig = {
23✔
209
        enabled: false,
210
        scrollTop: 0,
211
        viewportHeight: 0
212
    };
213

214
    private inViewportChildren: Element[] = [];
23✔
215
    private inViewportIndics = new Set<number>();
23✔
216
    private keyHeightMap = new Map<string, number>();
23✔
217
    private tryUpdateVirtualViewportAnimId: number;
218
    private tryMeasureInViewportChildrenHeightsAnimId: number;
219
    private editorResizeObserver?: ResizeObserver;
220
    private virtualToAnchorConfig: SlateVirtualScrollToAnchorConfig | null = null;
23✔
221
    private lastAnchorElement?: Element;
222

223
    constructor(
224
        public elementRef: ElementRef,
23✔
225
        public renderer2: Renderer2,
23✔
226
        public cdr: ChangeDetectorRef,
23✔
227
        private ngZone: NgZone,
23✔
228
        private injector: Injector
23✔
229
    ) {}
230

231
    ngOnInit() {
232
        this.editor.injector = this.injector;
23✔
233
        this.editor.children = [];
23✔
234
        let window = getDefaultView(this.elementRef.nativeElement);
23✔
235
        EDITOR_TO_WINDOW.set(this.editor, window);
23✔
236
        EDITOR_TO_ELEMENT.set(this.editor, this.elementRef.nativeElement);
23✔
237
        NODE_TO_ELEMENT.set(this.editor, this.elementRef.nativeElement);
23✔
238
        ELEMENT_TO_NODE.set(this.elementRef.nativeElement, this.editor);
23✔
239
        IS_READ_ONLY.set(this.editor, this.readonly);
23✔
240
        ELEMENT_KEY_TO_HEIGHTS.set(this.editor, this.keyHeightMap);
23✔
241
        EDITOR_TO_ON_CHANGE.set(this.editor, () => {
23✔
242
            this.ngZone.run(() => {
13✔
243
                this.onChange();
13✔
244
            });
245
        });
246
        this.ngZone.runOutsideAngular(() => {
23✔
247
            this.initialize();
23✔
248
        });
249
        this.initializeViewContext();
23✔
250
        this.initializeContext();
23✔
251

252
        // add browser class
253
        let browserClass = IS_FIREFOX ? 'firefox' : IS_SAFARI ? 'safari' : '';
23!
254
        browserClass && this.elementRef.nativeElement.classList.add(browserClass);
23!
255
        this.initializeVirtualScroll();
23✔
256
        this.listRender = new ListRender(this.viewContext, this.viewContainerRef, this.getOutletParent, this.getOutletElement);
23✔
257
    }
258

259
    ngOnChanges(simpleChanges: SimpleChanges) {
260
        if (!this.initialized) {
30✔
261
            return;
23✔
262
        }
263
        const decorateChange = simpleChanges['decorate'];
7✔
264
        if (decorateChange) {
7✔
265
            this.forceRender();
2✔
266
        }
267
        const placeholderChange = simpleChanges['placeholder'];
7✔
268
        if (placeholderChange) {
7✔
269
            this.render();
1✔
270
        }
271
        const readonlyChange = simpleChanges['readonly'];
7✔
272
        if (readonlyChange) {
7!
UNCOV
273
            IS_READ_ONLY.set(this.editor, this.readonly);
×
UNCOV
274
            this.render();
×
275
            this.toSlateSelection();
×
276
        }
277
    }
278

279
    registerOnChange(fn: any) {
280
        this.onChangeCallback = fn;
23✔
281
    }
282
    registerOnTouched(fn: any) {
283
        this.onTouchedCallback = fn;
23✔
284
    }
285

286
    writeValue(value: Element[]) {
287
        if (value && value.length) {
49✔
288
            this.editor.children = value;
26✔
289
            this.initializeContext();
26✔
290
            if (this.isEnabledVirtualScroll()) {
26!
UNCOV
291
                const virtualView = this.calculateVirtualViewport();
×
UNCOV
292
                this.applyVirtualView(virtualView);
×
UNCOV
293
                const childrenForRender = virtualView.inViewportChildren;
×
UNCOV
294
                if (!this.listRender.initialized) {
×
UNCOV
295
                    this.listRender.initialize(childrenForRender, this.editor, this.context);
×
296
                } else {
UNCOV
297
                    this.listRender.update(childrenForRender, this.editor, this.context);
×
298
                }
UNCOV
299
                this.tryMeasureInViewportChildrenHeights();
×
300
            } else {
301
                if (!this.listRender.initialized) {
26✔
302
                    this.listRender.initialize(this.editor.children, this.editor, this.context);
23✔
303
                } else {
304
                    this.listRender.update(this.editor.children, this.editor, this.context);
3✔
305
                }
306
            }
307
            this.cdr.markForCheck();
26✔
308
        }
309
    }
310

311
    initialize() {
312
        this.initialized = true;
23✔
313
        const window = AngularEditor.getWindow(this.editor);
23✔
314
        this.addEventListener(
23✔
315
            'selectionchange',
316
            event => {
317
                this.toSlateSelection();
2✔
318
            },
319
            window.document
320
        );
321
        if (HAS_BEFORE_INPUT_SUPPORT) {
23✔
322
            this.addEventListener('beforeinput', this.onDOMBeforeInput.bind(this));
23✔
323
        }
324
        this.addEventListener('blur', this.onDOMBlur.bind(this));
23✔
325
        this.addEventListener('click', this.onDOMClick.bind(this));
23✔
326
        this.addEventListener('compositionend', this.onDOMCompositionEnd.bind(this));
23✔
327
        this.addEventListener('compositionupdate', this.onDOMCompositionUpdate.bind(this));
23✔
328
        this.addEventListener('compositionstart', this.onDOMCompositionStart.bind(this));
23✔
329
        this.addEventListener('copy', this.onDOMCopy.bind(this));
23✔
330
        this.addEventListener('cut', this.onDOMCut.bind(this));
23✔
331
        this.addEventListener('dragover', this.onDOMDragOver.bind(this));
23✔
332
        this.addEventListener('dragstart', this.onDOMDragStart.bind(this));
23✔
333
        this.addEventListener('dragend', this.onDOMDragEnd.bind(this));
23✔
334
        this.addEventListener('drop', this.onDOMDrop.bind(this));
23✔
335
        this.addEventListener('focus', this.onDOMFocus.bind(this));
23✔
336
        this.addEventListener('keydown', this.onDOMKeydown.bind(this));
23✔
337
        this.addEventListener('paste', this.onDOMPaste.bind(this));
23✔
338
        BEFORE_INPUT_EVENTS.forEach(event => {
23✔
339
            this.addEventListener(event.name, () => {});
115✔
340
        });
341
    }
342

343
    calculateVirtualScrollSelection(selection: Selection) {
UNCOV
344
        if (selection) {
×
UNCOV
345
            const indics = Array.from(this.inViewportIndics.values());
×
UNCOV
346
            if (indics.length > 0) {
×
UNCOV
347
                const currentVisibleRange: Range = {
×
348
                    anchor: Editor.start(this.editor, [indics[0]]),
349
                    focus: Editor.end(this.editor, [indics[indics.length - 1]])
350
                };
UNCOV
351
                const [start, end] = Range.edges(selection);
×
UNCOV
352
                const forwardSelection = { anchor: start, focus: end };
×
353
                const intersectedSelection = Range.intersection(forwardSelection, currentVisibleRange);
×
354
                EDITOR_TO_VIRTUAL_SCROLL_SELECTION.set(this.editor, intersectedSelection);
×
UNCOV
355
                if (!intersectedSelection || !Range.equals(intersectedSelection, forwardSelection)) {
×
UNCOV
356
                    if (isDebug) {
×
UNCOV
357
                        this.debugLog(
×
358
                            'log',
359
                            `selection is not in visible range, selection: ${JSON.stringify(
360
                                selection
361
                            )}, intersectedSelection: ${JSON.stringify(intersectedSelection)}`
362
                        );
363
                    }
UNCOV
364
                    return intersectedSelection;
×
365
                }
UNCOV
366
                return selection;
×
367
            }
368
        }
UNCOV
369
        EDITOR_TO_VIRTUAL_SCROLL_SELECTION.set(this.editor, null);
×
UNCOV
370
        return selection;
×
371
    }
372

373
    toNativeSelection() {
374
        try {
15✔
375
            let { selection } = this.editor;
15✔
376
            if (this.isEnabledVirtualScroll()) {
15!
UNCOV
377
                selection = this.calculateVirtualScrollSelection(selection);
×
378
            }
379
            const root = AngularEditor.findDocumentOrShadowRoot(this.editor);
15✔
380
            const { activeElement } = root;
15✔
381
            const domSelection = (root as Document).getSelection();
15✔
382

383
            if ((this.isComposing && !IS_ANDROID) || !domSelection || !AngularEditor.isFocused(this.editor)) {
15!
384
                return;
14✔
385
            }
386

387
            const hasDomSelection = domSelection.type !== 'None';
1✔
388

389
            // If the DOM selection is properly unset, we're done.
390
            if (!selection && !hasDomSelection) {
1!
UNCOV
391
                return;
×
392
            }
393

394
            // If the DOM selection is already correct, we're done.
395
            // verify that the dom selection is in the editor
396
            const editorElement = EDITOR_TO_ELEMENT.get(this.editor)!;
1✔
397
            let hasDomSelectionInEditor = false;
1✔
398
            if (editorElement.contains(domSelection.anchorNode) && editorElement.contains(domSelection.focusNode)) {
1✔
399
                hasDomSelectionInEditor = true;
1✔
400
            }
401

402
            // If the DOM selection is in the editor and the editor selection is already correct, we're done.
403
            if (hasDomSelection && hasDomSelectionInEditor && selection && hasStringTarget(domSelection)) {
1✔
404
                const rangeFromDOMSelection = AngularEditor.toSlateRange(this.editor, domSelection, {
1✔
405
                    exactMatch: false,
406
                    suppressThrow: true
407
                });
408
                if (rangeFromDOMSelection && Range.equals(rangeFromDOMSelection, selection)) {
1!
UNCOV
409
                    return;
×
410
                }
411
            }
412

413
            // prevent updating native selection when active element is void element
414
            if (isTargetInsideVoid(this.editor, activeElement)) {
1!
UNCOV
415
                return;
×
416
            }
417

418
            // when <Editable/> is being controlled through external value
419
            // then its children might just change - DOM responds to it on its own
420
            // but Slate's value is not being updated through any operation
421
            // and thus it doesn't transform selection on its own
422
            if (selection && !AngularEditor.hasRange(this.editor, selection)) {
1!
UNCOV
423
                this.editor.selection = AngularEditor.toSlateRange(this.editor, domSelection, { exactMatch: false, suppressThrow: false });
×
UNCOV
424
                return;
×
425
            }
426

427
            // Otherwise the DOM selection is out of sync, so update it.
428
            const el = AngularEditor.toDOMNode(this.editor, this.editor);
1✔
429
            this.isUpdatingSelection = true;
1✔
430

431
            const newDomRange = selection && AngularEditor.toDOMRange(this.editor, selection);
1✔
432

433
            if (newDomRange) {
1!
434
                // COMPAT: Since the DOM range has no concept of backwards/forwards
435
                // we need to check and do the right thing here.
436
                if (Range.isBackward(selection)) {
1!
437
                    // eslint-disable-next-line max-len
438
                    domSelection.setBaseAndExtent(
×
439
                        newDomRange.endContainer,
440
                        newDomRange.endOffset,
441
                        newDomRange.startContainer,
442
                        newDomRange.startOffset
443
                    );
444
                } else {
445
                    // eslint-disable-next-line max-len
446
                    domSelection.setBaseAndExtent(
1✔
447
                        newDomRange.startContainer,
448
                        newDomRange.startOffset,
449
                        newDomRange.endContainer,
450
                        newDomRange.endOffset
451
                    );
452
                }
453
            } else {
454
                domSelection.removeAllRanges();
×
455
            }
456

457
            setTimeout(() => {
1✔
458
                // handle scrolling in setTimeout because of
459
                // dom should not have updated immediately after listRender's updating
460
                newDomRange && this.scrollSelectionIntoView(this.editor, newDomRange);
1✔
461
                // COMPAT: In Firefox, it's not enough to create a range, you also need
462
                // to focus the contenteditable element too. (2016/11/16)
463
                if (newDomRange && IS_FIREFOX) {
1!
UNCOV
464
                    el.focus();
×
465
                }
466

467
                this.isUpdatingSelection = false;
1✔
468
            });
469
        } catch (error) {
UNCOV
470
            this.editor.onError({
×
471
                code: SlateErrorCode.ToNativeSelectionError,
472
                nativeError: error
473
            });
474
            this.isUpdatingSelection = false;
×
475
        }
476
    }
477

478
    onChange() {
479
        this.forceRender();
13✔
480
        this.onChangeCallback(this.editor.children);
13✔
481
    }
482

483
    ngAfterViewChecked() {}
484

485
    ngDoCheck() {}
486

487
    forceRender() {
488
        this.updateContext();
15✔
489
        if (this.isEnabledVirtualScroll()) {
15!
490
            this.updateListRenderAndRemeasureHeights();
×
491
        } else {
492
            this.listRender.update(this.editor.children, this.editor, this.context);
15✔
493
        }
494
        // repair collaborative editing when Chinese input is interrupted by other users' cursors
495
        // when the DOMElement where the selection is located is removed
496
        // the compositionupdate and compositionend events will no longer be fired
497
        // so isComposing needs to be corrected
498
        // need exec after this.cdr.detectChanges() to render HTML
499
        // need exec before this.toNativeSelection() to correct native selection
500
        if (this.isComposing) {
15!
501
            // Composition input text be not rendered when user composition input with selection is expanded
502
            // At this time, the following matching conditions are met, assign isComposing to false, and the status is wrong
503
            // this time condition is true and isComposing is assigned false
504
            // Therefore, need to wait for the composition input text to be rendered before performing condition matching
UNCOV
505
            setTimeout(() => {
×
506
                const textNode = Node.get(this.editor, this.editor.selection.anchor.path);
×
507
                const textDOMNode = AngularEditor.toDOMNode(this.editor, textNode);
×
UNCOV
508
                let textContent = '';
×
509
                // skip decorate text
UNCOV
510
                textDOMNode.querySelectorAll('[editable-text]').forEach(stringDOMNode => {
×
UNCOV
511
                    let text = stringDOMNode.textContent;
×
UNCOV
512
                    const zeroChar = '\uFEFF';
×
513
                    // remove zero with char
UNCOV
514
                    if (text.startsWith(zeroChar)) {
×
UNCOV
515
                        text = text.slice(1);
×
516
                    }
UNCOV
517
                    if (text.endsWith(zeroChar)) {
×
518
                        text = text.slice(0, text.length - 1);
×
519
                    }
UNCOV
520
                    textContent += text;
×
521
                });
UNCOV
522
                if (Node.string(textNode).endsWith(textContent)) {
×
UNCOV
523
                    this.isComposing = false;
×
524
                }
525
            }, 0);
526
        }
527
        this.toNativeSelection();
15✔
528
    }
529

530
    render() {
531
        const changed = this.updateContext();
2✔
532
        if (changed) {
2✔
533
            if (this.isEnabledVirtualScroll()) {
2!
534
                this.updateListRenderAndRemeasureHeights();
×
535
            } else {
536
                this.listRender.update(this.editor.children, this.editor, this.context);
2✔
537
            }
538
        }
539
    }
540

541
    updateListRenderAndRemeasureHeights() {
UNCOV
542
        const virtualView = this.calculateVirtualViewport();
×
UNCOV
543
        const oldInViewportChildren = this.inViewportChildren;
×
UNCOV
544
        this.applyVirtualView(virtualView);
×
UNCOV
545
        this.listRender.update(this.inViewportChildren, this.editor, this.context);
×
546
        // 新增或者修改的才需要重算,计算出这个结果
UNCOV
547
        const remeasureIndics = [];
×
UNCOV
548
        const newInViewportIndics = Array.from(this.inViewportIndics);
×
UNCOV
549
        this.inViewportChildren.forEach((child, index) => {
×
UNCOV
550
            if (oldInViewportChildren.indexOf(child) === -1) {
×
UNCOV
551
                remeasureIndics.push(newInViewportIndics[index]);
×
552
            }
553
        });
UNCOV
554
        if (isDebug && remeasureIndics.length > 0) {
×
UNCOV
555
            console.log('remeasure height by indics: ', remeasureIndics);
×
556
        }
UNCOV
557
        this.remeasureHeightByIndics(remeasureIndics);
×
558
    }
559

560
    updateContext() {
561
        const decorations = this.generateDecorations();
17✔
562
        if (
17✔
563
            this.context.selection !== this.editor.selection ||
46✔
564
            this.context.decorate !== this.decorate ||
565
            this.context.readonly !== this.readonly ||
566
            !isDecoratorRangeListEqual(this.context.decorations, decorations)
567
        ) {
568
            this.context = {
10✔
569
                parent: this.editor,
570
                selection: this.editor.selection,
571
                decorations: decorations,
572
                decorate: this.decorate,
573
                readonly: this.readonly
574
            };
575
            return true;
10✔
576
        }
577
        return false;
7✔
578
    }
579

580
    initializeContext() {
581
        this.context = {
49✔
582
            parent: this.editor,
583
            selection: this.editor.selection,
584
            decorations: this.generateDecorations(),
585
            decorate: this.decorate,
586
            readonly: this.readonly
587
        };
588
    }
589

590
    initializeViewContext() {
591
        this.viewContext = {
23✔
592
            editor: this.editor,
593
            renderElement: this.renderElement,
594
            renderLeaf: this.renderLeaf,
595
            renderText: this.renderText,
596
            trackBy: this.trackBy,
597
            isStrictDecorate: this.isStrictDecorate
598
        };
599
    }
600

601
    composePlaceholderDecorate(editor: Editor) {
602
        if (this.placeholderDecorate) {
64!
UNCOV
603
            return this.placeholderDecorate(editor) || [];
×
604
        }
605

606
        if (this.placeholder && editor.children.length === 1 && Array.from(Node.texts(editor)).length === 1 && Node.string(editor) === '') {
64✔
607
            const start = Editor.start(editor, []);
3✔
608
            return [
3✔
609
                {
610
                    placeholder: this.placeholder,
611
                    anchor: start,
612
                    focus: start
613
                }
614
            ];
615
        } else {
616
            return [];
61✔
617
        }
618
    }
619

620
    generateDecorations() {
621
        const decorations = this.decorate([this.editor, []]);
66✔
622
        const placeholderDecorations = this.isComposing ? [] : this.composePlaceholderDecorate(this.editor);
66✔
623
        decorations.push(...placeholderDecorations);
66✔
624
        return decorations;
66✔
625
    }
626

627
    private isEnabledVirtualScroll() {
628
        return !!(this.virtualScrollConfig && this.virtualScrollConfig.enabled);
81✔
629
    }
630

631
    // the height from scroll container top to editor top height element
632
    private businessHeight: number = 0;
23✔
633

634
    virtualScrollInitialized = false;
23✔
635

636
    virtualTopHeightElement: HTMLElement;
637

638
    virtualBottomHeightElement: HTMLElement;
639

640
    virtualCenterOutlet: HTMLElement;
641

642
    initializeVirtualScroll() {
643
        if (this.virtualScrollInitialized) {
23!
644
            return;
×
645
        }
646
        if (this.isEnabledVirtualScroll()) {
23!
647
            this.virtualScrollInitialized = true;
×
648
            this.virtualTopHeightElement = document.createElement('div');
×
649
            this.virtualTopHeightElement.classList.add('virtual-top-height');
×
UNCOV
650
            this.virtualTopHeightElement.contentEditable = 'false';
×
UNCOV
651
            this.virtualBottomHeightElement = document.createElement('div');
×
652
            this.virtualBottomHeightElement.classList.add('virtual-bottom-height');
×
653
            this.virtualBottomHeightElement.contentEditable = 'false';
×
654
            this.virtualCenterOutlet = document.createElement('div');
×
655
            this.virtualCenterOutlet.classList.add('virtual-center-outlet');
×
UNCOV
656
            this.elementRef.nativeElement.appendChild(this.virtualTopHeightElement);
×
UNCOV
657
            this.elementRef.nativeElement.appendChild(this.virtualCenterOutlet);
×
UNCOV
658
            this.elementRef.nativeElement.appendChild(this.virtualBottomHeightElement);
×
UNCOV
659
            this.businessHeight = this.virtualTopHeightElement.getBoundingClientRect()?.top ?? 0;
×
UNCOV
660
            let editorResizeObserverRectWidth = this.elementRef.nativeElement.getBoundingClientRect()?.width ?? 0;
×
661
            this.editorResizeObserver = new ResizeObserver(entries => {
×
662
                if (entries.length > 0 && entries[0].contentRect.width !== editorResizeObserverRectWidth) {
×
UNCOV
663
                    editorResizeObserverRectWidth = entries[0].contentRect.width;
×
664
                    const remeasureIndics = Array.from(this.inViewportIndics);
×
665
                    this.remeasureHeightByIndics(remeasureIndics);
×
666
                }
667
            });
UNCOV
668
            this.editorResizeObserver.observe(this.elementRef.nativeElement);
×
669
            if (isDebug) {
×
670
                const doc = this.elementRef?.nativeElement?.ownerDocument ?? document;
×
UNCOV
671
                VirtualScrollDebugOverlay.getInstance(doc);
×
672
            }
673
        }
674
    }
675

676
    setVirtualSpaceHeight(topHeight: number, bottomHeight: number) {
677
        if (!this.virtualScrollInitialized) {
×
678
            return;
×
679
        }
UNCOV
680
        this.virtualTopHeightElement.style.height = `${topHeight}px`;
×
681
        this.virtualBottomHeightElement.style.height = `${bottomHeight}px`;
×
682
    }
683

684
    private debugLog(type: 'log' | 'warn', ...args: any[]) {
685
        const doc = this.elementRef?.nativeElement?.ownerDocument ?? document;
×
686
        VirtualScrollDebugOverlay.log(doc, type, ...args);
×
687
    }
688

689
    private tryUpdateVirtualViewport() {
UNCOV
690
        this.tryUpdateVirtualViewportAnimId && cancelAnimationFrame(this.tryUpdateVirtualViewportAnimId);
×
UNCOV
691
        this.tryUpdateVirtualViewportAnimId = requestAnimationFrame(() => {
×
692
            let virtualView = this.calculateVirtualViewport();
×
693
            let diff = this.diffVirtualViewport(virtualView);
×
694
            if (!diff.isDiff) {
×
695
                return;
×
696
            }
UNCOV
697
            if (diff.isMissingTop) {
×
UNCOV
698
                const remeasureIndics = diff.diffTopRenderedIndexes;
×
699
                const result = this.remeasureHeightByIndics(remeasureIndics);
×
UNCOV
700
                if (result) {
×
UNCOV
701
                    virtualView = this.calculateVirtualViewport();
×
UNCOV
702
                    diff = this.diffVirtualViewport(virtualView, 'second');
×
UNCOV
703
                    if (!diff.isDiff) {
×
704
                        return;
×
705
                    }
706
                }
707
            }
UNCOV
708
            this.applyVirtualView(virtualView);
×
UNCOV
709
            if (this.listRender.initialized) {
×
UNCOV
710
                this.listRender.update(virtualView.inViewportChildren, this.editor, this.context);
×
UNCOV
711
                if (!AngularEditor.isReadOnly(this.editor) && this.editor.selection) {
×
UNCOV
712
                    this.toNativeSelection();
×
713
                }
714
            }
715
            this.tryMeasureInViewportChildrenHeights();
×
716
        });
717
    }
718

719
    private tryAnchorScroll() {
NEW
720
        if (!this.isEnabledVirtualScroll()) {
×
NEW
721
            return;
×
722
        }
NEW
UNCOV
723
        const { anchorElement, scrollTo } = this.virtualToAnchorConfig || {};
×
NEW
UNCOV
724
        if (!anchorElement || !scrollTo) {
×
NEW
UNCOV
725
            return;
×
726
        }
NEW
UNCOV
727
        if (anchorElement === this.lastAnchorElement) {
×
NEW
UNCOV
728
            return;
×
729
        }
NEW
730
        const children = this.editor.children;
×
NEW
731
        if (!children.length) {
×
NEW
732
            return;
×
733
        }
NEW
734
        const anchorIndex = children.findIndex(item => item === anchorElement);
×
NEW
735
        if (anchorIndex < 0) {
×
NEW
736
            return;
×
737
        }
738

NEW
739
        const viewportHeight = this.virtualScrollConfig.viewportHeight ?? 0;
×
NEW
UNCOV
740
        if (!viewportHeight) {
×
NEW
741
            return;
×
742
        }
NEW
743
        const { accumulatedHeights } = this.buildHeightsAndAccumulatedHeights();
×
744

NEW
745
        const itemTop = accumulatedHeights[anchorIndex] ?? 0;
×
NEW
746
        const targetScrollTop = itemTop + this.businessHeight;
×
747

NEW
748
        this.lastAnchorElement = anchorElement;
×
NEW
UNCOV
749
        if (isDebug) {
×
NEW
750
            this.debugLog(
×
751
                'log',
752
                'anchorScroll element:',
753
                anchorElement,
754
                'targetScrollTop:',
755
                targetScrollTop,
756
                'businessHeight:',
757
                this.businessHeight
758
            );
759
        }
NEW
760
        scrollTo(targetScrollTop);
×
NEW
761
        this.lastAnchorElement = null;
×
762
    }
763

764
    private buildHeightsAndAccumulatedHeights() {
NEW
UNCOV
765
        const children = (this.editor.children || []) as Element[];
×
NEW
UNCOV
766
        const heights = new Array(children.length);
×
NEW
UNCOV
767
        const accumulatedHeights = new Array(children.length + 1);
×
NEW
UNCOV
768
        accumulatedHeights[0] = 0;
×
NEW
UNCOV
769
        for (let i = 0; i < children.length; i++) {
×
NEW
UNCOV
770
            const height = this.getBlockHeight(i);
×
NEW
UNCOV
771
            heights[i] = height;
×
NEW
UNCOV
772
            accumulatedHeights[i + 1] = accumulatedHeights[i] + height;
×
773
        }
NEW
774
        return { heights, accumulatedHeights };
×
775
    }
776

777
    private calculateVirtualViewport() {
UNCOV
778
        const children = (this.editor.children || []) as Element[];
×
UNCOV
779
        if (!children.length || !this.isEnabledVirtualScroll()) {
×
780
            return {
×
781
                inViewportChildren: children,
782
                visibleIndexes: new Set<number>(),
783
                top: 0,
784
                bottom: 0,
785
                heights: []
786
            };
787
        }
788
        const scrollTop = this.virtualScrollConfig.scrollTop;
×
789
        if (isDebug) {
×
790
            const doc = this.elementRef?.nativeElement?.ownerDocument ?? document;
×
791
            VirtualScrollDebugOverlay.syncScrollTop(doc, Number.isFinite(scrollTop) ? (scrollTop as number) : 0);
×
792
        }
793
        const viewportHeight = this.virtualScrollConfig.viewportHeight ?? 0;
×
794
        if (!viewportHeight) {
×
795
            return {
×
796
                inViewportChildren: [],
797
                visibleIndexes: new Set<number>(),
798
                top: 0,
799
                bottom: 0,
800
                heights: []
801
            };
802
        }
803
        const elementLength = children.length;
×
804
        const adjustedScrollTop = Math.max(0, scrollTop - this.businessHeight);
×
NEW
805
        const { heights, accumulatedHeights } = this.buildHeightsAndAccumulatedHeights();
×
UNCOV
806
        const totalHeight = accumulatedHeights[elementLength];
×
807
        const maxScrollTop = Math.max(0, totalHeight - viewportHeight);
×
UNCOV
808
        const limitedScrollTop = Math.min(adjustedScrollTop, maxScrollTop);
×
UNCOV
809
        const viewBottom = limitedScrollTop + viewportHeight + this.businessHeight;
×
810
        let accumulatedOffset = 0;
×
811
        let visibleStartIndex = -1;
×
812
        const visible: Element[] = [];
×
813
        const visibleIndexes: number[] = [];
×
814

815
        for (let i = 0; i < elementLength && accumulatedOffset < viewBottom; i++) {
×
UNCOV
816
            const currentHeight = heights[i];
×
UNCOV
817
            const nextOffset = accumulatedOffset + currentHeight;
×
818
            // 可视区域有交集,加入渲染
UNCOV
819
            if (nextOffset > limitedScrollTop && accumulatedOffset < viewBottom) {
×
820
                if (visibleStartIndex === -1) visibleStartIndex = i; // 第一个相交起始位置
×
821
                visible.push(children[i]);
×
822
                visibleIndexes.push(i);
×
823
            }
UNCOV
824
            accumulatedOffset = nextOffset;
×
825
        }
826

UNCOV
827
        if (visibleStartIndex === -1 && elementLength) {
×
828
            visibleStartIndex = elementLength - 1;
×
829
            visible.push(children[visibleStartIndex]);
×
830
            visibleIndexes.push(visibleStartIndex);
×
831
        }
832

833
        const visibleEndIndex =
UNCOV
834
            visibleStartIndex === -1 ? elementLength - 1 : (visibleIndexes[visibleIndexes.length - 1] ?? visibleStartIndex);
×
UNCOV
835
        const top = visibleStartIndex === -1 ? 0 : accumulatedHeights[visibleStartIndex];
×
UNCOV
836
        const bottom = totalHeight - accumulatedHeights[visibleEndIndex + 1];
×
837

838
        return {
×
839
            inViewportChildren: visible.length ? visible : children,
×
840
            visibleIndexes: new Set(visibleIndexes),
841
            top,
842
            bottom,
843
            heights
844
        };
845
    }
846

847
    private applyVirtualView(virtualView: VirtualViewResult) {
848
        this.inViewportChildren = virtualView.inViewportChildren;
×
UNCOV
849
        this.setVirtualSpaceHeight(virtualView.top, virtualView.bottom);
×
UNCOV
850
        this.inViewportIndics = virtualView.visibleIndexes;
×
851
    }
852

853
    private diffVirtualViewport(virtualView: VirtualViewResult, stage: 'first' | 'second' | 'onChange' = 'first') {
×
UNCOV
854
        if (!this.inViewportChildren.length) {
×
855
            return {
×
856
                isDiff: true,
857
                diffTopRenderedIndexes: [],
858
                diffBottomRenderedIndexes: []
859
            };
860
        }
UNCOV
861
        const oldVisibleIndexes = [...this.inViewportIndics];
×
UNCOV
862
        const newVisibleIndexes = [...virtualView.visibleIndexes];
×
UNCOV
863
        const firstNewIndex = newVisibleIndexes[0];
×
UNCOV
864
        const lastNewIndex = newVisibleIndexes[newVisibleIndexes.length - 1];
×
UNCOV
865
        const firstOldIndex = oldVisibleIndexes[0];
×
UNCOV
866
        const lastOldIndex = oldVisibleIndexes[oldVisibleIndexes.length - 1];
×
867
        if (firstNewIndex !== firstOldIndex || lastNewIndex !== lastOldIndex) {
×
UNCOV
868
            const diffTopRenderedIndexes = [];
×
869
            const diffBottomRenderedIndexes = [];
×
UNCOV
870
            const isMissingTop = firstNewIndex !== firstOldIndex && firstNewIndex > firstOldIndex;
×
UNCOV
871
            const isAddedTop = firstNewIndex !== firstOldIndex && firstNewIndex < firstOldIndex;
×
UNCOV
872
            const isMissingBottom = lastNewIndex !== lastOldIndex && lastOldIndex > lastNewIndex;
×
UNCOV
873
            const isAddedBottom = lastNewIndex !== lastOldIndex && lastOldIndex < lastNewIndex;
×
UNCOV
874
            if (isMissingTop || isAddedBottom) {
×
875
                // 向下
UNCOV
876
                for (let index = 0; index < oldVisibleIndexes.length; index++) {
×
UNCOV
877
                    const element = oldVisibleIndexes[index];
×
UNCOV
878
                    if (!newVisibleIndexes.includes(element)) {
×
879
                        diffTopRenderedIndexes.push(element);
×
880
                    } else {
UNCOV
881
                        break;
×
882
                    }
883
                }
UNCOV
884
                for (let index = newVisibleIndexes.length - 1; index >= 0; index--) {
×
UNCOV
885
                    const element = newVisibleIndexes[index];
×
UNCOV
886
                    if (!oldVisibleIndexes.includes(element)) {
×
887
                        diffBottomRenderedIndexes.push(element);
×
888
                    } else {
UNCOV
889
                        break;
×
890
                    }
891
                }
892
            } else if (isAddedTop || isMissingBottom) {
×
893
                // 向上
UNCOV
894
                for (let index = 0; index < newVisibleIndexes.length; index++) {
×
UNCOV
895
                    const element = newVisibleIndexes[index];
×
UNCOV
896
                    if (!oldVisibleIndexes.includes(element)) {
×
897
                        diffTopRenderedIndexes.push(element);
×
898
                    } else {
899
                        break;
×
900
                    }
901
                }
UNCOV
902
                for (let index = oldVisibleIndexes.length - 1; index >= 0; index--) {
×
903
                    const element = oldVisibleIndexes[index];
×
UNCOV
904
                    if (!newVisibleIndexes.includes(element)) {
×
905
                        diffBottomRenderedIndexes.push(element);
×
906
                    } else {
UNCOV
907
                        break;
×
908
                    }
909
                }
910
            }
UNCOV
911
            if (isDebug) {
×
912
                this.debugLog('log', `====== diffVirtualViewport stage: ${stage} ======`);
×
913
                this.debugLog('log', 'oldVisibleIndexes:', oldVisibleIndexes);
×
914
                this.debugLog('log', 'newVisibleIndexes:', newVisibleIndexes);
×
915
                this.debugLog(
×
916
                    'log',
917
                    'diffTopRenderedIndexes:',
918
                    isMissingTop ? '-' : isAddedTop ? '+' : '-',
×
919
                    diffTopRenderedIndexes,
UNCOV
920
                    diffTopRenderedIndexes.map(index => this.getBlockHeight(index, 0))
×
921
                );
UNCOV
922
                this.debugLog(
×
923
                    'log',
924
                    'diffBottomRenderedIndexes:',
925
                    isAddedBottom ? '+' : isMissingBottom ? '-' : '+',
×
926
                    diffBottomRenderedIndexes,
927
                    diffBottomRenderedIndexes.map(index => this.getBlockHeight(index, 0))
×
928
                );
929
                const needTop = virtualView.heights.slice(0, newVisibleIndexes[0]).reduce((acc, height) => acc + height, 0);
×
UNCOV
930
                const needBottom = virtualView.heights
×
931
                    .slice(newVisibleIndexes[newVisibleIndexes.length - 1] + 1)
932
                    .reduce((acc, height) => acc + height, 0);
×
933
                this.debugLog('log', 'newTopHeight:', needTop, 'prevTopHeight:', parseFloat(this.virtualTopHeightElement.style.height));
×
934
                this.debugLog(
×
935
                    'log',
936
                    'newBottomHeight:',
937
                    needBottom,
938
                    'prevBottomHeight:',
939
                    parseFloat(this.virtualBottomHeightElement.style.height)
940
                );
941
                this.debugLog('warn', '=========== Dividing line ===========');
×
942
            }
943
            return {
×
944
                isDiff: true,
945
                isMissingTop,
946
                isAddedTop,
947
                isMissingBottom,
948
                isAddedBottom,
949
                diffTopRenderedIndexes,
950
                diffBottomRenderedIndexes
951
            };
952
        }
953
        return {
×
954
            isDiff: false,
955
            diffTopRenderedIndexes: [],
956
            diffBottomRenderedIndexes: []
957
        };
958
    }
959

960
    private getBlockHeight(index: number, defaultHeight: number = VIRTUAL_SCROLL_DEFAULT_BLOCK_HEIGHT) {
×
961
        const node = this.editor.children[index] as Element;
×
UNCOV
962
        const isVisible = this.editor.isVisible(node);
×
UNCOV
963
        if (!isVisible) {
×
UNCOV
964
            return 0;
×
965
        }
UNCOV
966
        if (!node) {
×
UNCOV
967
            return defaultHeight;
×
968
        }
UNCOV
969
        const key = AngularEditor.findKey(this.editor, node);
×
970
        const height = this.keyHeightMap.get(key.id);
×
UNCOV
971
        if (typeof height === 'number') {
×
UNCOV
972
            return height;
×
973
        }
UNCOV
974
        if (this.keyHeightMap.has(key.id)) {
×
UNCOV
975
            console.error('getBlockHeight: invalid height value', key.id, height);
×
976
        }
UNCOV
977
        return defaultHeight;
×
978
    }
979

980
    private buildAccumulatedHeight(heights: number[]) {
981
        const accumulatedHeights = new Array(heights.length + 1).fill(0);
×
UNCOV
982
        for (let i = 0; i < heights.length; i++) {
×
983
            // 存储前 i 个的累计高度
UNCOV
984
            accumulatedHeights[i + 1] = accumulatedHeights[i] + heights[i];
×
985
        }
UNCOV
986
        return accumulatedHeights;
×
987
    }
988

989
    private tryMeasureInViewportChildrenHeights() {
UNCOV
990
        if (!this.isEnabledVirtualScroll()) {
×
UNCOV
991
            return;
×
992
        }
UNCOV
993
        this.tryMeasureInViewportChildrenHeightsAnimId && cancelAnimationFrame(this.tryMeasureInViewportChildrenHeightsAnimId);
×
UNCOV
994
        this.tryMeasureInViewportChildrenHeightsAnimId = requestAnimationFrame(() => {
×
UNCOV
995
            this.measureVisibleHeights();
×
996
        });
997
    }
998

999
    private measureVisibleHeights() {
UNCOV
1000
        const children = (this.editor.children || []) as Element[];
×
UNCOV
1001
        this.inViewportIndics.forEach(index => {
×
UNCOV
1002
            const node = children[index];
×
1003
            if (!node) {
×
1004
                return;
×
1005
            }
UNCOV
1006
            const key = AngularEditor.findKey(this.editor, node);
×
1007
            // 跳过已测过的块,除非强制测量
UNCOV
1008
            if (this.keyHeightMap.has(key.id)) {
×
UNCOV
1009
                return;
×
1010
            }
UNCOV
1011
            const view = ELEMENT_TO_COMPONENT.get(node);
×
1012
            if (!view) {
×
UNCOV
1013
                return;
×
1014
            }
UNCOV
1015
            const ret = (view as BaseElementComponent | BaseElementFlavour).getRealHeight();
×
UNCOV
1016
            if (ret instanceof Promise) {
×
UNCOV
1017
                ret.then(height => {
×
UNCOV
1018
                    this.keyHeightMap.set(key.id, height);
×
1019
                });
1020
            } else {
1021
                this.keyHeightMap.set(key.id, ret);
×
1022
            }
1023
        });
1024
    }
1025

1026
    private remeasureHeightByIndics(indics: number[]): boolean {
UNCOV
1027
        const children = (this.editor.children || []) as Element[];
×
UNCOV
1028
        let isHeightChanged = false;
×
UNCOV
1029
        indics.forEach((index, i) => {
×
UNCOV
1030
            const node = children[index];
×
UNCOV
1031
            if (!node) {
×
UNCOV
1032
                return;
×
1033
            }
UNCOV
1034
            const key = AngularEditor.findKey(this.editor, node);
×
UNCOV
1035
            const view = ELEMENT_TO_COMPONENT.get(node);
×
UNCOV
1036
            if (!view) {
×
UNCOV
1037
                return;
×
1038
            }
1039
            const prevHeight = this.keyHeightMap.get(key.id);
×
1040
            const ret = (view as BaseElementComponent | BaseElementFlavour).getRealHeight();
×
1041
            if (ret instanceof Promise) {
×
1042
                ret.then(height => {
×
1043
                    this.keyHeightMap.set(key.id, height);
×
1044
                    if (height !== prevHeight) {
×
1045
                        isHeightChanged = true;
×
1046
                        if (isDebug) {
×
1047
                            this.debugLog(
×
1048
                                'log',
1049
                                `remeasure element height, index: ${index} prevHeight: ${prevHeight} newHeight: ${height}`
1050
                            );
1051
                        }
1052
                    }
1053
                });
1054
            } else {
1055
                this.keyHeightMap.set(key.id, ret);
×
UNCOV
1056
                if (ret !== prevHeight) {
×
1057
                    isHeightChanged = true;
×
1058
                    if (isDebug) {
×
1059
                        this.debugLog('log', `remeasure element height, index: ${index} prevHeight: ${prevHeight} newHeight: ${ret}`);
×
1060
                    }
1061
                }
1062
            }
1063
        });
1064
        return isHeightChanged;
×
1065
    }
1066

1067
    //#region event proxy
1068
    private addEventListener(eventName: string, listener: EventListener, target: HTMLElement | Document = this.elementRef.nativeElement) {
460✔
1069
        this.manualListeners.push(
483✔
1070
            this.renderer2.listen(target, eventName, (event: Event) => {
1071
                const beforeInputEvent = extractBeforeInputEvent(event.type, null, event, event.target);
5✔
1072
                if (beforeInputEvent) {
5!
UNCOV
1073
                    this.onFallbackBeforeInput(beforeInputEvent);
×
1074
                }
1075
                listener(event);
5✔
1076
            })
1077
        );
1078
    }
1079

1080
    private toSlateSelection() {
1081
        if ((!this.isComposing || IS_ANDROID) && !this.isUpdatingSelection && !this.isDraggingInternally) {
2✔
1082
            try {
1✔
1083
                if (isDebug) {
1!
1084
                    console.log('toSlateSelection');
×
1085
                }
1086
                const root = AngularEditor.findDocumentOrShadowRoot(this.editor);
1✔
1087
                const { activeElement } = root;
1✔
1088
                const el = AngularEditor.toDOMNode(this.editor, this.editor);
1✔
1089
                const domSelection = (root as Document).getSelection();
1✔
1090

1091
                if (activeElement === el) {
1!
1092
                    this.latestElement = activeElement;
1✔
1093
                    IS_FOCUSED.set(this.editor, true);
1✔
1094
                } else {
1095
                    IS_FOCUSED.delete(this.editor);
×
1096
                }
1097

1098
                if (!domSelection) {
1!
UNCOV
1099
                    return Transforms.deselect(this.editor);
×
1100
                }
1101

1102
                const editorElement = EDITOR_TO_ELEMENT.get(this.editor);
1✔
1103
                const hasDomSelectionInEditor =
1104
                    editorElement.contains(domSelection.anchorNode) && editorElement.contains(domSelection.focusNode);
1✔
1105
                if (!hasDomSelectionInEditor) {
1!
UNCOV
1106
                    Transforms.deselect(this.editor);
×
UNCOV
1107
                    return;
×
1108
                }
1109

1110
                // try to get the selection directly, because some terrible case can be normalize for normalizeDOMPoint
1111
                // for example, double-click the last cell of the table to select a non-editable DOM
1112
                const range = AngularEditor.toSlateRange(this.editor, domSelection, { exactMatch: false, suppressThrow: true });
1✔
1113
                if (range) {
1✔
1114
                    if (this.editor.selection && Range.equals(range, this.editor.selection) && !hasStringTarget(domSelection)) {
1!
1115
                        if (!isTargetInsideVoid(this.editor, activeElement)) {
×
1116
                            // force adjust DOMSelection
UNCOV
1117
                            this.toNativeSelection();
×
1118
                        }
1119
                    } else {
1120
                        Transforms.select(this.editor, range);
1✔
1121
                    }
1122
                }
1123
            } catch (error) {
UNCOV
1124
                this.editor.onError({
×
1125
                    code: SlateErrorCode.ToSlateSelectionError,
1126
                    nativeError: error
1127
                });
1128
            }
1129
        }
1130
    }
1131

1132
    private onDOMBeforeInput(
1133
        event: Event & {
1134
            inputType: string;
1135
            isComposing: boolean;
1136
            data: string | null;
1137
            dataTransfer: DataTransfer | null;
1138
            getTargetRanges(): DOMStaticRange[];
1139
        }
1140
    ) {
1141
        const editor = this.editor;
×
1142
        const root = AngularEditor.findDocumentOrShadowRoot(this.editor);
×
UNCOV
1143
        const { activeElement } = root;
×
UNCOV
1144
        const { selection } = editor;
×
UNCOV
1145
        const { inputType: type } = event;
×
1146
        const data = event.dataTransfer || event.data || undefined;
×
1147
        if (IS_ANDROID) {
×
UNCOV
1148
            let targetRange: Range | null = null;
×
UNCOV
1149
            let [nativeTargetRange] = event.getTargetRanges();
×
UNCOV
1150
            if (nativeTargetRange) {
×
1151
                targetRange = AngularEditor.toSlateRange(editor, nativeTargetRange, { exactMatch: false, suppressThrow: false });
×
1152
            }
1153
            // COMPAT: SelectionChange event is fired after the action is performed, so we
1154
            // have to manually get the selection here to ensure it's up-to-date.
UNCOV
1155
            const window = AngularEditor.getWindow(editor);
×
1156
            const domSelection = window.getSelection();
×
1157
            if (!targetRange && domSelection) {
×
UNCOV
1158
                targetRange = AngularEditor.toSlateRange(editor, domSelection, { exactMatch: false, suppressThrow: false });
×
1159
            }
UNCOV
1160
            targetRange = targetRange ?? editor.selection;
×
1161
            if (type === 'insertCompositionText') {
×
1162
                if (data && data.toString().includes('\n')) {
×
UNCOV
1163
                    restoreDom(editor, () => {
×
UNCOV
1164
                        Editor.insertBreak(editor);
×
1165
                    });
1166
                } else {
1167
                    if (targetRange) {
×
UNCOV
1168
                        if (data) {
×
UNCOV
1169
                            restoreDom(editor, () => {
×
UNCOV
1170
                                Transforms.insertText(editor, data.toString(), { at: targetRange });
×
1171
                            });
1172
                        } else {
1173
                            restoreDom(editor, () => {
×
UNCOV
1174
                                Transforms.delete(editor, { at: targetRange });
×
1175
                            });
1176
                        }
1177
                    }
1178
                }
UNCOV
1179
                return;
×
1180
            }
UNCOV
1181
            if (type === 'deleteContentBackward') {
×
1182
                // gboard can not prevent default action, so must use restoreDom,
1183
                // sougou Keyboard can prevent default action(only in Chinese input mode).
1184
                // In order to avoid weird action in Sougou Keyboard, use resotreDom only range's isCollapsed is false (recognize gboard)
UNCOV
1185
                if (!Range.isCollapsed(targetRange)) {
×
UNCOV
1186
                    restoreDom(editor, () => {
×
UNCOV
1187
                        Transforms.delete(editor, { at: targetRange });
×
1188
                    });
UNCOV
1189
                    return;
×
1190
                }
1191
            }
UNCOV
1192
            if (type === 'insertText') {
×
UNCOV
1193
                restoreDom(editor, () => {
×
1194
                    if (typeof data === 'string') {
×
1195
                        Editor.insertText(editor, data);
×
1196
                    }
1197
                });
UNCOV
1198
                return;
×
1199
            }
1200
        }
UNCOV
1201
        if (
×
1202
            !this.readonly &&
×
1203
            AngularEditor.hasEditableTarget(editor, event.target) &&
1204
            !isTargetInsideVoid(editor, activeElement) &&
1205
            !this.isDOMEventHandled(event, this.beforeInput)
1206
        ) {
UNCOV
1207
            try {
×
UNCOV
1208
                event.preventDefault();
×
1209

1210
                // COMPAT: If the selection is expanded, even if the command seems like
1211
                // a delete forward/backward command it should delete the selection.
1212
                if (selection && Range.isExpanded(selection) && type.startsWith('delete')) {
×
UNCOV
1213
                    const direction = type.endsWith('Backward') ? 'backward' : 'forward';
×
UNCOV
1214
                    Editor.deleteFragment(editor, { direction });
×
UNCOV
1215
                    return;
×
1216
                }
1217

1218
                switch (type) {
×
1219
                    case 'deleteByComposition':
1220
                    case 'deleteByCut':
1221
                    case 'deleteByDrag': {
UNCOV
1222
                        Editor.deleteFragment(editor);
×
UNCOV
1223
                        break;
×
1224
                    }
1225

1226
                    case 'deleteContent':
1227
                    case 'deleteContentForward': {
1228
                        Editor.deleteForward(editor);
×
1229
                        break;
×
1230
                    }
1231

1232
                    case 'deleteContentBackward': {
1233
                        Editor.deleteBackward(editor);
×
UNCOV
1234
                        break;
×
1235
                    }
1236

1237
                    case 'deleteEntireSoftLine': {
1238
                        Editor.deleteBackward(editor, { unit: 'line' });
×
1239
                        Editor.deleteForward(editor, { unit: 'line' });
×
UNCOV
1240
                        break;
×
1241
                    }
1242

1243
                    case 'deleteHardLineBackward': {
1244
                        Editor.deleteBackward(editor, { unit: 'block' });
×
1245
                        break;
×
1246
                    }
1247

1248
                    case 'deleteSoftLineBackward': {
UNCOV
1249
                        Editor.deleteBackward(editor, { unit: 'line' });
×
UNCOV
1250
                        break;
×
1251
                    }
1252

1253
                    case 'deleteHardLineForward': {
1254
                        Editor.deleteForward(editor, { unit: 'block' });
×
1255
                        break;
×
1256
                    }
1257

1258
                    case 'deleteSoftLineForward': {
1259
                        Editor.deleteForward(editor, { unit: 'line' });
×
UNCOV
1260
                        break;
×
1261
                    }
1262

1263
                    case 'deleteWordBackward': {
UNCOV
1264
                        Editor.deleteBackward(editor, { unit: 'word' });
×
UNCOV
1265
                        break;
×
1266
                    }
1267

1268
                    case 'deleteWordForward': {
1269
                        Editor.deleteForward(editor, { unit: 'word' });
×
1270
                        break;
×
1271
                    }
1272

1273
                    case 'insertLineBreak':
1274
                    case 'insertParagraph': {
1275
                        Editor.insertBreak(editor);
×
UNCOV
1276
                        break;
×
1277
                    }
1278

1279
                    case 'insertFromComposition': {
1280
                        // COMPAT: in safari, `compositionend` event is dispatched after
1281
                        // the beforeinput event with the inputType "insertFromComposition" has been dispatched.
1282
                        // https://www.w3.org/TR/input-events-2/
1283
                        // so the following code is the right logic
1284
                        // because DOM selection in sync will be exec before `compositionend` event
1285
                        // isComposing is true will prevent DOM selection being update correctly.
UNCOV
1286
                        this.isComposing = false;
×
UNCOV
1287
                        preventInsertFromComposition(event, this.editor);
×
1288
                    }
1289
                    case 'insertFromDrop':
1290
                    case 'insertFromPaste':
1291
                    case 'insertFromYank':
1292
                    case 'insertReplacementText':
1293
                    case 'insertText': {
1294
                        // use a weak comparison instead of 'instanceof' to allow
1295
                        // programmatic access of paste events coming from external windows
1296
                        // like cypress where cy.window does not work realibly
UNCOV
1297
                        if (data?.constructor.name === 'DataTransfer') {
×
UNCOV
1298
                            AngularEditor.insertData(editor, data as DataTransfer);
×
1299
                        } else if (typeof data === 'string') {
×
1300
                            Editor.insertText(editor, data);
×
1301
                        }
UNCOV
1302
                        break;
×
1303
                    }
1304
                }
1305
            } catch (error) {
UNCOV
1306
                this.editor.onError({
×
1307
                    code: SlateErrorCode.OnDOMBeforeInputError,
1308
                    nativeError: error
1309
                });
1310
            }
1311
        }
1312
    }
1313

1314
    private onDOMBlur(event: FocusEvent) {
UNCOV
1315
        if (
×
1316
            this.readonly ||
×
1317
            this.isUpdatingSelection ||
1318
            !AngularEditor.hasEditableTarget(this.editor, event.target) ||
1319
            this.isDOMEventHandled(event, this.blur)
1320
        ) {
1321
            return;
×
1322
        }
1323

UNCOV
1324
        const window = AngularEditor.getWindow(this.editor);
×
1325

1326
        // COMPAT: If the current `activeElement` is still the previous
1327
        // one, this is due to the window being blurred when the tab
1328
        // itself becomes unfocused, so we want to abort early to allow to
1329
        // editor to stay focused when the tab becomes focused again.
UNCOV
1330
        const root = AngularEditor.findDocumentOrShadowRoot(this.editor);
×
UNCOV
1331
        if (this.latestElement === root.activeElement) {
×
UNCOV
1332
            return;
×
1333
        }
1334

1335
        const { relatedTarget } = event;
×
UNCOV
1336
        const el = AngularEditor.toDOMNode(this.editor, this.editor);
×
1337

1338
        // COMPAT: The event should be ignored if the focus is returning
1339
        // to the editor from an embedded editable element (eg. an <input>
1340
        // element inside a void node).
UNCOV
1341
        if (relatedTarget === el) {
×
1342
            return;
×
1343
        }
1344

1345
        // COMPAT: The event should be ignored if the focus is moving from
1346
        // the editor to inside a void node's spacer element.
1347
        if (isDOMElement(relatedTarget) && relatedTarget.hasAttribute('data-slate-spacer')) {
×
1348
            return;
×
1349
        }
1350

1351
        // COMPAT: The event should be ignored if the focus is moving to a
1352
        // non- editable section of an element that isn't a void node (eg.
1353
        // a list item of the check list example).
UNCOV
1354
        if (relatedTarget != null && isDOMNode(relatedTarget) && AngularEditor.hasDOMNode(this.editor, relatedTarget)) {
×
1355
            const node = AngularEditor.toSlateNode(this.editor, relatedTarget);
×
1356

1357
            if (Element.isElement(node) && !this.editor.isVoid(node)) {
×
1358
                return;
×
1359
            }
1360
        }
1361

UNCOV
1362
        IS_FOCUSED.delete(this.editor);
×
1363
    }
1364

1365
    private onDOMClick(event: MouseEvent) {
UNCOV
1366
        if (
×
1367
            !this.readonly &&
×
1368
            AngularEditor.hasTarget(this.editor, event.target) &&
1369
            !this.isDOMEventHandled(event, this.click) &&
1370
            isDOMNode(event.target)
1371
        ) {
UNCOV
1372
            const node = AngularEditor.toSlateNode(this.editor, event.target);
×
1373
            const path = AngularEditor.findPath(this.editor, node);
×
1374
            const start = Editor.start(this.editor, path);
×
UNCOV
1375
            const end = Editor.end(this.editor, path);
×
1376

UNCOV
1377
            const startVoid = Editor.void(this.editor, { at: start });
×
UNCOV
1378
            const endVoid = Editor.void(this.editor, { at: end });
×
1379

1380
            if (event.detail === TRIPLE_CLICK && path.length >= 1) {
×
1381
                let blockPath = path;
×
1382
                if (!(Element.isElement(node) && Editor.isBlock(this.editor, node))) {
×
UNCOV
1383
                    const block = Editor.above(this.editor, {
×
1384
                        match: n => Element.isElement(n) && Editor.isBlock(this.editor, n),
×
1385
                        at: path
1386
                    });
1387

1388
                    blockPath = block?.[1] ?? path.slice(0, 1);
×
1389
                }
1390

UNCOV
1391
                const range = Editor.range(this.editor, blockPath);
×
UNCOV
1392
                Transforms.select(this.editor, range);
×
1393
                return;
×
1394
            }
1395

UNCOV
1396
            if (
×
1397
                startVoid &&
×
1398
                endVoid &&
1399
                Path.equals(startVoid[1], endVoid[1]) &&
1400
                !(AngularEditor.isBlockCardLeftCursor(this.editor) || AngularEditor.isBlockCardRightCursor(this.editor))
×
1401
            ) {
1402
                const range = Editor.range(this.editor, start);
×
UNCOV
1403
                Transforms.select(this.editor, range);
×
1404
            }
1405
        }
1406
    }
1407

1408
    private onDOMCompositionStart(event: CompositionEvent) {
1409
        const { selection } = this.editor;
1✔
1410
        if (selection) {
1!
1411
            // solve the problem of cross node Chinese input
1412
            if (Range.isExpanded(selection)) {
×
1413
                Editor.deleteFragment(this.editor);
×
1414
                this.forceRender();
×
1415
            }
1416
        }
1417
        if (AngularEditor.hasEditableTarget(this.editor, event.target) && !this.isDOMEventHandled(event, this.compositionStart)) {
1✔
1418
            this.isComposing = true;
1✔
1419
        }
1420
        this.render();
1✔
1421
    }
1422

1423
    private onDOMCompositionUpdate(event: CompositionEvent) {
UNCOV
1424
        this.isDOMEventHandled(event, this.compositionUpdate);
×
1425
    }
1426

1427
    private onDOMCompositionEnd(event: CompositionEvent) {
UNCOV
1428
        if (!event.data && !Range.isCollapsed(this.editor.selection)) {
×
UNCOV
1429
            Transforms.delete(this.editor);
×
1430
        }
UNCOV
1431
        if (AngularEditor.hasEditableTarget(this.editor, event.target) && !this.isDOMEventHandled(event, this.compositionEnd)) {
×
1432
            // COMPAT: In Chrome/Firefox, `beforeinput` events for compositions
1433
            // aren't correct and never fire the "insertFromComposition"
1434
            // type that we need. So instead, insert whenever a composition
1435
            // ends since it will already have been committed to the DOM.
UNCOV
1436
            if (this.isComposing === true && !IS_SAFARI && !IS_ANDROID && event.data) {
×
UNCOV
1437
                preventInsertFromComposition(event, this.editor);
×
UNCOV
1438
                Editor.insertText(this.editor, event.data);
×
1439
            }
1440

1441
            // COMPAT: In Firefox 87.0 CompositionEnd fire twice
1442
            // so we need avoid repeat isnertText by isComposing === true,
UNCOV
1443
            this.isComposing = false;
×
1444
        }
UNCOV
1445
        this.render();
×
1446
    }
1447

1448
    private onDOMCopy(event: ClipboardEvent) {
UNCOV
1449
        const window = AngularEditor.getWindow(this.editor);
×
UNCOV
1450
        const isOutsideSlate = !hasStringTarget(window.getSelection()) && isTargetInsideVoid(this.editor, event.target);
×
UNCOV
1451
        if (!isOutsideSlate && AngularEditor.hasTarget(this.editor, event.target) && !this.isDOMEventHandled(event, this.copy)) {
×
UNCOV
1452
            event.preventDefault();
×
UNCOV
1453
            AngularEditor.setFragmentData(this.editor, event.clipboardData, 'copy');
×
1454
        }
1455
    }
1456

1457
    private onDOMCut(event: ClipboardEvent) {
1458
        if (!this.readonly && AngularEditor.hasEditableTarget(this.editor, event.target) && !this.isDOMEventHandled(event, this.cut)) {
×
1459
            event.preventDefault();
×
UNCOV
1460
            AngularEditor.setFragmentData(this.editor, event.clipboardData, 'cut');
×
UNCOV
1461
            const { selection } = this.editor;
×
1462

UNCOV
1463
            if (selection) {
×
UNCOV
1464
                AngularEditor.deleteCutData(this.editor);
×
1465
            }
1466
        }
1467
    }
1468

1469
    private onDOMDragOver(event: DragEvent) {
1470
        if (AngularEditor.hasTarget(this.editor, event.target) && !this.isDOMEventHandled(event, this.dragOver)) {
×
1471
            // Only when the target is void, call `preventDefault` to signal
1472
            // that drops are allowed. Editable content is droppable by
1473
            // default, and calling `preventDefault` hides the cursor.
UNCOV
1474
            const node = AngularEditor.toSlateNode(this.editor, event.target);
×
1475

UNCOV
1476
            if (Element.isElement(node) && Editor.isVoid(this.editor, node)) {
×
1477
                event.preventDefault();
×
1478
            }
1479
        }
1480
    }
1481

1482
    private onDOMDragStart(event: DragEvent) {
1483
        if (!this.readonly && AngularEditor.hasTarget(this.editor, event.target) && !this.isDOMEventHandled(event, this.dragStart)) {
×
UNCOV
1484
            const node = AngularEditor.toSlateNode(this.editor, event.target);
×
UNCOV
1485
            const path = AngularEditor.findPath(this.editor, node);
×
1486
            const voidMatch =
UNCOV
1487
                Element.isElement(node) && (Editor.isVoid(this.editor, node) || Editor.void(this.editor, { at: path, voids: true }));
×
1488

1489
            // If starting a drag on a void node, make sure it is selected
1490
            // so that it shows up in the selection's fragment.
1491
            if (voidMatch) {
×
1492
                const range = Editor.range(this.editor, path);
×
UNCOV
1493
                Transforms.select(this.editor, range);
×
1494
            }
1495

UNCOV
1496
            this.isDraggingInternally = true;
×
1497

1498
            AngularEditor.setFragmentData(this.editor, event.dataTransfer, 'drag');
×
1499
        }
1500
    }
1501

1502
    private onDOMDrop(event: DragEvent) {
UNCOV
1503
        const editor = this.editor;
×
UNCOV
1504
        if (!this.readonly && AngularEditor.hasTarget(this.editor, event.target) && !this.isDOMEventHandled(event, this.drop)) {
×
1505
            event.preventDefault();
×
1506
            // Keep a reference to the dragged range before updating selection
UNCOV
1507
            const draggedRange = editor.selection;
×
1508

1509
            // Find the range where the drop happened
UNCOV
1510
            const range = AngularEditor.findEventRange(editor, event);
×
UNCOV
1511
            const data = event.dataTransfer;
×
1512

1513
            Transforms.select(editor, range);
×
1514

1515
            if (this.isDraggingInternally) {
×
UNCOV
1516
                if (draggedRange) {
×
UNCOV
1517
                    Transforms.delete(editor, {
×
1518
                        at: draggedRange
1519
                    });
1520
                }
1521

UNCOV
1522
                this.isDraggingInternally = false;
×
1523
            }
1524

1525
            AngularEditor.insertData(editor, data);
×
1526

1527
            // When dragging from another source into the editor, it's possible
1528
            // that the current editor does not have focus.
UNCOV
1529
            if (!AngularEditor.isFocused(editor)) {
×
UNCOV
1530
                AngularEditor.focus(editor);
×
1531
            }
1532
        }
1533
    }
1534

1535
    private onDOMDragEnd(event: DragEvent) {
1536
        if (
×
1537
            !this.readonly &&
×
1538
            this.isDraggingInternally &&
1539
            AngularEditor.hasTarget(this.editor, event.target) &&
1540
            !this.isDOMEventHandled(event, this.dragEnd)
1541
        ) {
UNCOV
1542
            this.isDraggingInternally = false;
×
1543
        }
1544
    }
1545

1546
    private onDOMFocus(event: Event) {
1547
        if (
2✔
1548
            !this.readonly &&
8✔
1549
            !this.isUpdatingSelection &&
1550
            AngularEditor.hasEditableTarget(this.editor, event.target) &&
1551
            !this.isDOMEventHandled(event, this.focus)
1552
        ) {
1553
            const el = AngularEditor.toDOMNode(this.editor, this.editor);
2✔
1554
            const root = AngularEditor.findDocumentOrShadowRoot(this.editor);
2✔
1555
            this.latestElement = root.activeElement;
2✔
1556

1557
            // COMPAT: If the editor has nested editable elements, the focus
1558
            // can go to them. In Firefox, this must be prevented because it
1559
            // results in issues with keyboard navigation. (2017/03/30)
1560
            if (IS_FIREFOX && event.target !== el) {
2!
UNCOV
1561
                el.focus();
×
1562
                return;
×
1563
            }
1564

1565
            IS_FOCUSED.set(this.editor, true);
2✔
1566
        }
1567
    }
1568

1569
    private onDOMKeydown(event: KeyboardEvent) {
UNCOV
1570
        const editor = this.editor;
×
1571
        const root = AngularEditor.findDocumentOrShadowRoot(this.editor);
×
1572
        const { activeElement } = root;
×
UNCOV
1573
        if (
×
1574
            !this.readonly &&
×
1575
            AngularEditor.hasEditableTarget(editor, event.target) &&
1576
            !isTargetInsideVoid(editor, activeElement) && // stop fire keydown handle when focus void node
1577
            !this.isComposing &&
1578
            !this.isDOMEventHandled(event, this.keydown)
1579
        ) {
1580
            const nativeEvent = event;
×
UNCOV
1581
            const { selection } = editor;
×
1582

1583
            const element = editor.children[selection !== null ? selection.focus.path[0] : 0];
×
UNCOV
1584
            const isRTL = direction(Node.string(element)) === 'rtl';
×
1585

1586
            try {
×
1587
                // COMPAT: Since we prevent the default behavior on
1588
                // `beforeinput` events, the browser doesn't think there's ever
1589
                // any history stack to undo or redo, so we have to manage these
1590
                // hotkeys ourselves. (2019/11/06)
1591
                if (Hotkeys.isRedo(nativeEvent)) {
×
1592
                    event.preventDefault();
×
1593

UNCOV
1594
                    if (HistoryEditor.isHistoryEditor(editor)) {
×
UNCOV
1595
                        editor.redo();
×
1596
                    }
1597

UNCOV
1598
                    return;
×
1599
                }
1600

UNCOV
1601
                if (Hotkeys.isUndo(nativeEvent)) {
×
1602
                    event.preventDefault();
×
1603

1604
                    if (HistoryEditor.isHistoryEditor(editor)) {
×
UNCOV
1605
                        editor.undo();
×
1606
                    }
1607

1608
                    return;
×
1609
                }
1610

1611
                // COMPAT: Certain browsers don't handle the selection updates
1612
                // properly. In Chrome, the selection isn't properly extended.
1613
                // And in Firefox, the selection isn't properly collapsed.
1614
                // (2017/10/17)
UNCOV
1615
                if (Hotkeys.isMoveLineBackward(nativeEvent)) {
×
1616
                    event.preventDefault();
×
1617
                    Transforms.move(editor, { unit: 'line', reverse: true });
×
UNCOV
1618
                    return;
×
1619
                }
1620

1621
                if (Hotkeys.isMoveLineForward(nativeEvent)) {
×
UNCOV
1622
                    event.preventDefault();
×
UNCOV
1623
                    Transforms.move(editor, { unit: 'line' });
×
1624
                    return;
×
1625
                }
1626

1627
                if (Hotkeys.isExtendLineBackward(nativeEvent)) {
×
1628
                    event.preventDefault();
×
UNCOV
1629
                    Transforms.move(editor, {
×
1630
                        unit: 'line',
1631
                        edge: 'focus',
1632
                        reverse: true
1633
                    });
UNCOV
1634
                    return;
×
1635
                }
1636

UNCOV
1637
                if (Hotkeys.isExtendLineForward(nativeEvent)) {
×
1638
                    event.preventDefault();
×
UNCOV
1639
                    Transforms.move(editor, { unit: 'line', edge: 'focus' });
×
UNCOV
1640
                    return;
×
1641
                }
1642

1643
                // COMPAT: If a void node is selected, or a zero-width text node
1644
                // adjacent to an inline is selected, we need to handle these
1645
                // hotkeys manually because browsers won't be able to skip over
1646
                // the void node with the zero-width space not being an empty
1647
                // string.
UNCOV
1648
                if (Hotkeys.isMoveBackward(nativeEvent)) {
×
1649
                    event.preventDefault();
×
1650

UNCOV
1651
                    if (selection && Range.isCollapsed(selection)) {
×
1652
                        Transforms.move(editor, { reverse: !isRTL });
×
1653
                    } else {
UNCOV
1654
                        Transforms.collapse(editor, { edge: 'start' });
×
1655
                    }
1656

UNCOV
1657
                    return;
×
1658
                }
1659

UNCOV
1660
                if (Hotkeys.isMoveForward(nativeEvent)) {
×
UNCOV
1661
                    event.preventDefault();
×
UNCOV
1662
                    if (selection && Range.isCollapsed(selection)) {
×
1663
                        Transforms.move(editor, { reverse: isRTL });
×
1664
                    } else {
UNCOV
1665
                        Transforms.collapse(editor, { edge: 'end' });
×
1666
                    }
1667

UNCOV
1668
                    return;
×
1669
                }
1670

UNCOV
1671
                if (Hotkeys.isMoveWordBackward(nativeEvent)) {
×
1672
                    event.preventDefault();
×
1673

UNCOV
1674
                    if (selection && Range.isExpanded(selection)) {
×
UNCOV
1675
                        Transforms.collapse(editor, { edge: 'focus' });
×
1676
                    }
1677

UNCOV
1678
                    Transforms.move(editor, { unit: 'word', reverse: !isRTL });
×
UNCOV
1679
                    return;
×
1680
                }
1681

UNCOV
1682
                if (Hotkeys.isMoveWordForward(nativeEvent)) {
×
1683
                    event.preventDefault();
×
1684

UNCOV
1685
                    if (selection && Range.isExpanded(selection)) {
×
1686
                        Transforms.collapse(editor, { edge: 'focus' });
×
1687
                    }
1688

UNCOV
1689
                    Transforms.move(editor, { unit: 'word', reverse: isRTL });
×
UNCOV
1690
                    return;
×
1691
                }
1692

UNCOV
1693
                if (isKeyHotkey('mod+a', event)) {
×
1694
                    this.editor.selectAll();
×
UNCOV
1695
                    event.preventDefault();
×
UNCOV
1696
                    return;
×
1697
                }
1698

1699
                // COMPAT: Certain browsers don't support the `beforeinput` event, so we
1700
                // fall back to guessing at the input intention for hotkeys.
1701
                // COMPAT: In iOS, some of these hotkeys are handled in the
UNCOV
1702
                if (!HAS_BEFORE_INPUT_SUPPORT) {
×
1703
                    // We don't have a core behavior for these, but they change the
1704
                    // DOM if we don't prevent them, so we have to.
1705
                    if (Hotkeys.isBold(nativeEvent) || Hotkeys.isItalic(nativeEvent) || Hotkeys.isTransposeCharacter(nativeEvent)) {
×
1706
                        event.preventDefault();
×
UNCOV
1707
                        return;
×
1708
                    }
1709

UNCOV
1710
                    if (Hotkeys.isSplitBlock(nativeEvent)) {
×
1711
                        event.preventDefault();
×
1712
                        Editor.insertBreak(editor);
×
UNCOV
1713
                        return;
×
1714
                    }
1715

UNCOV
1716
                    if (Hotkeys.isDeleteBackward(nativeEvent)) {
×
UNCOV
1717
                        event.preventDefault();
×
1718

UNCOV
1719
                        if (selection && Range.isExpanded(selection)) {
×
UNCOV
1720
                            Editor.deleteFragment(editor, {
×
1721
                                direction: 'backward'
1722
                            });
1723
                        } else {
UNCOV
1724
                            Editor.deleteBackward(editor);
×
1725
                        }
1726

UNCOV
1727
                        return;
×
1728
                    }
1729

UNCOV
1730
                    if (Hotkeys.isDeleteForward(nativeEvent)) {
×
UNCOV
1731
                        event.preventDefault();
×
1732

UNCOV
1733
                        if (selection && Range.isExpanded(selection)) {
×
UNCOV
1734
                            Editor.deleteFragment(editor, {
×
1735
                                direction: 'forward'
1736
                            });
1737
                        } else {
UNCOV
1738
                            Editor.deleteForward(editor);
×
1739
                        }
1740

1741
                        return;
×
1742
                    }
1743

UNCOV
1744
                    if (Hotkeys.isDeleteLineBackward(nativeEvent)) {
×
UNCOV
1745
                        event.preventDefault();
×
1746

UNCOV
1747
                        if (selection && Range.isExpanded(selection)) {
×
UNCOV
1748
                            Editor.deleteFragment(editor, {
×
1749
                                direction: 'backward'
1750
                            });
1751
                        } else {
UNCOV
1752
                            Editor.deleteBackward(editor, { unit: 'line' });
×
1753
                        }
1754

UNCOV
1755
                        return;
×
1756
                    }
1757

1758
                    if (Hotkeys.isDeleteLineForward(nativeEvent)) {
×
1759
                        event.preventDefault();
×
1760

UNCOV
1761
                        if (selection && Range.isExpanded(selection)) {
×
UNCOV
1762
                            Editor.deleteFragment(editor, {
×
1763
                                direction: 'forward'
1764
                            });
1765
                        } else {
UNCOV
1766
                            Editor.deleteForward(editor, { unit: 'line' });
×
1767
                        }
1768

UNCOV
1769
                        return;
×
1770
                    }
1771

UNCOV
1772
                    if (Hotkeys.isDeleteWordBackward(nativeEvent)) {
×
UNCOV
1773
                        event.preventDefault();
×
1774

UNCOV
1775
                        if (selection && Range.isExpanded(selection)) {
×
UNCOV
1776
                            Editor.deleteFragment(editor, {
×
1777
                                direction: 'backward'
1778
                            });
1779
                        } else {
1780
                            Editor.deleteBackward(editor, { unit: 'word' });
×
1781
                        }
1782

UNCOV
1783
                        return;
×
1784
                    }
1785

UNCOV
1786
                    if (Hotkeys.isDeleteWordForward(nativeEvent)) {
×
UNCOV
1787
                        event.preventDefault();
×
1788

UNCOV
1789
                        if (selection && Range.isExpanded(selection)) {
×
UNCOV
1790
                            Editor.deleteFragment(editor, {
×
1791
                                direction: 'forward'
1792
                            });
1793
                        } else {
UNCOV
1794
                            Editor.deleteForward(editor, { unit: 'word' });
×
1795
                        }
1796

UNCOV
1797
                        return;
×
1798
                    }
1799
                } else {
UNCOV
1800
                    if (IS_CHROME || IS_SAFARI) {
×
1801
                        // COMPAT: Chrome and Safari support `beforeinput` event but do not fire
1802
                        // an event when deleting backwards in a selected void inline node
1803
                        if (
×
1804
                            selection &&
×
1805
                            (Hotkeys.isDeleteBackward(nativeEvent) || Hotkeys.isDeleteForward(nativeEvent)) &&
1806
                            Range.isCollapsed(selection)
1807
                        ) {
1808
                            const currentNode = Node.parent(editor, selection.anchor.path);
×
UNCOV
1809
                            if (
×
1810
                                Element.isElement(currentNode) &&
×
1811
                                Editor.isVoid(editor, currentNode) &&
1812
                                (Editor.isInline(editor, currentNode) || Editor.isBlock(editor, currentNode))
1813
                            ) {
UNCOV
1814
                                event.preventDefault();
×
1815
                                Editor.deleteBackward(editor, {
×
1816
                                    unit: 'block'
1817
                                });
UNCOV
1818
                                return;
×
1819
                            }
1820
                        }
1821
                    }
1822
                }
1823
            } catch (error) {
UNCOV
1824
                this.editor.onError({
×
1825
                    code: SlateErrorCode.OnDOMKeydownError,
1826
                    nativeError: error
1827
                });
1828
            }
1829
        }
1830
    }
1831

1832
    private onDOMPaste(event: ClipboardEvent) {
1833
        // COMPAT: Certain browsers don't support the `beforeinput` event, so we
1834
        // fall back to React's `onPaste` here instead.
1835
        // COMPAT: Firefox, Chrome and Safari are not emitting `beforeinput` events
1836
        // when "paste without formatting" option is used.
1837
        // This unfortunately needs to be handled with paste events instead.
UNCOV
1838
        if (
×
1839
            !this.isDOMEventHandled(event, this.paste) &&
×
1840
            (!HAS_BEFORE_INPUT_SUPPORT || isPlainTextOnlyPaste(event) || forceOnDOMPaste) &&
1841
            !this.readonly &&
1842
            AngularEditor.hasEditableTarget(this.editor, event.target)
1843
        ) {
UNCOV
1844
            event.preventDefault();
×
UNCOV
1845
            AngularEditor.insertData(this.editor, event.clipboardData);
×
1846
        }
1847
    }
1848

1849
    private onFallbackBeforeInput(event: BeforeInputEvent) {
1850
        // COMPAT: Certain browsers don't support the `beforeinput` event, so we
1851
        // fall back to React's leaky polyfill instead just for it. It
1852
        // only works for the `insertText` input type.
1853
        if (
×
1854
            !HAS_BEFORE_INPUT_SUPPORT &&
×
1855
            !this.readonly &&
1856
            !this.isDOMEventHandled(event.nativeEvent, this.beforeInput) &&
1857
            AngularEditor.hasEditableTarget(this.editor, event.nativeEvent.target)
1858
        ) {
1859
            event.nativeEvent.preventDefault();
×
UNCOV
1860
            try {
×
UNCOV
1861
                const text = event.data;
×
UNCOV
1862
                if (!Range.isCollapsed(this.editor.selection)) {
×
UNCOV
1863
                    Editor.deleteFragment(this.editor);
×
1864
                }
1865
                // just handle Non-IME input
UNCOV
1866
                if (!this.isComposing) {
×
UNCOV
1867
                    Editor.insertText(this.editor, text);
×
1868
                }
1869
            } catch (error) {
UNCOV
1870
                this.editor.onError({
×
1871
                    code: SlateErrorCode.ToNativeSelectionError,
1872
                    nativeError: error
1873
                });
1874
            }
1875
        }
1876
    }
1877

1878
    private isDOMEventHandled(event: Event, handler?: (event: Event) => void) {
1879
        if (!handler) {
3✔
1880
            return false;
3✔
1881
        }
UNCOV
1882
        handler(event);
×
UNCOV
1883
        return event.defaultPrevented;
×
1884
    }
1885
    //#endregion
1886

1887
    ngOnDestroy() {
1888
        this.editorResizeObserver?.disconnect();
23✔
1889
        NODE_TO_ELEMENT.delete(this.editor);
23✔
1890
        this.manualListeners.forEach(manualListener => {
23✔
1891
            manualListener();
483✔
1892
        });
1893
        this.destroy$.complete();
23✔
1894
        EDITOR_TO_ON_CHANGE.delete(this.editor);
23✔
1895
    }
1896
}
1897

1898
export const defaultScrollSelectionIntoView = (editor: AngularEditor, domRange: DOMRange) => {
1✔
1899
    // This was affecting the selection of multiple blocks and dragging behavior,
1900
    // so enabled only if the selection has been collapsed.
UNCOV
1901
    if (domRange.getBoundingClientRect && (!editor.selection || (editor.selection && Range.isCollapsed(editor.selection)))) {
×
UNCOV
1902
        const leafEl = domRange.startContainer.parentElement!;
×
1903

1904
        // COMPAT: In Chrome, domRange.getBoundingClientRect() can return zero dimensions for valid ranges (e.g. line breaks).
1905
        // When this happens, do not scroll like most editors do.
UNCOV
1906
        const domRect = domRange.getBoundingClientRect();
×
UNCOV
1907
        const isZeroDimensionRect = domRect.width === 0 && domRect.height === 0 && domRect.x === 0 && domRect.y === 0;
×
1908

UNCOV
1909
        if (isZeroDimensionRect) {
×
UNCOV
1910
            const leafRect = leafEl.getBoundingClientRect();
×
UNCOV
1911
            const leafHasDimensions = leafRect.width > 0 || leafRect.height > 0;
×
1912

UNCOV
1913
            if (leafHasDimensions) {
×
UNCOV
1914
                return;
×
1915
            }
1916
        }
1917

UNCOV
1918
        leafEl.getBoundingClientRect = domRange.getBoundingClientRect.bind(domRange);
×
UNCOV
1919
        scrollIntoView(leafEl, {
×
1920
            scrollMode: 'if-needed'
1921
        });
UNCOV
1922
        delete leafEl.getBoundingClientRect;
×
1923
    }
1924
};
1925

1926
/**
1927
 * Check if the target is inside void and in the editor.
1928
 */
1929

1930
const isTargetInsideVoid = (editor: AngularEditor, target: EventTarget | null): boolean => {
1✔
1931
    let slateNode: Node | null = null;
1✔
1932
    try {
1✔
1933
        slateNode = AngularEditor.hasTarget(editor, target) && AngularEditor.toSlateNode(editor, target);
1✔
1934
    } catch (error) {}
1935
    return slateNode && Element.isElement(slateNode) && Editor.isVoid(editor, slateNode);
1!
1936
};
1937

1938
const hasStringTarget = (domSelection: DOMSelection) => {
1✔
1939
    return (
2✔
1940
        (domSelection.anchorNode.parentElement.hasAttribute('data-slate-string') ||
4!
1941
            domSelection.anchorNode.parentElement.hasAttribute('data-slate-zero-width')) &&
1942
        (domSelection.focusNode.parentElement.hasAttribute('data-slate-string') ||
1943
            domSelection.focusNode.parentElement.hasAttribute('data-slate-zero-width'))
1944
    );
1945
};
1946

1947
/**
1948
 * remove default insert from composition
1949
 * @param text
1950
 */
1951
const preventInsertFromComposition = (event: Event, editor: AngularEditor) => {
1✔
UNCOV
1952
    const types = ['compositionend', 'insertFromComposition'];
×
UNCOV
1953
    if (!types.includes(event.type)) {
×
UNCOV
1954
        return;
×
1955
    }
UNCOV
1956
    const insertText = (event as CompositionEvent).data;
×
UNCOV
1957
    const window = AngularEditor.getWindow(editor);
×
UNCOV
1958
    const domSelection = window.getSelection();
×
1959
    // ensure text node insert composition input text
UNCOV
1960
    if (insertText && domSelection.anchorNode instanceof Text && domSelection.anchorNode.textContent.endsWith(insertText)) {
×
UNCOV
1961
        const textNode = domSelection.anchorNode;
×
UNCOV
1962
        textNode.splitText(textNode.length - insertText.length).remove();
×
1963
    }
1964
};
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc