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); });