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:
@@ -2101,6 +2101,157 @@ describe('html to snapshot', () => {
|
|||||||
expect(nanoidReplacement(rawBlockSnapshot)).toEqual(blockSnapshot);
|
expect(nanoidReplacement(rawBlockSnapshot)).toEqual(blockSnapshot);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('paragraph with br should split into multiple blocks', async () => {
|
||||||
|
const html = template(`<p>aaa<br>bbb<br>ccc</p>`);
|
||||||
|
|
||||||
|
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(
|
||||||
|
`<p><strong>aaa</strong><br><a href="https://www.google.com/">bbb</a><br><em>ccc</em></p>`
|
||||||
|
);
|
||||||
|
|
||||||
|
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 () => {
|
test('nested list', async () => {
|
||||||
const html = template(`<ul><li>111<ul><li>222</li></ul></li></ul>`);
|
const html = template(`<ul><li>111<ul><li>222</li></ul></li></ul>`);
|
||||||
|
|
||||||
|
|||||||
@@ -37,6 +37,126 @@ const tagsInAncestor = (o: NodeProps<HtmlAST>, tagNames: Array<string>) => {
|
|||||||
return false;
|
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<object> | undefined) ?? new WeakSet<object>();
|
||||||
|
emittedNodes.add(node as object);
|
||||||
|
walkerContext.setGlobalContext(
|
||||||
|
MULTI_PARAGRAPH_EMITTED_NODES_CONTEXT_KEY,
|
||||||
|
emittedNodes
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const consumeMultiParagraphEmittedMark = (
|
||||||
|
walkerContext: any,
|
||||||
|
node: HtmlAST
|
||||||
|
) => {
|
||||||
|
const emittedNodes = walkerContext.getGlobalContext(
|
||||||
|
MULTI_PARAGRAPH_EMITTED_NODES_CONTEXT_KEY
|
||||||
|
) as WeakSet<object> | undefined;
|
||||||
|
if (!emittedNodes) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return emittedNodes.delete(node as object);
|
||||||
|
};
|
||||||
|
|
||||||
export const paragraphBlockHtmlAdapterMatcher: BlockHtmlAdapterMatcher = {
|
export const paragraphBlockHtmlAdapterMatcher: BlockHtmlAdapterMatcher = {
|
||||||
flavour: ParagraphBlockSchema.model.flavour,
|
flavour: ParagraphBlockSchema.model.flavour,
|
||||||
toMatch: o =>
|
toMatch: o =>
|
||||||
@@ -88,41 +208,37 @@ export const paragraphBlockHtmlAdapterMatcher: BlockHtmlAdapterMatcher = {
|
|||||||
!tagsInAncestor(o, ['p', 'li']) &&
|
!tagsInAncestor(o, ['p', 'li']) &&
|
||||||
HastUtils.isParagraphLike(o.node)
|
HastUtils.isParagraphLike(o.node)
|
||||||
) {
|
) {
|
||||||
walkerContext
|
const delta = deltaConverter.astToDelta(o.node);
|
||||||
.openNode(
|
const deltas = getParagraphDeltas(o.node, delta);
|
||||||
{
|
openParagraphBlocks(deltas, 'text', walkerContext);
|
||||||
type: 'block',
|
|
||||||
id: nanoid(),
|
|
||||||
flavour: 'affine:paragraph',
|
|
||||||
props: {
|
|
||||||
type: 'text',
|
|
||||||
text: {
|
|
||||||
'$blocksuite:internal:text$': true,
|
|
||||||
delta: deltaConverter.astToDelta(o.node),
|
|
||||||
},
|
|
||||||
},
|
|
||||||
children: [],
|
|
||||||
},
|
|
||||||
'children'
|
|
||||||
)
|
|
||||||
.closeNode();
|
|
||||||
walkerContext.skipAllChildren();
|
walkerContext.skipAllChildren();
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
case 'p': {
|
case 'p': {
|
||||||
|
const type = walkerContext.getGlobalContext('hast:blockquote')
|
||||||
|
? 'quote'
|
||||||
|
: 'text';
|
||||||
|
const delta = deltaConverter.astToDelta(o.node);
|
||||||
|
const deltas = getParagraphDeltas(o.node, delta);
|
||||||
|
|
||||||
|
if (deltas.length > 1) {
|
||||||
|
openParagraphBlocks(deltas, type, walkerContext);
|
||||||
|
markMultiParagraphEmitted(walkerContext, o.node);
|
||||||
|
walkerContext.skipAllChildren();
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
walkerContext.openNode(
|
walkerContext.openNode(
|
||||||
{
|
{
|
||||||
type: 'block',
|
type: 'block',
|
||||||
id: nanoid(),
|
id: nanoid(),
|
||||||
flavour: 'affine:paragraph',
|
flavour: 'affine:paragraph',
|
||||||
props: {
|
props: {
|
||||||
type: walkerContext.getGlobalContext('hast:blockquote')
|
type,
|
||||||
? 'quote'
|
|
||||||
: 'text',
|
|
||||||
text: {
|
text: {
|
||||||
'$blocksuite:internal:text$': true,
|
'$blocksuite:internal:text$': true,
|
||||||
delta: deltaConverter.astToDelta(o.node),
|
delta,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
children: [],
|
children: [],
|
||||||
@@ -192,6 +308,9 @@ export const paragraphBlockHtmlAdapterMatcher: BlockHtmlAdapterMatcher = {
|
|||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
case 'p': {
|
case 'p': {
|
||||||
|
if (consumeMultiParagraphEmittedMark(walkerContext, o.node)) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
if (
|
if (
|
||||||
o.next?.type === 'element' &&
|
o.next?.type === 'element' &&
|
||||||
o.next.tagName === 'div' &&
|
o.next.tagName === 'div' &&
|
||||||
|
|||||||
@@ -86,6 +86,7 @@ export class PageClipboard extends ReadOnlyClipboard {
|
|||||||
|
|
||||||
if (this.std.store.readonly) return;
|
if (this.std.store.readonly) return;
|
||||||
this.std.store.captureSync();
|
this.std.store.captureSync();
|
||||||
|
let hasPasteTarget = false;
|
||||||
this.std.command
|
this.std.command
|
||||||
.chain()
|
.chain()
|
||||||
.try<{}>(cmd => [
|
.try<{}>(cmd => [
|
||||||
@@ -144,18 +145,39 @@ export class PageClipboard extends ReadOnlyClipboard {
|
|||||||
if (!ctx.parentBlock) {
|
if (!ctx.parentBlock) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
hasPasteTarget = true;
|
||||||
this.std.clipboard
|
this.std.clipboard
|
||||||
.paste(
|
.paste(
|
||||||
e,
|
e,
|
||||||
this.std.store,
|
this.std.store,
|
||||||
ctx.parentBlock.model.id,
|
ctx.parentBlock.model.id,
|
||||||
ctx.blockIndex ? ctx.blockIndex + 1 : 1
|
ctx.blockIndex !== undefined ? ctx.blockIndex + 1 : 1
|
||||||
)
|
)
|
||||||
.catch(console.error);
|
.catch(console.error);
|
||||||
|
|
||||||
return next();
|
return next();
|
||||||
})
|
})
|
||||||
.run();
|
.run();
|
||||||
|
|
||||||
|
if (hasPasteTarget) return;
|
||||||
|
|
||||||
|
// If no valid selection target exists (for example, stale block selection
|
||||||
|
// right after cut), create/focus the default paragraph and paste after it.
|
||||||
|
const firstParagraphId = document
|
||||||
|
.querySelector('affine-page-root')
|
||||||
|
?.focusFirstParagraph?.()?.id;
|
||||||
|
const parentModel = firstParagraphId
|
||||||
|
? this.std.store.getParent(firstParagraphId)
|
||||||
|
: null;
|
||||||
|
const paragraphIndex =
|
||||||
|
firstParagraphId && parentModel
|
||||||
|
? parentModel.children.findIndex(child => child.id === firstParagraphId)
|
||||||
|
: -1;
|
||||||
|
const insertIndex = paragraphIndex >= 0 ? paragraphIndex + 1 : undefined;
|
||||||
|
|
||||||
|
this.std.clipboard
|
||||||
|
.paste(e, this.std.store, parentModel?.id, insertIndex)
|
||||||
|
.catch(console.error);
|
||||||
};
|
};
|
||||||
|
|
||||||
override mounted() {
|
override mounted() {
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { FontFamily, FontStyle, FontWeight } from '@blocksuite/affine-model';
|
||||||
import { createIdentifier } from '@blocksuite/global/di';
|
import { createIdentifier } from '@blocksuite/global/di';
|
||||||
import { IS_FIREFOX } from '@blocksuite/global/env';
|
import { IS_FIREFOX } from '@blocksuite/global/env';
|
||||||
import { LifeCycleWatcher } from '@blocksuite/std';
|
import { LifeCycleWatcher } from '@blocksuite/std';
|
||||||
@@ -20,33 +21,171 @@ const initFontFace = IS_FIREFOX
|
|||||||
export class FontLoaderService extends LifeCycleWatcher {
|
export class FontLoaderService extends LifeCycleWatcher {
|
||||||
static override readonly key = 'font-loader';
|
static override readonly key = 'font-loader';
|
||||||
|
|
||||||
|
private static readonly DEFERRED_LOAD_DELAY_MS = 5000;
|
||||||
|
|
||||||
|
private static readonly DEFERRED_LOAD_BATCH_SIZE = 4;
|
||||||
|
|
||||||
|
private static readonly DEFERRED_LOAD_BATCH_INTERVAL_MS = 1000;
|
||||||
|
|
||||||
|
private _idleLoadTaskId: number | null = null;
|
||||||
|
|
||||||
|
private _lazyLoadTimeoutId: number | null = null;
|
||||||
|
|
||||||
|
private _deferredFontsQueue: FontConfig[] = [];
|
||||||
|
|
||||||
|
private _deferredFontsCursor = 0;
|
||||||
|
|
||||||
|
private readonly _loadedFontKeys = new Set<string>();
|
||||||
|
|
||||||
readonly fontFaces: FontFace[] = [];
|
readonly fontFaces: FontFace[] = [];
|
||||||
|
|
||||||
get ready() {
|
get ready() {
|
||||||
return Promise.all(this.fontFaces.map(fontFace => fontFace.loaded));
|
return Promise.all(this.fontFaces.map(fontFace => fontFace.loaded));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private readonly _fontKey = ({ font, weight, style, url }: FontConfig) => {
|
||||||
|
return `${font}:${weight}:${style}:${url}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
private readonly _isCriticalCanvasFont = ({
|
||||||
|
font,
|
||||||
|
weight,
|
||||||
|
style,
|
||||||
|
}: FontConfig) => {
|
||||||
|
if (style !== FontStyle.Normal) return false;
|
||||||
|
|
||||||
|
if (font === FontFamily.Poppins) {
|
||||||
|
return (
|
||||||
|
weight === FontWeight.Regular ||
|
||||||
|
weight === FontWeight.Medium ||
|
||||||
|
weight === FontWeight.SemiBold
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (font === FontFamily.Inter) {
|
||||||
|
return weight === FontWeight.Regular || weight === FontWeight.SemiBold;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (font === FontFamily.Kalam) {
|
||||||
|
// Mindmap style four uses bold Kalam text.
|
||||||
|
// We map to SemiBold because this is the strongest shipped Kalam weight.
|
||||||
|
return weight === FontWeight.SemiBold;
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
|
||||||
|
private readonly _scheduleDeferredLoad = (fonts: FontConfig[]) => {
|
||||||
|
if (fonts.length === 0 || typeof window === 'undefined') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this._deferredFontsQueue = fonts;
|
||||||
|
this._deferredFontsCursor = 0;
|
||||||
|
|
||||||
|
const win = window as Window & {
|
||||||
|
requestIdleCallback?: (
|
||||||
|
callback: () => void,
|
||||||
|
options?: { timeout?: number }
|
||||||
|
) => number;
|
||||||
|
cancelIdleCallback?: (handle: number) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
const scheduleBatch = (delayMs: number) => {
|
||||||
|
this._lazyLoadTimeoutId = window.setTimeout(() => {
|
||||||
|
this._lazyLoadTimeoutId = null;
|
||||||
|
const runBatch = () => {
|
||||||
|
this._idleLoadTaskId = null;
|
||||||
|
|
||||||
|
const start = this._deferredFontsCursor;
|
||||||
|
const end = Math.min(
|
||||||
|
start + FontLoaderService.DEFERRED_LOAD_BATCH_SIZE,
|
||||||
|
this._deferredFontsQueue.length
|
||||||
|
);
|
||||||
|
const batch = this._deferredFontsQueue.slice(start, end);
|
||||||
|
this._deferredFontsCursor = end;
|
||||||
|
this.load(batch);
|
||||||
|
|
||||||
|
if (this._deferredFontsCursor < this._deferredFontsQueue.length) {
|
||||||
|
scheduleBatch(FontLoaderService.DEFERRED_LOAD_BATCH_INTERVAL_MS);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (typeof win.requestIdleCallback === 'function') {
|
||||||
|
this._idleLoadTaskId = win.requestIdleCallback(runBatch, {
|
||||||
|
timeout: 2000,
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
runBatch();
|
||||||
|
}, delayMs);
|
||||||
|
};
|
||||||
|
|
||||||
|
scheduleBatch(FontLoaderService.DEFERRED_LOAD_DELAY_MS);
|
||||||
|
};
|
||||||
|
|
||||||
|
private readonly _cancelDeferredLoad = () => {
|
||||||
|
if (typeof window === 'undefined') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const win = window as Window & {
|
||||||
|
cancelIdleCallback?: (handle: number) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
if (
|
||||||
|
this._idleLoadTaskId !== null &&
|
||||||
|
typeof win.cancelIdleCallback === 'function'
|
||||||
|
) {
|
||||||
|
win.cancelIdleCallback(this._idleLoadTaskId);
|
||||||
|
this._idleLoadTaskId = null;
|
||||||
|
}
|
||||||
|
if (this._lazyLoadTimeoutId !== null) {
|
||||||
|
window.clearTimeout(this._lazyLoadTimeoutId);
|
||||||
|
this._lazyLoadTimeoutId = null;
|
||||||
|
}
|
||||||
|
this._deferredFontsQueue = [];
|
||||||
|
this._deferredFontsCursor = 0;
|
||||||
|
};
|
||||||
|
|
||||||
load(fonts: FontConfig[]) {
|
load(fonts: FontConfig[]) {
|
||||||
this.fontFaces.push(
|
for (const font of fonts) {
|
||||||
...fonts.map(font => {
|
const key = this._fontKey(font);
|
||||||
const fontFace = initFontFace(font);
|
if (this._loadedFontKeys.has(key)) {
|
||||||
document.fonts.add(fontFace);
|
continue;
|
||||||
fontFace.load().catch(console.error);
|
}
|
||||||
return fontFace;
|
this._loadedFontKeys.add(key);
|
||||||
})
|
const fontFace = initFontFace(font);
|
||||||
);
|
document.fonts.add(fontFace);
|
||||||
|
fontFace.load().catch(console.error);
|
||||||
|
this.fontFaces.push(fontFace);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
override mounted() {
|
override mounted() {
|
||||||
const config = this.std.getOptional(FontConfigIdentifier);
|
const config = this.std.getOptional(FontConfigIdentifier);
|
||||||
if (config) {
|
if (!config || config.length === 0) {
|
||||||
this.load(config);
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const criticalFonts = config.filter(this._isCriticalCanvasFont);
|
||||||
|
const eagerFonts =
|
||||||
|
criticalFonts.length > 0 ? criticalFonts : config.slice(0, 3);
|
||||||
|
const eagerFontKeySet = new Set(eagerFonts.map(this._fontKey));
|
||||||
|
const deferredFonts = config.filter(
|
||||||
|
font => !eagerFontKeySet.has(this._fontKey(font))
|
||||||
|
);
|
||||||
|
|
||||||
|
this.load(eagerFonts);
|
||||||
|
this._scheduleDeferredLoad(deferredFonts);
|
||||||
}
|
}
|
||||||
|
|
||||||
override unmounted() {
|
override unmounted() {
|
||||||
this.fontFaces.forEach(fontFace => document.fonts.delete(fontFace));
|
this._cancelDeferredLoad();
|
||||||
|
for (const fontFace of this.fontFaces) {
|
||||||
|
document.fonts.delete(fontFace);
|
||||||
|
}
|
||||||
this.fontFaces.splice(0, this.fontFaces.length);
|
this.fontFaces.splice(0, this.fontFaces.length);
|
||||||
|
this._loadedFontKeys.clear();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -14,6 +14,17 @@ import {
|
|||||||
} from '../config.js';
|
} from '../config.js';
|
||||||
import type { AffineDragHandleWidget } from '../drag-handle.js';
|
import type { AffineDragHandleWidget } from '../drag-handle.js';
|
||||||
|
|
||||||
|
type HoveredElemArea = {
|
||||||
|
left: number;
|
||||||
|
top: number;
|
||||||
|
right: number;
|
||||||
|
bottom: number;
|
||||||
|
width: number;
|
||||||
|
height: number;
|
||||||
|
padding: number;
|
||||||
|
containerWidth: number;
|
||||||
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Used to control the drag handle visibility in edgeless mode
|
* Used to control the drag handle visibility in edgeless mode
|
||||||
*
|
*
|
||||||
@@ -21,6 +32,52 @@ import type { AffineDragHandleWidget } from '../drag-handle.js';
|
|||||||
* 2. Multiple selection is not supported
|
* 2. Multiple selection is not supported
|
||||||
*/
|
*/
|
||||||
export class EdgelessWatcher {
|
export class EdgelessWatcher {
|
||||||
|
private _pendingHoveredElemArea: HoveredElemArea | null = null;
|
||||||
|
|
||||||
|
private _lastAppliedHoveredElemArea: HoveredElemArea | null = null;
|
||||||
|
|
||||||
|
private _showDragHandleRafId: number | null = null;
|
||||||
|
|
||||||
|
private _surfaceElementUpdatedRafId: number | null = null;
|
||||||
|
|
||||||
|
private readonly _cloneArea = (area: HoveredElemArea): HoveredElemArea => ({
|
||||||
|
left: area.left,
|
||||||
|
top: area.top,
|
||||||
|
right: area.right,
|
||||||
|
bottom: area.bottom,
|
||||||
|
width: area.width,
|
||||||
|
height: area.height,
|
||||||
|
padding: area.padding,
|
||||||
|
containerWidth: area.containerWidth,
|
||||||
|
});
|
||||||
|
|
||||||
|
private readonly _isAreaEqual = (
|
||||||
|
left: HoveredElemArea | null,
|
||||||
|
right: HoveredElemArea | null
|
||||||
|
) => {
|
||||||
|
if (!left || !right) return false;
|
||||||
|
return (
|
||||||
|
left.left === right.left &&
|
||||||
|
left.top === right.top &&
|
||||||
|
left.right === right.right &&
|
||||||
|
left.bottom === right.bottom &&
|
||||||
|
left.width === right.width &&
|
||||||
|
left.height === right.height &&
|
||||||
|
left.padding === right.padding &&
|
||||||
|
left.containerWidth === right.containerWidth
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
private readonly _scheduleShowDragHandleFromSurfaceUpdate = () => {
|
||||||
|
if (this._surfaceElementUpdatedRafId !== null) return;
|
||||||
|
|
||||||
|
this._surfaceElementUpdatedRafId = requestAnimationFrame(() => {
|
||||||
|
this._surfaceElementUpdatedRafId = null;
|
||||||
|
if (!this.widget.isGfxDragHandleVisible) return;
|
||||||
|
this._showDragHandle();
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
private readonly _handleEdgelessToolUpdated = (
|
private readonly _handleEdgelessToolUpdated = (
|
||||||
newTool: ToolOptionWithType
|
newTool: ToolOptionWithType
|
||||||
) => {
|
) => {
|
||||||
@@ -43,46 +100,123 @@ export class EdgelessWatcher {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (
|
if (
|
||||||
this.widget.center[0] !== center[0] &&
|
this.widget.center[0] !== center[0] ||
|
||||||
this.widget.center[1] !== center[1]
|
this.widget.center[1] !== center[1]
|
||||||
) {
|
) {
|
||||||
this.widget.center = [...center];
|
this.widget.center = [...center];
|
||||||
}
|
}
|
||||||
|
|
||||||
if (this.widget.isGfxDragHandleVisible) {
|
if (this.widget.isGfxDragHandleVisible) {
|
||||||
this._showDragHandle();
|
const area = this.hoveredElemArea;
|
||||||
this._updateDragHoverRectTopLevelBlock();
|
this._showDragHandle(area);
|
||||||
|
this._updateDragHoverRectTopLevelBlock(area);
|
||||||
} else if (this.widget.activeDragHandle) {
|
} else if (this.widget.activeDragHandle) {
|
||||||
this.widget.hide();
|
this.widget.hide();
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
private readonly _showDragHandle = () => {
|
private readonly _flushShowDragHandle = () => {
|
||||||
if (!this.widget.anchorBlockId) return;
|
this._showDragHandleRafId = null;
|
||||||
|
|
||||||
|
if (!this.widget.anchorBlockId.peek()) return;
|
||||||
|
|
||||||
const container = this.widget.dragHandleContainer;
|
const container = this.widget.dragHandleContainer;
|
||||||
const grabber = this.widget.dragHandleGrabber;
|
const grabber = this.widget.dragHandleGrabber;
|
||||||
if (!container || !grabber) return;
|
if (!container || !grabber) return;
|
||||||
|
|
||||||
const area = this.hoveredElemArea;
|
const area = this._pendingHoveredElemArea ?? this.hoveredElemArea;
|
||||||
|
this._pendingHoveredElemArea = null;
|
||||||
if (!area) return;
|
if (!area) return;
|
||||||
|
|
||||||
container.style.transition = 'none';
|
if (
|
||||||
container.style.paddingTop = `0px`;
|
this.widget.isGfxDragHandleVisible &&
|
||||||
container.style.paddingBottom = `0px`;
|
this._isAreaEqual(this._lastAppliedHoveredElemArea, area)
|
||||||
container.style.left = `${area.left}px`;
|
) {
|
||||||
container.style.top = `${area.top}px`;
|
return;
|
||||||
container.style.display = 'flex';
|
}
|
||||||
|
|
||||||
|
if (container.style.transition !== 'none') {
|
||||||
|
container.style.transition = 'none';
|
||||||
|
}
|
||||||
|
const nextPaddingTop = '0px';
|
||||||
|
if (container.style.paddingTop !== nextPaddingTop) {
|
||||||
|
container.style.paddingTop = nextPaddingTop;
|
||||||
|
}
|
||||||
|
const nextPaddingBottom = '0px';
|
||||||
|
if (container.style.paddingBottom !== nextPaddingBottom) {
|
||||||
|
container.style.paddingBottom = nextPaddingBottom;
|
||||||
|
}
|
||||||
|
const nextLeft = `${area.left}px`;
|
||||||
|
if (container.style.left !== nextLeft) {
|
||||||
|
container.style.left = nextLeft;
|
||||||
|
}
|
||||||
|
const nextTop = `${area.top}px`;
|
||||||
|
if (container.style.top !== nextTop) {
|
||||||
|
container.style.top = nextTop;
|
||||||
|
}
|
||||||
|
if (container.style.display !== 'flex') {
|
||||||
|
container.style.display = 'flex';
|
||||||
|
}
|
||||||
|
|
||||||
this.widget.handleAnchorModelDisposables();
|
this.widget.handleAnchorModelDisposables();
|
||||||
|
|
||||||
this.widget.activeDragHandle = 'gfx';
|
this.widget.activeDragHandle = 'gfx';
|
||||||
|
this._lastAppliedHoveredElemArea = this._cloneArea(area);
|
||||||
};
|
};
|
||||||
|
|
||||||
private readonly _updateDragHoverRectTopLevelBlock = () => {
|
private readonly _showDragHandle = (area?: HoveredElemArea | null) => {
|
||||||
|
const nextArea = area ?? this.hoveredElemArea;
|
||||||
|
this._pendingHoveredElemArea = nextArea;
|
||||||
|
if (!this._pendingHoveredElemArea) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
this.widget.isGfxDragHandleVisible &&
|
||||||
|
this._showDragHandleRafId === null &&
|
||||||
|
this._isAreaEqual(
|
||||||
|
this._lastAppliedHoveredElemArea,
|
||||||
|
this._pendingHoveredElemArea
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (this._showDragHandleRafId !== null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this._showDragHandleRafId = requestAnimationFrame(
|
||||||
|
this._flushShowDragHandle
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
private readonly _updateDragHoverRectTopLevelBlock = (
|
||||||
|
area?: HoveredElemArea | null
|
||||||
|
) => {
|
||||||
if (!this.widget.dragHoverRect) return;
|
if (!this.widget.dragHoverRect) return;
|
||||||
|
|
||||||
this.widget.dragHoverRect = this.hoveredElemAreaRect;
|
const nextArea = area ?? this.hoveredElemArea;
|
||||||
|
if (!nextArea) {
|
||||||
|
this.widget.dragHoverRect = null;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const nextRect = new Rect(
|
||||||
|
nextArea.left,
|
||||||
|
nextArea.top,
|
||||||
|
nextArea.right,
|
||||||
|
nextArea.bottom
|
||||||
|
);
|
||||||
|
const prevRect = this.widget.dragHoverRect;
|
||||||
|
if (
|
||||||
|
prevRect &&
|
||||||
|
prevRect.left === nextRect.left &&
|
||||||
|
prevRect.top === nextRect.top &&
|
||||||
|
prevRect.width === nextRect.width &&
|
||||||
|
prevRect.height === nextRect.height
|
||||||
|
) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.widget.dragHoverRect = nextRect;
|
||||||
};
|
};
|
||||||
|
|
||||||
get gfx() {
|
get gfx() {
|
||||||
@@ -123,7 +257,7 @@ export class EdgelessWatcher {
|
|||||||
return new Rect(area.left, area.top, area.right, area.bottom);
|
return new Rect(area.left, area.top, area.right, area.bottom);
|
||||||
}
|
}
|
||||||
|
|
||||||
get hoveredElemArea() {
|
get hoveredElemArea(): HoveredElemArea | null {
|
||||||
const edgelessElement = this.widget.anchorEdgelessElement.peek();
|
const edgelessElement = this.widget.anchorEdgelessElement.peek();
|
||||||
|
|
||||||
if (!edgelessElement) return null;
|
if (!edgelessElement) return null;
|
||||||
@@ -174,6 +308,19 @@ export class EdgelessWatcher {
|
|||||||
viewport.viewportUpdated.subscribe(this._handleEdgelessViewPortUpdated)
|
viewport.viewportUpdated.subscribe(this._handleEdgelessViewPortUpdated)
|
||||||
);
|
);
|
||||||
|
|
||||||
|
disposables.add(() => {
|
||||||
|
if (this._showDragHandleRafId !== null) {
|
||||||
|
cancelAnimationFrame(this._showDragHandleRafId);
|
||||||
|
this._showDragHandleRafId = null;
|
||||||
|
}
|
||||||
|
if (this._surfaceElementUpdatedRafId !== null) {
|
||||||
|
cancelAnimationFrame(this._surfaceElementUpdatedRafId);
|
||||||
|
this._surfaceElementUpdatedRafId = null;
|
||||||
|
}
|
||||||
|
this._pendingHoveredElemArea = null;
|
||||||
|
this._lastAppliedHoveredElemArea = null;
|
||||||
|
});
|
||||||
|
|
||||||
disposables.add(
|
disposables.add(
|
||||||
selection.slots.updated.subscribe(() => {
|
selection.slots.updated.subscribe(() => {
|
||||||
this.updateAnchorElement();
|
this.updateAnchorElement();
|
||||||
@@ -216,7 +363,7 @@ export class EdgelessWatcher {
|
|||||||
this.widget.hide();
|
this.widget.hide();
|
||||||
}
|
}
|
||||||
if (payload.type === 'update') {
|
if (payload.type === 'update') {
|
||||||
this._showDragHandle();
|
this._scheduleShowDragHandleFromSurfaceUpdate();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
@@ -224,9 +371,10 @@ export class EdgelessWatcher {
|
|||||||
|
|
||||||
if (surface) {
|
if (surface) {
|
||||||
disposables.add(
|
disposables.add(
|
||||||
surface.elementUpdated.subscribe(() => {
|
surface.elementUpdated.subscribe(({ id }) => {
|
||||||
if (this.widget.isGfxDragHandleVisible) {
|
if (this.widget.isGfxDragHandleVisible) {
|
||||||
this._showDragHandle();
|
if (id !== this.widget.anchorBlockId.peek()) return;
|
||||||
|
this._scheduleShowDragHandleFromSurfaceUpdate();
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -153,6 +153,10 @@ export class PointerEventWatcher {
|
|||||||
|
|
||||||
private _lastShowedBlock: { id: string; el: BlockComponent } | null = null;
|
private _lastShowedBlock: { id: string; el: BlockComponent } | null = null;
|
||||||
|
|
||||||
|
private _lastPointerHitBlockId: string | null = null;
|
||||||
|
|
||||||
|
private _lastPointerHitBlockElement: Element | null = null;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* When pointer move on block, should show drag handle
|
* When pointer move on block, should show drag handle
|
||||||
* And update hover block id and path
|
* And update hover block id and path
|
||||||
@@ -169,6 +173,7 @@ export class PointerEventWatcher {
|
|||||||
point
|
point
|
||||||
);
|
);
|
||||||
if (!closestBlock) {
|
if (!closestBlock) {
|
||||||
|
this._lastPointerHitBlockId = null;
|
||||||
this.widget.anchorBlockId.value = null;
|
this.widget.anchorBlockId.value = null;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -237,19 +242,38 @@ export class PointerEventWatcher {
|
|||||||
|
|
||||||
const state = ctx.get('pointerState');
|
const state = ctx.get('pointerState');
|
||||||
|
|
||||||
// When pointer is moving, should do nothing
|
|
||||||
if (state.delta.x !== 0 && state.delta.y !== 0) return;
|
|
||||||
|
|
||||||
const { target } = state.raw;
|
const { target } = state.raw;
|
||||||
const element = captureEventTarget(target);
|
const element = captureEventTarget(target);
|
||||||
// When pointer not on block or on dragging, should do nothing
|
// When pointer not on block or on dragging, should do nothing
|
||||||
if (!element) return;
|
if (!element) {
|
||||||
|
this._lastPointerHitBlockId = null;
|
||||||
|
this._lastPointerHitBlockElement = null;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// When pointer on drag handle, should do nothing
|
// When pointer on drag handle, should do nothing
|
||||||
if (element.closest('.affine-drag-handle-container')) return;
|
if (element.closest('.affine-drag-handle-container')) return;
|
||||||
|
|
||||||
if (!this.widget.rootComponent) return;
|
if (!this.widget.rootComponent) return;
|
||||||
|
|
||||||
|
const hitBlock = element.closest(`[${BLOCK_ID_ATTR}]`);
|
||||||
|
const hitBlockId = hitBlock?.getAttribute(BLOCK_ID_ATTR) ?? null;
|
||||||
|
|
||||||
|
// Pointer move events are high-frequency. If hovered block identity is
|
||||||
|
// unchanged and the underlying block element is the same, skip the
|
||||||
|
// closest-note lookup.
|
||||||
|
if (
|
||||||
|
hitBlockId &&
|
||||||
|
this.widget.isBlockDragHandleVisible &&
|
||||||
|
hitBlockId === this._lastPointerHitBlockId &&
|
||||||
|
hitBlock === this._lastPointerHitBlockElement &&
|
||||||
|
isBlockIdEqual(this.widget.anchorBlockId.peek(), hitBlockId)
|
||||||
|
) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this._lastPointerHitBlockId = hitBlockId;
|
||||||
|
this._lastPointerHitBlockElement = hitBlock;
|
||||||
|
|
||||||
// When pointer out of note block hover area or inside database, should hide drag handle
|
// When pointer out of note block hover area or inside database, should hide drag handle
|
||||||
const point = new Point(state.raw.x, state.raw.y);
|
const point = new Point(state.raw.x, state.raw.y);
|
||||||
|
|
||||||
@@ -354,6 +378,8 @@ export class PointerEventWatcher {
|
|||||||
reset() {
|
reset() {
|
||||||
this._lastHoveredBlockId = null;
|
this._lastHoveredBlockId = null;
|
||||||
this._lastShowedBlock = null;
|
this._lastShowedBlock = null;
|
||||||
|
this._lastPointerHitBlockId = null;
|
||||||
|
this._lastPointerHitBlockElement = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
watch() {
|
watch() {
|
||||||
|
|||||||
@@ -10,25 +10,15 @@ import type { InlineRange } from '../types.js';
|
|||||||
import { deltaInsertsToChunks } from '../utils/delta-convert.js';
|
import { deltaInsertsToChunks } from '../utils/delta-convert.js';
|
||||||
|
|
||||||
export class RenderService<TextAttributes extends BaseTextAttributes> {
|
export class RenderService<TextAttributes extends BaseTextAttributes> {
|
||||||
private readonly _onYTextChange = (
|
private _pendingRemoteInlineRangeSync = false;
|
||||||
_: Y.YTextEvent,
|
|
||||||
transaction: Y.Transaction
|
|
||||||
) => {
|
|
||||||
this.editor.slots.textChange.next();
|
|
||||||
|
|
||||||
const yText = this.editor.yText;
|
private _carriageReturnValidationCounter = 0;
|
||||||
|
|
||||||
if (yText.toString().includes('\r')) {
|
private _renderVersion = 0;
|
||||||
throw new BlockSuiteError(
|
|
||||||
ErrorCode.InlineEditorError,
|
|
||||||
'yText must not contain "\\r" because it will break the range synchronization'
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
this.render();
|
|
||||||
|
|
||||||
|
private readonly _syncRemoteInlineRange = () => {
|
||||||
const inlineRange = this.editor.inlineRange$.peek();
|
const inlineRange = this.editor.inlineRange$.peek();
|
||||||
if (!inlineRange || transaction.local) return;
|
if (!inlineRange) return;
|
||||||
|
|
||||||
const lastStartRelativePosition = this.editor.lastStartRelativePosition;
|
const lastStartRelativePosition = this.editor.lastStartRelativePosition;
|
||||||
const lastEndRelativePosition = this.editor.lastEndRelativePosition;
|
const lastEndRelativePosition = this.editor.lastEndRelativePosition;
|
||||||
@@ -50,7 +40,7 @@ export class RenderService<TextAttributes extends BaseTextAttributes> {
|
|||||||
|
|
||||||
const startIndex = absoluteStart?.index;
|
const startIndex = absoluteStart?.index;
|
||||||
const endIndex = absoluteEnd?.index;
|
const endIndex = absoluteEnd?.index;
|
||||||
if (!startIndex || !endIndex) return;
|
if (startIndex == null || endIndex == null) return;
|
||||||
|
|
||||||
const newInlineRange: InlineRange = {
|
const newInlineRange: InlineRange = {
|
||||||
index: startIndex,
|
index: startIndex,
|
||||||
@@ -59,7 +49,31 @@ export class RenderService<TextAttributes extends BaseTextAttributes> {
|
|||||||
if (!this.editor.isValidInlineRange(newInlineRange)) return;
|
if (!this.editor.isValidInlineRange(newInlineRange)) return;
|
||||||
|
|
||||||
this.editor.setInlineRange(newInlineRange);
|
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 = () => {
|
mount = () => {
|
||||||
@@ -70,6 +84,7 @@ export class RenderService<TextAttributes extends BaseTextAttributes> {
|
|||||||
editor.disposables.add({
|
editor.disposables.add({
|
||||||
dispose: () => {
|
dispose: () => {
|
||||||
yText.unobserve(this._onYTextChange);
|
yText.unobserve(this._onYTextChange);
|
||||||
|
this._pendingRemoteInlineRangeSync = false;
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
@@ -82,6 +97,7 @@ export class RenderService<TextAttributes extends BaseTextAttributes> {
|
|||||||
render = () => {
|
render = () => {
|
||||||
if (!this.editor.rootElement) return;
|
if (!this.editor.rootElement) return;
|
||||||
|
|
||||||
|
const renderVersion = ++this._renderVersion;
|
||||||
this._rendering = true;
|
this._rendering = true;
|
||||||
|
|
||||||
const rootElement = this.editor.rootElement;
|
const rootElement = this.editor.rootElement;
|
||||||
@@ -152,11 +168,21 @@ export class RenderService<TextAttributes extends BaseTextAttributes> {
|
|||||||
this.editor
|
this.editor
|
||||||
.waitForUpdate()
|
.waitForUpdate()
|
||||||
.then(() => {
|
.then(() => {
|
||||||
|
if (renderVersion !== this._renderVersion) return;
|
||||||
|
if (this._pendingRemoteInlineRangeSync) {
|
||||||
|
this._pendingRemoteInlineRangeSync = false;
|
||||||
|
this._syncRemoteInlineRange();
|
||||||
|
}
|
||||||
this._rendering = false;
|
this._rendering = false;
|
||||||
this.editor.slots.renderComplete.next();
|
this.editor.slots.renderComplete.next();
|
||||||
this.editor.syncInlineRange();
|
this.editor.syncInlineRange();
|
||||||
})
|
})
|
||||||
.catch(console.error);
|
.catch(error => {
|
||||||
|
if (renderVersion === this._renderVersion) {
|
||||||
|
this._rendering = false;
|
||||||
|
}
|
||||||
|
console.error(error);
|
||||||
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
rerenderWholeEditor = () => {
|
rerenderWholeEditor = () => {
|
||||||
|
|||||||
@@ -9,7 +9,12 @@ import {
|
|||||||
isVElement,
|
isVElement,
|
||||||
isVLine,
|
isVLine,
|
||||||
} from './guard.js';
|
} from './guard.js';
|
||||||
import { calculateTextLength, getTextNodesFromElement } from './text.js';
|
import {
|
||||||
|
calculateTextLength,
|
||||||
|
getInlineRootTextCache,
|
||||||
|
getTextNodesFromElement,
|
||||||
|
invalidateInlineRootTextCache,
|
||||||
|
} from './text.js';
|
||||||
|
|
||||||
export function nativePointToTextPoint(
|
export function nativePointToTextPoint(
|
||||||
node: unknown,
|
node: unknown,
|
||||||
@@ -67,19 +72,6 @@ export function textPointToDomPoint(
|
|||||||
|
|
||||||
if (!rootElement.contains(text)) return null;
|
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;
|
const textParentElement = text.parentElement;
|
||||||
if (!textParentElement) {
|
if (!textParentElement) {
|
||||||
throw new BlockSuiteError(
|
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(
|
const lineIndex = Array.from(rootElement.querySelectorAll('v-line')).indexOf(
|
||||||
lineElement
|
lineElement
|
||||||
);
|
);
|
||||||
|
if (lineIndex < 0) return null;
|
||||||
|
|
||||||
return { text, index: index + lineIndex };
|
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[] {
|
export function getTextNodesFromElement(element: Element): Text[] {
|
||||||
const textSpanElements = Array.from(
|
const textSpanElements = Array.from(
|
||||||
element.querySelectorAll('[data-v-text="true"]')
|
element.querySelectorAll('[data-v-text="true"]')
|
||||||
|
|||||||
@@ -47,7 +47,10 @@ describe('frame', () => {
|
|||||||
expect(rect!.width).toBeGreaterThan(0);
|
expect(rect!.width).toBeGreaterThan(0);
|
||||||
expect(rect!.height).toBeGreaterThan(0);
|
expect(rect!.height).toBeGreaterThan(0);
|
||||||
|
|
||||||
const [titleX, titleY] = service.viewport.toModelCoord(rect!.x, rect!.y);
|
const [titleX, titleY] = service.viewport.toModelCoordFromClientCoord([
|
||||||
|
rect!.x,
|
||||||
|
rect!.y,
|
||||||
|
]);
|
||||||
expect(titleX).toBeCloseTo(0);
|
expect(titleX).toBeCloseTo(0);
|
||||||
expect(titleY).toBeLessThan(0);
|
expect(titleY).toBeLessThan(0);
|
||||||
|
|
||||||
@@ -66,10 +69,11 @@ describe('frame', () => {
|
|||||||
if (!nestedTitle) return;
|
if (!nestedTitle) return;
|
||||||
|
|
||||||
const nestedTitleRect = nestedTitle.getBoundingClientRect()!;
|
const nestedTitleRect = nestedTitle.getBoundingClientRect()!;
|
||||||
const [nestedTitleX, nestedTitleY] = service.viewport.toModelCoord(
|
const [nestedTitleX, nestedTitleY] =
|
||||||
nestedTitleRect.x,
|
service.viewport.toModelCoordFromClientCoord([
|
||||||
nestedTitleRect.y
|
nestedTitleRect.x,
|
||||||
);
|
nestedTitleRect.y,
|
||||||
|
]);
|
||||||
|
|
||||||
expect(nestedTitleX).toBeGreaterThan(20);
|
expect(nestedTitleX).toBeGreaterThan(20);
|
||||||
expect(nestedTitleY).toBeGreaterThan(20);
|
expect(nestedTitleY).toBeGreaterThan(20);
|
||||||
|
|||||||
@@ -1,13 +1,28 @@
|
|||||||
import { getCurrentUserQuery } from '@affine/graphql';
|
import { getCurrentUserQuery } from '@affine/graphql';
|
||||||
|
|
||||||
|
import { JobExecutor } from '../../../base/job/queue/executor';
|
||||||
import { DatabaseDocReader, DocReader } from '../../../core/doc';
|
import { DatabaseDocReader, DocReader } from '../../../core/doc';
|
||||||
import { createApp } from '../create-app';
|
import { createApp } from '../create-app';
|
||||||
import { e2e } from '../test';
|
import { e2e } from '../test';
|
||||||
|
|
||||||
e2e('should init doc service', async t => {
|
type TestFlavor = 'doc' | 'graphql' | 'sync' | 'renderer' | 'front';
|
||||||
|
|
||||||
|
const createFlavorApp = async (flavor: TestFlavor) => {
|
||||||
// @ts-expect-error override
|
// @ts-expect-error override
|
||||||
globalThis.env.FLAVOR = 'doc';
|
globalThis.env.FLAVOR = flavor;
|
||||||
await using app = await createApp();
|
return await createApp({
|
||||||
|
tapModule(module) {
|
||||||
|
module.overrideProvider(JobExecutor).useValue({
|
||||||
|
onConfigInit: async () => {},
|
||||||
|
onConfigChanged: async () => {},
|
||||||
|
onModuleDestroy: async () => {},
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
e2e('should init doc service', async t => {
|
||||||
|
await using app = await createFlavorApp('doc');
|
||||||
|
|
||||||
const res = await app.GET('/info').expect(200);
|
const res = await app.GET('/info').expect(200);
|
||||||
t.is(res.body.flavor, 'doc');
|
t.is(res.body.flavor, 'doc');
|
||||||
@@ -16,9 +31,7 @@ e2e('should init doc service', async t => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
e2e('should init graphql service', async t => {
|
e2e('should init graphql service', async t => {
|
||||||
// @ts-expect-error override
|
await using app = await createFlavorApp('graphql');
|
||||||
globalThis.env.FLAVOR = 'graphql';
|
|
||||||
await using app = await createApp();
|
|
||||||
|
|
||||||
const res = await app.GET('/info').expect(200);
|
const res = await app.GET('/info').expect(200);
|
||||||
|
|
||||||
@@ -29,27 +42,21 @@ e2e('should init graphql service', async t => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
e2e('should init sync service', async t => {
|
e2e('should init sync service', async t => {
|
||||||
// @ts-expect-error override
|
await using app = await createFlavorApp('sync');
|
||||||
globalThis.env.FLAVOR = 'sync';
|
|
||||||
await using app = await createApp();
|
|
||||||
|
|
||||||
const res = await app.GET('/info').expect(200);
|
const res = await app.GET('/info').expect(200);
|
||||||
t.is(res.body.flavor, 'sync');
|
t.is(res.body.flavor, 'sync');
|
||||||
});
|
});
|
||||||
|
|
||||||
e2e('should init renderer service', async t => {
|
e2e('should init renderer service', async t => {
|
||||||
// @ts-expect-error override
|
await using app = await createFlavorApp('renderer');
|
||||||
globalThis.env.FLAVOR = 'renderer';
|
|
||||||
await using app = await createApp();
|
|
||||||
|
|
||||||
const res = await app.GET('/info').expect(200);
|
const res = await app.GET('/info').expect(200);
|
||||||
t.is(res.body.flavor, 'renderer');
|
t.is(res.body.flavor, 'renderer');
|
||||||
});
|
});
|
||||||
|
|
||||||
e2e('should init front service', async t => {
|
e2e('should init front service', async t => {
|
||||||
// @ts-expect-error override
|
await using app = await createFlavorApp('front');
|
||||||
globalThis.env.FLAVOR = 'front';
|
|
||||||
await using app = await createApp();
|
|
||||||
|
|
||||||
const res = await app.GET('/info').expect(200);
|
const res = await app.GET('/info').expect(200);
|
||||||
t.is(res.body.flavor, 'front');
|
t.is(res.body.flavor, 'front');
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { cssVar } from '@toeverything/theme';
|
import { cssVar } from '@toeverything/theme';
|
||||||
import { cssVarV2 } from '@toeverything/theme/v2';
|
import { cssVarV2 } from '@toeverything/theme/v2';
|
||||||
import { style } from '@vanilla-extract/css';
|
import { globalStyle, style } from '@vanilla-extract/css';
|
||||||
|
|
||||||
export const mainContainer = style({
|
export const mainContainer = style({
|
||||||
containerType: 'inline-size',
|
containerType: 'inline-size',
|
||||||
@@ -47,6 +47,12 @@ export const affineDocViewport = style({
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
export const pageModeViewportContentBox = style({});
|
||||||
|
globalStyle(`${pageModeViewportContentBox} >:first-child`, {
|
||||||
|
display: 'table !important',
|
||||||
|
minWidth: '100%',
|
||||||
|
});
|
||||||
|
|
||||||
export const scrollbar = style({
|
export const scrollbar = style({
|
||||||
marginRight: '4px',
|
marginRight: '4px',
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -347,7 +347,8 @@ const DetailPageImpl = memo(function DetailPageImpl() {
|
|||||||
className={clsx(
|
className={clsx(
|
||||||
'affine-page-viewport',
|
'affine-page-viewport',
|
||||||
styles.affineDocViewport,
|
styles.affineDocViewport,
|
||||||
styles.editorContainer
|
styles.editorContainer,
|
||||||
|
{ [styles.pageModeViewportContentBox]: mode === 'page' }
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<PageDetailEditor onLoad={onLoad} readonly={readonly} />
|
<PageDetailEditor onLoad={onLoad} readonly={readonly} />
|
||||||
|
|||||||
@@ -231,7 +231,7 @@ test('items in favourites can be reordered by dragging', async ({ page }) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// some how this test always timeout, so we skip it
|
// some how this test always timeout, so we skip it
|
||||||
test.skip('drag a page link in editor to favourites', async ({ page }) => {
|
test('drag a page link in editor to favourites', async ({ page }) => {
|
||||||
await clickNewPageButton(page);
|
await clickNewPageButton(page);
|
||||||
await page.waitForTimeout(500);
|
await page.waitForTimeout(500);
|
||||||
await page.keyboard.press('Enter');
|
await page.keyboard.press('Enter');
|
||||||
|
|||||||
Reference in New Issue
Block a user