feat: improve editor performance (#14429)

#### PR Dependency Tree


* **PR #14429** 👈

This tree was auto-generated by
[Charcoal](https://github.com/danerwilliams/charcoal)

<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## Summary by CodeRabbit

* **New Features**
* HTML import now splits lines on <br> into separate paragraphs while
preserving inline formatting.

* **Bug Fixes**
* Paste falls back to inserting after the first paragraph when no
explicit target is found.

* **Style**
  * Improved page-mode viewport styling for consistent content layout.

* **Tests**
* Added snapshot tests for <br>-based paragraph splitting; re-enabled an
e2e drag-page test.

* **Chores**
* Deferred/deduplicated font loading, inline text caching,
drag-handle/pointer optimizations, and safer inline render
synchronization.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
This commit is contained in:
DarkSky
2026-02-14 00:43:36 +08:00
committed by GitHub
parent 98e5747fdc
commit 72df9cb457
14 changed files with 873 additions and 111 deletions

View File

@@ -10,25 +10,15 @@ import type { InlineRange } from '../types.js';
import { deltaInsertsToChunks } from '../utils/delta-convert.js';
export class RenderService<TextAttributes extends BaseTextAttributes> {
private readonly _onYTextChange = (
_: Y.YTextEvent,
transaction: Y.Transaction
) => {
this.editor.slots.textChange.next();
private _pendingRemoteInlineRangeSync = false;
const yText = this.editor.yText;
private _carriageReturnValidationCounter = 0;
if (yText.toString().includes('\r')) {
throw new BlockSuiteError(
ErrorCode.InlineEditorError,
'yText must not contain "\\r" because it will break the range synchronization'
);
}
this.render();
private _renderVersion = 0;
private readonly _syncRemoteInlineRange = () => {
const inlineRange = this.editor.inlineRange$.peek();
if (!inlineRange || transaction.local) return;
if (!inlineRange) return;
const lastStartRelativePosition = this.editor.lastStartRelativePosition;
const lastEndRelativePosition = this.editor.lastEndRelativePosition;
@@ -50,7 +40,7 @@ export class RenderService<TextAttributes extends BaseTextAttributes> {
const startIndex = absoluteStart?.index;
const endIndex = absoluteEnd?.index;
if (!startIndex || !endIndex) return;
if (startIndex == null || endIndex == null) return;
const newInlineRange: InlineRange = {
index: startIndex,
@@ -59,7 +49,31 @@ export class RenderService<TextAttributes extends BaseTextAttributes> {
if (!this.editor.isValidInlineRange(newInlineRange)) return;
this.editor.setInlineRange(newInlineRange);
this.editor.syncInlineRange();
};
private readonly _onYTextChange = (
_: Y.YTextEvent,
transaction: Y.Transaction
) => {
this.editor.slots.textChange.next();
const yText = this.editor.yText;
if (
(this._carriageReturnValidationCounter++ & 0x3f) === 0 &&
yText.toString().includes('\r')
) {
throw new BlockSuiteError(
ErrorCode.InlineEditorError,
'yText must not contain "\\r" because it will break the range synchronization'
);
}
if (!transaction.local) {
this._pendingRemoteInlineRangeSync = true;
}
this.render();
};
mount = () => {
@@ -70,6 +84,7 @@ export class RenderService<TextAttributes extends BaseTextAttributes> {
editor.disposables.add({
dispose: () => {
yText.unobserve(this._onYTextChange);
this._pendingRemoteInlineRangeSync = false;
},
});
};
@@ -82,6 +97,7 @@ export class RenderService<TextAttributes extends BaseTextAttributes> {
render = () => {
if (!this.editor.rootElement) return;
const renderVersion = ++this._renderVersion;
this._rendering = true;
const rootElement = this.editor.rootElement;
@@ -152,11 +168,21 @@ export class RenderService<TextAttributes extends BaseTextAttributes> {
this.editor
.waitForUpdate()
.then(() => {
if (renderVersion !== this._renderVersion) return;
if (this._pendingRemoteInlineRangeSync) {
this._pendingRemoteInlineRangeSync = false;
this._syncRemoteInlineRange();
}
this._rendering = false;
this.editor.slots.renderComplete.next();
this.editor.syncInlineRange();
})
.catch(console.error);
.catch(error => {
if (renderVersion === this._renderVersion) {
this._rendering = false;
}
console.error(error);
});
};
rerenderWholeEditor = () => {

View File

@@ -9,7 +9,12 @@ import {
isVElement,
isVLine,
} from './guard.js';
import { calculateTextLength, getTextNodesFromElement } from './text.js';
import {
calculateTextLength,
getInlineRootTextCache,
getTextNodesFromElement,
invalidateInlineRootTextCache,
} from './text.js';
export function nativePointToTextPoint(
node: unknown,
@@ -67,19 +72,6 @@ export function textPointToDomPoint(
if (!rootElement.contains(text)) return null;
const texts = getTextNodesFromElement(rootElement);
if (texts.length === 0) return null;
const goalIndex = texts.indexOf(text);
let index = 0;
for (const text of texts.slice(0, goalIndex)) {
index += calculateTextLength(text);
}
if (text.wholeText !== ZERO_WIDTH_FOR_EMPTY_LINE) {
index += offset;
}
const textParentElement = text.parentElement;
if (!textParentElement) {
throw new BlockSuiteError(
@@ -97,9 +89,44 @@ export function textPointToDomPoint(
);
}
const textOffset = text.wholeText === ZERO_WIDTH_FOR_EMPTY_LINE ? 0 : offset;
for (let attempt = 0; attempt < 2; attempt++) {
const { textNodes, textNodeIndexMap, prefixLengths, lineIndexMap } =
getInlineRootTextCache(rootElement);
if (textNodes.length === 0) return null;
const goalIndex = textNodeIndexMap.get(text);
const lineIndex = lineIndexMap.get(lineElement);
if (goalIndex !== undefined && lineIndex !== undefined) {
const index = (prefixLengths[goalIndex] ?? 0) + textOffset;
return { text, index: index + lineIndex };
}
if (attempt === 0) {
// MutationObserver marks cache dirty asynchronously; force one sync retry
// when a newly-added node is queried within the same task.
invalidateInlineRootTextCache(rootElement);
}
}
// Fallback to linear scan when cache still misses. This keeps behavior
// stable even if MutationObserver-based invalidation lags behind.
const texts = getTextNodesFromElement(rootElement);
if (texts.length === 0) return null;
const goalIndex = texts.indexOf(text);
if (goalIndex < 0) return null;
let index = textOffset;
for (const beforeText of texts.slice(0, goalIndex)) {
index += calculateTextLength(beforeText);
}
const lineIndex = Array.from(rootElement.querySelectorAll('v-line')).indexOf(
lineElement
);
if (lineIndex < 0) return null;
return { text, index: index + lineIndex };
}

View File

@@ -8,6 +8,92 @@ export function calculateTextLength(text: Text): number {
}
}
type InlineRootTextCache = {
dirty: boolean;
observer: MutationObserver | null;
textNodes: Text[];
textNodeIndexMap: WeakMap<Text, number>;
prefixLengths: number[];
lineIndexMap: WeakMap<Element, number>;
};
const inlineRootTextCaches = new WeakMap<HTMLElement, InlineRootTextCache>();
const buildInlineRootTextCache = (
rootElement: HTMLElement,
cache: InlineRootTextCache
) => {
const textSpanElements = Array.from(
rootElement.querySelectorAll('[data-v-text="true"]')
);
const textNodes: Text[] = [];
const textNodeIndexMap = new WeakMap<Text, number>();
const prefixLengths: number[] = [];
let prefixLength = 0;
for (const textSpanElement of textSpanElements) {
const textNode = Array.from(textSpanElement.childNodes).find(
(node): node is Text => node instanceof Text
);
if (!textNode) continue;
prefixLengths.push(prefixLength);
textNodeIndexMap.set(textNode, textNodes.length);
textNodes.push(textNode);
prefixLength += calculateTextLength(textNode);
}
const lineIndexMap = new WeakMap<Element, number>();
const lineElements = Array.from(rootElement.querySelectorAll('v-line'));
for (const [index, line] of lineElements.entries()) {
lineIndexMap.set(line, index);
}
cache.textNodes = textNodes;
cache.textNodeIndexMap = textNodeIndexMap;
cache.prefixLengths = prefixLengths;
cache.lineIndexMap = lineIndexMap;
cache.dirty = false;
};
export function invalidateInlineRootTextCache(rootElement: HTMLElement) {
const cache = inlineRootTextCaches.get(rootElement);
if (cache) {
cache.dirty = true;
}
}
export function getInlineRootTextCache(rootElement: HTMLElement) {
let cache = inlineRootTextCaches.get(rootElement);
if (!cache) {
cache = {
dirty: true,
observer: null,
textNodes: [],
textNodeIndexMap: new WeakMap(),
prefixLengths: [],
lineIndexMap: new WeakMap(),
};
inlineRootTextCaches.set(rootElement, cache);
}
if (!cache.observer && typeof MutationObserver !== 'undefined') {
cache.observer = new MutationObserver(() => {
cache!.dirty = true;
});
cache.observer.observe(rootElement, {
subtree: true,
childList: true,
characterData: true,
});
}
if (cache.dirty) {
buildInlineRootTextCache(rootElement, cache);
}
return cache;
}
export function getTextNodesFromElement(element: Element): Text[] {
const textSpanElements = Array.from(
element.querySelectorAll('[data-v-text="true"]')