Skip to content

Commit

Permalink
feat(std): add image support for new clipboard (toeverything#4802)
Browse files Browse the repository at this point in the history
  • Loading branch information
Saul-Mirone authored Sep 18, 2023
1 parent 21f7463 commit a132906
Show file tree
Hide file tree
Showing 7 changed files with 263 additions and 58 deletions.
1 change: 1 addition & 0 deletions packages/block-std/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@
},
"dependencies": {
"@blocksuite/global": "workspace:*",
"lz-string": "^1.5.0",
"w3c-keyname": "^2.2.8",
"zod": "^3.22.2"
},
Expand Down
116 changes: 92 additions & 24 deletions packages/block-std/src/clipboard/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import type {
Slice,
} from '@blocksuite/store';
import { Job } from '@blocksuite/store';
import * as lz from 'lz-string';

import type { BlockStdProvider } from '../provider/index.js';

Expand Down Expand Up @@ -52,22 +53,81 @@ export class Clipboard {
assertExists(adapterItem);
const { adapter } = adapterItem;
const snapshot = await job.sliceToSnapshot(slice);
return adapter.fromSliceSnapshot({ snapshot: snapshot });
return adapter.fromSliceSnapshot({ snapshot, assets: job.assetsManager });
}

private _getSnapshotByPriority = async (
getItem: (type: string) => string,
page: Page,
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 (item) {
const job = this._getJob();
const sliceSnapshot = await adapter.toSliceSnapshot({
file: item,
assets: job.assetsManager,
});
if (sliceSnapshot) {
return job.snapshotToSlice(sliceSnapshot, page, parent, index);
}
}
}
return null;
};

copy = async (event: ClipboardEvent, slice: Slice) => {
const data = event.clipboardData;
if (!data) {
return;
}
const items: Record<string, string> = {
'text/plain': '',
'text/html': '',
'image/png': '',
};
await Promise.all(
Array.from(this._adapterMap.keys()).map(async type => {
const item = await this._getClipboardItem(slice, type);
if (typeof item === 'string') {
data.setData(type, item);
items[type] = item;
}
})
);
const text = items['text/plain'];
const innerHTML = items['text/html'];
const png = items['image/png'];

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.length > 0) {
const pngBlob = new Blob([png], {
type: 'image/png',
});
clipboardItems['image/png'] = pngBlob;
}
await navigator.clipboard.write([new ClipboardItem(clipboardItems)]);
};

