Files
AFFiNE-Mirror/blocksuite/affine/blocks/root/src/clipboard/page-clipboard.ts
DarkSky 72df9cb457 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 -->
2026-02-14 00:43:36 +08:00

199 lines
6.1 KiB
TypeScript

import { deleteTextCommand } from '@blocksuite/affine-inline-preset';
import {
pasteMiddleware,
replaceIdMiddleware,
surfaceRefToEmbed,
uploadMiddleware,
} from '@blocksuite/affine-shared/adapters';
import {
clearAndSelectFirstModelCommand,
deleteSelectedModelsCommand,
getBlockIndexCommand,
getBlockSelectionsCommand,
getImageSelectionsCommand,
getSelectedModelsCommand,
getTextSelectionCommand,
retainFirstModelCommand,
} from '@blocksuite/affine-shared/commands';
import { DisposableGroup } from '@blocksuite/global/disposable';
import type { UIEventHandler } from '@blocksuite/std';
import type { BlockSnapshot, Store } from '@blocksuite/store';
import { ReadOnlyClipboard } from './readonly-clipboard';
/**
* PageClipboard is a class that provides a clipboard for the page root block.
* It is supported to copy and paste models in the page root block.
*/
export class PageClipboard extends ReadOnlyClipboard {
static override key = 'affine-page-clipboard';
protected _init = () => {
this._initAdapters();
const paste = pasteMiddleware(this.std);
// Use surfaceRefToEmbed middleware to convert surface-ref to embed-linked-doc
// When pastina a surface-ref block to another doc
const surfaceRefToEmbedMiddleware = surfaceRefToEmbed(this.std);
const replaceId = replaceIdMiddleware(this.std.store.workspace.idGenerator);
const upload = uploadMiddleware(this.std);
this.std.clipboard.use(paste);
this.std.clipboard.use(surfaceRefToEmbedMiddleware);
this.std.clipboard.use(replaceId);
this.std.clipboard.use(upload);
this._disposables.add({
dispose: () => {
this.std.clipboard.unuse(paste);
this.std.clipboard.unuse(surfaceRefToEmbedMiddleware);
this.std.clipboard.unuse(replaceId);
this.std.clipboard.unuse(upload);
},
});
};
onBlockSnapshotPaste = async (
snapshot: BlockSnapshot,
doc: Store,
parent?: string,
index?: number
) => {
const block = await this.std.clipboard.pasteBlockSnapshot(
snapshot,
doc,
parent,
index
);
return block?.id ?? null;
};
onPageCut: UIEventHandler = ctx => {
const e = ctx.get('clipboardState').raw;
e.preventDefault();
this._copySelectedInPage(() => {
this.std.command
.chain()
.try<{}>(cmd => [
cmd.pipe(getTextSelectionCommand).pipe(deleteTextCommand),
cmd.pipe(getSelectedModelsCommand).pipe(deleteSelectedModelsCommand),
])
.run();
}).run();
};
onPagePaste: UIEventHandler = ctx => {
const e = ctx.get('clipboardState').raw;
e.preventDefault();
if (this.std.store.readonly) return;
this.std.store.captureSync();
let hasPasteTarget = false;
this.std.command
.chain()
.try<{}>(cmd => [
cmd.pipe(getTextSelectionCommand).pipe((ctx, next) => {
const { currentTextSelection } = ctx;
if (!currentTextSelection) {
return;
}
const { from, to } = currentTextSelection;
if (to && from.blockId !== to.blockId) {
this.std.command.exec(deleteTextCommand, {
currentTextSelection,
});
}
return next();
}),
cmd
.pipe(getSelectedModelsCommand)
.pipe(clearAndSelectFirstModelCommand)
.pipe(retainFirstModelCommand)
.pipe(deleteSelectedModelsCommand),
])
.try<{ currentSelectionPath: string }>(cmd => [
cmd.pipe(getTextSelectionCommand).pipe((ctx, next) => {
const textSelection = ctx.currentTextSelection;
if (!textSelection) {
return;
}
next({ currentSelectionPath: textSelection.from.blockId });
}),
cmd.pipe(getBlockSelectionsCommand).pipe((ctx, next) => {
const currentBlockSelections = ctx.currentBlockSelections;
if (!currentBlockSelections) {
return;
}
const blockSelection = currentBlockSelections.at(-1);
if (!blockSelection) {
return;
}
next({ currentSelectionPath: blockSelection.blockId });
}),
cmd.pipe(getImageSelectionsCommand).pipe((ctx, next) => {
const currentImageSelections = ctx.currentImageSelections;
if (!currentImageSelections) {
return;
}
const imageSelection = currentImageSelections.at(-1);
if (!imageSelection) {
return;
}
next({ currentSelectionPath: imageSelection.blockId });
}),
])
.pipe(getBlockIndexCommand)
.pipe((ctx, next) => {
if (!ctx.parentBlock) {
return;
}
hasPasteTarget = true;
this.std.clipboard
.paste(
e,
this.std.store,
ctx.parentBlock.model.id,
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() {
if (!navigator.clipboard) {
console.error(
'navigator.clipboard is not supported in current environment.'
);
return;
}
if (this._disposables.disposed) {
this._disposables = new DisposableGroup();
}
this.std.event.add('copy', this.onPageCopy);
this.std.event.add('paste', this.onPagePaste);
this.std.event.add('cut', this.onPageCut);
this._init();
}
}