feat(editor): edgeless clipboard config extension (#11168)

This commit is contained in:
Saul-Mirone
2025-03-25 12:09:23 +00:00
parent 1bb324eeed
commit df057b4c12
10 changed files with 648 additions and 532 deletions

View File

@@ -1,9 +1,13 @@
import { addAttachments } from '@blocksuite/affine-block-attachment'; import { addAttachments } from '@blocksuite/affine-block-attachment';
import { EdgelessFrameManagerIdentifier } from '@blocksuite/affine-block-frame';
import { addImages } from '@blocksuite/affine-block-image'; import { addImages } from '@blocksuite/affine-block-image';
import { import {
CanvasElementType, CanvasElementType,
type ClipboardConfigCreationContext,
EdgelessClipboardConfigIdentifier,
EdgelessCRUDIdentifier, EdgelessCRUDIdentifier,
ExportManager, ExportManager,
getSurfaceComponent,
SurfaceGroupLikeModel, SurfaceGroupLikeModel,
TextUtils, TextUtils,
} from '@blocksuite/affine-block-surface'; } from '@blocksuite/affine-block-surface';
@@ -14,7 +18,6 @@ import {
DEFAULT_NOTE_WIDTH, DEFAULT_NOTE_WIDTH,
FrameBlockModel, FrameBlockModel,
MAX_IMAGE_WIDTH, MAX_IMAGE_WIDTH,
ReferenceInfoSchema,
} from '@blocksuite/affine-model'; } from '@blocksuite/affine-model';
import { import {
CANVAS_EXPORT_IGNORE_TAGS, CANVAS_EXPORT_IGNORE_TAGS,
@@ -34,6 +37,7 @@ import {
referenceToNode, referenceToNode,
} from '@blocksuite/affine-shared/utils'; } from '@blocksuite/affine-shared/utils';
import type { import type {
BlockComponent,
BlockStdScope, BlockStdScope,
EditorHost, EditorHost,
SurfaceSelection, SurfaceSelection,
@@ -63,7 +67,6 @@ import { assertType } from '@blocksuite/global/utils';
import { import {
type BlockSnapshot, type BlockSnapshot,
BlockSnapshotSchema, BlockSnapshotSchema,
fromJSON,
type SliceSnapshot, type SliceSnapshot,
} from '@blocksuite/store'; } from '@blocksuite/store';
import DOMPurify from 'dompurify'; import DOMPurify from 'dompurify';
@@ -75,15 +78,12 @@ import {
decodeClipboardBlobs, decodeClipboardBlobs,
encodeClipboardBlobs, encodeClipboardBlobs,
} from '../../clipboard/utils.js'; } from '../../clipboard/utils.js';
import type { RootBlockComponent } from '../../types.js';
import type { EdgelessRootBlockComponent } from '../edgeless-root-block.js';
import { edgelessElementsBoundFromRawData } from '../utils/bound-utils.js'; import { edgelessElementsBoundFromRawData } from '../utils/bound-utils.js';
import { createNewPresentationIndexes } from '../utils/clipboard-utils.js'; import { createNewPresentationIndexes } from '../utils/clipboard-utils.js';
import { import {
getSortedCloneElements, getSortedCloneElements,
serializeElement, serializeElement,
} from '../utils/clone-utils.js'; } from '../utils/clone-utils.js';
import { deleteElements } from '../utils/crud.js';
import { import {
isAttachmentBlock, isAttachmentBlock,
isCanvasElementWithText, isCanvasElementWithText,
@@ -95,41 +95,13 @@ const BLOCKSUITE_SURFACE = 'blocksuite/surface';
const { GROUP, MINDMAP, CONNECTOR } = CanvasElementType; const { GROUP, MINDMAP, CONNECTOR } = CanvasElementType;
const IMAGE_PADDING = 5; // for rotated shapes some padding is needed const IMAGE_PADDING = 5; // for rotated shapes some padding is needed
type CreationContext = {
/**
* element old id to new id
*/
oldToNewIdMap: Map<string, string>;
/**
* element old id to new layer index
*/
originalIndexes: Map<string, string>;
/**
* frame old id to new presentation index
*/
newPresentationIndexes: Map<string, string>;
};
type BlockCreationFunction = (
snapshot: BlockSnapshot,
context: CreationContext
) => Promise<string | null> | string | null; // new Id
interface CanvasExportOptions { interface CanvasExportOptions {
dpr?: number; dpr?: number;
padding?: number; padding?: number;
background?: string; background?: string;
} }
interface BlockConfig {
flavour: string;
createFunction: BlockCreationFunction;
}
export class EdgelessClipboardController extends PageClipboard { export class EdgelessClipboardController extends PageClipboard {
private readonly _blockConfigs: BlockConfig[] = [];
private readonly _initEdgelessClipboard = () => { private readonly _initEdgelessClipboard = () => {
this.host.handleEvent( this.host.handleEvent(
'copy', 'copy',
@@ -210,7 +182,7 @@ export class EdgelessClipboardController extends PageClipboard {
this.selectionManager.selectedElements this.selectionManager.selectedElements
); );
this.doc.transact(() => { this.doc.transact(() => {
deleteElements(this.edgeless, elements); this.crud.deleteElements(elements);
}); });
this.selectionManager.set({ this.selectionManager.set({
@@ -241,6 +213,8 @@ export class EdgelessClipboardController extends PageClipboard {
const data = event.clipboardData; const data = event.clipboardData;
if (!data) return; if (!data) return;
if (!this.surface) return;
const lastMousePos = this.toolManager.lastMousePos$.peek(); const lastMousePos = this.toolManager.lastMousePos$.peek();
const point: IVec = [lastMousePos.x, lastMousePos.y]; const point: IVec = [lastMousePos.x, lastMousePos.y];
@@ -283,7 +257,7 @@ export class EdgelessClipboardController extends PageClipboard {
if (isUrlInClipboard(data)) { if (isUrlInClipboard(data)) {
const url = data.getData('text/plain'); const url = data.getData('text/plain');
const lastMousePos = this.toolManager.lastMousePos$.peek(); const lastMousePos = this.toolManager.lastMousePos$.peek();
const [x, y] = this.host.service.viewport.toModelCoord( const [x, y] = this.gfx.viewport.toModelCoord(
lastMousePos.x, lastMousePos.x,
lastMousePos.y lastMousePos.y
); );
@@ -407,7 +381,7 @@ export class EdgelessClipboardController extends PageClipboard {
} }
private get selectionManager() { private get selectionManager() {
return this.std.get(GfxControllerIdentifier).selection; return this.gfx.selection;
} }
private get std() { private get std() {
@@ -415,7 +389,15 @@ export class EdgelessClipboardController extends PageClipboard {
} }
private get surface() { private get surface() {
return this.host.surface; return getSurfaceComponent(this.std);
}
private get frame() {
return this.std.get(EdgelessFrameManagerIdentifier);
}
private get gfx() {
return this.std.get(GfxControllerIdentifier);
} }
private get crud() { private get crud() {
@@ -423,36 +405,7 @@ export class EdgelessClipboardController extends PageClipboard {
} }
private get toolManager() { private get toolManager() {
return this.host.gfx.tool; return this.gfx.tool;
}
constructor(public override host: EdgelessRootBlockComponent) {
super(host);
// Register existing block creation functions
this.registerBlock('affine:note', this._createNoteBlock);
this.registerBlock('affine:edgeless-text', this._createEdgelessTextBlock);
this.registerBlock('affine:image', this._createImageBlock);
this.registerBlock('affine:frame', this._createFrameBlock);
this.registerBlock('affine:attachment', this._createAttachmentBlock);
// external links
this.registerBlock('affine:bookmark', this._createBookmarkBlock);
this.registerBlock('affine:embed-figma', this._createFigmaEmbedBlock);
this.registerBlock('affine:embed-github', this._createGithubEmbedBlock);
this.registerBlock('affine:embed-html', this._createHtmlEmbedBlock);
this.registerBlock('affine:embed-loom', this._createLoomEmbedBlock);
this.registerBlock('affine:embed-youtube', this._createYoutubeEmbedBlock);
this.registerBlock('affine:embed-iframe', this._createIframeEmbedBlock);
// internal links
this.registerBlock(
'affine:embed-linked-doc',
this._createLinkedDocEmbedBlock
);
this.registerBlock(
'affine:embed-synced-doc',
this._createSyncedDocEmbedBlock
);
} }
private _checkCanContinueToCanvas( private _checkCanContinueToCanvas(
@@ -468,54 +421,9 @@ export class EdgelessClipboardController extends PageClipboard {
} }
} }
private async _createAttachmentBlock(attachment: BlockSnapshot) {
const { xywh, rotate, sourceId, name, size, type, embed, style } =
attachment.props;
if (!(await this.host.std.workspace.blobSync.get(sourceId as string))) {
return null;
}
const attachmentId = this.crud.addBlock(
'affine:attachment',
{
xywh,
rotate,
sourceId,
name,
size,
type,
embed,
style,
},
this.surface.model.id
);
return attachmentId;
}
private _createBookmarkBlock(bookmark: BlockSnapshot) {
const { xywh, style, url, caption, description, icon, image, title } =
bookmark.props;
const bookmarkId = this.crud.addBlock(
'affine:bookmark',
{
xywh,
style,
url,
caption,
description,
icon,
image,
title,
},
this.surface.model.id
);
return bookmarkId;
}
private _createCanvasElement( private _createCanvasElement(
clipboardData: SerializedElement, clipboardData: SerializedElement,
context: CreationContext, context: ClipboardConfigCreationContext,
newXYWH: SerializedXYWH newXYWH: SerializedXYWH
): GfxPrimitiveElementModel | null { ): GfxPrimitiveElementModel | null {
if (clipboardData.type === GROUP) { if (clipboardData.type === GROUP) {
@@ -614,307 +522,8 @@ export class EdgelessClipboardController extends PageClipboard {
return element; return element;
} }
private async _createEdgelessTextBlock(edgelessText: BlockSnapshot) {
const oldId = edgelessText.id;
delete edgelessText.props.index;
if (!edgelessText.props.xywh) {
console.error(
`EdgelessText block(id: ${oldId}) does not have xywh property`
);
return null;
}
const newId = await this.onBlockSnapshotPaste(
edgelessText,
this.doc,
this.edgeless.surface.model.id
);
if (!newId) {
console.error(`Failed to paste EdgelessText block(id: ${oldId})`);
return null;
}
return newId;
}
private _createFigmaEmbedBlock(figmaEmbed: BlockSnapshot) {
const { xywh, style, url, caption, title, description } = figmaEmbed.props;
const embedFigmaId = this.crud.addBlock(
'affine:embed-figma',
{
xywh,
style,
url,
caption,
title,
description,
},
this.surface.model.id
);
return embedFigmaId;
}
private _createFrameBlock(frame: BlockSnapshot, context: CreationContext) {
const { oldToNewIdMap, newPresentationIndexes } = context;
const { xywh, title, background, childElementIds } = frame.props;
const newChildElementIds: Record<string, boolean> = {};
if (typeof childElementIds === 'object' && childElementIds !== null) {
Object.keys(childElementIds).forEach(oldId => {
const newId = oldToNewIdMap.get(oldId);
if (newId) {
newChildElementIds[newId] = true;
}
});
}
const frameId = this.crud.addBlock(
'affine:frame',
{
xywh,
background,
title: fromJSON(title),
childElementIds: newChildElementIds,
presentationIndex: newPresentationIndexes.get(frame.id),
},
this.surface.model.id
);
return frameId;
}
private _createGithubEmbedBlock(githubEmbed: BlockSnapshot) {
const {
xywh,
style,
owner,
repo,
githubType,
githubId,
url,
caption,
image,
status,
statusReason,
title,
description,
createdAt,
assignees,
} = githubEmbed.props;
const embedGithubId = this.crud.addBlock(
'affine:embed-github',
{
xywh,
style,
owner,
repo,
githubType,
githubId,
url,
caption,
image,
status,
statusReason,
title,
description,
createdAt,
assignees,
},
this.surface.model.id
);
return embedGithubId;
}
private _createHtmlEmbedBlock(htmlEmbed: BlockSnapshot) {
const { xywh, style, caption, html, design } = htmlEmbed.props;
const embedHtmlId = this.crud.addBlock(
'affine:embed-html',
{
xywh,
style,
caption,
html,
design,
},
this.surface.model.id
);
return embedHtmlId;
}
private async _createImageBlock(image: BlockSnapshot) {
const { xywh, rotate, sourceId, size, width, height, caption } =
image.props;
if (!(await this.host.std.workspace.blobSync.get(sourceId as string))) {
return null;
}
return this.crud.addBlock(
'affine:image',
{
caption,
sourceId,
xywh,
rotate,
size,
width,
height,
},
this.surface.model.id
);
}
private _createLinkedDocEmbedBlock(linkedDocEmbed: BlockSnapshot) {
const { xywh, style, caption, pageId, params, title, description } =
linkedDocEmbed.props;
const referenceInfo = ReferenceInfoSchema.parse({
pageId,
params,
title,
description,
});
return this.crud.addBlock(
'affine:embed-linked-doc',
{
xywh,
style,
caption,
...referenceInfo,
},
this.surface.model.id
);
}
private _createLoomEmbedBlock(loomEmbed: BlockSnapshot) {
const { xywh, style, url, caption, videoId, image, title, description } =
loomEmbed.props;
const embedLoomId = this.crud.addBlock(
'affine:embed-loom',
{
xywh,
style,
url,
caption,
videoId,
image,
title,
description,
},
this.surface.model.id
);
return embedLoomId;
}
private async _createNoteBlock(note: BlockSnapshot) {
const oldId = note.id;
delete note.props.index;
if (!note.props.xywh) {
console.error(`Note block(id: ${oldId}) does not have xywh property`);
return null;
}
const newId = await this.onBlockSnapshotPaste(
note,
this.doc,
this.doc.root!.id
);
if (!newId) {
console.error(`Failed to paste note block(id: ${oldId})`);
return null;
}
return newId;
}
private _createSyncedDocEmbedBlock(syncedDocEmbed: BlockSnapshot) {
const { xywh, style, caption, scale, pageId, params } =
syncedDocEmbed.props;
const referenceInfo = ReferenceInfoSchema.parse({ pageId, params });
return this.crud.addBlock(
'affine:embed-synced-doc',
{
xywh,
style,
caption,
scale,
...referenceInfo,
},
this.surface.model.id
);
}
private _createYoutubeEmbedBlock(youtubeEmbed: BlockSnapshot) {
const {
xywh,
style,
url,
caption,
videoId,
image,
title,
description,
creator,
creatorUrl,
creatorImage,
} = youtubeEmbed.props;
const embedYoutubeId = this.crud.addBlock(
'affine:embed-youtube',
{
xywh,
style,
url,
caption,
videoId,
image,
title,
description,
creator,
creatorUrl,
creatorImage,
},
this.surface.model.id
);
return embedYoutubeId;
}
private _createIframeEmbedBlock(embedIframe: BlockSnapshot) {
const {
xywh,
caption,
url,
title,
description,
iframeUrl,
scale,
width,
height,
} = embedIframe.props;
return this.crud.addBlock(
'affine:embed-iframe',
{
url,
iframeUrl,
xywh,
caption,
title,
description,
scale,
width,
height,
},
this.surface.model.id
);
}
private async _edgelessToCanvas( private async _edgelessToCanvas(
edgeless: EdgelessRootBlockComponent, edgeless: BlockComponent,
bound: IBound, bound: IBound,
nodes?: GfxBlockElementModel[], nodes?: GfxBlockElementModel[],
canvasElements: GfxPrimitiveElementModel[] = [], canvasElements: GfxPrimitiveElementModel[] = [],
@@ -930,18 +539,11 @@ export class EdgelessClipboardController extends PageClipboard {
const html2canvas = (await import('html2canvas')).default; const html2canvas = (await import('html2canvas')).default;
if (!(html2canvas instanceof Function)) return; if (!(html2canvas instanceof Function)) return;
if (!this.surface) return;
const pathname = location.pathname; const pathname = location.pathname;
const editorMode = isInsidePageEditor(host); const editorMode = isInsidePageEditor(host);
const rootComponent = getRootByEditorHost(host);
if (!rootComponent) return;
const container = rootComponent.querySelector(
'.affine-block-children-container'
);
if (!container) return;
const canvas = document.createElement('canvas'); const canvas = document.createElement('canvas');
canvas.width = (bound.w + padding * 2) * dpr; canvas.width = (bound.w + padding * 2) * dpr;
canvas.height = (bound.h + padding * 2) * dpr; canvas.height = (bound.h + padding * 2) * dpr;
@@ -1024,7 +626,7 @@ export class EdgelessClipboardController extends PageClipboard {
const nodeElements = const nodeElements =
nodes ?? nodes ??
(edgeless.service.gfx.getElementsByBound(bound, { (this.gfx.getElementsByBound(bound, {
type: 'block', type: 'block',
}) as GfxBlockElementModel[]); }) as GfxBlockElementModel[]);
for (const nodeElement of nodeElements) { for (const nodeElement of nodeElements) {
@@ -1032,15 +634,13 @@ export class EdgelessClipboardController extends PageClipboard {
if (matchModels(nodeElement, [FrameBlockModel])) { if (matchModels(nodeElement, [FrameBlockModel])) {
const blocksInsideFrame: GfxBlockElementModel[] = []; const blocksInsideFrame: GfxBlockElementModel[] = [];
this.edgeless.service.frame this.frame.getElementsInFrameBound(nodeElement, false).forEach(ele => {
.getElementsInFrameBound(nodeElement, false) if (isTopLevelBlock(ele)) {
.forEach(ele => { blocksInsideFrame.push(ele as GfxBlockElementModel);
if (isTopLevelBlock(ele)) { } else {
blocksInsideFrame.push(ele as GfxBlockElementModel); canvasElements.push(ele as GfxPrimitiveElementModel);
} else { }
canvasElements.push(ele as GfxPrimitiveElementModel); });
}
});
// eslint-disable-next-line @typescript-eslint/prefer-for-of // eslint-disable-next-line @typescript-eslint/prefer-for-of
for (let i = 0; i < blocksInsideFrame.length; i++) { for (let i = 0; i < blocksInsideFrame.length; i++) {
@@ -1052,7 +652,7 @@ export class EdgelessClipboardController extends PageClipboard {
this._checkCanContinueToCanvas(host, pathname, editorMode); this._checkCanContinueToCanvas(host, pathname, editorMode);
} }
const surfaceCanvas = edgeless.surface.renderer.getCanvasByBound( const surfaceCanvas = this.surface.renderer.getCanvasByBound(
bound, bound,
canvasElements canvasElements
); );
@@ -1114,9 +714,8 @@ export class EdgelessClipboardController extends PageClipboard {
} }
private async _pasteTextContentAsNote(content: BlockSnapshot[] | string) { private async _pasteTextContentAsNote(content: BlockSnapshot[] | string) {
const edgeless = this.host;
const lastMousePos = this.toolManager.lastMousePos$.peek(); const lastMousePos = this.toolManager.lastMousePos$.peek();
const [x, y] = edgeless.service.viewport.toModelCoord( const [x, y] = this.gfx.viewport.toModelCoord(
lastMousePos.x, lastMousePos.x,
lastMousePos.y lastMousePos.y
); );
@@ -1167,11 +766,11 @@ export class EdgelessClipboardController extends PageClipboard {
} }
} }
edgeless.service.selection.set({ this.gfx.selection.set({
elements: [noteId], elements: [noteId],
editing: false, editing: false,
}); });
edgeless.gfx.tool.setTool('default'); this.gfx.tool.setTool('default');
} }
private _replaceRichTextWithSvgElement(element: HTMLElement) { private _replaceRichTextWithSvgElement(element: HTMLElement) {
@@ -1221,7 +820,7 @@ export class EdgelessClipboardController extends PageClipboard {
} }
} }
const idxGenerator = this.edgeless.service.layer.createIndexGenerator(); const idxGenerator = this.gfx.layer.createIndexGenerator();
const sortedElements = elements.sort(compare); const sortedElements = elements.sort(compare);
sortedElements.forEach(ele => { sortedElements.forEach(ele => {
const newIndex = idxGenerator(); const newIndex = idxGenerator();
@@ -1250,7 +849,7 @@ export class EdgelessClipboardController extends PageClipboard {
const lastMousePos = this.toolManager.lastMousePos$.peek(); const lastMousePos = this.toolManager.lastMousePos$.peek();
pasteCenter = pasteCenter =
pasteCenter ?? pasteCenter ??
this.host.service.viewport.toModelCoord(lastMousePos.x, lastMousePos.y); this.gfx.viewport.toModelCoord(lastMousePos.x, lastMousePos.y);
const [modelX, modelY] = pasteCenter; const [modelX, modelY] = pasteCenter;
oldCommonBound = edgelessElementsBoundFromRawData(elementsRawData); oldCommonBound = edgelessElementsBoundFromRawData(elementsRawData);
@@ -1270,7 +869,7 @@ export class EdgelessClipboardController extends PageClipboard {
// create blocks and canvas elements // create blocks and canvas elements
const context: CreationContext = { const context: ClipboardConfigCreationContext = {
oldToNewIdMap: new Map<string, string>(), oldToNewIdMap: new Map<string, string>(),
originalIndexes: new Map<string, string>(), originalIndexes: new Map<string, string>(),
newPresentationIndexes: createNewPresentationIndexes( newPresentationIndexes: createNewPresentationIndexes(
@@ -1288,8 +887,8 @@ export class EdgelessClipboardController extends PageClipboard {
if (blockSnapshot) { if (blockSnapshot) {
const oldId = blockSnapshot.id; const oldId = blockSnapshot.id;
const config = this._blockConfigs.find( const config = this.std.getOptional(
({ flavour }) => flavour === blockSnapshot.flavour EdgelessClipboardConfigIdentifier(blockSnapshot.flavour)
); );
if (!config) continue; if (!config) continue;
@@ -1311,7 +910,7 @@ export class EdgelessClipboardController extends PageClipboard {
); );
blockSnapshot.props.lockedBySelf = false; blockSnapshot.props.lockedBySelf = false;
const newId = await config.createFunction(blockSnapshot, context); const newId = await config.createBlock(blockSnapshot, context);
if (!newId) continue; if (!newId) continue;
const block = this.doc.getBlock(newId); const block = this.doc.getBlock(newId);
@@ -1369,13 +968,6 @@ export class EdgelessClipboardController extends PageClipboard {
this._initEdgelessClipboard(); this._initEdgelessClipboard();
} }
registerBlock(flavour: string, createFunction: BlockCreationFunction) {
this._blockConfigs.push({
flavour,
createFunction: createFunction.bind(this),
});
}
async toCanvas( async toCanvas(
blocks: GfxBlockElementModel[], blocks: GfxBlockElementModel[],
shapes: ShapeElementModel[], shapes: ShapeElementModel[],
@@ -1466,12 +1058,3 @@ function tryGetSvgFromClipboard(clipboardData: DataTransfer) {
const file = new File([blob], 'pasted-image.svg', { type: 'image/svg+xml' }); const file = new File([blob], 'pasted-image.svg', { type: 'image/svg+xml' });
return file; return file;
} }
function getRootByEditorHost(
editorHost: EditorHost
): RootBlockComponent | null {
const model = editorHost.doc.root;
if (!model) return null;
const root = editorHost.view.getBlock(model.id);
return root as RootBlockComponent | null;
}

View File

@@ -0,0 +1,435 @@
import {
type ClipboardConfigCreationContext,
EdgelessClipboardConfig,
} from '@blocksuite/affine-block-surface';
import { ReferenceInfoSchema } from '@blocksuite/affine-model';
import { type BlockSnapshot, fromJSON } from '@blocksuite/store';
export class EdgelessClipboardNoteConfig extends EdgelessClipboardConfig {
static override readonly key = 'affine:note';
override async createBlock(note: BlockSnapshot): Promise<null | string> {
const oldId = note.id;
delete note.props.index;
if (!note.props.xywh) {
console.error(`Note block(id: ${oldId}) does not have xywh property`);
return null;
}
const newId = await this.onBlockSnapshotPaste(
note,
this.std.store,
this.std.store.root!.id
);
if (!newId) {
console.error(`Failed to paste note block(id: ${oldId})`);
return null;
}
return newId;
}
}
export class EdgelessClipboardEdgelessTextConfig extends EdgelessClipboardConfig {
static override readonly key = 'affine:edgeless-text';
override async createBlock(
edgelessText: BlockSnapshot
): Promise<string | null> {
const oldId = edgelessText.id;
delete edgelessText.props.index;
if (!edgelessText.props.xywh) {
console.error(
`EdgelessText block(id: ${oldId}) does not have xywh property`
);
return null;
}
if (!this.surface) {
return null;
}
const newId = await this.onBlockSnapshotPaste(
edgelessText,
this.std.store,
this.surface.model.id
);
if (!newId) {
console.error(`Failed to paste EdgelessText block(id: ${oldId})`);
return null;
}
return newId;
}
}
export class EdgelessClipboardImageConfig extends EdgelessClipboardConfig {
static override readonly key = 'affine:image';
override async createBlock(image: BlockSnapshot) {
const { xywh, rotate, sourceId, size, width, height, caption } =
image.props;
if (!this.surface) return null;
if (!(await this.std.workspace.blobSync.get(sourceId as string))) {
return null;
}
return this.crud.addBlock(
'affine:image',
{
caption,
sourceId,
xywh,
rotate,
size,
width,
height,
},
this.surface.model.id
);
}
}
export class EdgelessClipboardFrameConfig extends EdgelessClipboardConfig {
static override readonly key = 'affine:frame';
override createBlock(
frame: BlockSnapshot,
context: ClipboardConfigCreationContext
): string | null {
if (!this.surface) return null;
const { oldToNewIdMap, newPresentationIndexes } = context;
const { xywh, title, background, childElementIds } = frame.props;
const newChildElementIds: Record<string, boolean> = {};
if (typeof childElementIds === 'object' && childElementIds !== null) {
Object.keys(childElementIds).forEach(oldId => {
const newId = oldToNewIdMap.get(oldId);
if (newId) {
newChildElementIds[newId] = true;
}
});
}
const frameId = this.crud.addBlock(
'affine:frame',
{
xywh,
background,
title: fromJSON(title),
childElementIds: newChildElementIds,
presentationIndex: newPresentationIndexes.get(frame.id),
},
this.surface.model.id
);
return frameId;
}
}
export class EdgelessClipboardAttachmentConfig extends EdgelessClipboardConfig {
static override readonly key = 'affine:attachment';
override async createBlock(
attachment: BlockSnapshot
): Promise<string | null> {
if (!this.surface) return null;
const { xywh, rotate, sourceId, name, size, type, embed, style } =
attachment.props;
if (!(await this.std.workspace.blobSync.get(sourceId as string))) {
return null;
}
const attachmentId = this.crud.addBlock(
'affine:attachment',
{
xywh,
rotate,
sourceId,
name,
size,
type,
embed,
style,
},
this.surface.model.id
);
return attachmentId;
}
}
export class EdgelessClipboardBookmarkConfig extends EdgelessClipboardConfig {
static override readonly key = 'affine:bookmark';
override createBlock(bookmark: BlockSnapshot): string | null {
if (!this.surface) return null;
const { xywh, style, url, caption, description, icon, image, title } =
bookmark.props;
const bookmarkId = this.crud.addBlock(
'affine:bookmark',
{
xywh,
style,
url,
caption,
description,
icon,
image,
title,
},
this.surface.model.id
);
return bookmarkId;
}
}
export class EdgelessClipboardEmbedFigmaConfig extends EdgelessClipboardConfig {
static override readonly key = 'affine:embed-figma';
override createBlock(figmaEmbed: BlockSnapshot): string | null {
if (!this.surface) return null;
const { xywh, style, url, caption, title, description } = figmaEmbed.props;
const embedFigmaId = this.crud.addBlock(
'affine:embed-figma',
{
xywh,
style,
url,
caption,
title,
description,
},
this.surface.model.id
);
return embedFigmaId;
}
}
export class EdgelessClipboardEmbedGithubConfig extends EdgelessClipboardConfig {
static override readonly key = 'affine:embed-github';
override createBlock(githubEmbed: BlockSnapshot): string | null {
if (!this.surface) return null;
const {
xywh,
style,
owner,
repo,
githubType,
githubId,
url,
caption,
image,
status,
statusReason,
title,
description,
createdAt,
assignees,
} = githubEmbed.props;
const embedGithubId = this.crud.addBlock(
'affine:embed-github',
{
xywh,
style,
owner,
repo,
githubType,
githubId,
url,
caption,
image,
status,
statusReason,
title,
description,
createdAt,
assignees,
},
this.surface.model.id
);
return embedGithubId;
}
}
export class EdgelessClipboardEmbedHtmlConfig extends EdgelessClipboardConfig {
static override readonly key = 'affine:embed-html';
override createBlock(htmlEmbed: BlockSnapshot): string | null {
if (!this.surface) return null;
const { xywh, style, caption, html, design } = htmlEmbed.props;
const embedHtmlId = this.crud.addBlock(
'affine:embed-html',
{
xywh,
style,
caption,
html,
design,
},
this.surface.model.id
);
return embedHtmlId;
}
}
export class EdgelessClipboardEmbedLoomConfig extends EdgelessClipboardConfig {
static override readonly key = 'affine:embed-loom';
override createBlock(loomEmbed: BlockSnapshot): string | null {
if (!this.surface) return null;
const { xywh, style, url, caption, videoId, image, title, description } =
loomEmbed.props;
const embedLoomId = this.crud.addBlock(
'affine:embed-loom',
{
xywh,
style,
url,
caption,
videoId,
image,
title,
description,
},
this.surface.model.id
);
return embedLoomId;
}
}
export class EdgelessClipboardEmbedYoutubeConfig extends EdgelessClipboardConfig {
static override readonly key = 'affine:embed-youtube';
override createBlock(youtubeEmbed: BlockSnapshot): string | null {
if (!this.surface) return null;
const {
xywh,
style,
url,
caption,
videoId,
image,
title,
description,
creator,
creatorUrl,
creatorImage,
} = youtubeEmbed.props;
const embedYoutubeId = this.crud.addBlock(
'affine:embed-youtube',
{
xywh,
style,
url,
caption,
videoId,
image,
title,
description,
creator,
creatorUrl,
creatorImage,
},
this.surface.model.id
);
return embedYoutubeId;
}
}
export class EdgelessClipboardEmbedIframeConfig extends EdgelessClipboardConfig {
static override readonly key = 'affine:embed-iframe';
override createBlock(embedIframe: BlockSnapshot): string | null {
if (!this.surface) return null;
const {
xywh,
caption,
url,
title,
description,
iframeUrl,
scale,
width,
height,
} = embedIframe.props;
return this.crud.addBlock(
'affine:embed-iframe',
{
url,
iframeUrl,
xywh,
caption,
title,
description,
scale,
width,
height,
},
this.surface.model.id
);
}
}
export class EdgelessClipboardEmbedLinkedDocConfig extends EdgelessClipboardConfig {
static override readonly key = 'affine:embed-linked-doc';
override createBlock(linkedDocEmbed: BlockSnapshot): string | null {
if (!this.surface) return null;
const { xywh, style, caption, pageId, params, title, description } =
linkedDocEmbed.props;
const referenceInfo = ReferenceInfoSchema.parse({
pageId,
params,
title,
description,
});
return this.crud.addBlock(
'affine:embed-linked-doc',
{
xywh,
style,
caption,
...referenceInfo,
},
this.surface.model.id
);
}
}
export class EdgelessClipboardEmbedSyncedDocConfig extends EdgelessClipboardConfig {
static override readonly key = 'affine:embed-synced-doc';
override createBlock(syncedDocEmbed: BlockSnapshot): string | null {
if (!this.surface) return null;
const { xywh, style, caption, scale, pageId, params } =
syncedDocEmbed.props;
const referenceInfo = ReferenceInfoSchema.parse({ pageId, params });
return this.crud.addBlock(
'affine:embed-synced-doc',
{
xywh,
style,
caption,
scale,
...referenceInfo,
},
this.surface.model.id
);
}
}

View File

@@ -20,6 +20,22 @@ import { literal, unsafeStatic } from 'lit/static-html.js';
import { CommonSpecs } from '../common-specs/index.js'; import { CommonSpecs } from '../common-specs/index.js';
import { edgelessNavigatorBgWidget } from '../widgets/edgeless-navigator-bg/index.js'; import { edgelessNavigatorBgWidget } from '../widgets/edgeless-navigator-bg/index.js';
import { AFFINE_EDGELESS_ZOOM_TOOLBAR_WIDGET } from '../widgets/edgeless-zoom-toolbar/index.js'; import { AFFINE_EDGELESS_ZOOM_TOOLBAR_WIDGET } from '../widgets/edgeless-zoom-toolbar/index.js';
import {
EdgelessClipboardAttachmentConfig,
EdgelessClipboardBookmarkConfig,
EdgelessClipboardEdgelessTextConfig,
EdgelessClipboardEmbedFigmaConfig,
EdgelessClipboardEmbedGithubConfig,
EdgelessClipboardEmbedHtmlConfig,
EdgelessClipboardEmbedIframeConfig,
EdgelessClipboardEmbedLinkedDocConfig,
EdgelessClipboardEmbedLoomConfig,
EdgelessClipboardEmbedSyncedDocConfig,
EdgelessClipboardEmbedYoutubeConfig,
EdgelessClipboardFrameConfig,
EdgelessClipboardImageConfig,
EdgelessClipboardNoteConfig,
} from './clipboard/config.js';
import { NOTE_SLICER_WIDGET } from './components/note-slicer/index.js'; import { NOTE_SLICER_WIDGET } from './components/note-slicer/index.js';
import { EDGELESS_DRAGGING_AREA_WIDGET } from './components/rects/edgeless-dragging-area-rect.js'; import { EDGELESS_DRAGGING_AREA_WIDGET } from './components/rects/edgeless-dragging-area-rect.js';
import { EDGELESS_SELECTED_RECT_WIDGET } from './components/rects/edgeless-selected-rect.js'; import { EDGELESS_SELECTED_RECT_WIDGET } from './components/rects/edgeless-selected-rect.js';
@@ -56,6 +72,23 @@ class EdgelessLocker extends LifeCycleWatcher {
} }
} }
const EdgelessClipboardConfigs: ExtensionType[] = [
EdgelessClipboardNoteConfig,
EdgelessClipboardEdgelessTextConfig,
EdgelessClipboardImageConfig,
EdgelessClipboardFrameConfig,
EdgelessClipboardAttachmentConfig,
EdgelessClipboardBookmarkConfig,
EdgelessClipboardEmbedFigmaConfig,
EdgelessClipboardEmbedGithubConfig,
EdgelessClipboardEmbedHtmlConfig,
EdgelessClipboardEmbedLoomConfig,
EdgelessClipboardEmbedYoutubeConfig,
EdgelessClipboardEmbedIframeConfig,
EdgelessClipboardEmbedLinkedDocConfig,
EdgelessClipboardEmbedSyncedDocConfig,
];
const EdgelessCommonExtension: ExtensionType[] = [ const EdgelessCommonExtension: ExtensionType[] = [
CommonSpecs, CommonSpecs,
ToolController, ToolController,
@@ -65,6 +98,7 @@ const EdgelessCommonExtension: ExtensionType[] = [
ConnectorElementView, ConnectorElementView,
...quickTools, ...quickTools,
...seniorTools, ...seniorTools,
...EdgelessClipboardConfigs,
].flat(); ].flat();
export const EdgelessRootBlockSpec: ExtensionType[] = [ export const EdgelessRootBlockSpec: ExtensionType[] = [

View File

@@ -0,0 +1,80 @@
import { type BlockStdScope, StdIdentifier } from '@blocksuite/block-std';
import { type Container, createIdentifier } from '@blocksuite/global/di';
import { BlockSuiteError } from '@blocksuite/global/exceptions';
import { type BlockSnapshot, Extension, type Store } from '@blocksuite/store';
import { getSurfaceComponent } from '../utils/get-surface-block';
import { EdgelessCRUDIdentifier } from './crud-extension';
export type ClipboardConfigCreationContext = {
/**
* element old id to new id
*/
oldToNewIdMap: Map<string, string>;
/**
* element old id to new layer index
*/
originalIndexes: Map<string, string>;
/**
* frame old id to new presentation index
*/
newPresentationIndexes: Map<string, string>;
};
export const EdgelessClipboardConfigIdentifier =
createIdentifier<EdgelessClipboardConfig>('edgeless-clipboard-config');
export abstract class EdgelessClipboardConfig extends Extension {
static key: string;
constructor(readonly std: BlockStdScope) {
super();
}
get surface() {
return getSurfaceComponent(this.std);
}
get crud() {
return this.std.get(EdgelessCRUDIdentifier);
}
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;
};
abstract createBlock(
snapshot: BlockSnapshot,
context: ClipboardConfigCreationContext
): string | null | Promise<string | null>;
static override setup(di: Container) {
if (!this.key) {
throw new BlockSuiteError(
BlockSuiteError.ErrorCode.ValueNotExists,
'Key is not defined in the EdgelessClipboardConfig'
);
}
di.add(
this as unknown as { new (std: BlockStdScope): EdgelessClipboardConfig },
[StdIdentifier]
);
di.addImpl(EdgelessClipboardConfigIdentifier(this.key), provider =>
provider.get(this)
);
}
}

View File

@@ -1,3 +1,4 @@
export * from './clipboard-config';
export * from './crud-extension'; export * from './crud-extension';
export * from './export-manager'; export * from './export-manager';
export * from './legacy-slot-extension'; export * from './legacy-slot-extension';

View File

@@ -2,7 +2,7 @@ import rehypeParse from 'rehype-parse';
import { unified } from 'unified'; import { unified } from 'unified';
import { describe, expect, test } from 'vitest'; import { describe, expect, test } from 'vitest';
import { onlyContainImgElement } from '../clipboard/index.js'; import { onlyContainImgElement } from '../clipboard/utils.js';
describe('only contains img elements', () => { describe('only contains img elements', () => {
test('normal with head', () => { test('normal with head', () => {

View File

@@ -9,12 +9,12 @@ import type {
TransformerMiddleware, TransformerMiddleware,
} from '@blocksuite/store'; } from '@blocksuite/store';
import DOMPurify from 'dompurify'; import DOMPurify from 'dompurify';
import type { RootContentMap } from 'hast';
import * as lz from 'lz-string'; import * as lz from 'lz-string';
import rehypeParse from 'rehype-parse'; import rehypeParse from 'rehype-parse';
import { unified } from 'unified'; import { unified } from 'unified';
import { LifeCycleWatcher } from '../extension/index.js'; import { LifeCycleWatcher } from '../extension/index.js';
import { onlyContainImgElement } from './utils.js';
type AdapterConstructor<T extends BaseAdapter> = type AdapterConstructor<T extends BaseAdapter> =
| { new (job: Transformer): T } | { new (job: Transformer): T }
@@ -28,38 +28,6 @@ type AdapterMap = Map<
} }
>; >;
type HastUnionType<
K extends keyof RootContentMap,
V extends RootContentMap[K],
> = V;
export function onlyContainImgElement(
ast: HastUnionType<keyof RootContentMap, RootContentMap[keyof RootContentMap]>
): 'yes' | 'no' | 'maybe' {
if (ast.type === 'element') {
switch (ast.tagName) {
case 'html':
case 'body':
return ast.children.map(onlyContainImgElement).reduce((a, b) => {
if (a === 'no' || b === 'no') {
return 'no';
}
if (a === 'maybe' && b === 'maybe') {
return 'maybe';
}
return 'yes';
}, 'maybe');
case 'img':
return 'yes';
case 'head':
return 'maybe';
default:
return 'no';
}
}
return 'maybe';
}
export class Clipboard extends LifeCycleWatcher { export class Clipboard extends LifeCycleWatcher {
static override readonly key = 'clipboard'; static override readonly key = 'clipboard';

View File

@@ -0,0 +1,33 @@
import type { RootContentMap } from 'hast';
type HastUnionType<
K extends keyof RootContentMap,
V extends RootContentMap[K],
> = V;
export function onlyContainImgElement(
ast: HastUnionType<keyof RootContentMap, RootContentMap[keyof RootContentMap]>
): 'yes' | 'no' | 'maybe' {
if (ast.type === 'element') {
switch (ast.tagName) {
case 'html':
case 'body':
return ast.children.map(onlyContainImgElement).reduce((a, b) => {
if (a === 'no' || b === 'no') {
return 'no';
}
if (a === 'maybe' && b === 'maybe') {
return 'maybe';
}
return 'yes';
}, 'maybe');
case 'img':
return 'yes';
case 'head':
return 'maybe';
default:
return 'no';
}
}
return 'maybe';
}

View File

@@ -57,7 +57,7 @@ import {
import { patchDatabaseBlockConfigService } from '../extensions/database-block-config-service'; import { patchDatabaseBlockConfigService } from '../extensions/database-block-config-service';
import { patchDocModeService } from '../extensions/doc-mode-service'; import { patchDocModeService } from '../extensions/doc-mode-service';
import { patchDocUrlExtensions } from '../extensions/doc-url'; import { patchDocUrlExtensions } from '../extensions/doc-url';
import { EdgelessClipboardWatcher } from '../extensions/edgeless-clipboard'; import { EdgelessClipboardAIChatConfig } from '../extensions/edgeless-clipboard';
import { patchForClipboardInElectron } from '../extensions/electron-clipboard'; import { patchForClipboardInElectron } from '../extensions/electron-clipboard';
import { enableEditorExtension } from '../extensions/entry/enable-editor'; import { enableEditorExtension } from '../extensions/entry/enable-editor';
import { enableMobileExtension } from '../extensions/entry/enable-mobile'; import { enableMobileExtension } from '../extensions/entry/enable-mobile';
@@ -169,7 +169,7 @@ const usePatchSpecs = (mode: DocMode) => {
patchNotificationService(confirmModal), patchNotificationService(confirmModal),
patchPeekViewService(peekViewService), patchPeekViewService(peekViewService),
patchOpenDocExtension(), patchOpenDocExtension(),
EdgelessClipboardWatcher, EdgelessClipboardAIChatConfig,
patchDocUrlExtensions(framework), patchDocUrlExtensions(framework),
patchQuickSearchService(framework), patchQuickSearchService(framework),
patchSideBarService(framework), patchSideBarService(framework),

View File

@@ -1,44 +1,26 @@
import { AIChatBlockSchema } from '@affine/core/blocksuite/ai/blocks'; import { AIChatBlockSchema } from '@affine/core/blocksuite/ai/blocks';
import { LifeCycleWatcher } from '@blocksuite/affine/block-std'; import { EdgelessClipboardConfig } from '@blocksuite/affine/blocks/surface';
import { EdgelessRootBlockComponent } from '@blocksuite/affine/blocks/root';
import type { BlockSnapshot } from '@blocksuite/affine/store'; import type { BlockSnapshot } from '@blocksuite/affine/store';
export class EdgelessClipboardWatcher extends LifeCycleWatcher { export class EdgelessClipboardAIChatConfig extends EdgelessClipboardConfig {
static override key = 'edgeless-clipboard-watcher'; static override readonly key = AIChatBlockSchema.model.flavour;
override mounted() { override createBlock(block: BlockSnapshot): null | string {
super.mounted(); if (!this.surface) return null;
const { view } = this.std; const { xywh, scale, messages, sessionId, rootDocId, rootWorkspaceId } =
view.viewUpdated.subscribe(payload => { block.props;
if (payload.type !== 'block' || payload.method !== 'add') { const blockId = this.crud.addBlock(
return; AIChatBlockSchema.model.flavour,
} {
const component = payload.view; xywh,
if (!(component instanceof EdgelessRootBlockComponent)) { scale,
return; messages,
} sessionId,
const AIChatBlockFlavour = AIChatBlockSchema.model.flavour; rootDocId,
const createFunc = (block: BlockSnapshot) => { rootWorkspaceId,
const { xywh, scale, messages, sessionId, rootDocId, rootWorkspaceId } = },
block.props; this.surface.model.id
const blockId = component.service.crud.addBlock( );
AIChatBlockFlavour, return blockId;
{
xywh,
scale,
messages,
sessionId,
rootDocId,
rootWorkspaceId,
},
component.surface.model.id
);
return blockId;
};
component.clipboardController.registerBlock(
AIChatBlockFlavour,
createFunc
);
});
} }
} }