diff --git a/.changeset/pre.json b/.changeset/pre.json
new file mode 100644
index 00000000..bf054487
--- /dev/null
+++ b/.changeset/pre.json
@@ -0,0 +1,8 @@
+{
+ "mode": "pre",
+ "tag": "next",
+ "initialVersions": {
+ "slate-angular": "20.1.0"
+ },
+ "changesets": []
+}
diff --git a/demo/app/huge-document/huge-document.component.html b/demo/app/huge-document/huge-document.component.html
index 5626810d..131930e5 100644
--- a/demo/app/huge-document/huge-document.component.html
+++ b/demo/app/huge-document/huge-document.component.html
@@ -1,3 +1,20 @@
+
+
+
+
+@if (mode === 'virtual') {
+
+
+
+
+}
@if (mode === 'default') {
;
+
constructor(private ngZone: NgZone) {}
ngOnInit() {
@@ -36,6 +45,22 @@ export class DemoHugeDocumentComponent implements OnInit, AfterViewInit {
this.ngZone.onStable.pipe(take(1)).subscribe(() => {
console.timeEnd();
});
+ this.syncVirtualConfig();
+ }
+
+ switchScrollMode(mode: 'default' | 'component' | 'virtual') {
+ this.mode = mode;
+ this.syncVirtualConfig();
+ }
+
+ @HostListener('window:scroll')
+ onWindowScroll() {
+ this.syncVirtualConfig();
+ }
+
+ @HostListener('window:resize')
+ onWindowResize() {
+ this.syncVirtualConfig();
}
renderElement() {
@@ -48,6 +73,17 @@ export class DemoHugeDocumentComponent implements OnInit, AfterViewInit {
}
valueChange(event) {}
+
+ private syncVirtualConfig() {
+ if (this.mode !== 'virtual') {
+ return;
+ }
+ this.virtualConfig = {
+ ...this.virtualConfig,
+ scrollTop: window.scrollY || 0,
+ viewportHeight: window.innerHeight || 0
+ };
+ }
}
export const buildInitialValue = () => {
@@ -68,5 +104,9 @@ export const buildInitialValue = () => {
});
}
}
+ initialValue.push({
+ type: 'paragraph',
+ children: [{ text: '==== END ====' }]
+ });
return initialValue;
};
diff --git a/packages/src/components/editable/editable.component.ts b/packages/src/components/editable/editable.component.ts
index 9a6d0be0..75d8548b 100644
--- a/packages/src/components/editable/editable.component.ts
+++ b/packages/src/components/editable/editable.component.ts
@@ -39,7 +39,15 @@ import {
IS_READ_ONLY
} from 'slate-dom';
import { Subject } from 'rxjs';
-import { IS_FIREFOX, IS_SAFARI, IS_CHROME, HAS_BEFORE_INPUT_SUPPORT, IS_ANDROID } from '../../utils/environment';
+import {
+ IS_FIREFOX,
+ IS_SAFARI,
+ IS_CHROME,
+ HAS_BEFORE_INPUT_SUPPORT,
+ IS_ANDROID,
+ VIRTUAL_SCROLL_DEFAULT_BUFFER_COUNT,
+ VIRTUAL_SCROLL_DEFAULT_BLOCK_HEIGHT
+} from '../../utils/environment';
import Hotkeys from '../../utils/hotkeys';
import { BeforeInputEvent, extractBeforeInputEvent } from '../../custom-event/BeforeInputEventPlugin';
import { BEFORE_INPUT_EVENTS } from '../../custom-event/before-input-polyfill';
@@ -48,15 +56,24 @@ import { NG_VALUE_ACCESSOR } from '@angular/forms';
import { SlateChildrenContext, SlateViewContext } from '../../view/context';
import { ViewType } from '../../types/view';
import { HistoryEditor } from 'slate-history';
-import { isDecoratorRangeListEqual } from '../../utils';
+import { ELEMENT_TO_COMPONENT, isDecoratorRangeListEqual } from '../../utils';
import { SlatePlaceholder } from '../../types/feature';
import { restoreDom } from '../../utils/restore-dom';
import { ListRender } from '../../view/render/list-render';
import { TRIPLE_CLICK, EDITOR_TO_ON_CHANGE } from 'slate-dom';
+import { BaseElementComponent } from '../../view/base';
+import { BaseElementFlavour } from '../../view/flavour/element';
// not correctly clipboardData on beforeinput
const forceOnDOMPaste = IS_SAFARI;
+export interface SlateVirtualScrollConfig {
+ enabled?: boolean;
+ scrollTop: number;
+ viewportHeight: number;
+ bufferCount?: number;
+}
+
@Component({
selector: 'slate-editable',
host: {
@@ -119,6 +136,22 @@ export class SlateEditable implements OnInit, OnChanges, OnDestroy, AfterViewChe
@Input() placeholder: string;
+ @Input()
+ set virtualScroll(config: SlateVirtualScrollConfig) {
+ this.virtualConfig = config;
+ this.refreshVirtualViewAnimId && cancelAnimationFrame(this.refreshVirtualViewAnimId);
+ this.refreshVirtualViewAnimId = requestAnimationFrame(() => {
+ this.refreshVirtualView();
+ if (this.listRender.initialized) {
+ this.listRender.update(this.renderedChildren, this.editor, this.context);
+ }
+ this.scheduleMeasureVisibleHeights();
+ });
+ }
+
+ @HostBinding('style.--virtual-top-padding.px') virtualTopPadding = 0;
+ @HostBinding('style.--virtual-bottom-padding.px') virtualBottomPadding = 0;
+
//#region input event handler
@Input() beforeInput: (event: Event) => void;
@Input() blur: (event: Event) => void;
@@ -159,6 +192,18 @@ export class SlateEditable implements OnInit, OnChanges, OnDestroy, AfterViewChe
listRender: ListRender;
+ private virtualConfig: SlateVirtualScrollConfig = {
+ enabled: false,
+ scrollTop: 0,
+ viewportHeight: 0
+ };
+ private renderedChildren: Element[] = [];
+ private virtualVisibleIndexes = new Set();
+ private measuredHeights = new Map();
+ private measurePending = false;
+ private refreshVirtualViewAnimId: number;
+ private measureVisibleHeightsAnimId: number;
+
constructor(
public elementRef: ElementRef,
public renderer2: Renderer2,
@@ -224,11 +269,14 @@ export class SlateEditable implements OnInit, OnChanges, OnDestroy, AfterViewChe
if (value && value.length) {
this.editor.children = value;
this.initializeContext();
+ this.refreshVirtualView();
+ const childrenForRender = this.renderedChildren;
if (!this.listRender.initialized) {
- this.listRender.initialize(this.editor.children, this.editor, this.context);
+ this.listRender.initialize(childrenForRender, this.editor, this.context);
} else {
- this.listRender.update(this.editor.children, this.editor, this.context);
+ this.listRender.update(childrenForRender, this.editor, this.context);
}
+ this.scheduleMeasureVisibleHeights();
this.cdr.markForCheck();
}
}
@@ -378,7 +426,9 @@ export class SlateEditable implements OnInit, OnChanges, OnDestroy, AfterViewChe
forceRender() {
this.updateContext();
- this.listRender.update(this.editor.children, this.editor, this.context);
+ this.refreshVirtualView();
+ this.listRender.update(this.renderedChildren, this.editor, this.context);
+ this.scheduleMeasureVisibleHeights();
// repair collaborative editing when Chinese input is interrupted by other users' cursors
// when the DOMElement where the selection is located is removed
// the compositionupdate and compositionend events will no longer be fired
@@ -418,7 +468,9 @@ export class SlateEditable implements OnInit, OnChanges, OnDestroy, AfterViewChe
render() {
const changed = this.updateContext();
if (changed) {
- this.listRender.update(this.editor.children, this.editor, this.context);
+ this.refreshVirtualView();
+ this.listRender.update(this.renderedChildren, this.editor, this.context);
+ this.scheduleMeasureVisibleHeights();
}
}
@@ -489,6 +541,139 @@ export class SlateEditable implements OnInit, OnChanges, OnDestroy, AfterViewChe
return decorations;
}
+ private shouldUseVirtual() {
+ return !!(this.virtualConfig && this.virtualConfig.enabled);
+ }
+
+ private refreshVirtualView() {
+ const children = (this.editor.children || []) as Element[];
+ if (!children.length || !this.shouldUseVirtual()) {
+ this.renderedChildren = children;
+ this.virtualTopPadding = 0;
+ this.virtualBottomPadding = 0;
+ this.virtualVisibleIndexes.clear();
+ return;
+ }
+ const scrollTop = this.virtualConfig.scrollTop ?? 0;
+ const viewportHeight = this.virtualConfig.viewportHeight ?? 0;
+ if (!viewportHeight) {
+ // 已经启用虚拟滚动,但可视区域高度还未获取到,先置空不渲染
+ this.renderedChildren = [];
+ this.virtualTopPadding = 0;
+ this.virtualBottomPadding = 0;
+ this.virtualVisibleIndexes.clear();
+ return;
+ }
+ const bufferCount = this.virtualConfig.bufferCount ?? VIRTUAL_SCROLL_DEFAULT_BUFFER_COUNT;
+ const heights = children.map((_, idx) => this.getBlockHeight(idx));
+ const accumulatedHeights = this.buildAccumulatedHeight(heights);
+ const total = accumulatedHeights[accumulatedHeights.length - 1] || 0;
+
+ let visibleStart = 0;
+ // 按真实或估算高度往后累加,找到滚动起点所在块
+ while (visibleStart < heights.length && accumulatedHeights[visibleStart + 1] <= scrollTop) {
+ visibleStart++;
+ }
+
+ // 向上预留 bufferCount 块
+ const startIndex = Math.max(0, visibleStart - bufferCount);
+ const top = accumulatedHeights[startIndex];
+ const bufferBelowHeight = this.getBufferBelowHeight(viewportHeight, visibleStart, bufferCount);
+ const targetHeight = accumulatedHeights[visibleStart] - top + viewportHeight + bufferBelowHeight;
+
+ const visible: Element[] = [];
+ const visibleIndexes: number[] = [];
+ let accumulated = 0;
+ let cursor = startIndex;
+ // 循环累计高度超出目标高度(可视高度 + 上下 buffer)
+ while (cursor < children.length && accumulated < targetHeight) {
+ visible.push(children[cursor]);
+ visibleIndexes.push(cursor);
+ accumulated += this.getBlockHeight(cursor);
+ cursor++;
+ }
+ const bottom = Math.max(total - top - accumulated, 0); // 下占位高度
+ this.renderedChildren = visible.length ? visible : children;
+ // padding 占位
+ this.virtualTopPadding = this.renderedChildren === visible ? Math.round(top) : 0;
+ this.virtualBottomPadding = this.renderedChildren === visible ? Math.round(bottom) : 0;
+ this.virtualVisibleIndexes = new Set(visibleIndexes);
+ }
+
+ private getBlockHeight(index: number) {
+ const node = this.editor.children[index];
+ if (!node) {
+ return VIRTUAL_SCROLL_DEFAULT_BLOCK_HEIGHT;
+ }
+ const key = AngularEditor.findKey(this.editor, node);
+ return this.measuredHeights.get(key.id) ?? VIRTUAL_SCROLL_DEFAULT_BLOCK_HEIGHT;
+ }
+
+ private buildAccumulatedHeight(heights: number[]) {
+ const accumulatedHeights = new Array(heights.length + 1).fill(0);
+ for (let i = 0; i < heights.length; i++) {
+ // 存储前 i 个的累计高度
+ accumulatedHeights[i + 1] = accumulatedHeights[i] + heights[i];
+ }
+ return accumulatedHeights;
+ }
+
+ private getBufferBelowHeight(viewportHeight: number, visibleStart: number, bufferCount: number) {
+ let blockHeight = 0;
+ let start = visibleStart;
+ // 循环累计高度超出视图高度代表找到向下缓冲区的起始位置
+ while (blockHeight < viewportHeight) {
+ blockHeight += this.getBlockHeight(start);
+ start++;
+ }
+ let bufferHeight = 0;
+ for (let i = start; i < start + bufferCount; i++) {
+ bufferHeight += this.getBlockHeight(i);
+ }
+ return bufferHeight;
+ }
+
+ private scheduleMeasureVisibleHeights() {
+ if (!this.shouldUseVirtual()) {
+ return;
+ }
+ if (this.measurePending) {
+ return;
+ }
+ this.measurePending = true;
+ this.measureVisibleHeightsAnimId && cancelAnimationFrame(this.measureVisibleHeightsAnimId);
+ this.measureVisibleHeightsAnimId = requestAnimationFrame(() => {
+ this.measureVisibleHeights();
+ this.measurePending = false;
+ });
+ }
+
+ private measureVisibleHeights() {
+ const children = (this.editor.children || []) as Element[];
+ this.virtualVisibleIndexes.forEach(index => {
+ const node = children[index];
+ if (!node) {
+ return;
+ }
+ const key = AngularEditor.findKey(this.editor, node);
+ // 跳过已测过的块
+ if (this.measuredHeights.has(key.id)) {
+ return;
+ }
+ const view = ELEMENT_TO_COMPONENT.get(node);
+ if (!view) {
+ return;
+ }
+ (view as BaseElementComponent | BaseElementFlavour).getRealHeight()?.then(height => {
+ const actualHeight =
+ height +
+ parseFloat(getComputedStyle(view.nativeElement).marginTop) +
+ parseFloat(getComputedStyle(view.nativeElement).marginBottom);
+ this.measuredHeights.set(key.id, actualHeight);
+ });
+ });
+ }
+
//#region event proxy
private addEventListener(eventName: string, listener: EventListener, target: HTMLElement | Document = this.elementRef.nativeElement) {
this.manualListeners.push(
diff --git a/packages/src/styles/index.scss b/packages/src/styles/index.scss
index e49f4a3d..f737fc3d 100644
--- a/packages/src/styles/index.scss
+++ b/packages/src/styles/index.scss
@@ -2,8 +2,21 @@
.slate-editable-container {
display: block;
outline: none;
- padding: 32px;
+ padding: 0 32px;
white-space: break-spaces;
+ &::before,
+ &::after {
+ content: '';
+ display: block;
+ pointer-events: none;
+ height: 0;
+ }
+ &::before {
+ height: var(--virtual-top-padding, 0px);
+ }
+ &::after {
+ height: var(--virtual-bottom-padding, 0px);
+ }
& [contenteditable='true'] {
outline: none;
}
diff --git a/packages/src/utils/environment.ts b/packages/src/utils/environment.ts
index 8cb8e999..303c48dd 100644
--- a/packages/src/utils/environment.ts
+++ b/packages/src/utils/environment.ts
@@ -47,3 +47,7 @@ export const HAS_BEFORE_INPUT_SUPPORT =
globalThis.InputEvent &&
// @ts-ignore The `getTargetRanges` property isn't recognized.
typeof globalThis.InputEvent.prototype.getTargetRanges === 'function';
+
+export const VIRTUAL_SCROLL_DEFAULT_BUFFER_COUNT = 3;
+
+export const VIRTUAL_SCROLL_DEFAULT_BLOCK_HEIGHT = 40;
diff --git a/packages/src/view/base.ts b/packages/src/view/base.ts
index 70794791..87028b6e 100644
--- a/packages/src/view/base.ts
+++ b/packages/src/view/base.ts
@@ -198,6 +198,10 @@ export class BaseElementComponent {
+ return Promise.resolve(this.nativeElement.offsetHeight);
+ }
}
/**
diff --git a/packages/src/view/flavour/element.ts b/packages/src/view/flavour/element.ts
index f986de49..c9243735 100644
--- a/packages/src/view/flavour/element.ts
+++ b/packages/src/view/flavour/element.ts
@@ -6,6 +6,8 @@ import { addAfterViewInitQueue, ListRender } from '../render/list-render';
import { ELEMENT_TO_NODE, NODE_TO_ELEMENT } from 'slate-dom';
import { ELEMENT_TO_COMPONENT } from '../../utils/weak-maps';
+export const DEFAULT_ELEMENT_HEIGHT = 24;
+
export abstract class BaseElementFlavour extends BaseFlavour<
SlateElementContext,
K
@@ -119,6 +121,10 @@ export abstract class BaseElementFlavour {
+ return Promise.resolve(this.nativeElement.offsetHeight);
+ }
+
abstract render(): void;
abstract rerender(): void;
diff --git a/packages/src/view/render/list-render.ts b/packages/src/view/render/list-render.ts
index 31288784..6a6b519e 100644
--- a/packages/src/view/render/list-render.ts
+++ b/packages/src/view/render/list-render.ts
@@ -32,11 +32,13 @@ export class ListRender {
public initialize(children: Descendant[], parent: Ancestor, childrenContext: SlateChildrenContext) {
this.initialized = true;
this.children = children;
+ const isRoot = parent === this.viewContext.editor;
+ const firstIndex = isRoot ? this.viewContext.editor.children.indexOf(children[0]) : 0;
const parentPath = AngularEditor.findPath(this.viewContext.editor, parent);
- children.forEach((descendant, index) => {
- NODE_TO_INDEX.set(descendant, index);
+ children.forEach((descendant, _index) => {
+ NODE_TO_INDEX.set(descendant, firstIndex + _index);
NODE_TO_PARENT.set(descendant, parent);
- const context = getContext(index, descendant, parentPath, childrenContext, this.viewContext);
+ const context = getContext(firstIndex + _index, descendant, parentPath, childrenContext, this.viewContext);
const viewType = getViewType(descendant, parent, this.viewContext);
const view = createEmbeddedViewOrComponentOrFlavour(viewType, context, this.viewContext, this.viewContainerRef);
const blockCard = createBlockCard(descendant, view, this.viewContext);
@@ -65,6 +67,8 @@ export class ListRender {
const outletParent = this.getOutletParent();
const diffResult = this.differ.diff(children);
const parentPath = AngularEditor.findPath(this.viewContext.editor, parent);
+ const isRoot = parent === this.viewContext.editor;
+ const firstIndex = isRoot ? this.viewContext.editor.children.indexOf(children[0]) : 0;
if (diffResult) {
let firstRootNode = getRootNodes(this.views[0], this.blockCards[0])[0];
const newContexts = [];
@@ -72,9 +76,10 @@ export class ListRender {
const newViews = [];
const newBlockCards: (BlockCardRef | null)[] = [];
diffResult.forEachItem(record => {
- NODE_TO_INDEX.set(record.item, record.currentIndex);
+ const currentIndex = firstIndex + record.currentIndex;
+ NODE_TO_INDEX.set(record.item, currentIndex);
NODE_TO_PARENT.set(record.item, parent);
- let context = getContext(record.currentIndex, record.item, parentPath, childrenContext, this.viewContext);
+ let context = getContext(currentIndex, record.item, parentPath, childrenContext, this.viewContext);
const viewType = getViewType(record.item, parent, this.viewContext);
newViewTypes.push(viewType);
let view: EmbeddedViewRef | ComponentRef | FlavourRef;
@@ -154,15 +159,15 @@ export class ListRender {
}
} else {
const newContexts = [];
- this.children.forEach((child, index) => {
- NODE_TO_INDEX.set(child, index);
+ this.children.forEach((child, _index) => {
+ NODE_TO_INDEX.set(child, firstIndex + _index);
NODE_TO_PARENT.set(child, parent);
- let context = getContext(index, child, parentPath, childrenContext, this.viewContext);
- const previousContext = this.contexts[index];
+ let context = getContext(firstIndex + _index, child, parentPath, childrenContext, this.viewContext);
+ const previousContext = this.contexts[_index];
if (memoizedContext(this.viewContext, child, previousContext as any, context as any)) {
context = previousContext;
} else {
- updateContext(this.views[index], context, this.viewContext);
+ updateContext(this.views[_index], context, this.viewContext);
}
newContexts.push(context);
});