mirror of
https://github.com/toeverything/AFFiNE.git
synced 2026-02-19 07:17:00 +08:00
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:
@@ -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 = () => {
|
||||
|
||||
@@ -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"]')
|
||||
|
||||
Reference in New Issue
Block a user