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 | undefined) ?? new WeakSet(); + 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 | undefined; + if (!emittedNodes) { + return false; + } + return emittedNodes.delete(node as object); +}; + export const paragraphBlockHtmlAdapterMatcher: BlockHtmlAdapterMatcher = { flavour: ParagraphBlockSchema.model.flavour, toMatch: o => @@ -88,41 +208,37 @@ export const paragraphBlockHtmlAdapterMatcher: BlockHtmlAdapterMatcher = { !tagsInAncestor(o, ['p', 'li']) && HastUtils.isParagraphLike(o.node) ) { - walkerContext - .openNode( - { - type: 'block', - id: nanoid(), - flavour: 'affine:paragraph', - props: { - type: 'text', - text: { - '$blocksuite:internal:text$': true, - delta: deltaConverter.astToDelta(o.node), - }, - }, - children: [], - }, - 'children' - ) - .closeNode(); + const delta = deltaConverter.astToDelta(o.node); + const deltas = getParagraphDeltas(o.node, delta); + openParagraphBlocks(deltas, 'text', walkerContext); walkerContext.skipAllChildren(); } break; } 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( { type: 'block', id: nanoid(), flavour: 'affine:paragraph', props: { - type: walkerContext.getGlobalContext('hast:blockquote') - ? 'quote' - : 'text', + type, text: { '$blocksuite:internal:text$': true, - delta: deltaConverter.astToDelta(o.node), + delta, }, }, children: [], @@ -192,6 +308,9 @@ export const paragraphBlockHtmlAdapterMatcher: BlockHtmlAdapterMatcher = { break; } case 'p': { + if (consumeMultiParagraphEmittedMark(walkerContext, o.node)) { + break; + } if ( o.next?.type === 'element' && o.next.tagName === 'div' && diff --git a/blocksuite/affine/blocks/root/src/clipboard/page-clipboard.ts b/blocksuite/affine/blocks/root/src/clipboard/page-clipboard.ts index 6e4c4b3123..0d70f27b30 100644 --- a/blocksuite/affine/blocks/root/src/clipboard/page-clipboard.ts +++ b/blocksuite/affine/blocks/root/src/clipboard/page-clipboard.ts @@ -86,6 +86,7 @@ export class PageClipboard extends ReadOnlyClipboard { if (this.std.store.readonly) return; this.std.store.captureSync(); + let hasPasteTarget = false; this.std.command .chain() .try<{}>(cmd => [ @@ -144,18 +145,39 @@ export class PageClipboard extends ReadOnlyClipboard { if (!ctx.parentBlock) { return; } + hasPasteTarget = true; this.std.clipboard .paste( e, this.std.store, ctx.parentBlock.model.id, - ctx.blockIndex ? ctx.blockIndex + 1 : 1 + ctx.blockIndex !== undefined ? ctx.blockIndex + 1 : 1 ) .catch(console.error); return next(); }) .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() { diff --git a/blocksuite/affine/shared/src/services/font-loader/font-loader-service.ts b/blocksuite/affine/shared/src/services/font-loader/font-loader-service.ts index 1c9411cbaa..12ffaa47c8 100644 --- a/blocksuite/affine/shared/src/services/font-loader/font-loader-service.ts +++ b/blocksuite/affine/shared/src/services/font-loader/font-loader-service.ts @@ -1,3 +1,4 @@ +import { FontFamily, FontStyle, FontWeight } from '@blocksuite/affine-model'; import { createIdentifier } from '@blocksuite/global/di'; import { IS_FIREFOX } from '@blocksuite/global/env'; import { LifeCycleWatcher } from '@blocksuite/std'; @@ -20,33 +21,171 @@ const initFontFace = IS_FIREFOX export class FontLoaderService extends LifeCycleWatcher { 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(); + readonly fontFaces: FontFace[] = []; get ready() { 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[]) { - this.fontFaces.push( - ...fonts.map(font => { - const fontFace = initFontFace(font); - document.fonts.add(fontFace); - fontFace.load().catch(console.error); - return fontFace; - }) - ); + for (const font of fonts) { + const key = this._fontKey(font); + if (this._loadedFontKeys.has(key)) { + continue; + } + this._loadedFontKeys.add(key); + const fontFace = initFontFace(font); + document.fonts.add(fontFace); + fontFace.load().catch(console.error); + this.fontFaces.push(fontFace); + } } override mounted() { const config = this.std.getOptional(FontConfigIdentifier); - if (config) { - this.load(config); + if (!config || config.length === 0) { + 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() { - 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._loadedFontKeys.clear(); } } diff --git a/blocksuite/affine/widgets/drag-handle/src/watchers/edgeless-watcher.ts b/blocksuite/affine/widgets/drag-handle/src/watchers/edgeless-watcher.ts index 498e8ad804..52cff1f582 100644 --- a/blocksuite/affine/widgets/drag-handle/src/watchers/edgeless-watcher.ts +++ b/blocksuite/affine/widgets/drag-handle/src/watchers/edgeless-watcher.ts @@ -14,6 +14,17 @@ import { } from '../config.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 * @@ -21,6 +32,52 @@ import type { AffineDragHandleWidget } from '../drag-handle.js'; * 2. Multiple selection is not supported */ 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 = ( newTool: ToolOptionWithType ) => { @@ -43,46 +100,123 @@ export class EdgelessWatcher { } if ( - this.widget.center[0] !== center[0] && + this.widget.center[0] !== center[0] || this.widget.center[1] !== center[1] ) { this.widget.center = [...center]; } if (this.widget.isGfxDragHandleVisible) { - this._showDragHandle(); - this._updateDragHoverRectTopLevelBlock(); + const area = this.hoveredElemArea; + this._showDragHandle(area); + this._updateDragHoverRectTopLevelBlock(area); } else if (this.widget.activeDragHandle) { this.widget.hide(); } }; - private readonly _showDragHandle = () => { - if (!this.widget.anchorBlockId) return; + private readonly _flushShowDragHandle = () => { + this._showDragHandleRafId = null; + + if (!this.widget.anchorBlockId.peek()) return; const container = this.widget.dragHandleContainer; const grabber = this.widget.dragHandleGrabber; if (!container || !grabber) return; - const area = this.hoveredElemArea; + const area = this._pendingHoveredElemArea ?? this.hoveredElemArea; + this._pendingHoveredElemArea = null; if (!area) return; - container.style.transition = 'none'; - container.style.paddingTop = `0px`; - container.style.paddingBottom = `0px`; - container.style.left = `${area.left}px`; - container.style.top = `${area.top}px`; - container.style.display = 'flex'; + if ( + this.widget.isGfxDragHandleVisible && + this._isAreaEqual(this._lastAppliedHoveredElemArea, area) + ) { + return; + } + + 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.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; - 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() { @@ -123,7 +257,7 @@ export class EdgelessWatcher { return new Rect(area.left, area.top, area.right, area.bottom); } - get hoveredElemArea() { + get hoveredElemArea(): HoveredElemArea | null { const edgelessElement = this.widget.anchorEdgelessElement.peek(); if (!edgelessElement) return null; @@ -174,6 +308,19 @@ export class EdgelessWatcher { 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( selection.slots.updated.subscribe(() => { this.updateAnchorElement(); @@ -216,7 +363,7 @@ export class EdgelessWatcher { this.widget.hide(); } if (payload.type === 'update') { - this._showDragHandle(); + this._scheduleShowDragHandleFromSurfaceUpdate(); } } }) @@ -224,9 +371,10 @@ export class EdgelessWatcher { if (surface) { disposables.add( - surface.elementUpdated.subscribe(() => { + surface.elementUpdated.subscribe(({ id }) => { if (this.widget.isGfxDragHandleVisible) { - this._showDragHandle(); + if (id !== this.widget.anchorBlockId.peek()) return; + this._scheduleShowDragHandleFromSurfaceUpdate(); } }) ); diff --git a/blocksuite/affine/widgets/drag-handle/src/watchers/pointer-event-watcher.ts b/blocksuite/affine/widgets/drag-handle/src/watchers/pointer-event-watcher.ts index 739340ccd5..cc2059627c 100644 --- a/blocksuite/affine/widgets/drag-handle/src/watchers/pointer-event-watcher.ts +++ b/blocksuite/affine/widgets/drag-handle/src/watchers/pointer-event-watcher.ts @@ -153,6 +153,10 @@ export class PointerEventWatcher { 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 * And update hover block id and path @@ -169,6 +173,7 @@ export class PointerEventWatcher { point ); if (!closestBlock) { + this._lastPointerHitBlockId = null; this.widget.anchorBlockId.value = null; return; } @@ -237,19 +242,38 @@ export class PointerEventWatcher { 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 element = captureEventTarget(target); // 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 if (element.closest('.affine-drag-handle-container')) 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 const point = new Point(state.raw.x, state.raw.y); @@ -354,6 +378,8 @@ export class PointerEventWatcher { reset() { this._lastHoveredBlockId = null; this._lastShowedBlock = null; + this._lastPointerHitBlockId = null; + this._lastPointerHitBlockElement = null; } watch() { diff --git a/blocksuite/framework/std/src/inline/services/render.ts b/blocksuite/framework/std/src/inline/services/render.ts index 0626ce347a..5ea776d0c0 100644 --- a/blocksuite/framework/std/src/inline/services/render.ts +++ b/blocksuite/framework/std/src/inline/services/render.ts @@ -10,25 +10,15 @@ import type { InlineRange } from '../types.js'; import { deltaInsertsToChunks } from '../utils/delta-convert.js'; export class RenderService { - 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 { 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 { 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 { editor.disposables.add({ dispose: () => { yText.unobserve(this._onYTextChange); + this._pendingRemoteInlineRangeSync = false; }, }); }; @@ -82,6 +97,7 @@ export class RenderService { 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 { 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 = () => { diff --git a/blocksuite/framework/std/src/inline/utils/point-conversion.ts b/blocksuite/framework/std/src/inline/utils/point-conversion.ts index dd0a7bf659..59c56a61fb 100644 --- a/blocksuite/framework/std/src/inline/utils/point-conversion.ts +++ b/blocksuite/framework/std/src/inline/utils/point-conversion.ts @@ -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 }; } diff --git a/blocksuite/framework/std/src/inline/utils/text.ts b/blocksuite/framework/std/src/inline/utils/text.ts index a9534c72d9..296f8b1ef8 100644 --- a/blocksuite/framework/std/src/inline/utils/text.ts +++ b/blocksuite/framework/std/src/inline/utils/text.ts @@ -8,6 +8,92 @@ export function calculateTextLength(text: Text): number { } } +type InlineRootTextCache = { + dirty: boolean; + observer: MutationObserver | null; + textNodes: Text[]; + textNodeIndexMap: WeakMap; + prefixLengths: number[]; + lineIndexMap: WeakMap; +}; + +const inlineRootTextCaches = new WeakMap(); + +const buildInlineRootTextCache = ( + rootElement: HTMLElement, + cache: InlineRootTextCache +) => { + const textSpanElements = Array.from( + rootElement.querySelectorAll('[data-v-text="true"]') + ); + const textNodes: Text[] = []; + const textNodeIndexMap = new WeakMap(); + 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(); + 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"]') diff --git a/blocksuite/integration-test/src/__tests__/edgeless/frame.spec.ts b/blocksuite/integration-test/src/__tests__/edgeless/frame.spec.ts index 31af958ecb..04921a6aec 100644 --- a/blocksuite/integration-test/src/__tests__/edgeless/frame.spec.ts +++ b/blocksuite/integration-test/src/__tests__/edgeless/frame.spec.ts @@ -47,7 +47,10 @@ describe('frame', () => { expect(rect!.width).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(titleY).toBeLessThan(0); @@ -66,10 +69,11 @@ describe('frame', () => { if (!nestedTitle) return; const nestedTitleRect = nestedTitle.getBoundingClientRect()!; - const [nestedTitleX, nestedTitleY] = service.viewport.toModelCoord( - nestedTitleRect.x, - nestedTitleRect.y - ); + const [nestedTitleX, nestedTitleY] = + service.viewport.toModelCoordFromClientCoord([ + nestedTitleRect.x, + nestedTitleRect.y, + ]); expect(nestedTitleX).toBeGreaterThan(20); expect(nestedTitleY).toBeGreaterThan(20); diff --git a/packages/backend/server/src/__tests__/e2e/apps/flavors.spec.ts b/packages/backend/server/src/__tests__/e2e/apps/flavors.spec.ts index fb7bf84ba3..8603eb45cd 100644 --- a/packages/backend/server/src/__tests__/e2e/apps/flavors.spec.ts +++ b/packages/backend/server/src/__tests__/e2e/apps/flavors.spec.ts @@ -1,13 +1,28 @@ import { getCurrentUserQuery } from '@affine/graphql'; +import { JobExecutor } from '../../../base/job/queue/executor'; import { DatabaseDocReader, DocReader } from '../../../core/doc'; import { createApp } from '../create-app'; 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 - globalThis.env.FLAVOR = 'doc'; - await using app = await createApp(); + globalThis.env.FLAVOR = flavor; + 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); t.is(res.body.flavor, 'doc'); @@ -16,9 +31,7 @@ e2e('should init doc service', async t => { }); e2e('should init graphql service', async t => { - // @ts-expect-error override - globalThis.env.FLAVOR = 'graphql'; - await using app = await createApp(); + await using app = await createFlavorApp('graphql'); 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 => { - // @ts-expect-error override - globalThis.env.FLAVOR = 'sync'; - await using app = await createApp(); + await using app = await createFlavorApp('sync'); const res = await app.GET('/info').expect(200); t.is(res.body.flavor, 'sync'); }); e2e('should init renderer service', async t => { - // @ts-expect-error override - globalThis.env.FLAVOR = 'renderer'; - await using app = await createApp(); + await using app = await createFlavorApp('renderer'); const res = await app.GET('/info').expect(200); t.is(res.body.flavor, 'renderer'); }); e2e('should init front service', async t => { - // @ts-expect-error override - globalThis.env.FLAVOR = 'front'; - await using app = await createApp(); + await using app = await createFlavorApp('front'); const res = await app.GET('/info').expect(200); t.is(res.body.flavor, 'front'); diff --git a/packages/frontend/core/src/desktop/pages/workspace/detail-page/detail-page.css.ts b/packages/frontend/core/src/desktop/pages/workspace/detail-page/detail-page.css.ts index 92349f231f..722c464d5f 100644 --- a/packages/frontend/core/src/desktop/pages/workspace/detail-page/detail-page.css.ts +++ b/packages/frontend/core/src/desktop/pages/workspace/detail-page/detail-page.css.ts @@ -1,6 +1,6 @@ import { cssVar } from '@toeverything/theme'; import { cssVarV2 } from '@toeverything/theme/v2'; -import { style } from '@vanilla-extract/css'; +import { globalStyle, style } from '@vanilla-extract/css'; export const mainContainer = style({ 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({ marginRight: '4px', }); diff --git a/packages/frontend/core/src/desktop/pages/workspace/detail-page/detail-page.tsx b/packages/frontend/core/src/desktop/pages/workspace/detail-page/detail-page.tsx index d4f1061297..c0b8aa0185 100644 --- a/packages/frontend/core/src/desktop/pages/workspace/detail-page/detail-page.tsx +++ b/packages/frontend/core/src/desktop/pages/workspace/detail-page/detail-page.tsx @@ -347,7 +347,8 @@ const DetailPageImpl = memo(function DetailPageImpl() { className={clsx( 'affine-page-viewport', styles.affineDocViewport, - styles.editorContainer + styles.editorContainer, + { [styles.pageModeViewportContentBox]: mode === 'page' } )} > diff --git a/tests/affine-local/e2e/drag-page.spec.ts b/tests/affine-local/e2e/drag-page.spec.ts index 4aa1d8b108..dbe174c1d0 100644 --- a/tests/affine-local/e2e/drag-page.spec.ts +++ b/tests/affine-local/e2e/drag-page.spec.ts @@ -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 -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 page.waitForTimeout(500); await page.keyboard.press('Enter');