From 72df9cb457e57a4b0a0af4925b2e7c0cb027f814 Mon Sep 17 00:00:00 2001
From: DarkSky <25152247+darkskygit@users.noreply.github.com>
Date: Sat, 14 Feb 2026 00:43:36 +0800
Subject: [PATCH] feat: improve editor performance (#14429)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
#### PR Dependency Tree
* **PR #14429** 👈
This tree was auto-generated by
[Charcoal](https://github.com/danerwilliams/charcoal)
## Summary by CodeRabbit
* **New Features**
* HTML import now splits lines on
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
-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.
---
.../src/__tests__/adapters/html.unit.spec.ts | 151 ++++++++++++++
.../blocks/paragraph/src/adapters/html.ts | 163 +++++++++++++---
.../root/src/clipboard/page-clipboard.ts | 24 ++-
.../font-loader/font-loader-service.ts | 161 +++++++++++++--
.../src/watchers/edgeless-watcher.ts | 184 ++++++++++++++++--
.../src/watchers/pointer-event-watcher.ts | 34 +++-
.../std/src/inline/services/render.ts | 62 ++++--
.../std/src/inline/utils/point-conversion.ts | 55 ++++--
.../framework/std/src/inline/utils/text.ts | 86 ++++++++
.../src/__tests__/edgeless/frame.spec.ts | 14 +-
.../src/__tests__/e2e/apps/flavors.spec.ts | 37 ++--
.../workspace/detail-page/detail-page.css.ts | 8 +-
.../workspace/detail-page/detail-page.tsx | 3 +-
tests/affine-local/e2e/drag-page.spec.ts | 2 +-
14 files changed, 873 insertions(+), 111 deletions(-)
diff --git a/blocksuite/affine/all/src/__tests__/adapters/html.unit.spec.ts b/blocksuite/affine/all/src/__tests__/adapters/html.unit.spec.ts
index 382ff1957f..099869f277 100644
--- a/blocksuite/affine/all/src/__tests__/adapters/html.unit.spec.ts
+++ b/blocksuite/affine/all/src/__tests__/adapters/html.unit.spec.ts
@@ -2101,6 +2101,157 @@ describe('html to snapshot', () => {
expect(nanoidReplacement(rawBlockSnapshot)).toEqual(blockSnapshot);
});
+ test('paragraph with br should split into multiple blocks', async () => {
+ const html = template(`
aaa
bbb
ccc
`);
+
+ const blockSnapshot: BlockSnapshot = {
+ type: 'block',
+ id: 'matchesReplaceMap[0]',
+ flavour: 'affine:note',
+ props: {
+ xywh: '[0,0,800,95]',
+ background: DefaultTheme.noteBackgrounColor,
+ index: 'a0',
+ hidden: false,
+ displayMode: NoteDisplayMode.DocAndEdgeless,
+ },
+ children: [
+ {
+ type: 'block',
+ id: 'matchesReplaceMap[1]',
+ flavour: 'affine:paragraph',
+ props: {
+ type: 'text',
+ text: {
+ '$blocksuite:internal:text$': true,
+ delta: [{ insert: 'aaa' }],
+ },
+ },
+ children: [],
+ },
+ {
+ type: 'block',
+ id: 'matchesReplaceMap[2]',
+ flavour: 'affine:paragraph',
+ props: {
+ type: 'text',
+ text: {
+ '$blocksuite:internal:text$': true,
+ delta: [{ insert: 'bbb' }],
+ },
+ },
+ children: [],
+ },
+ {
+ type: 'block',
+ id: 'matchesReplaceMap[3]',
+ flavour: 'affine:paragraph',
+ props: {
+ type: 'text',
+ text: {
+ '$blocksuite:internal:text$': true,
+ delta: [{ insert: 'ccc' }],
+ },
+ },
+ children: [],
+ },
+ ],
+ };
+
+ const htmlAdapter = new HtmlAdapter(createJob(), provider);
+ const rawBlockSnapshot = await htmlAdapter.toBlockSnapshot({
+ file: html,
+ });
+ expect(nanoidReplacement(rawBlockSnapshot)).toEqual(blockSnapshot);
+ });
+
+ test('paragraph with br should keep inline styles in each split line', async () => {
+ const html = template(
+ `aaa
bbb
ccc
`
+ );
+
+ const blockSnapshot: BlockSnapshot = {
+ type: 'block',
+ id: 'matchesReplaceMap[0]',
+ flavour: 'affine:note',
+ props: {
+ xywh: '[0,0,800,95]',
+ background: DefaultTheme.noteBackgrounColor,
+ index: 'a0',
+ hidden: false,
+ displayMode: NoteDisplayMode.DocAndEdgeless,
+ },
+ children: [
+ {
+ type: 'block',
+ id: 'matchesReplaceMap[1]',
+ flavour: 'affine:paragraph',
+ props: {
+ type: 'text',
+ text: {
+ '$blocksuite:internal:text$': true,
+ delta: [
+ {
+ insert: 'aaa',
+ attributes: {
+ bold: true,
+ },
+ },
+ ],
+ },
+ },
+ children: [],
+ },
+ {
+ type: 'block',
+ id: 'matchesReplaceMap[2]',
+ flavour: 'affine:paragraph',
+ props: {
+ type: 'text',
+ text: {
+ '$blocksuite:internal:text$': true,
+ delta: [
+ {
+ insert: 'bbb',
+ attributes: {
+ link: 'https://www.google.com/',
+ },
+ },
+ ],
+ },
+ },
+ children: [],
+ },
+ {
+ type: 'block',
+ id: 'matchesReplaceMap[3]',
+ flavour: 'affine:paragraph',
+ props: {
+ type: 'text',
+ text: {
+ '$blocksuite:internal:text$': true,
+ delta: [
+ {
+ insert: 'ccc',
+ attributes: {
+ italic: true,
+ },
+ },
+ ],
+ },
+ },
+ children: [],
+ },
+ ],
+ };
+
+ const htmlAdapter = new HtmlAdapter(createJob(), provider);
+ const rawBlockSnapshot = await htmlAdapter.toBlockSnapshot({
+ file: html,
+ });
+ expect(nanoidReplacement(rawBlockSnapshot)).toEqual(blockSnapshot);
+ });
+
test('nested list', async () => {
const html = template(``);
diff --git a/blocksuite/affine/blocks/paragraph/src/adapters/html.ts b/blocksuite/affine/blocks/paragraph/src/adapters/html.ts
index 6fd51bbba9..7dc12c14fa 100644
--- a/blocksuite/affine/blocks/paragraph/src/adapters/html.ts
+++ b/blocksuite/affine/blocks/paragraph/src/adapters/html.ts
@@ -37,6 +37,126 @@ const tagsInAncestor = (o: NodeProps, tagNames: Array) => {
return false;
};
+const splitDeltaByNewline = (delta: DeltaInsert[]) => {
+ const lines: DeltaInsert[][] = [[]];
+ const pending = [...delta];
+
+ while (pending.length > 0) {
+ const op = pending.shift();
+ if (!op) continue;
+
+ const insert = op.insert;
+ if (typeof insert !== 'string') {
+ lines[lines.length - 1].push(op);
+ continue;
+ }
+
+ if (!insert.includes('\n')) {
+ if (insert.length === 0) {
+ continue;
+ }
+ lines[lines.length - 1].push(op);
+ continue;
+ }
+
+ const splitIndex = insert.indexOf('\n');
+ const linePart = insert.slice(0, splitIndex);
+ const remainPart = insert.slice(splitIndex + 1);
+ if (linePart.length > 0) {
+ lines[lines.length - 1].push({ ...op, insert: linePart });
+ }
+ lines.push([]);
+ if (remainPart) {
+ pending.unshift({ ...op, insert: remainPart });
+ }
+ }
+
+ return lines;
+};
+
+const hasBlockElementDescendant = (node: HtmlAST): boolean => {
+ if (!HastUtils.isElement(node)) {
+ return false;
+ }
+ return node.children.some(child => {
+ if (!HastUtils.isElement(child)) {
+ return false;
+ }
+ return (
+ (HastUtils.isTagBlock(child.tagName) && child.tagName !== 'br') ||
+ hasBlockElementDescendant(child)
+ );
+ });
+};
+
+const getParagraphDeltas = (
+ node: HtmlAST,
+ delta: DeltaInsert[]
+): DeltaInsert[][] => {
+ if (!HastUtils.isElement(node)) return [delta];
+ if (hasBlockElementDescendant(node)) return [delta];
+
+ const hasBr = !!HastUtils.querySelector(node, 'br');
+ if (!hasBr) return [delta];
+
+ const hasNewline = delta.some(
+ op => typeof op.insert === 'string' && op.insert.includes('\n')
+ );
+ if (!hasNewline) return [delta];
+
+ return splitDeltaByNewline(delta);
+};
+
+const openParagraphBlocks = (
+ deltas: DeltaInsert[][],
+ type: string,
+ // AST walker context from html adapter transform pipeline.
+ walkerContext: any
+) => {
+ for (const delta of deltas) {
+ walkerContext
+ .openNode(
+ {
+ type: 'block',
+ id: nanoid(),
+ flavour: 'affine:paragraph',
+ props: { type, text: { '$blocksuite:internal:text$': true, delta } },
+ children: [],
+ },
+ 'children'
+ )
+ .closeNode();
+ }
+};
+
+const MULTI_PARAGRAPH_EMITTED_NODES_CONTEXT_KEY =
+ 'affine:paragraph:multi-emitted-nodes';
+
+const markMultiParagraphEmitted = (walkerContext: any, node: HtmlAST) => {
+ const emittedNodes =
+ (walkerContext.getGlobalContext(
+ MULTI_PARAGRAPH_EMITTED_NODES_CONTEXT_KEY
+ ) as WeakSet