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

worktile / slate-angular / 35a3ce5b-aada-4a4f-b885-fdc3ea26d53c

24 Dec 2025 02:36AM UTC coverage: 36.769% (-0.1%) from 36.904%
35a3ce5b-aada-4a4f-b885-fdc3ea26d53c

push

circleci

web-flow
feat(virtual-scroll): support pre render top elements (#329)

* chore: remove addedTop recalculate

* feat(virtual-scroll): improve virtual scroll performance and add pre-rendering

add EDITOR_TO_WIDTH weakmap to track editor width
implement pre-rendering of offscreen elements to reduce jank
store huge document value in localStorage for persistence
simplify anchor scroll logic by using fixed index
optimize virtual viewport updates and height measurements

* chore: xx

* fix: list-render error

* feat: apply overflow-anchor: none

* fix: xxx

* chore: remove setting

* chore: remove EDITOR_TO_WIDTH

* feat: revert measure update

* chore: remove

* refactor: xxx

* feat(virtual-scroll): support pre render top elements

* chore: revert header

382 of 1242 branches covered (30.76%)

Branch coverage included in aggregate %.

3 of 59 new or added lines in 2 files covered. (5.08%)

2 existing lines in 1 file now uncovered.

1077 of 2726 relevant lines covered (39.51%)

23.97 hits per line

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

86.81
/packages/src/view/render/list-render.ts
1
import { Ancestor, Descendant, Range, Editor, Element, Path } from 'slate';
2
import { ComponentRef, EmbeddedViewRef, IterableDiffer, IterableDiffers, ViewContainerRef } from '@angular/core';
3
import { ViewType } from '../../types/view';
4
import { SlateChildrenContext, SlateElementContext, SlateTextContext, SlateViewContext } from '../context';
5
import { AngularEditor } from '../../plugins/angular-editor';
6
import { SlateErrorCode } from '../../types/error';
7
import { EDITOR_TO_AFTER_VIEW_INIT_QUEUE } from '../../utils/weak-maps';
8
import { isDecoratorRangeListEqual } from '../../utils/range-list';
9
import { createEmbeddedViewOrComponentOrFlavour, getRootNodes, mount, mountOnItemChange, updateContext } from './utils';
10
import { NODE_TO_INDEX, NODE_TO_PARENT } from 'slate-dom';
11
import { DefaultElementFlavour } from '../../components/element.flavour';
12
import { DefaultTextFlavour, VoidTextFlavour } from '../../components/text/default-text.flavour';
13
import { BlockCardRef, FlavourRef } from '../flavour/ref';
14
import { SlateBlockCard } from '../../components/block-card/block-card';
15

16
export class ListRender {
17
    private children: Descendant[] = [];
218✔
18
    private views: (EmbeddedViewRef<any> | ComponentRef<any> | FlavourRef)[] = [];
218✔
19
    private blockCards: (BlockCardRef | null)[] = [];
218✔
20
    private contexts: (SlateTextContext | SlateElementContext)[] = [];
218✔
21
    private viewTypes: ViewType[] = [];
218✔
22
    private differ: IterableDiffer<any> | null = null;
218✔
23
    public initialized = false;
218✔
24
    private preRenderingHTMLElement: HTMLElement[][] = [];
218✔
25

26
    constructor(
27
        private viewContext: SlateViewContext,
218✔
28
        private viewContainerRef: ViewContainerRef,
218✔
29
        private getOutletParent: () => HTMLElement,
218✔
30
        private getOutletElement: () => HTMLElement | null
218✔
31
    ) {}
32

33
    public initialize(children: Descendant[], parent: Ancestor, childrenContext: SlateChildrenContext, preRenderingCount = 0) {
218✔
34
        this.initialized = true;
218✔
35
        this.children = children;
218✔
36
        const isRoot = parent === this.viewContext.editor;
218✔
37
        const firstIndex = isRoot ? this.viewContext.editor.children.indexOf(children[0]) : 0;
218✔
38
        const parentPath = AngularEditor.findPath(this.viewContext.editor, parent);
218✔
39
        children.forEach((descendant, _index) => {
218✔
40
            NODE_TO_INDEX.set(descendant, firstIndex + _index);
338✔
41
            NODE_TO_PARENT.set(descendant, parent);
338✔
42
            const context = getContext(firstIndex + _index, descendant, parentPath, childrenContext, this.viewContext);
338✔
43
            const viewType = getViewType(descendant, parent, this.viewContext);
338✔
44
            const view = createEmbeddedViewOrComponentOrFlavour(viewType, context, this.viewContext, this.viewContainerRef);
338✔
45
            const blockCard = createBlockCard(descendant, view, this.viewContext);
338✔
46
            this.views.push(view);
338✔
47
            this.contexts.push(context);
338✔
48
            this.viewTypes.push(viewType);
338✔
49
            this.blockCards.push(blockCard);
338✔
50
        });
51
        mount(this.views, this.blockCards, this.getOutletParent(), this.getOutletElement());
218✔
52
        const newDiffers = this.viewContext.editor.injector.get(IterableDiffers);
218✔
53
        this.differ = newDiffers.find(children).create(trackBy(this.viewContext));
218✔
54
        this.differ.diff(children);
218✔
55
        if (parent === this.viewContext.editor) {
218✔
56
            executeAfterViewInit(this.viewContext.editor);
23✔
57
        }
58
    }
59

60
    public update(children: Descendant[], parent: Ancestor, childrenContext: SlateChildrenContext, preRenderingCount = 0) {
40✔
61
        if (!this.initialized || this.children.length === 0) {
40!
NEW
62
            this.initialize(children, parent, childrenContext, preRenderingCount);
×
63
            return;
×
64
        }
65
        if (!this.differ) {
40!
66
            throw new Error('Exception: Can not find differ ');
×
67
        }
68
        const outletParent = this.getOutletParent();
40✔
69
        if (this.preRenderingHTMLElement.length > 0) {
40!
NEW
70
            const preRenderingElement = [...this.preRenderingHTMLElement];
×
NEW
71
            preRenderingElement.forEach((rootNodes, index) => {
×
NEW
72
                rootNodes.forEach(rootNode => {
×
NEW
73
                    rootNode.style.position = '';
×
NEW
74
                    rootNode.style.top = '';
×
NEW
75
                    rootNode.style.width = '';
×
76
                });
77
            });
NEW
78
            this.preRenderingHTMLElement = [];
×
79
        }
80
        const diffResult = this.differ.diff(children);
40✔
81
        const parentPath = AngularEditor.findPath(this.viewContext.editor, parent);
40✔
82
        const isRoot = parent === this.viewContext.editor;
40✔
83
        const firstIndex = isRoot ? this.viewContext.editor.children.indexOf(children[0]) : 0;
40✔
84
        if (diffResult) {
40✔
85
            let firstRootNode = getRootNodes(this.views[0], this.blockCards[0])[0];
28✔
86
            const newContexts = [];
28✔
87
            const newViewTypes = [];
28✔
88
            const newViews = [];
28✔
89
            const newBlockCards: (BlockCardRef | null)[] = [];
28✔
90
            diffResult.forEachItem(record => {
28✔
91
                const currentIndex = firstIndex + record.currentIndex;
68✔
92
                NODE_TO_INDEX.set(record.item, currentIndex);
68✔
93
                NODE_TO_PARENT.set(record.item, parent);
68✔
94
                let context = getContext(currentIndex, record.item, parentPath, childrenContext, this.viewContext);
68✔
95
                const viewType = getViewType(record.item, parent, this.viewContext);
68✔
96
                newViewTypes.push(viewType);
68✔
97
                let view: EmbeddedViewRef<any> | ComponentRef<any> | FlavourRef;
98
                let blockCard: BlockCardRef | null;
99
                if (record.previousIndex === null) {
68✔
100
                    view = createEmbeddedViewOrComponentOrFlavour(viewType, context, this.viewContext, this.viewContainerRef);
7✔
101
                    blockCard = createBlockCard(record.item, view, this.viewContext);
7✔
102
                    newContexts.push(context);
7✔
103
                    newViews.push(view);
7✔
104
                    newBlockCards.push(blockCard);
7✔
105
                    mountOnItemChange(
7✔
106
                        record.currentIndex,
107
                        record.item,
108
                        newViews,
109
                        newBlockCards,
110
                        outletParent,
111
                        firstRootNode,
112
                        this.viewContext
113
                    );
114
                } else {
115
                    const previousView = this.views[record.previousIndex];
61✔
116
                    const previousViewType = this.viewTypes[record.previousIndex];
61✔
117
                    const previousContext = this.contexts[record.previousIndex];
61✔
118
                    const previousBlockCard = this.blockCards[record.previousIndex];
61✔
119
                    if (previousViewType !== viewType) {
61!
120
                        view = createEmbeddedViewOrComponentOrFlavour(viewType, context, this.viewContext, this.viewContainerRef);
×
121
                        blockCard = createBlockCard(record.item, view, this.viewContext);
×
122
                        const firstRootNode = getRootNodes(previousView, previousBlockCard)[0];
×
123
                        const newRootNodes = getRootNodes(view, blockCard);
×
124
                        firstRootNode.replaceWith(...newRootNodes);
×
125
                        previousView.destroy();
×
126
                        previousBlockCard?.destroy();
×
127
                    } else {
128
                        view = previousView;
61✔
129
                        blockCard = previousBlockCard;
61✔
130
                        if (memoizedContext(this.viewContext, record.item, previousContext as any, context as any)) {
61✔
131
                            context = previousContext;
44✔
132
                        } else {
133
                            updateContext(previousView, context, this.viewContext);
17✔
134
                        }
135
                    }
136
                    newContexts.push(context);
61✔
137
                    newViews.push(view);
61✔
138
                    newBlockCards.push(blockCard);
61✔
139
                }
140
            });
141
            diffResult.forEachOperation(record => {
28✔
142
                // removed
143
                if (record.currentIndex === null) {
19✔
144
                    const view = this.views[record.previousIndex];
5✔
145
                    const blockCard = this.blockCards[record.previousIndex];
5✔
146
                    view.destroy();
5✔
147
                    blockCard?.destroy();
5✔
148
                }
149
                // moved
150
                if (record.previousIndex !== null && record.currentIndex !== null) {
19✔
151
                    mountOnItemChange(
7✔
152
                        record.currentIndex,
153
                        record.item,
154
                        newViews,
155
                        newBlockCards,
156
                        outletParent,
157
                        firstRootNode,
158
                        this.viewContext
159
                    );
160
                    // Solve the block-card DOMElement loss when moving nodes
161
                    newBlockCards[record.currentIndex]?.instance.append();
7✔
162
                }
163
            });
164
            this.viewTypes = newViewTypes;
28✔
165
            this.views = newViews;
28✔
166
            this.contexts = newContexts;
28✔
167
            this.children = children;
28✔
168
            this.blockCards = newBlockCards;
28✔
169
            if (parent === this.viewContext.editor) {
28✔
170
                executeAfterViewInit(this.viewContext.editor);
14✔
171
            }
172
        } else {
173
            const newContexts = [];
12✔
174
            this.children.forEach((child, _index) => {
12✔
175
                NODE_TO_INDEX.set(child, firstIndex + _index);
61✔
176
                NODE_TO_PARENT.set(child, parent);
61✔
177
                let context = getContext(firstIndex + _index, child, parentPath, childrenContext, this.viewContext);
61✔
178
                const previousContext = this.contexts[_index];
61✔
179
                if (memoizedContext(this.viewContext, child, previousContext as any, context as any)) {
61✔
180
                    context = previousContext;
52✔
181
                } else {
182
                    updateContext(this.views[_index], context, this.viewContext);
9✔
183
                }
184
                newContexts.push(context);
61✔
185
            });
186
            this.contexts = newContexts;
12✔
187
        }
188
        if (preRenderingCount > 0) {
40!
NEW
189
            for (let i = 0; i < preRenderingCount; i++) {
×
NEW
190
                const rootNodes = [...getRootNodes(this.views[i], this.blockCards[i])];
×
NEW
191
                rootNodes.forEach(rootNode => {
×
NEW
192
                    rootNode.style = `position: absolute;top: -100%;`;
×
193
                });
NEW
194
                this.preRenderingHTMLElement.push(rootNodes);
×
195
            }
196
        }
197
    }
198

199
    public destroy() {
200
        this.children.forEach((element: Element, index: number) => {
9✔
201
            if (this.views[index]) {
10✔
202
                this.views[index].destroy();
10✔
203
            }
204
            if (this.blockCards[index]) {
10!
205
                this.blockCards[index].destroy();
×
206
            }
207
        });
208
        this.children = [];
9✔
209
        this.views = [];
9✔
210
        this.blockCards = [];
9✔
211
        this.contexts = [];
9✔
212
        this.viewTypes = [];
9✔
213
        this.initialized = false;
9✔
214
        this.differ = null;
9✔
215
    }
216
}
217

218
export function getContext(
219
    index: number,
220
    item: Descendant,
221
    parentPath: Path,
222
    childrenContext: SlateChildrenContext,
223
    viewContext: SlateViewContext
224
): SlateElementContext | SlateTextContext {
225
    if (Element.isElement(item)) {
467✔
226
        const computedContext = getCommonContext(index, item, parentPath, viewContext, childrenContext);
308✔
227
        const key = AngularEditor.findKey(viewContext.editor, item);
308✔
228
        const isInline = viewContext.editor.isInline(item);
308✔
229
        const isVoid = viewContext.editor.isVoid(item);
308✔
230
        const elementContext: SlateElementContext = {
308✔
231
            element: item,
232
            ...computedContext,
233
            attributes: {
234
                'data-slate-node': 'element',
235
                'data-slate-key': key.id
236
            },
237
            decorate: childrenContext.decorate,
238
            readonly: childrenContext.readonly
239
        };
240
        if (isInline) {
308!
241
            elementContext.attributes['data-slate-inline'] = true;
×
242
        }
243
        if (isVoid) {
308✔
244
            elementContext.attributes['data-slate-void'] = true;
1✔
245
        }
246
        // add contentEditable for block element only to avoid chinese input be broken
247
        if (isVoid && !isInline) {
308✔
248
            elementContext.contentEditable = false;
1✔
249
        }
250
        return elementContext;
308✔
251
    } else {
252
        const computedContext = getCommonContext(index, item, parentPath, viewContext, childrenContext);
159✔
253
        const isLeafBlock = AngularEditor.isLeafBlock(viewContext.editor, childrenContext.parent);
159✔
254
        const textContext: SlateTextContext = {
159✔
255
            decorations: computedContext.decorations,
256
            isLast: isLeafBlock && index === childrenContext.parent.children.length - 1,
318✔
257
            parent: childrenContext.parent as Element,
258
            text: item
259
        };
260
        return textContext;
159✔
261
    }
262
}
263

264
export function getCommonContext(
265
    index: number,
266
    item: Descendant,
267
    parentPath: Path,
268
    viewContext: SlateViewContext,
269
    childrenContext: SlateChildrenContext
270
): { selection: Range; decorations: Range[] } {
271
    const p = parentPath.concat(index);
467✔
272
    try {
467✔
273
        const ds = childrenContext.decorate([item, p]);
467✔
274
        // [list-render] performance optimization: reduce the number of calls to the `Editor.range(viewContext.editor, p)` method
275
        if (childrenContext.selection || childrenContext.decorations.length > 0) {
467✔
276
            const range = Editor.range(viewContext.editor, p);
73✔
277
            const sel = childrenContext.selection && Range.intersection(range, childrenContext.selection);
73✔
278
            for (const dec of childrenContext.decorations) {
73✔
279
                const d = Range.intersection(dec, range);
6✔
280
                if (d) {
6✔
281
                    ds.push(d);
6✔
282
                }
283
            }
284
            return { selection: sel, decorations: ds };
73✔
285
        } else {
286
            return { selection: null, decorations: ds };
394✔
287
        }
288
    } catch (error) {
289
        viewContext.editor.onError({
×
290
            code: SlateErrorCode.GetStartPointError,
291
            nativeError: error
292
        });
293
        return { selection: null, decorations: [] };
×
294
    }
295
}
296

297
export function getViewType(item: Descendant, parent: Ancestor, viewContext: SlateViewContext) {
298
    if (Element.isElement(item)) {
406✔
299
        return (viewContext.renderElement && viewContext.renderElement(item)) || DefaultElementFlavour;
253✔
300
    } else {
301
        const isVoid = viewContext.editor.isVoid(parent as Element);
153✔
302
        return isVoid ? VoidTextFlavour : (viewContext.renderText && viewContext.renderText(item)) || DefaultTextFlavour;
153!
303
    }
304
}
305

306
export function createBlockCard(
307
    item: Descendant,
308
    view: EmbeddedViewRef<any> | ComponentRef<any> | FlavourRef,
309
    viewContext: SlateViewContext
310
) {
311
    const isBlockCard = viewContext.editor.isBlockCard(item);
345✔
312
    if (isBlockCard) {
345✔
313
        const rootNodes = getRootNodes(view);
1✔
314
        const blockCardRef = new BlockCardRef();
1✔
315
        blockCardRef.instance = new SlateBlockCard();
1✔
316
        blockCardRef.instance.onInit();
1✔
317
        blockCardRef.instance.initializeCenter(rootNodes);
1✔
318
        return blockCardRef;
1✔
319
    } else {
320
        return null;
344✔
321
    }
322
}
323

324
export function trackBy(viewContext: SlateViewContext) {
325
    return (index, node) => {
218✔
326
        return viewContext.trackBy(node) || AngularEditor.findKey(viewContext.editor, node);
467✔
327
    };
328
}
329

330
export function memoizedContext(
331
    viewContext: SlateViewContext,
332
    descendant: Descendant,
333
    prev: SlateElementContext | SlateTextContext,
334
    next: SlateElementContext | SlateTextContext
335
): boolean {
336
    if (Element.isElement(descendant)) {
122✔
337
        return memoizedElementContext(viewContext, prev as SlateElementContext, next as SlateElementContext);
113✔
338
    } else {
339
        return memoizedTextContext(prev as SlateTextContext, next as SlateTextContext);
9✔
340
    }
341
}
342

343
export function memoizedElementContext(viewContext: SlateViewContext, prev: SlateElementContext, next: SlateElementContext) {
344
    return (
113✔
345
        prev.element === next.element &&
602!
346
        (!viewContext.isStrictDecorate || prev.decorate === next.decorate) &&
347
        prev.readonly === next.readonly &&
348
        isDecoratorRangeListEqual(prev.decorations, next.decorations) &&
349
        (prev.selection === next.selection || (!!prev.selection && !!next.selection && Range.equals(prev.selection, next.selection)))
350
    );
351
}
352

353
export function memoizedTextContext(prev: SlateTextContext, next: SlateTextContext) {
354
    return (
9✔
355
        next.parent === prev.parent &&
27✔
356
        next.isLast === prev.isLast &&
357
        next.text === prev.text &&
358
        isDecoratorRangeListEqual(next.decorations, prev.decorations)
359
    );
360
}
361

362
export function addAfterViewInitQueue(editor: Editor, afterViewInitCallback: () => void) {
363
    const queue = getAfterViewInitQueue(editor);
195✔
364
    queue.push(afterViewInitCallback);
195✔
365
    EDITOR_TO_AFTER_VIEW_INIT_QUEUE.set(editor, queue);
195✔
366
}
367

368
export function getAfterViewInitQueue(editor: Editor) {
369
    return EDITOR_TO_AFTER_VIEW_INIT_QUEUE.get(editor) || [];
232✔
370
}
371

372
export function clearAfterViewInitQueue(editor: Editor) {
373
    EDITOR_TO_AFTER_VIEW_INIT_QUEUE.set(editor, []);
37✔
374
}
375

376
export function executeAfterViewInit(editor: Editor) {
377
    const queue = getAfterViewInitQueue(editor);
37✔
378
    queue.forEach(callback => callback());
195✔
379
    clearAfterViewInitQueue(editor);
37✔
380
}
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