mirror of
https://github.com/toeverything/AFFiNE.git
synced 2026-02-25 18:26:05 +08:00
feat(editor): clipboard config extensions (#11171)
This commit is contained in:
@@ -14,8 +14,68 @@ import {
|
|||||||
draftSelectedModelsCommand,
|
draftSelectedModelsCommand,
|
||||||
getSelectedModelsCommand,
|
getSelectedModelsCommand,
|
||||||
} from '@blocksuite/affine-shared/commands';
|
} from '@blocksuite/affine-shared/commands';
|
||||||
import type { BlockComponent, UIEventHandler } from '@blocksuite/block-std';
|
import {
|
||||||
|
type BlockComponent,
|
||||||
|
ClipboardAdapterConfigExtension,
|
||||||
|
type UIEventHandler,
|
||||||
|
} from '@blocksuite/block-std';
|
||||||
import { DisposableGroup } from '@blocksuite/global/disposable';
|
import { DisposableGroup } from '@blocksuite/global/disposable';
|
||||||
|
import type { ExtensionType } from '@blocksuite/store';
|
||||||
|
|
||||||
|
const SnapshotClipboardConfig = ClipboardAdapterConfigExtension({
|
||||||
|
mimeType: ClipboardAdapter.MIME,
|
||||||
|
adapter: ClipboardAdapter,
|
||||||
|
priority: 100,
|
||||||
|
});
|
||||||
|
|
||||||
|
const NotionClipboardConfig = ClipboardAdapterConfigExtension({
|
||||||
|
mimeType: 'text/_notion-text-production',
|
||||||
|
adapter: NotionTextAdapter,
|
||||||
|
priority: 95,
|
||||||
|
});
|
||||||
|
|
||||||
|
const HtmlClipboardConfig = ClipboardAdapterConfigExtension({
|
||||||
|
mimeType: 'text/html',
|
||||||
|
adapter: HtmlAdapter,
|
||||||
|
priority: 90,
|
||||||
|
});
|
||||||
|
|
||||||
|
const imageClipboardConfigs = [
|
||||||
|
'image/apng',
|
||||||
|
'image/avif',
|
||||||
|
'image/gif',
|
||||||
|
'image/jpeg',
|
||||||
|
'image/png',
|
||||||
|
'image/svg+xml',
|
||||||
|
'image/webp',
|
||||||
|
].map(mimeType => {
|
||||||
|
return ClipboardAdapterConfigExtension({
|
||||||
|
mimeType,
|
||||||
|
adapter: ImageAdapter,
|
||||||
|
priority: 80,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
const PlainTextClipboardConfig = ClipboardAdapterConfigExtension({
|
||||||
|
mimeType: 'text/plain',
|
||||||
|
adapter: MixTextAdapter,
|
||||||
|
priority: 70,
|
||||||
|
});
|
||||||
|
|
||||||
|
const AttachmentClipboardConfig = ClipboardAdapterConfigExtension({
|
||||||
|
mimeType: '*/*',
|
||||||
|
adapter: AttachmentAdapter,
|
||||||
|
priority: 60,
|
||||||
|
});
|
||||||
|
|
||||||
|
export const clipboardConfigs: ExtensionType[] = [
|
||||||
|
SnapshotClipboardConfig,
|
||||||
|
NotionClipboardConfig,
|
||||||
|
HtmlClipboardConfig,
|
||||||
|
...imageClipboardConfigs,
|
||||||
|
PlainTextClipboardConfig,
|
||||||
|
AttachmentClipboardConfig,
|
||||||
|
];
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* ReadOnlyClipboard is a class that provides a read-only clipboard for the root block.
|
* ReadOnlyClipboard is a class that provides a read-only clipboard for the root block.
|
||||||
@@ -34,30 +94,6 @@ export class ReadOnlyClipboard {
|
|||||||
protected _disposables = new DisposableGroup();
|
protected _disposables = new DisposableGroup();
|
||||||
|
|
||||||
protected _initAdapters = () => {
|
protected _initAdapters = () => {
|
||||||
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 copy = copyMiddleware(this._std);
|
||||||
this._std.clipboard.use(copy);
|
this._std.clipboard.use(copy);
|
||||||
this._std.clipboard.use(
|
this._std.clipboard.use(
|
||||||
@@ -67,19 +103,6 @@ export class ReadOnlyClipboard {
|
|||||||
|
|
||||||
this._disposables.add({
|
this._disposables.add({
|
||||||
dispose: () => {
|
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(copy);
|
||||||
this._std.clipboard.unuse(
|
this._std.clipboard.unuse(
|
||||||
titleMiddleware(this._std.store.workspace.meta.docMetas)
|
titleMiddleware(this._std.store.workspace.meta.docMetas)
|
||||||
@@ -120,5 +143,3 @@ export class ReadOnlyClipboard {
|
|||||||
this._disposables.dispose();
|
this._disposables.dispose();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export { copyMiddleware };
|
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ import {
|
|||||||
import type { ExtensionType } from '@blocksuite/store';
|
import type { ExtensionType } from '@blocksuite/store';
|
||||||
|
|
||||||
import { RootBlockAdapterExtensions } from '../adapters/extension';
|
import { RootBlockAdapterExtensions } from '../adapters/extension';
|
||||||
|
import { clipboardConfigs } from '../clipboard';
|
||||||
import { builtinToolbarConfig } from '../configs/toolbar';
|
import { builtinToolbarConfig } from '../configs/toolbar';
|
||||||
import {
|
import {
|
||||||
innerModalWidget,
|
innerModalWidget,
|
||||||
@@ -39,6 +40,7 @@ export const CommonSpecs: ExtensionType[] = [
|
|||||||
FileDropExtension,
|
FileDropExtension,
|
||||||
ToolbarRegistryExtension,
|
ToolbarRegistryExtension,
|
||||||
...RootBlockAdapterExtensions,
|
...RootBlockAdapterExtensions,
|
||||||
|
...clipboardConfigs,
|
||||||
|
|
||||||
modalWidget,
|
modalWidget,
|
||||||
innerModalWidget,
|
innerModalWidget,
|
||||||
|
|||||||
@@ -0,0 +1,33 @@
|
|||||||
|
import { createIdentifier, type ServiceProvider } from '@blocksuite/global/di';
|
||||||
|
import type {
|
||||||
|
BaseAdapter,
|
||||||
|
ExtensionType,
|
||||||
|
Transformer,
|
||||||
|
} from '@blocksuite/store';
|
||||||
|
|
||||||
|
type AdapterConstructor = new (
|
||||||
|
job: Transformer,
|
||||||
|
provider: ServiceProvider
|
||||||
|
) => BaseAdapter;
|
||||||
|
|
||||||
|
export interface ClipboardAdapterConfig {
|
||||||
|
mimeType: string;
|
||||||
|
priority: number;
|
||||||
|
adapter: AdapterConstructor;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ClipboardAdapterConfigIdentifier =
|
||||||
|
createIdentifier<ClipboardAdapterConfig>('clipboard-adapter-config');
|
||||||
|
|
||||||
|
export function ClipboardAdapterConfigExtension(
|
||||||
|
config: ClipboardAdapterConfig
|
||||||
|
): ExtensionType {
|
||||||
|
return {
|
||||||
|
setup: di => {
|
||||||
|
di.addImpl(
|
||||||
|
ClipboardAdapterConfigIdentifier(config.mimeType),
|
||||||
|
() => config
|
||||||
|
);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
311
blocksuite/framework/block-std/src/clipboard/clipboard.ts
Normal file
311
blocksuite/framework/block-std/src/clipboard/clipboard.ts
Normal file
@@ -0,0 +1,311 @@
|
|||||||
|
import { BlockSuiteError, ErrorCode } from '@blocksuite/global/exceptions';
|
||||||
|
import type {
|
||||||
|
BlockSnapshot,
|
||||||
|
Slice,
|
||||||
|
Store,
|
||||||
|
TransformerMiddleware,
|
||||||
|
} from '@blocksuite/store';
|
||||||
|
import DOMPurify from 'dompurify';
|
||||||
|
import * as lz from 'lz-string';
|
||||||
|
import rehypeParse from 'rehype-parse';
|
||||||
|
import { unified } from 'unified';
|
||||||
|
|
||||||
|
import { LifeCycleWatcher } from '../extension/index.js';
|
||||||
|
import { ClipboardAdapterConfigIdentifier } from './clipboard-adapter.js';
|
||||||
|
import { onlyContainImgElement } from './utils.js';
|
||||||
|
|
||||||
|
export class Clipboard extends LifeCycleWatcher {
|
||||||
|
static override readonly key = 'clipboard';
|
||||||
|
|
||||||
|
private get _adapters() {
|
||||||
|
const adapterConfigs = this.std.provider.getAll(
|
||||||
|
ClipboardAdapterConfigIdentifier
|
||||||
|
);
|
||||||
|
return Array.from(adapterConfigs.values());
|
||||||
|
}
|
||||||
|
|
||||||
|
// Need to be cloned to a map for later use
|
||||||
|
private readonly _getDataByType = (clipboardData: DataTransfer) => {
|
||||||
|
const data = new Map<string, string | File[]>();
|
||||||
|
for (const type of clipboardData.types) {
|
||||||
|
if (type === 'Files') {
|
||||||
|
data.set(type, Array.from(clipboardData.files));
|
||||||
|
} else {
|
||||||
|
data.set(type, clipboardData.getData(type));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (data.get('Files') && data.get('text/html')) {
|
||||||
|
const htmlAst = unified()
|
||||||
|
.use(rehypeParse)
|
||||||
|
.parse(data.get('text/html') as string);
|
||||||
|
|
||||||
|
const isImgOnly =
|
||||||
|
htmlAst.children.map(onlyContainImgElement).reduce((a, b) => {
|
||||||
|
if (a === 'no' || b === 'no') {
|
||||||
|
return 'no';
|
||||||
|
}
|
||||||
|
if (a === 'maybe' && b === 'maybe') {
|
||||||
|
return 'maybe';
|
||||||
|
}
|
||||||
|
return 'yes';
|
||||||
|
}, 'maybe') === 'yes';
|
||||||
|
|
||||||
|
if (isImgOnly) {
|
||||||
|
data.delete('text/html');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return (type: string) => {
|
||||||
|
const item = data.get(type);
|
||||||
|
if (item) {
|
||||||
|
return item;
|
||||||
|
}
|
||||||
|
const files = (data.get('Files') ?? []) as File[];
|
||||||
|
if (files.length > 0) {
|
||||||
|
return files;
|
||||||
|
}
|
||||||
|
return '';
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
private readonly _getSnapshotByPriority = async (
|
||||||
|
getItem: (type: string) => string | File[],
|
||||||
|
doc: Store,
|
||||||
|
parent?: string,
|
||||||
|
index?: number
|
||||||
|
) => {
|
||||||
|
const byPriority = Array.from(this._adapters).sort(
|
||||||
|
(a, b) => b.priority - a.priority
|
||||||
|
);
|
||||||
|
for (const { adapter, mimeType } of byPriority) {
|
||||||
|
const item = getItem(mimeType);
|
||||||
|
if (Array.isArray(item)) {
|
||||||
|
if (item.length === 0) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
// if all files are not the same target type, fallback to */*
|
||||||
|
!item
|
||||||
|
.map(f => f.type === mimeType || mimeType === '*/*')
|
||||||
|
.reduce((a, b) => a && b, true)
|
||||||
|
) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (item) {
|
||||||
|
const job = this._getJob();
|
||||||
|
const adapterInstance = new adapter(job, this.std.provider);
|
||||||
|
const payload = {
|
||||||
|
file: item,
|
||||||
|
assets: job.assetsManager,
|
||||||
|
workspaceId: doc.workspace.id,
|
||||||
|
pageId: doc.id,
|
||||||
|
};
|
||||||
|
const result = await adapterInstance.toSlice(
|
||||||
|
payload,
|
||||||
|
doc,
|
||||||
|
parent,
|
||||||
|
index
|
||||||
|
);
|
||||||
|
if (result) {
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
|
||||||
|
private _jobMiddlewares: TransformerMiddleware[] = [];
|
||||||
|
|
||||||
|
copy = async (slice: Slice) => {
|
||||||
|
return this.copySlice(slice);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Gated by https://developer.mozilla.org/en-US/docs/Glossary/Transient_activation
|
||||||
|
copySlice = async (slice: Slice) => {
|
||||||
|
const adapterKeys = this._adapters.map(adapter => adapter.mimeType);
|
||||||
|
|
||||||
|
await this.writeToClipboard(async _items => {
|
||||||
|
const items = { ..._items };
|
||||||
|
|
||||||
|
await Promise.all(
|
||||||
|
adapterKeys.map(async type => {
|
||||||
|
const item = await this._getClipboardItem(slice, type);
|
||||||
|
if (typeof item === 'string') {
|
||||||
|
items[type] = item;
|
||||||
|
}
|
||||||
|
})
|
||||||
|
);
|
||||||
|
return items;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
duplicateSlice = async (
|
||||||
|
slice: Slice,
|
||||||
|
doc: Store,
|
||||||
|
parent?: string,
|
||||||
|
index?: number,
|
||||||
|
type = 'BLOCKSUITE/SNAPSHOT'
|
||||||
|
) => {
|
||||||
|
const items = {
|
||||||
|
[type]: await this._getClipboardItem(slice, type),
|
||||||
|
};
|
||||||
|
|
||||||
|
await this._getSnapshotByPriority(
|
||||||
|
type => (items[type] as string | File[]) ?? '',
|
||||||
|
doc,
|
||||||
|
parent,
|
||||||
|
index
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
paste = async (
|
||||||
|
event: ClipboardEvent,
|
||||||
|
doc: Store,
|
||||||
|
parent?: string,
|
||||||
|
index?: number
|
||||||
|
) => {
|
||||||
|
const data = event.clipboardData;
|
||||||
|
if (!data) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const json = this.readFromClipboard(data);
|
||||||
|
const slice = await this._getSnapshotByPriority(
|
||||||
|
type => json[type],
|
||||||
|
doc,
|
||||||
|
parent,
|
||||||
|
index
|
||||||
|
);
|
||||||
|
if (!slice) {
|
||||||
|
throw new BlockSuiteError(
|
||||||
|
ErrorCode.TransformerError,
|
||||||
|
'No snapshot found'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return slice;
|
||||||
|
} catch {
|
||||||
|
const getDataByType = this._getDataByType(data);
|
||||||
|
const slice = await this._getSnapshotByPriority(
|
||||||
|
type => getDataByType(type),
|
||||||
|
doc,
|
||||||
|
parent,
|
||||||
|
index
|
||||||
|
);
|
||||||
|
|
||||||
|
return slice;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
pasteBlockSnapshot = async (
|
||||||
|
snapshot: BlockSnapshot,
|
||||||
|
doc: Store,
|
||||||
|
parent?: string,
|
||||||
|
index?: number
|
||||||
|
) => {
|
||||||
|
return this._getJob().snapshotToBlock(snapshot, doc, parent, index);
|
||||||
|
};
|
||||||
|
|
||||||
|
unuse = (middleware: TransformerMiddleware) => {
|
||||||
|
this._jobMiddlewares = this._jobMiddlewares.filter(m => m !== middleware);
|
||||||
|
};
|
||||||
|
|
||||||
|
use = (middleware: TransformerMiddleware) => {
|
||||||
|
this._jobMiddlewares.push(middleware);
|
||||||
|
};
|
||||||
|
|
||||||
|
get configs() {
|
||||||
|
return this._getJob().adapterConfigs;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async _getClipboardItem(slice: Slice, type: string) {
|
||||||
|
const job = this._getJob();
|
||||||
|
const adapterItem = this.std.getOptional(
|
||||||
|
ClipboardAdapterConfigIdentifier(type)
|
||||||
|
);
|
||||||
|
if (!adapterItem) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const { adapter } = adapterItem;
|
||||||
|
const adapterInstance = new adapter(job, this.std.provider);
|
||||||
|
const result = await adapterInstance.fromSlice(slice);
|
||||||
|
if (!result) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
return result.file;
|
||||||
|
}
|
||||||
|
|
||||||
|
private _getJob() {
|
||||||
|
return this.std.store.getTransformer(this._jobMiddlewares);
|
||||||
|
}
|
||||||
|
|
||||||
|
readFromClipboard(clipboardData: DataTransfer) {
|
||||||
|
const items = clipboardData.getData('text/html');
|
||||||
|
const sanitizedItems = DOMPurify.sanitize(items);
|
||||||
|
const domParser = new DOMParser();
|
||||||
|
const doc = domParser.parseFromString(sanitizedItems, 'text/html');
|
||||||
|
const dom = doc.querySelector<HTMLDivElement>('[data-blocksuite-snapshot]');
|
||||||
|
if (!dom) {
|
||||||
|
throw new BlockSuiteError(
|
||||||
|
ErrorCode.TransformerError,
|
||||||
|
'No snapshot found'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const json = JSON.parse(
|
||||||
|
lz.decompressFromEncodedURIComponent(
|
||||||
|
dom.dataset.blocksuiteSnapshot as string
|
||||||
|
)
|
||||||
|
);
|
||||||
|
return json;
|
||||||
|
}
|
||||||
|
|
||||||
|
sliceToSnapshot(slice: Slice) {
|
||||||
|
const job = this._getJob();
|
||||||
|
return job.sliceToSnapshot(slice);
|
||||||
|
}
|
||||||
|
|
||||||
|
async writeToClipboard(
|
||||||
|
updateItems: (
|
||||||
|
items: Record<string, unknown>
|
||||||
|
) => Promise<Record<string, unknown>> | Record<string, unknown>
|
||||||
|
) {
|
||||||
|
const _items = {
|
||||||
|
'text/plain': '',
|
||||||
|
'text/html': '',
|
||||||
|
'image/png': '',
|
||||||
|
};
|
||||||
|
|
||||||
|
const items = await updateItems(_items);
|
||||||
|
|
||||||
|
const text = items['text/plain'] as string;
|
||||||
|
const innerHTML = items['text/html'] as string;
|
||||||
|
const png = items['image/png'] as string | Blob;
|
||||||
|
|
||||||
|
delete items['text/plain'];
|
||||||
|
delete items['text/html'];
|
||||||
|
delete items['image/png'];
|
||||||
|
|
||||||
|
const snapshot = lz.compressToEncodedURIComponent(JSON.stringify(items));
|
||||||
|
const html = `<div data-blocksuite-snapshot='${snapshot}'>${innerHTML}</div>`;
|
||||||
|
const htmlBlob = new Blob([html], {
|
||||||
|
type: 'text/html',
|
||||||
|
});
|
||||||
|
const clipboardItems: Record<string, Blob> = {
|
||||||
|
'text/html': htmlBlob,
|
||||||
|
};
|
||||||
|
if (text.length > 0) {
|
||||||
|
const textBlob = new Blob([text], {
|
||||||
|
type: 'text/plain',
|
||||||
|
});
|
||||||
|
clipboardItems['text/plain'] = textBlob;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (png instanceof Blob) {
|
||||||
|
clipboardItems['image/png'] = png;
|
||||||
|
} else if (png.length > 0) {
|
||||||
|
const pngBlob = new Blob([png], {
|
||||||
|
type: 'image/png',
|
||||||
|
});
|
||||||
|
clipboardItems['image/png'] = pngBlob;
|
||||||
|
}
|
||||||
|
await navigator.clipboard.write([new ClipboardItem(clipboardItems)]);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,330 +1,2 @@
|
|||||||
import type { ServiceProvider } from '@blocksuite/global/di';
|
export * from './clipboard';
|
||||||
import { BlockSuiteError, ErrorCode } from '@blocksuite/global/exceptions';
|
export * from './clipboard-adapter';
|
||||||
import type {
|
|
||||||
BaseAdapter,
|
|
||||||
BlockSnapshot,
|
|
||||||
Slice,
|
|
||||||
Store,
|
|
||||||
Transformer,
|
|
||||||
TransformerMiddleware,
|
|
||||||
} from '@blocksuite/store';
|
|
||||||
import DOMPurify from 'dompurify';
|
|
||||||
import * as lz from 'lz-string';
|
|
||||||
import rehypeParse from 'rehype-parse';
|
|
||||||
import { unified } from 'unified';
|
|
||||||
|
|
||||||
import { LifeCycleWatcher } from '../extension/index.js';
|
|
||||||
import { onlyContainImgElement } from './utils.js';
|
|
||||||
|
|
||||||
type AdapterConstructor<T extends BaseAdapter> =
|
|
||||||
| { new (job: Transformer): T }
|
|
||||||
| (new (job: Transformer, provider: ServiceProvider) => T);
|
|
||||||
|
|
||||||
type AdapterMap = Map<
|
|
||||||
string,
|
|
||||||
{
|
|
||||||
adapter: AdapterConstructor<BaseAdapter>;
|
|
||||||
priority: number;
|
|
||||||
}
|
|
||||||
>;
|
|
||||||
|
|
||||||
export class Clipboard extends LifeCycleWatcher {
|
|
||||||
static override readonly key = 'clipboard';
|
|
||||||
|
|
||||||
private readonly _adapterMap: AdapterMap = new Map();
|
|
||||||
|
|
||||||
// Need to be cloned to a map for later use
|
|
||||||
private readonly _getDataByType = (clipboardData: DataTransfer) => {
|
|
||||||
const data = new Map<string, string | File[]>();
|
|
||||||
for (const type of clipboardData.types) {
|
|
||||||
if (type === 'Files') {
|
|
||||||
data.set(type, Array.from(clipboardData.files));
|
|
||||||
} else {
|
|
||||||
data.set(type, clipboardData.getData(type));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (data.get('Files') && data.get('text/html')) {
|
|
||||||
const htmlAst = unified()
|
|
||||||
.use(rehypeParse)
|
|
||||||
.parse(data.get('text/html') as string);
|
|
||||||
|
|
||||||
const isImgOnly =
|
|
||||||
htmlAst.children.map(onlyContainImgElement).reduce((a, b) => {
|
|
||||||
if (a === 'no' || b === 'no') {
|
|
||||||
return 'no';
|
|
||||||
}
|
|
||||||
if (a === 'maybe' && b === 'maybe') {
|
|
||||||
return 'maybe';
|
|
||||||
}
|
|
||||||
return 'yes';
|
|
||||||
}, 'maybe') === 'yes';
|
|
||||||
|
|
||||||
if (isImgOnly) {
|
|
||||||
data.delete('text/html');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return (type: string) => {
|
|
||||||
const item = data.get(type);
|
|
||||||
if (item) {
|
|
||||||
return item;
|
|
||||||
}
|
|
||||||
const files = (data.get('Files') ?? []) as File[];
|
|
||||||
if (files.length > 0) {
|
|
||||||
return files;
|
|
||||||
}
|
|
||||||
return '';
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
private readonly _getSnapshotByPriority = async (
|
|
||||||
getItem: (type: string) => string | File[],
|
|
||||||
doc: Store,
|
|
||||||
parent?: string,
|
|
||||||
index?: number
|
|
||||||
) => {
|
|
||||||
const byPriority = Array.from(this._adapterMap.entries()).sort(
|
|
||||||
(a, b) => b[1].priority - a[1].priority
|
|
||||||
);
|
|
||||||
for (const [type, { adapter }] of byPriority) {
|
|
||||||
const item = getItem(type);
|
|
||||||
if (Array.isArray(item)) {
|
|
||||||
if (item.length === 0) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
if (
|
|
||||||
// if all files are not the same target type, fallback to */*
|
|
||||||
!item
|
|
||||||
.map(f => f.type === type || type === '*/*')
|
|
||||||
.reduce((a, b) => a && b, true)
|
|
||||||
) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (item) {
|
|
||||||
const job = this._getJob();
|
|
||||||
const adapterInstance = new adapter(job, this.std.provider);
|
|
||||||
const payload = {
|
|
||||||
file: item,
|
|
||||||
assets: job.assetsManager,
|
|
||||||
workspaceId: doc.workspace.id,
|
|
||||||
pageId: doc.id,
|
|
||||||
};
|
|
||||||
const result = await adapterInstance.toSlice(
|
|
||||||
payload,
|
|
||||||
doc,
|
|
||||||
parent,
|
|
||||||
index
|
|
||||||
);
|
|
||||||
if (result) {
|
|
||||||
return result;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
};
|
|
||||||
|
|
||||||
private _jobMiddlewares: TransformerMiddleware[] = [];
|
|
||||||
|
|
||||||
copy = async (slice: Slice) => {
|
|
||||||
return this.copySlice(slice);
|
|
||||||
};
|
|
||||||
|
|
||||||
// Gated by https://developer.mozilla.org/en-US/docs/Glossary/Transient_activation
|
|
||||||
copySlice = async (slice: Slice) => {
|
|
||||||
const adapterKeys = Array.from(this._adapterMap.keys());
|
|
||||||
|
|
||||||
await this.writeToClipboard(async _items => {
|
|
||||||
const items = { ..._items };
|
|
||||||
|
|
||||||
await Promise.all(
|
|
||||||
adapterKeys.map(async type => {
|
|
||||||
const item = await this._getClipboardItem(slice, type);
|
|
||||||
if (typeof item === 'string') {
|
|
||||||
items[type] = item;
|
|
||||||
}
|
|
||||||
})
|
|
||||||
);
|
|
||||||
return items;
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
duplicateSlice = async (
|
|
||||||
slice: Slice,
|
|
||||||
doc: Store,
|
|
||||||
parent?: string,
|
|
||||||
index?: number,
|
|
||||||
type = 'BLOCKSUITE/SNAPSHOT'
|
|
||||||
) => {
|
|
||||||
const items = {
|
|
||||||
[type]: await this._getClipboardItem(slice, type),
|
|
||||||
};
|
|
||||||
|
|
||||||
await this._getSnapshotByPriority(
|
|
||||||
type => (items[type] as string | File[]) ?? '',
|
|
||||||
doc,
|
|
||||||
parent,
|
|
||||||
index
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
paste = async (
|
|
||||||
event: ClipboardEvent,
|
|
||||||
doc: Store,
|
|
||||||
parent?: string,
|
|
||||||
index?: number
|
|
||||||
) => {
|
|
||||||
const data = event.clipboardData;
|
|
||||||
if (!data) return;
|
|
||||||
|
|
||||||
try {
|
|
||||||
const json = this.readFromClipboard(data);
|
|
||||||
const slice = await this._getSnapshotByPriority(
|
|
||||||
type => json[type],
|
|
||||||
doc,
|
|
||||||
parent,
|
|
||||||
index
|
|
||||||
);
|
|
||||||
if (!slice) {
|
|
||||||
throw new BlockSuiteError(
|
|
||||||
ErrorCode.TransformerError,
|
|
||||||
'No snapshot found'
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return slice;
|
|
||||||
} catch {
|
|
||||||
const getDataByType = this._getDataByType(data);
|
|
||||||
const slice = await this._getSnapshotByPriority(
|
|
||||||
type => getDataByType(type),
|
|
||||||
doc,
|
|
||||||
parent,
|
|
||||||
index
|
|
||||||
);
|
|
||||||
|
|
||||||
return slice;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
pasteBlockSnapshot = async (
|
|
||||||
snapshot: BlockSnapshot,
|
|
||||||
doc: Store,
|
|
||||||
parent?: string,
|
|
||||||
index?: number
|
|
||||||
) => {
|
|
||||||
return this._getJob().snapshotToBlock(snapshot, doc, parent, index);
|
|
||||||
};
|
|
||||||
|
|
||||||
registerAdapter = <T extends BaseAdapter>(
|
|
||||||
mimeType: string,
|
|
||||||
adapter: AdapterConstructor<T>,
|
|
||||||
priority = 0
|
|
||||||
) => {
|
|
||||||
this._adapterMap.set(mimeType, { adapter, priority });
|
|
||||||
};
|
|
||||||
|
|
||||||
unregisterAdapter = (mimeType: string) => {
|
|
||||||
this._adapterMap.delete(mimeType);
|
|
||||||
};
|
|
||||||
|
|
||||||
unuse = (middleware: TransformerMiddleware) => {
|
|
||||||
this._jobMiddlewares = this._jobMiddlewares.filter(m => m !== middleware);
|
|
||||||
};
|
|
||||||
|
|
||||||
use = (middleware: TransformerMiddleware) => {
|
|
||||||
this._jobMiddlewares.push(middleware);
|
|
||||||
};
|
|
||||||
|
|
||||||
get configs() {
|
|
||||||
return this._getJob().adapterConfigs;
|
|
||||||
}
|
|
||||||
|
|
||||||
private async _getClipboardItem(slice: Slice, type: string) {
|
|
||||||
const job = this._getJob();
|
|
||||||
const adapterItem = this._adapterMap.get(type);
|
|
||||||
if (!adapterItem) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const { adapter } = adapterItem;
|
|
||||||
const adapterInstance = new adapter(job, this.std.provider);
|
|
||||||
const result = await adapterInstance.fromSlice(slice);
|
|
||||||
if (!result) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
return result.file;
|
|
||||||
}
|
|
||||||
|
|
||||||
private _getJob() {
|
|
||||||
return this.std.store.getTransformer(this._jobMiddlewares);
|
|
||||||
}
|
|
||||||
|
|
||||||
readFromClipboard(clipboardData: DataTransfer) {
|
|
||||||
const items = clipboardData.getData('text/html');
|
|
||||||
const sanitizedItems = DOMPurify.sanitize(items);
|
|
||||||
const domParser = new DOMParser();
|
|
||||||
const doc = domParser.parseFromString(sanitizedItems, 'text/html');
|
|
||||||
const dom = doc.querySelector<HTMLDivElement>('[data-blocksuite-snapshot]');
|
|
||||||
if (!dom) {
|
|
||||||
throw new BlockSuiteError(
|
|
||||||
ErrorCode.TransformerError,
|
|
||||||
'No snapshot found'
|
|
||||||
);
|
|
||||||
}
|
|
||||||
const json = JSON.parse(
|
|
||||||
lz.decompressFromEncodedURIComponent(
|
|
||||||
dom.dataset.blocksuiteSnapshot as string
|
|
||||||
)
|
|
||||||
);
|
|
||||||
return json;
|
|
||||||
}
|
|
||||||
|
|
||||||
sliceToSnapshot(slice: Slice) {
|
|
||||||
const job = this._getJob();
|
|
||||||
return job.sliceToSnapshot(slice);
|
|
||||||
}
|
|
||||||
|
|
||||||
async writeToClipboard(
|
|
||||||
updateItems: (
|
|
||||||
items: Record<string, unknown>
|
|
||||||
) => Promise<Record<string, unknown>> | Record<string, unknown>
|
|
||||||
) {
|
|
||||||
const _items = {
|
|
||||||
'text/plain': '',
|
|
||||||
'text/html': '',
|
|
||||||
'image/png': '',
|
|
||||||
};
|
|
||||||
|
|
||||||
const items = await updateItems(_items);
|
|
||||||
|
|
||||||
const text = items['text/plain'] as string;
|
|
||||||
const innerHTML = items['text/html'] as string;
|
|
||||||
const png = items['image/png'] as string | Blob;
|
|
||||||
|
|
||||||
delete items['text/plain'];
|
|
||||||
delete items['text/html'];
|
|
||||||
delete items['image/png'];
|
|
||||||
|
|
||||||
const snapshot = lz.compressToEncodedURIComponent(JSON.stringify(items));
|
|
||||||
const html = `<div data-blocksuite-snapshot='${snapshot}'>${innerHTML}</div>`;
|
|
||||||
const htmlBlob = new Blob([html], {
|
|
||||||
type: 'text/html',
|
|
||||||
});
|
|
||||||
const clipboardItems: Record<string, Blob> = {
|
|
||||||
'text/html': htmlBlob,
|
|
||||||
};
|
|
||||||
if (text.length > 0) {
|
|
||||||
const textBlob = new Blob([text], {
|
|
||||||
type: 'text/plain',
|
|
||||||
});
|
|
||||||
clipboardItems['text/plain'] = textBlob;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (png instanceof Blob) {
|
|
||||||
clipboardItems['image/png'] = png;
|
|
||||||
} else if (png.length > 0) {
|
|
||||||
const pngBlob = new Blob([png], {
|
|
||||||
type: 'image/png',
|
|
||||||
});
|
|
||||||
clipboardItems['image/png'] = pngBlob;
|
|
||||||
}
|
|
||||||
await navigator.clipboard.write([new ClipboardItem(clipboardItems)]);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
Reference in New Issue
Block a user