mirror of
https://github.com/toeverything/AFFiNE.git
synced 2026-08-09 13:15:51 +08:00
chore: merge blocksuite source code (#9213)
This commit is contained in:
@@ -0,0 +1,92 @@
|
||||
import { BlockSuiteError, ErrorCode } from '@blocksuite/global/exceptions';
|
||||
import { assertExists } from '@blocksuite/global/utils';
|
||||
import type {
|
||||
BlockSnapshot,
|
||||
DocSnapshot,
|
||||
FromBlockSnapshotPayload,
|
||||
FromBlockSnapshotResult,
|
||||
FromDocSnapshotPayload,
|
||||
FromDocSnapshotResult,
|
||||
FromSliceSnapshotPayload,
|
||||
FromSliceSnapshotResult,
|
||||
SliceSnapshot,
|
||||
ToBlockSnapshotPayload,
|
||||
ToDocSnapshotPayload,
|
||||
ToSliceSnapshotPayload,
|
||||
} from '@blocksuite/store';
|
||||
import { BaseAdapter } from '@blocksuite/store';
|
||||
|
||||
import { decodeClipboardBlobs, encodeClipboardBlobs } from './utils.js';
|
||||
|
||||
export type FileSnapshot = {
|
||||
name: string;
|
||||
type: string;
|
||||
content: string;
|
||||
};
|
||||
|
||||
export class ClipboardAdapter extends BaseAdapter<string> {
|
||||
static MIME = 'BLOCKSUITE/SNAPSHOT';
|
||||
|
||||
override fromBlockSnapshot(
|
||||
_payload: FromBlockSnapshotPayload
|
||||
): Promise<FromBlockSnapshotResult<string>> {
|
||||
throw new BlockSuiteError(
|
||||
ErrorCode.TransformerNotImplementedError,
|
||||
'ClipboardAdapter.fromBlockSnapshot is not implemented'
|
||||
);
|
||||
}
|
||||
|
||||
override fromDocSnapshot(
|
||||
_payload: FromDocSnapshotPayload
|
||||
): Promise<FromDocSnapshotResult<string>> {
|
||||
throw new BlockSuiteError(
|
||||
ErrorCode.TransformerNotImplementedError,
|
||||
'ClipboardAdapter.fromDocSnapshot is not implemented'
|
||||
);
|
||||
}
|
||||
|
||||
override async fromSliceSnapshot(
|
||||
payload: FromSliceSnapshotPayload
|
||||
): Promise<FromSliceSnapshotResult<string>> {
|
||||
const snapshot = payload.snapshot;
|
||||
const assets = payload.assets;
|
||||
assertExists(assets);
|
||||
const map = assets.getAssets();
|
||||
const blobs: Record<string, FileSnapshot> = await encodeClipboardBlobs(map);
|
||||
return {
|
||||
file: JSON.stringify({
|
||||
snapshot,
|
||||
blobs,
|
||||
}),
|
||||
assetsIds: [],
|
||||
};
|
||||
}
|
||||
|
||||
override toBlockSnapshot(
|
||||
_payload: ToBlockSnapshotPayload<string>
|
||||
): Promise<BlockSnapshot> {
|
||||
throw new BlockSuiteError(
|
||||
ErrorCode.TransformerNotImplementedError,
|
||||
'ClipboardAdapter.toBlockSnapshot is not implemented'
|
||||
);
|
||||
}
|
||||
|
||||
override toDocSnapshot(
|
||||
_payload: ToDocSnapshotPayload<string>
|
||||
): Promise<DocSnapshot> {
|
||||
throw new BlockSuiteError(
|
||||
ErrorCode.TransformerNotImplementedError,
|
||||
'ClipboardAdapter.toDocSnapshot is not implemented'
|
||||
);
|
||||
}
|
||||
|
||||
override toSliceSnapshot(
|
||||
payload: ToSliceSnapshotPayload<string>
|
||||
): Promise<SliceSnapshot> {
|
||||
const json = JSON.parse(payload.file);
|
||||
const { blobs, snapshot } = json;
|
||||
const map = payload.assets?.getAssets();
|
||||
decodeClipboardBlobs(blobs, map);
|
||||
return Promise.resolve(snapshot);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,218 @@
|
||||
import type { BlockComponent, UIEventHandler } from '@blocksuite/block-std';
|
||||
import { DisposableGroup } from '@blocksuite/global/utils';
|
||||
import type { BlockSnapshot, Doc } from '@blocksuite/store';
|
||||
|
||||
import {
|
||||
AttachmentAdapter,
|
||||
HtmlAdapter,
|
||||
ImageAdapter,
|
||||
MixTextAdapter,
|
||||
NotionTextAdapter,
|
||||
} from '../../_common/adapters/index.js';
|
||||
import {
|
||||
defaultImageProxyMiddleware,
|
||||
replaceIdMiddleware,
|
||||
titleMiddleware,
|
||||
} from '../../_common/transformers/middlewares.js';
|
||||
import { ClipboardAdapter } from './adapter.js';
|
||||
import { copyMiddleware, pasteMiddleware } from './middlewares/index.js';
|
||||
|
||||
export class PageClipboard {
|
||||
private _copySelected = (onCopy?: () => void) => {
|
||||
return this._std.command
|
||||
.chain()
|
||||
.with({ onCopy })
|
||||
.getSelectedModels()
|
||||
.draftSelectedModels()
|
||||
.copySelectedModels();
|
||||
};
|
||||
|
||||
protected _disposables = new DisposableGroup();
|
||||
|
||||
protected _init = () => {
|
||||
this._std.clipboard.registerAdapter(
|
||||
ClipboardAdapter.MIME,
|
||||
ClipboardAdapter,
|
||||
100
|
||||
);
|
||||
this._std.clipboard.registerAdapter(
|
||||
'text/_notion-text-production',
|
||||
NotionTextAdapter,
|
||||
95
|
||||
);
|
||||
this._std.clipboard.registerAdapter('text/html', HtmlAdapter, 90);
|
||||
[
|
||||
'image/apng',
|
||||
'image/avif',
|
||||
'image/gif',
|
||||
'image/jpeg',
|
||||
'image/png',
|
||||
'image/svg+xml',
|
||||
'image/webp',
|
||||
].forEach(type =>
|
||||
this._std.clipboard.registerAdapter(type, ImageAdapter, 80)
|
||||
);
|
||||
this._std.clipboard.registerAdapter('text/plain', MixTextAdapter, 70);
|
||||
this._std.clipboard.registerAdapter('*/*', AttachmentAdapter, 60);
|
||||
const copy = copyMiddleware(this._std);
|
||||
const paste = pasteMiddleware(this._std);
|
||||
this._std.clipboard.use(copy);
|
||||
this._std.clipboard.use(paste);
|
||||
this._std.clipboard.use(replaceIdMiddleware);
|
||||
this._std.clipboard.use(titleMiddleware);
|
||||
this._std.clipboard.use(defaultImageProxyMiddleware);
|
||||
|
||||
this._disposables.add({
|
||||
dispose: () => {
|
||||
this._std.clipboard.unregisterAdapter(ClipboardAdapter.MIME);
|
||||
this._std.clipboard.unregisterAdapter('text/plain');
|
||||
[
|
||||
'image/apng',
|
||||
'image/avif',
|
||||
'image/gif',
|
||||
'image/jpeg',
|
||||
'image/png',
|
||||
'image/svg+xml',
|
||||
'image/webp',
|
||||
].forEach(type => this._std.clipboard.unregisterAdapter(type));
|
||||
this._std.clipboard.unregisterAdapter('text/html');
|
||||
this._std.clipboard.unregisterAdapter('*/*');
|
||||
this._std.clipboard.unuse(copy);
|
||||
this._std.clipboard.unuse(paste);
|
||||
this._std.clipboard.unuse(replaceIdMiddleware);
|
||||
this._std.clipboard.unuse(titleMiddleware);
|
||||
this._std.clipboard.unuse(defaultImageProxyMiddleware);
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
host: BlockComponent;
|
||||
|
||||
onBlockSnapshotPaste = async (
|
||||
snapshot: BlockSnapshot,
|
||||
doc: Doc,
|
||||
parent?: string,
|
||||
index?: number
|
||||
) => {
|
||||
const block = await this._std.clipboard.pasteBlockSnapshot(
|
||||
snapshot,
|
||||
doc,
|
||||
parent,
|
||||
index
|
||||
);
|
||||
return block?.id ?? null;
|
||||
};
|
||||
|
||||
onPageCopy: UIEventHandler = ctx => {
|
||||
const e = ctx.get('clipboardState').raw;
|
||||
e.preventDefault();
|
||||
|
||||
this._copySelected().run();
|
||||
};
|
||||
|
||||
onPageCut: UIEventHandler = ctx => {
|
||||
const e = ctx.get('clipboardState').raw;
|
||||
e.preventDefault();
|
||||
|
||||
this._copySelected(() => {
|
||||
this._std.command
|
||||
.chain()
|
||||
.try(cmd => [
|
||||
cmd.getTextSelection().deleteText(),
|
||||
cmd.getSelectedModels().deleteSelectedModels(),
|
||||
])
|
||||
.run();
|
||||
}).run();
|
||||
};
|
||||
|
||||
onPagePaste: UIEventHandler = ctx => {
|
||||
const e = ctx.get('clipboardState').raw;
|
||||
e.preventDefault();
|
||||
|
||||
this._std.doc.captureSync();
|
||||
this._std.command
|
||||
.chain()
|
||||
.try(cmd => [
|
||||
cmd.getTextSelection(),
|
||||
cmd
|
||||
.getSelectedModels()
|
||||
.clearAndSelectFirstModel()
|
||||
.retainFirstModel()
|
||||
.deleteSelectedModels(),
|
||||
])
|
||||
.try(cmd => [
|
||||
cmd.getTextSelection().inline<'currentSelectionPath'>((ctx, next) => {
|
||||
const textSelection = ctx.currentTextSelection;
|
||||
if (!textSelection) {
|
||||
return;
|
||||
}
|
||||
next({ currentSelectionPath: textSelection.from.blockId });
|
||||
}),
|
||||
cmd.getBlockSelections().inline<'currentSelectionPath'>((ctx, next) => {
|
||||
const currentBlockSelections = ctx.currentBlockSelections;
|
||||
if (!currentBlockSelections) {
|
||||
return;
|
||||
}
|
||||
const blockSelection = currentBlockSelections.at(-1);
|
||||
if (!blockSelection) {
|
||||
return;
|
||||
}
|
||||
next({ currentSelectionPath: blockSelection.blockId });
|
||||
}),
|
||||
cmd.getImageSelections().inline<'currentSelectionPath'>((ctx, next) => {
|
||||
const currentImageSelections = ctx.currentImageSelections;
|
||||
if (!currentImageSelections) {
|
||||
return;
|
||||
}
|
||||
const imageSelection = currentImageSelections.at(-1);
|
||||
if (!imageSelection) {
|
||||
return;
|
||||
}
|
||||
next({ currentSelectionPath: imageSelection.blockId });
|
||||
}),
|
||||
])
|
||||
.getBlockIndex()
|
||||
.inline((ctx, next) => {
|
||||
if (!ctx.parentBlock) {
|
||||
return;
|
||||
}
|
||||
this._std.clipboard
|
||||
.paste(
|
||||
e,
|
||||
this._std.doc,
|
||||
ctx.parentBlock.model.id,
|
||||
ctx.blockIndex ? ctx.blockIndex + 1 : 1
|
||||
)
|
||||
.catch(console.error);
|
||||
|
||||
return next();
|
||||
})
|
||||
.run();
|
||||
};
|
||||
|
||||
private get _std() {
|
||||
return this.host.std;
|
||||
}
|
||||
|
||||
constructor(host: BlockComponent) {
|
||||
this.host = host;
|
||||
}
|
||||
|
||||
hostConnected() {
|
||||
if (this._disposables.disposed) {
|
||||
this._disposables = new DisposableGroup();
|
||||
}
|
||||
if (navigator.clipboard) {
|
||||
this.host.handleEvent('copy', this.onPageCopy);
|
||||
this.host.handleEvent('paste', this.onPagePaste);
|
||||
this.host.handleEvent('cut', this.onPageCut);
|
||||
this._init();
|
||||
}
|
||||
}
|
||||
|
||||
hostDisconnected() {
|
||||
this._disposables.dispose();
|
||||
}
|
||||
}
|
||||
|
||||
export { copyMiddleware, pasteMiddleware };
|
||||
@@ -0,0 +1,54 @@
|
||||
import type { EditorHost, TextRangePoint } from '@blocksuite/block-std';
|
||||
import type {
|
||||
BlockSnapshot,
|
||||
DraftModel,
|
||||
JobMiddleware,
|
||||
JobSlots,
|
||||
} from '@blocksuite/store';
|
||||
|
||||
import { matchFlavours } from '../../../_common/utils/index.js';
|
||||
|
||||
const handlePoint = (
|
||||
point: TextRangePoint,
|
||||
snapshot: BlockSnapshot,
|
||||
model: DraftModel
|
||||
) => {
|
||||
const { index, length } = point;
|
||||
if (matchFlavours(model, ['affine:page'])) {
|
||||
if (length === 0) return;
|
||||
(snapshot.props.title as Record<string, unknown>).delta =
|
||||
model.title.sliceToDelta(index, length + index);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!snapshot.props.text || length === 0) {
|
||||
return;
|
||||
}
|
||||
(snapshot.props.text as Record<string, unknown>).delta =
|
||||
model.text?.sliceToDelta(index, length + index);
|
||||
};
|
||||
|
||||
const sliceText = (slots: JobSlots, std: EditorHost['std']) => {
|
||||
slots.afterExport.on(payload => {
|
||||
if (payload.type === 'block') {
|
||||
const snapshot = payload.snapshot;
|
||||
|
||||
const model = payload.model;
|
||||
const text = std.selection.find('text');
|
||||
if (text && text.from.blockId === model.id) {
|
||||
handlePoint(text.from, snapshot, model);
|
||||
return;
|
||||
}
|
||||
if (text && text.to && text.to.blockId === model.id) {
|
||||
handlePoint(text.to, snapshot, model);
|
||||
return;
|
||||
}
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
export const copyMiddleware = (std: EditorHost['std']): JobMiddleware => {
|
||||
return ({ slots }) => {
|
||||
sliceText(slots, std);
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,2 @@
|
||||
export * from './copy.js';
|
||||
export * from './paste.js';
|
||||
@@ -0,0 +1,529 @@
|
||||
import { REFERENCE_NODE } from '@blocksuite/affine-components/rich-text';
|
||||
import type { ParagraphBlockModel } from '@blocksuite/affine-model';
|
||||
import {
|
||||
ParseDocUrlProvider,
|
||||
type ParseDocUrlService,
|
||||
TelemetryProvider,
|
||||
} from '@blocksuite/affine-shared/services';
|
||||
import type { AffineTextAttributes } from '@blocksuite/affine-shared/types';
|
||||
import { referenceToNode } from '@blocksuite/affine-shared/utils';
|
||||
import {
|
||||
BLOCK_ID_ATTR,
|
||||
type BlockComponent,
|
||||
type EditorHost,
|
||||
type TextRangePoint,
|
||||
type TextSelection,
|
||||
} from '@blocksuite/block-std';
|
||||
import { BlockSuiteError, ErrorCode } from '@blocksuite/global/exceptions';
|
||||
import { assertExists } from '@blocksuite/global/utils';
|
||||
import {
|
||||
type BlockModel,
|
||||
type BlockSnapshot,
|
||||
type DeltaOperation,
|
||||
DocCollection,
|
||||
fromJSON,
|
||||
type JobMiddleware,
|
||||
type SliceSnapshot,
|
||||
type Text,
|
||||
} from '@blocksuite/store';
|
||||
|
||||
import { matchFlavours } from '../../../_common/utils/index.js';
|
||||
import { extractSearchParams } from '../../../_common/utils/url.js';
|
||||
|
||||
function findLastMatchingNode(
|
||||
root: BlockSnapshot[],
|
||||
fn: (node: BlockSnapshot) => boolean
|
||||
): BlockSnapshot | null {
|
||||
let lastMatchingNode: BlockSnapshot | null = null;
|
||||
|
||||
function traverse(node: BlockSnapshot) {
|
||||
if (fn(node)) {
|
||||
lastMatchingNode = node;
|
||||
}
|
||||
if (node.children) {
|
||||
for (const child of node.children) {
|
||||
traverse(child);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
root.forEach(traverse);
|
||||
return lastMatchingNode;
|
||||
}
|
||||
|
||||
// find last child that has text as prop
|
||||
const findLast = (snapshot: SliceSnapshot): BlockSnapshot | null => {
|
||||
return findLastMatchingNode(snapshot.content, node => !!node.props.text);
|
||||
};
|
||||
|
||||
class PointState {
|
||||
private _blockFromPath = (path: string) => {
|
||||
const block = this.std.view.getBlock(path);
|
||||
assertExists(block);
|
||||
return block;
|
||||
};
|
||||
|
||||
readonly block: BlockComponent;
|
||||
|
||||
readonly model: BlockModel;
|
||||
|
||||
readonly text: Text;
|
||||
|
||||
constructor(
|
||||
readonly std: EditorHost['std'],
|
||||
readonly point: TextRangePoint
|
||||
) {
|
||||
this.block = this._blockFromPath(point.blockId);
|
||||
this.model = this.block.model;
|
||||
const text = this.model.text;
|
||||
if (!text) {
|
||||
console.error(this.point);
|
||||
throw new BlockSuiteError(
|
||||
ErrorCode.TransformerError,
|
||||
'Text point without text model'
|
||||
);
|
||||
}
|
||||
this.text = text;
|
||||
}
|
||||
}
|
||||
|
||||
class PasteTr {
|
||||
private _getDeltas = () => {
|
||||
const firstTextSnapshot = this._textFromSnapshot(this.firstSnapshot!);
|
||||
const lastTextSnapshot = this._textFromSnapshot(this.lastSnapshot!);
|
||||
const fromDelta = this.pointState.text.sliceToDelta(
|
||||
0,
|
||||
this.pointState.point.index
|
||||
);
|
||||
const toDelta = this.pointState.text.sliceToDelta(
|
||||
this.pointState.point.index + this.pointState.point.length,
|
||||
this.pointState.text.length
|
||||
);
|
||||
const firstDelta = firstTextSnapshot.delta;
|
||||
const lastDelta = lastTextSnapshot.delta;
|
||||
return {
|
||||
firstTextSnapshot,
|
||||
lastTextSnapshot,
|
||||
fromDelta,
|
||||
toDelta,
|
||||
firstDelta,
|
||||
lastDelta,
|
||||
};
|
||||
};
|
||||
|
||||
private _mergeCode = () => {
|
||||
const deltas: DeltaOperation[] = [{ retain: this.pointState.point.index }];
|
||||
this.snapshot.content.forEach((blockSnapshot, i) => {
|
||||
if (blockSnapshot.props.text) {
|
||||
const text = this._textFromSnapshot(blockSnapshot);
|
||||
if (i > 0) {
|
||||
deltas.push({ insert: '\n' });
|
||||
}
|
||||
deltas.push(...text.delta);
|
||||
}
|
||||
});
|
||||
this.pointState.text.applyDelta(deltas);
|
||||
this.snapshot.content = [];
|
||||
};
|
||||
|
||||
private _mergeMultiple = () => {
|
||||
this._updateFlavour();
|
||||
|
||||
const { lastTextSnapshot, toDelta, firstDelta, lastDelta } =
|
||||
this._getDeltas();
|
||||
|
||||
this.pointState.text.applyDelta([
|
||||
{ retain: this.pointState.point.index },
|
||||
this.pointState.text.length - this.pointState.point.index > 0
|
||||
? { delete: this.pointState.text.length - this.pointState.point.index }
|
||||
: {},
|
||||
...firstDelta,
|
||||
]);
|
||||
|
||||
const removedFirstSnapshot = this.snapshot.content.shift();
|
||||
removedFirstSnapshot?.children.forEach(block => {
|
||||
this.snapshot.content.unshift(block);
|
||||
});
|
||||
this.pasteStartModelChildrenCount =
|
||||
removedFirstSnapshot?.children.length ?? 0;
|
||||
|
||||
this._updateSnapshot();
|
||||
|
||||
lastTextSnapshot.delta = [...lastDelta, ...toDelta];
|
||||
};
|
||||
|
||||
private _mergeSingle = () => {
|
||||
this._updateFlavour();
|
||||
const { firstDelta } = this._getDeltas();
|
||||
const { index, length } = this.pointState.point;
|
||||
|
||||
// Pastes a link
|
||||
if (length && firstDelta.length === 1 && firstDelta[0].attributes?.link) {
|
||||
this.pointState.text.format(index, length, firstDelta[0].attributes);
|
||||
} else {
|
||||
const ops: DeltaOperation[] = [{ retain: index }];
|
||||
if (length) ops.push({ delete: length });
|
||||
ops.push(...firstDelta);
|
||||
|
||||
this.pointState.text.applyDelta(ops);
|
||||
}
|
||||
|
||||
this.snapshot.content.splice(0, 1);
|
||||
this._updateSnapshot();
|
||||
};
|
||||
|
||||
private _textFromSnapshot = (snapshot: BlockSnapshot) => {
|
||||
return (snapshot.props.text ?? { delta: [] }) as Record<
|
||||
'delta',
|
||||
DeltaOperation[]
|
||||
>;
|
||||
};
|
||||
|
||||
private _updateSnapshot = () => {
|
||||
if (this.snapshot.content.length === 0) {
|
||||
this.firstSnapshot = this.lastSnapshot = undefined;
|
||||
return;
|
||||
}
|
||||
this.firstSnapshot = this.snapshot.content[0];
|
||||
this.lastSnapshot = findLast(this.snapshot) ?? this.firstSnapshot;
|
||||
};
|
||||
|
||||
private firstSnapshot?: BlockSnapshot;
|
||||
|
||||
private readonly firstSnapshotIsPlainText: boolean;
|
||||
|
||||
private lastIndex: number;
|
||||
|
||||
private lastSnapshot?: BlockSnapshot;
|
||||
|
||||
private needCleanup = false;
|
||||
|
||||
private pasteStartModelChildrenCount = 0;
|
||||
|
||||
private readonly pointState: PointState;
|
||||
|
||||
canMerge = () => {
|
||||
if (this.snapshot.content.length === 0) {
|
||||
return false;
|
||||
}
|
||||
if (!this.firstSnapshot!.props.text) {
|
||||
return false;
|
||||
}
|
||||
const firstTextSnapshot = this._textFromSnapshot(this.firstSnapshot!);
|
||||
const lastTextSnapshot = this._textFromSnapshot(this.lastSnapshot!);
|
||||
return (
|
||||
firstTextSnapshot &&
|
||||
lastTextSnapshot &&
|
||||
(this.pointState.text.length > 0 || this.firstSnapshotIsPlainText)
|
||||
);
|
||||
};
|
||||
|
||||
convertToLinkedDoc = () => {
|
||||
const parseDocUrlService = this.std.getOptional(ParseDocUrlProvider);
|
||||
|
||||
if (!parseDocUrlService) {
|
||||
return;
|
||||
}
|
||||
|
||||
const linkToDocId = new Map<string, string | null>();
|
||||
|
||||
for (const blockSnapshot of this.snapshot.content) {
|
||||
if (blockSnapshot.props.text) {
|
||||
const [delta, transformed] = this._transformLinkDelta(
|
||||
this._textFromSnapshot(blockSnapshot).delta,
|
||||
linkToDocId,
|
||||
parseDocUrlService
|
||||
);
|
||||
const model = this.std.doc.getBlock(blockSnapshot.id)?.model;
|
||||
if (transformed && model) {
|
||||
this.std.doc.captureSync();
|
||||
this.std.doc.transact(() => {
|
||||
const text = model.text as Text;
|
||||
text.clear();
|
||||
text.applyDelta(delta);
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const fromPointStateText = this.pointState.model.text;
|
||||
if (!fromPointStateText) {
|
||||
return;
|
||||
}
|
||||
const [delta, transformed] = this._transformLinkDelta(
|
||||
fromPointStateText.toDelta(),
|
||||
linkToDocId,
|
||||
parseDocUrlService
|
||||
);
|
||||
if (!transformed) {
|
||||
return;
|
||||
}
|
||||
this.std.doc.captureSync();
|
||||
this.std.doc.transact(() => {
|
||||
fromPointStateText.clear();
|
||||
fromPointStateText.applyDelta(delta);
|
||||
});
|
||||
};
|
||||
|
||||
focusPasted = () => {
|
||||
const host = this.std.host;
|
||||
|
||||
const cursorBlock =
|
||||
this.pointState.model.flavour === 'affine:code' || !this.lastSnapshot
|
||||
? this.std.doc.getBlock(this.pointState.model.id)
|
||||
: this.std.doc.getBlock(this.lastSnapshot.id);
|
||||
if (!cursorBlock) {
|
||||
return;
|
||||
}
|
||||
const { model: cursorModel } = cursorBlock;
|
||||
|
||||
host.updateComplete
|
||||
.then(() => {
|
||||
const target = this.std.host.querySelector<BlockComponent>(
|
||||
`[${BLOCK_ID_ATTR}="${cursorModel.id}"]`
|
||||
);
|
||||
if (!target) {
|
||||
return;
|
||||
}
|
||||
if (!cursorModel.text) {
|
||||
if (matchFlavours(cursorModel, ['affine:image'])) {
|
||||
const selection = this.std.selection.create('image', {
|
||||
blockId: target.blockId,
|
||||
});
|
||||
this.std.selection.setGroup('note', [selection]);
|
||||
return;
|
||||
}
|
||||
const selection = this.std.selection.create('block', {
|
||||
blockId: target.blockId,
|
||||
});
|
||||
this.std.selection.setGroup('note', [selection]);
|
||||
return;
|
||||
}
|
||||
const selection = this.std.selection.create('text', {
|
||||
from: {
|
||||
blockId: target.blockId,
|
||||
index: cursorModel.text ? this.lastIndex : 0,
|
||||
length: 0,
|
||||
},
|
||||
to: null,
|
||||
});
|
||||
this.std.selection.setGroup('note', [selection]);
|
||||
})
|
||||
.catch(console.error);
|
||||
};
|
||||
|
||||
pasted = () => {
|
||||
if (!(this.needCleanup || this.pointState.text.length === 0)) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.lastSnapshot) {
|
||||
const lastModel = this.std.doc.getBlock(this.lastSnapshot.id)?.model;
|
||||
if (!lastModel) {
|
||||
return;
|
||||
}
|
||||
this.std.doc.moveBlocks(this.pointState.model.children, lastModel);
|
||||
}
|
||||
|
||||
this.std.doc.moveBlocks(
|
||||
this.std.doc
|
||||
.getNexts(this.pointState.model.id)
|
||||
.slice(0, this.pasteStartModelChildrenCount),
|
||||
this.pointState.model
|
||||
);
|
||||
|
||||
if (!this.firstSnapshotIsPlainText && this.pointState.text.length == 0) {
|
||||
this.std.doc.deleteBlock(this.pointState.model);
|
||||
}
|
||||
};
|
||||
|
||||
constructor(
|
||||
readonly std: EditorHost['std'],
|
||||
readonly text: TextSelection,
|
||||
readonly snapshot: SliceSnapshot
|
||||
) {
|
||||
const { from } = text;
|
||||
|
||||
this.pointState = new PointState(std, from);
|
||||
|
||||
this.firstSnapshot = snapshot.content[0];
|
||||
this.lastSnapshot = findLast(snapshot) ?? this.firstSnapshot;
|
||||
if (
|
||||
this.firstSnapshot !== this.lastSnapshot &&
|
||||
this.lastSnapshot.props.text &&
|
||||
!matchFlavours(this.pointState.model, ['affine:code'])
|
||||
) {
|
||||
const text = fromJSON(this.lastSnapshot.props.text) as Text;
|
||||
const doc = new DocCollection.Y.Doc();
|
||||
const temp = doc.getMap('temp');
|
||||
temp.set('text', text.yText);
|
||||
this.lastIndex = text.length;
|
||||
} else {
|
||||
this.lastIndex =
|
||||
this.pointState.point.index +
|
||||
this.snapshot.content
|
||||
.map(snapshot =>
|
||||
this._textFromSnapshot(snapshot)
|
||||
.delta.map(op => {
|
||||
if (op.insert) {
|
||||
return op.insert.length;
|
||||
} else if (op.delete) {
|
||||
return -op.delete;
|
||||
} else {
|
||||
return 0;
|
||||
}
|
||||
})
|
||||
.reduce((a, b) => a + b, 0)
|
||||
)
|
||||
.reduce((a, b) => a + b + 1, -1);
|
||||
}
|
||||
this.firstSnapshotIsPlainText =
|
||||
this.firstSnapshot.flavour === 'affine:paragraph' &&
|
||||
this.firstSnapshot.props.type === 'text';
|
||||
}
|
||||
|
||||
private _transformLinkDelta(
|
||||
delta: DeltaOperation[],
|
||||
linkToDocId: Map<string, string | null>,
|
||||
parseDocUrlService: ParseDocUrlService
|
||||
): [DeltaOperation[], boolean] {
|
||||
let transformed = false;
|
||||
const needToConvert = new Map<DeltaOperation, string>();
|
||||
for (const op of delta) {
|
||||
if (op.attributes?.link) {
|
||||
let docId = linkToDocId.get(op.attributes.link);
|
||||
if (!docId) {
|
||||
const searchResult = parseDocUrlService.parseDocUrl(
|
||||
op.attributes.link
|
||||
);
|
||||
if (searchResult) {
|
||||
const doc = this.std.collection.getDoc(searchResult.docId);
|
||||
if (doc) {
|
||||
docId = doc.id;
|
||||
linkToDocId.set(op.attributes.link, doc.id);
|
||||
}
|
||||
}
|
||||
}
|
||||
if (docId) {
|
||||
needToConvert.set(op, docId);
|
||||
}
|
||||
}
|
||||
}
|
||||
const newDelta = delta.map(op => {
|
||||
if (!needToConvert.has(op)) {
|
||||
return { ...op };
|
||||
}
|
||||
|
||||
const link = op.attributes?.link;
|
||||
|
||||
if (!link) {
|
||||
return { ...op };
|
||||
}
|
||||
|
||||
const pageId = needToConvert.get(op);
|
||||
|
||||
if (!pageId) {
|
||||
// External link
|
||||
this.std.getOptional(TelemetryProvider)?.track('Link', {
|
||||
page: 'doc editor',
|
||||
category: 'pasted link',
|
||||
other: 'external link',
|
||||
type: 'link',
|
||||
});
|
||||
|
||||
return { ...op };
|
||||
}
|
||||
|
||||
const reference: AffineTextAttributes['reference'] = {
|
||||
pageId,
|
||||
type: 'LinkedPage',
|
||||
};
|
||||
// Title alias
|
||||
if (op.insert && op.insert !== REFERENCE_NODE && op.insert !== link) {
|
||||
reference.title = op.insert;
|
||||
}
|
||||
|
||||
const extractedParams = extractSearchParams(link);
|
||||
const isLinkedBlock = extractedParams
|
||||
? referenceToNode({ pageId, ...extractedParams })
|
||||
: false;
|
||||
|
||||
Object.assign(reference, extractedParams);
|
||||
|
||||
// Internal link
|
||||
this.std.getOptional(TelemetryProvider)?.track('LinkedDocCreated', {
|
||||
page: 'doc editor',
|
||||
category: 'pasted link',
|
||||
other: 'existing doc',
|
||||
type: isLinkedBlock ? 'block' : 'doc',
|
||||
});
|
||||
|
||||
transformed = true;
|
||||
|
||||
return {
|
||||
...op,
|
||||
attributes: { reference },
|
||||
insert: REFERENCE_NODE,
|
||||
};
|
||||
});
|
||||
return [newDelta, transformed];
|
||||
}
|
||||
|
||||
private _updateFlavour() {
|
||||
this.firstSnapshot!.flavour = this.pointState.model.flavour;
|
||||
if (this.firstSnapshot!.props.type) {
|
||||
this.firstSnapshot!.props.type = (
|
||||
this.pointState.model as ParagraphBlockModel
|
||||
).type;
|
||||
}
|
||||
}
|
||||
|
||||
merge() {
|
||||
if (this.pointState.model.flavour === 'affine:code') {
|
||||
this._mergeCode();
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.firstSnapshot === this.lastSnapshot) {
|
||||
this._mergeSingle();
|
||||
return;
|
||||
}
|
||||
|
||||
this.needCleanup = true;
|
||||
this._mergeMultiple();
|
||||
}
|
||||
}
|
||||
|
||||
function flatNote(snapshot: SliceSnapshot) {
|
||||
if (snapshot.content[0]?.flavour === 'affine:note') {
|
||||
snapshot.content = snapshot.content[0].children;
|
||||
}
|
||||
}
|
||||
|
||||
export const pasteMiddleware = (std: EditorHost['std']): JobMiddleware => {
|
||||
return ({ slots }) => {
|
||||
let tr: PasteTr | undefined;
|
||||
slots.beforeImport.on(payload => {
|
||||
if (payload.type === 'slice') {
|
||||
const { snapshot } = payload;
|
||||
flatNote(snapshot);
|
||||
|
||||
const text = std.selection.find('text');
|
||||
if (!text) {
|
||||
return;
|
||||
}
|
||||
tr = new PasteTr(std, text, payload.snapshot);
|
||||
if (tr.canMerge()) {
|
||||
tr.merge();
|
||||
}
|
||||
}
|
||||
});
|
||||
slots.afterImport.on(payload => {
|
||||
if (tr && payload.type === 'slice') {
|
||||
tr.pasted();
|
||||
tr.focusPasted();
|
||||
tr.convertToLinkedDoc();
|
||||
}
|
||||
});
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,124 @@
|
||||
import { toast } from '@blocksuite/affine-components/toast';
|
||||
import { assertExists } from '@blocksuite/global/utils';
|
||||
|
||||
import type { FileSnapshot } from './adapter.js';
|
||||
|
||||
const chars =
|
||||
'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/';
|
||||
|
||||
// Use a lookup table to find the index.
|
||||
const lookup = typeof Uint8Array === 'undefined' ? [] : new Uint8Array(256);
|
||||
for (let i = 0; i < chars.length; i++) {
|
||||
lookup[chars.charCodeAt(i)] = i;
|
||||
}
|
||||
|
||||
export const encode = (arraybuffer: ArrayBuffer): string => {
|
||||
const bytes = new Uint8Array(arraybuffer);
|
||||
const len = bytes.length;
|
||||
let i,
|
||||
base64 = '';
|
||||
|
||||
for (i = 0; i < len; i += 3) {
|
||||
base64 += chars[bytes[i] >> 2];
|
||||
base64 += chars[((bytes[i] & 3) << 4) | (bytes[i + 1] >> 4)];
|
||||
base64 += chars[((bytes[i + 1] & 15) << 2) | (bytes[i + 2] >> 6)];
|
||||
base64 += chars[bytes[i + 2] & 63];
|
||||
}
|
||||
|
||||
if (len % 3 === 2) {
|
||||
base64 = base64.substring(0, base64.length - 1) + '=';
|
||||
} else if (len % 3 === 1) {
|
||||
base64 = base64.substring(0, base64.length - 2) + '==';
|
||||
}
|
||||
|
||||
return base64;
|
||||
};
|
||||
|
||||
export const decode = (base64: string): ArrayBuffer => {
|
||||
const len = base64.length;
|
||||
let bufferLength = base64.length * 0.75,
|
||||
i,
|
||||
p = 0,
|
||||
encoded1,
|
||||
encoded2,
|
||||
encoded3,
|
||||
encoded4;
|
||||
|
||||
if (base64[base64.length - 1] === '=') {
|
||||
bufferLength--;
|
||||
if (base64[base64.length - 2] === '=') {
|
||||
bufferLength--;
|
||||
}
|
||||
}
|
||||
|
||||
const arraybuffer = new ArrayBuffer(bufferLength),
|
||||
bytes = new Uint8Array(arraybuffer);
|
||||
|
||||
for (i = 0; i < len; i += 4) {
|
||||
encoded1 = lookup[base64.charCodeAt(i)];
|
||||
encoded2 = lookup[base64.charCodeAt(i + 1)];
|
||||
encoded3 = lookup[base64.charCodeAt(i + 2)];
|
||||
encoded4 = lookup[base64.charCodeAt(i + 3)];
|
||||
|
||||
bytes[p++] = (encoded1 << 2) | (encoded2 >> 4);
|
||||
bytes[p++] = ((encoded2 & 15) << 4) | (encoded3 >> 2);
|
||||
bytes[p++] = ((encoded3 & 3) << 6) | (encoded4 & 63);
|
||||
}
|
||||
|
||||
return arraybuffer;
|
||||
};
|
||||
|
||||
export async function encodeClipboardBlobs(map: Map<string, Blob>) {
|
||||
const blobs: Record<string, FileSnapshot> = {};
|
||||
let sumSize = 0;
|
||||
await Promise.all(
|
||||
Array.from(map.entries()).map(async ([id, blob]) => {
|
||||
if (blob.size > 4 * 1024 * 1024) {
|
||||
const host = document.querySelector('editor-host');
|
||||
if (!host) {
|
||||
return;
|
||||
}
|
||||
toast(
|
||||
host,
|
||||
(blob as File).name ?? 'File' + ' is too large to be copied'
|
||||
);
|
||||
return;
|
||||
}
|
||||
sumSize += blob.size;
|
||||
if (sumSize > 6 * 1024 * 1024) {
|
||||
const host = document.querySelector('editor-host');
|
||||
if (!host) {
|
||||
return;
|
||||
}
|
||||
toast(
|
||||
host,
|
||||
(blob as File).name ??
|
||||
'File' + ' cannot be copied due to the clipboard size limit'
|
||||
);
|
||||
return;
|
||||
}
|
||||
const content = encode(await blob.arrayBuffer());
|
||||
const file: FileSnapshot = {
|
||||
name: (blob as File).name,
|
||||
type: blob.type,
|
||||
content,
|
||||
};
|
||||
blobs[id] = file;
|
||||
})
|
||||
);
|
||||
return blobs;
|
||||
}
|
||||
|
||||
export function decodeClipboardBlobs(
|
||||
blobs: Record<string, FileSnapshot>,
|
||||
map: Map<string, Blob> | undefined
|
||||
) {
|
||||
Object.entries<FileSnapshot>(blobs).forEach(([sourceId, file]) => {
|
||||
const blob = new Blob([decode(file.content)]);
|
||||
const f = new File([blob], file.name, {
|
||||
type: file.type,
|
||||
});
|
||||
assertExists(map);
|
||||
map.set(sourceId, f);
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user