Skip to content

Commit 72df9cb

Browse files
authored
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 -->
1 parent 98e5747 commit 72df9cb

File tree

14 files changed

+873
-111
lines changed

14 files changed

+873
-111
lines changed

blocksuite/affine/all/src/__tests__/adapters/html.unit.spec.ts

Lines changed: 151 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2101,6 +2101,157 @@ describe('html to snapshot', () => {
21012101
expect(nanoidReplacement(rawBlockSnapshot)).toEqual(blockSnapshot);
21022102
});
21032103

2104+
test('paragraph with br should split into multiple blocks', async () => {
2105+
const html = template(`<p>aaa<br>bbb<br>ccc</p>`);
2106+
2107+
const blockSnapshot: BlockSnapshot = {
2108+
type: 'block',
2109+
id: 'matchesReplaceMap[0]',
2110+
flavour: 'affine:note',
2111+
props: {
2112+
xywh: '[0,0,800,95]',
2113+
background: DefaultTheme.noteBackgrounColor,
2114+
index: 'a0',
2115+
hidden: false,
2116+
displayMode: NoteDisplayMode.DocAndEdgeless,
2117+
},
2118+
children: [
2119+
{
2120+
type: 'block',
2121+
id: 'matchesReplaceMap[1]',
2122+
flavour: 'affine:paragraph',
2123+
props: {
2124+
type: 'text',
2125+
text: {
2126+
'$blocksuite:internal:text$': true,
2127+
delta: [{ insert: 'aaa' }],
2128+
},
2129+
},
2130+
children: [],
2131+
},
2132+
{
2133+
type: 'block',
2134+
id: 'matchesReplaceMap[2]',
2135+
flavour: 'affine:paragraph',
2136+
props: {
2137+
type: 'text',
2138+
text: {
2139+
'$blocksuite:internal:text$': true,
2140+
delta: [{ insert: 'bbb' }],
2141+
},
2142+
},
2143+
children: [],
2144+
},
2145+
{
2146+
type: 'block',
2147+
id: 'matchesReplaceMap[3]',
2148+
flavour: 'affine:paragraph',
2149+
props: {
2150+
type: 'text',
2151+
text: {
2152+
'$blocksuite:internal:text$': true,
2153+
delta: [{ insert: 'ccc' }],
2154+
},
2155+
},
2156+
children: [],
2157+
},
2158+
],
2159+
};
2160+
2161+
const htmlAdapter = new HtmlAdapter(createJob(), provider);
2162+
const rawBlockSnapshot = await htmlAdapter.toBlockSnapshot({
2163+
file: html,
2164+
});
2165+
expect(nanoidReplacement(rawBlockSnapshot)).toEqual(blockSnapshot);
2166+
});
2167+
2168+
test('paragraph with br should keep inline styles in each split line', async () => {
2169+
const html = template(
2170+
`<p><strong>aaa</strong><br><a href="https://www.google.com/">bbb</a><br><em>ccc</em></p>`
2171+
);
2172+
2173+
const blockSnapshot: BlockSnapshot = {
2174+
type: 'block',
2175+
id: 'matchesReplaceMap[0]',
2176+
flavour: 'affine:note',
2177+
props: {
2178+
xywh: '[0,0,800,95]',
2179+
background: DefaultTheme.noteBackgrounColor,
2180+
index: 'a0',
2181+
hidden: false,
2182+
displayMode: NoteDisplayMode.DocAndEdgeless,
2183+
},
2184+
children: [
2185+
{
2186+
type: 'block',
2187+
id: 'matchesReplaceMap[1]',
2188+
flavour: 'affine:paragraph',
2189+
props: {
2190+
type: 'text',
2191+
text: {
2192+
'$blocksuite:internal:text$': true,
2193+
delta: [
2194+
{
2195+
insert: 'aaa',
2196+
attributes: {
2197+
bold: true,
2198+
},
2199+
},
2200+
],
2201+
},
2202+
},
2203+
children: [],
2204+
},
2205+
{
2206+
type: 'block',
2207+
id: 'matchesReplaceMap[2]',
2208+
flavour: 'affine:paragraph',
2209+
props: {
2210+
type: 'text',
2211+
text: {
2212+
'$blocksuite:internal:text$': true,
2213+
delta: [
2214+
{
2215+
insert: 'bbb',
2216+
attributes: {
2217+
link: 'https://www.google.com/',
2218+
},
2219+
},
2220+
],
2221+
},
2222+
},
2223+
children: [],
2224+
},
2225+
{
2226+
type: 'block',
2227+
id: 'matchesReplaceMap[3]',
2228+
flavour: 'affine:paragraph',
2229+
props: {
2230+
type: 'text',
2231+
text: {
2232+
'$blocksuite:internal:text$': true,
2233+
delta: [
2234+
{
2235+
insert: 'ccc',
2236+
attributes: {
2237+
italic: true,
2238+
},
2239+
},
2240+
],
2241+
},
2242+
},
2243+
children: [],
2244+
},
2245+
],
2246+
};
2247+
2248+
const htmlAdapter = new HtmlAdapter(createJob(), provider);
2249+
const rawBlockSnapshot = await htmlAdapter.toBlockSnapshot({
2250+
file: html,
2251+
});
2252+
expect(nanoidReplacement(rawBlockSnapshot)).toEqual(blockSnapshot);
2253+
});
2254+
21042255
test('nested list', async () => {
21052256
const html = template(`<ul><li>111<ul><li>222</li></ul></li></ul>`);
21062257

blocksuite/affine/blocks/paragraph/src/adapters/html.ts

Lines changed: 141 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,126 @@ const tagsInAncestor = (o: NodeProps<HtmlAST>, tagNames: Array<string>) => {
3737
return false;
3838
};
3939

40+
const splitDeltaByNewline = (delta: DeltaInsert[]) => {
41+
const lines: DeltaInsert[][] = [[]];
42+
const pending = [...delta];
43+
44+
while (pending.length > 0) {
45+
const op = pending.shift();
46+
if (!op) continue;
47+
48+
const insert = op.insert;
49+
if (typeof insert !== 'string') {
50+
lines[lines.length - 1].push(op);
51+
continue;
52+
}
53+
54+
if (!insert.includes('\n')) {
55+
if (insert.length === 0) {
56+
continue;
57+
}
58+
lines[lines.length - 1].push(op);
59+
continue;
60+
}
61+
62+
const splitIndex = insert.indexOf('\n');
63+
const linePart = insert.slice(0, splitIndex);
64+
const remainPart = insert.slice(splitIndex + 1);
65+
if (linePart.length > 0) {
66+
lines[lines.length - 1].push({ ...op, insert: linePart });
67+
}
68+
lines.push([]);
69+
if (remainPart) {
70+
pending.unshift({ ...op, insert: remainPart });
71+
}
72+
}
73+
74+
return lines;
75+
};
76+
77+
const hasBlockElementDescendant = (node: HtmlAST): boolean => {
78+
if (!HastUtils.isElement(node)) {
79+
return false;
80+
}
81+
return node.children.some(child => {
82+
if (!HastUtils.isElement(child)) {
83+
return false;
84+
}
85+
return (
86+
(HastUtils.isTagBlock(child.tagName) && child.tagName !== 'br') ||
87+
hasBlockElementDescendant(child)
88+
);
89+
});
90+
};
91+
92+
const getParagraphDeltas = (
93+
node: HtmlAST,
94+
delta: DeltaInsert[]
95+
): DeltaInsert[][] => {
96+
if (!HastUtils.isElement(node)) return [delta];
97+
if (hasBlockElementDescendant(node)) return [delta];
98+
99+
const hasBr = !!HastUtils.querySelector(node, 'br');
100+
if (!hasBr) return [delta];
101+
102+
const hasNewline = delta.some(
103+
op => typeof op.insert === 'string' && op.insert.includes('\n')
104+
);
105+
if (!hasNewline) return [delta];
106+
107+
return splitDeltaByNewline(delta);
108+
};
109+
110+
const openParagraphBlocks = (
111+
deltas: DeltaInsert[][],
112+
type: string,
113+
// AST walker context from html adapter transform pipeline.
114+
walkerContext: any
115+
) => {
116+
for (const delta of deltas) {
117+
walkerContext
118+
.openNode(
119+
{
120+
type: 'block',
121+
id: nanoid(),
122+
flavour: 'affine:paragraph',
123+
props: { type, text: { '$blocksuite:internal:text$': true, delta } },
124+
children: [],
125+
},
126+
'children'
127+
)
128+
.closeNode();
129+
}
130+
};
131+
132+
const MULTI_PARAGRAPH_EMITTED_NODES_CONTEXT_KEY =
133+
'affine:paragraph:multi-emitted-nodes';
134+
135+
const markMultiParagraphEmitted = (walkerContext: any, node: HtmlAST) => {
136+
const emittedNodes =
137+
(walkerContext.getGlobalContext(
138+
MULTI_PARAGRAPH_EMITTED_NODES_CONTEXT_KEY
139+
) as WeakSet<object> | undefined) ?? new WeakSet<object>();
140+
emittedNodes.add(node as object);
141+
walkerContext.setGlobalContext(
142+
MULTI_PARAGRAPH_EMITTED_NODES_CONTEXT_KEY,
143+
emittedNodes
144+
);
145+
};
146+
147+
const consumeMultiParagraphEmittedMark = (
148+
walkerContext: any,
149+
node: HtmlAST
150+
) => {
151+
const emittedNodes = walkerContext.getGlobalContext(
152+
MULTI_PARAGRAPH_EMITTED_NODES_CONTEXT_KEY
153+
) as WeakSet<object> | undefined;
154+
if (!emittedNodes) {
155+
return false;
156+
}
157+
return emittedNodes.delete(node as object);
158+
};
159+
40160
export const paragraphBlockHtmlAdapterMatcher: BlockHtmlAdapterMatcher = {
41161
flavour: ParagraphBlockSchema.model.flavour,
42162
toMatch: o =>
@@ -88,41 +208,37 @@ export const paragraphBlockHtmlAdapterMatcher: BlockHtmlAdapterMatcher = {
88208
!tagsInAncestor(o, ['p', 'li']) &&
89209
HastUtils.isParagraphLike(o.node)
90210
) {
91-
walkerContext
92-
.openNode(
93-
{
94-
type: 'block',
95-
id: nanoid(),
96-
flavour: 'affine:paragraph',
97-
props: {
98-
type: 'text',
99-
text: {
100-
'$blocksuite:internal:text$': true,
101-
delta: deltaConverter.astToDelta(o.node),
102-
},
103-
},
104-
children: [],
105-
},
106-
'children'
107-
)
108-
.closeNode();
211+
const delta = deltaConverter.astToDelta(o.node);
212+
const deltas = getParagraphDeltas(o.node, delta);
213+
openParagraphBlocks(deltas, 'text', walkerContext);
109214
walkerContext.skipAllChildren();
110215
}
111216
break;
112217
}
113218
case 'p': {
219+
const type = walkerContext.getGlobalContext('hast:blockquote')
220+
? 'quote'
221+
: 'text';
222+
const delta = deltaConverter.astToDelta(o.node);
223+
const deltas = getParagraphDeltas(o.node, delta);
224+
225+
if (deltas.length > 1) {
226+
openParagraphBlocks(deltas, type, walkerContext);
227+
markMultiParagraphEmitted(walkerContext, o.node);
228+
walkerContext.skipAllChildren();
229+
break;
230+
}
231+
114232
walkerContext.openNode(
115233
{
116234
type: 'block',
117235
id: nanoid(),
118236
flavour: 'affine:paragraph',
119237
props: {
120-
type: walkerContext.getGlobalContext('hast:blockquote')
121-
? 'quote'
122-
: 'text',
238+
type,
123239
text: {
124240
'$blocksuite:internal:text$': true,
125-
delta: deltaConverter.astToDelta(o.node),
241+
delta,
126242
},
127243
},
128244
children: [],
@@ -192,6 +308,9 @@ export const paragraphBlockHtmlAdapterMatcher: BlockHtmlAdapterMatcher = {
192308
break;
193309
}
194310
case 'p': {
311+
if (consumeMultiParagraphEmittedMark(walkerContext, o.node)) {
312+
break;
313+
}
195314
if (
196315
o.next?.type === 'element' &&
197316
o.next.tagName === 'div' &&

0 commit comments

Comments
 (0)