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
@@ -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 };
}
@@ -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"]')