paste = async (
Expand All @@ -80,28 +140,36 @@ export class Clipboard {
if (!data) {
return;
}
const byPriority = Array.from(this._adapterMap.entries()).sort(
(a, b) => b[1].priority - a[1].priority
);
for (const [type, { adapter }] of byPriority) {
const item = data.getData(type);
if (item) {
const job = this._getJob();
const sliceSnapshot = await adapter.toSliceSnapshot({
file: item,
assets: job.assetsManager,
});
if (sliceSnapshot) {
const slice = await job.snapshotToSlice(
sliceSnapshot,
page,
parent,
index
);
return slice;
}
}
const items = data.getData('text/html');
try {
const domParser = new DOMParser();
const doc = domParser.parseFromString(items, 'text/html');
const dom = doc.querySelector<HTMLDivElement>(
'[data-blocksuite-snapshot]'
);
assertExists(dom);
const json = JSON.parse(
lz.decompressFromEncodedURIComponent(
dom.dataset.blocksuiteSnapshot as string
)
);
const slice = await this._getSnapshotByPriority(
type => json[type],
page,
parent,
index
);
assertExists(slice);
return slice;
} catch {
const slice = await this._getSnapshotByPriority(
type => data.getData(type),
page,
parent,
index
);

return slice;
}
return null;
};
}
47 changes: 44 additions & 3 deletions packages/blocks/src/page-block/clipboard/adapter.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { assertExists } from '@blocksuite/global/utils';
import type {
FromBlockSnapshotPayload,
FromPageSnapshotPayload,
Expand All @@ -13,6 +14,14 @@ import type {
} from '@blocksuite/store';
import { BaseAdapter } from '@blocksuite/store';

import { decode, encode } from './utils.js';

type FileSnapshot = {
name: string;
type: string;
content: string;
};

export class ClipboardAdapter extends BaseAdapter<string> {
static MIME = 'BLOCKSUITE/SNAPSHOT';
override fromPageSnapshot(
Expand All @@ -39,16 +48,48 @@ export class ClipboardAdapter extends BaseAdapter<string> {
throw new Error('not implemented');
}

override fromSliceSnapshot(
override async fromSliceSnapshot(
payload: FromSliceSnapshotPayload
): Promise<string> {
return Promise.resolve(JSON.stringify(payload.snapshot));
const snapshot = payload.snapshot;
const assets = payload.assets;
assertExists(assets);
const map = assets.getAssets();
const blobs = await Array.from(map.entries()).reduce(
async (acc, [id, blob]) => {
const text = encode(await blob.arrayBuffer());
const file: FileSnapshot = {
name: blob.name,
type: blob.type,
content: text,
};
return {
...acc,
[id]: file,
};
},
Promise.resolve({} as Record<string, FileSnapshot>)
);
return JSON.stringify({
snapshot,
blobs,
});
}

override toSliceSnapshot(
payload: ToSliceSnapshotPayload<string>
): Promise<SliceSnapshot> {
const json = JSON.parse(payload.file);
return Promise.resolve(json);
const { blobs, snapshot } = json;
const map = payload.assets?.getAssets();
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);
});
return Promise.resolve(snapshot);
}
}
19 changes: 0 additions & 19 deletions packages/blocks/src/page-block/clipboard/middlewares/copy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,24 +21,6 @@ const handlePoint = (
model.text?.sliceToDelta(index, length + index);
};

const replaceId = (slots: JobSlots, std: BlockSuiteRoot['std']) => {
const idMap = new Map<string, string>();
slots.afterExport.on(payload => {
if (payload.type === 'block') {
const snapshot = payload.snapshot;
const original = snapshot.id;
let newId: string;
if (idMap.has(original)) {
newId = idMap.get(original)!;
} else {
newId = std.page.workspace.idGenerator('block');
idMap.set(original, newId);
}
snapshot.id = newId;
}
});
};

const sliceText = (slots: JobSlots, std: BlockSuiteRoot['std']) => {
slots.afterExport.on(payload => {
if (payload.type === 'block') {
Expand All @@ -60,7 +42,6 @@ const sliceText = (slots: JobSlots, std: BlockSuiteRoot['std']) => {

export const copyMiddleware = (std: BlockSuiteRoot['std']): JobMiddleware => {
return ({ slots }) => {
replaceId(slots, std);
sliceText(slots, std);
};
};
66 changes: 54 additions & 12 deletions packages/blocks/src/page-block/clipboard/middlewares/paste.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,8 @@
import type { TextRangePoint, TextSelection } from '@blocksuite/block-std';
import type {
BaseSelection,
TextRangePoint,
TextSelection,
} from '@blocksuite/block-std';
import { PathFinder } from '@blocksuite/block-std';
import { assertExists } from '@blocksuite/global/utils';
import type { BlockSuiteRoot } from '@blocksuite/lit';
Expand All @@ -11,6 +15,7 @@ import type {
} from '@blocksuite/store';
import type { Text } from '@blocksuite/store';
import type { BaseBlockModel } from '@blocksuite/store';
import type { JobSlots } from '@blocksuite/store';

import type { ParagraphBlockModel } from '../../../paragraph-block/index.js';

Expand Down Expand Up @@ -68,6 +73,12 @@ class PasteTr {
this.lastIndex = this.endPointState.text.length - end.index - end.length;
}

canMerge = () => {
const firstTextSnapshot = this._textFromSnapshot(this.firstSnapshot);
const lastTextSnapshot = this._textFromSnapshot(this.lastSnapshot);
return firstTextSnapshot && lastTextSnapshot;
};

private _textFromSnapshot = (snapshot: BlockSnapshot) => {
return snapshot.props.text as Record<'delta', DeltaOperation[]>;
};
Expand Down Expand Up @@ -142,11 +153,13 @@ class PasteTr {
};

pasted = () => {
this.std.page.deleteBlock(this.fromPointState.model);
if (this.to) {
const toBlock = this._blockFromPath(this.to.path);
if (toBlock) {
this.std.page.deleteBlock(toBlock.model);
if (this.canMerge() || this.endPointState.text.length === 0) {
this.std.page.deleteBlock(this.fromPointState.model);
if (this.to) {
const toBlock = this._blockFromPath(this.to.path);
if (toBlock) {
this.std.page.deleteBlock(toBlock.model);
}
}
}
};
Expand All @@ -162,16 +175,25 @@ class PasteTr {
}

const parentId = PathFinder.parent(this.endPointState.point.path);
this.std.selection.setGroup('note', [
this.std.selection.getInstance('text', {

let selection: BaseSelection;

if (!this.canMerge()) {
selection = this.std.selection.getInstance('block', {
path: parentId.concat(blockId),
});
} else {
selection = this.std.selection.getInstance('text', {
from: {
path: parentId.concat(blockId),
index,
length: 0,
},
to: null,
}),
]);
});
}

this.std.selection.setGroup('note', [selection]);
};

merge() {
Expand All @@ -184,18 +206,38 @@ class PasteTr {
}
}

const replaceId = (slots: JobSlots, std: BlockSuiteRoot['std']) => {
const idMap = new Map<string, string>();
slots.beforeImport.on(payload => {
if (payload.type === 'block') {
const snapshot = payload.snapshot;
const original = snapshot.id;
let newId: string;
if (idMap.has(original)) {
newId = idMap.get(original)!;
} else {
newId = std.page.workspace.idGenerator('block');
idMap.set(original, newId);
}
snapshot.id = newId;
}
});
};

export const pasteMiddleware = (std: BlockSuiteRoot['std']): JobMiddleware => {
return ({ slots }) => {
let tr: PasteTr | undefined;
replaceId(slots, std);
slots.beforeImport.on(payload => {
if (payload.type === 'slice') {
const text = std.selection.find('text');
if (!text) {
return;
}
tr = new PasteTr(std, text, payload.snapshot);

tr.merge();
if (tr.canMerge()) {
tr.merge();
}
}
});
slots.afterImport.on(payload => {
Expand Down
Loading

0 comments on commit a132906

Please sign in to comment.