feat(editor): add command for edgeless clipboard (#11173)

This commit is contained in:
Saul-Mirone
2025-03-25 12:09:24 +00:00
parent 1871428021
commit 27d07a6e24
6 changed files with 369 additions and 303 deletions

View File

@@ -0,0 +1,117 @@
import {
CanvasElementType,
type ClipboardConfigCreationContext,
EdgelessCRUDIdentifier,
} from '@blocksuite/affine-block-surface';
import type { Connection } from '@blocksuite/affine-model';
import { TelemetryProvider } from '@blocksuite/affine-shared/services';
import type { BlockStdScope } from '@blocksuite/block-std';
import type {
GfxPrimitiveElementModel,
SerializedElement,
} from '@blocksuite/block-std/gfx';
import { Bound, type SerializedXYWH, Vec } from '@blocksuite/global/gfx';
import * as Y from 'yjs';
const { GROUP, MINDMAP, CONNECTOR } = CanvasElementType;
export function createCanvasElement(
std: BlockStdScope,
clipboardData: SerializedElement,
context: ClipboardConfigCreationContext,
newXYWH: SerializedXYWH
) {
if (clipboardData.type === GROUP) {
const yMap = new Y.Map();
const children = clipboardData.children ?? {};
for (const [key, value] of Object.entries(children)) {
const newKey = context.oldToNewIdMap.get(key);
if (!newKey) {
console.error(
`Copy failed: cannot find the copied child in group, key: ${key}`
);
return null;
}
yMap.set(newKey, value);
}
clipboardData.children = yMap;
clipboardData.xywh = newXYWH;
} else if (clipboardData.type === MINDMAP) {
const yMap = new Y.Map();
const children = clipboardData.children ?? {};
for (const [oldKey, oldValue] of Object.entries(children)) {
const newKey = context.oldToNewIdMap.get(oldKey);
const newValue = {
...oldValue,
};
if (!newKey) {
console.error(
`Copy failed: cannot find the copied node in mind map, key: ${oldKey}`
);
return null;
}
if (oldValue.parent) {
const newParent = context.oldToNewIdMap.get(oldValue.parent);
if (!newParent) {
console.error(
`Copy failed: cannot find the copied node in mind map, parent: ${oldValue.parent}`
);
return null;
}
newValue.parent = newParent;
}
yMap.set(newKey, newValue);
}
clipboardData.children = yMap;
} else if (clipboardData.type === CONNECTOR) {
const source = clipboardData.source as Connection;
const target = clipboardData.target as Connection;
const oldBound = Bound.deserialize(clipboardData.xywh);
const newBound = Bound.deserialize(newXYWH);
const offset = Vec.sub([newBound.x, newBound.y], [oldBound.x, oldBound.y]);
if (source.id) {
source.id = context.oldToNewIdMap.get(source.id) ?? source.id;
} else if (source.position) {
source.position = Vec.add(source.position, offset);
}
if (target.id) {
target.id = context.oldToNewIdMap.get(target.id) ?? target.id;
} else if (target.position) {
target.position = Vec.add(target.position, offset);
}
} else {
clipboardData.xywh = newXYWH;
}
clipboardData.lockedBySelf = false;
const crud = std.get(EdgelessCRUDIdentifier);
const id = crud.addElement(
clipboardData.type as CanvasElementType,
clipboardData
);
if (!id) {
return null;
}
std.getOptional(TelemetryProvider)?.track('CanvasElementAdded', {
control: 'canvas:paste',
page: 'whiteboard editor',
module: 'toolbar',
segment: 'toolbar',
type: clipboardData.type as string,
});
const element = crud.getElementById(id) as GfxPrimitiveElementModel;
if (!element) {
console.error(`Copy failed: cannot find the copied element, id: ${id}`);
return null;
}
return element;
}

View File

@@ -2,16 +2,12 @@ import { addAttachments } from '@blocksuite/affine-block-attachment';
import { EdgelessFrameManagerIdentifier } from '@blocksuite/affine-block-frame';
import { addImages } from '@blocksuite/affine-block-image';
import {
CanvasElementType,
type ClipboardConfigCreationContext,
EdgelessClipboardConfigIdentifier,
EdgelessCRUDIdentifier,
ExportManager,
getSurfaceComponent,
SurfaceGroupLikeModel,
TextUtils,
} from '@blocksuite/affine-block-surface';
import type { Connection, ShapeElementModel } from '@blocksuite/affine-model';
import type { ShapeElementModel } from '@blocksuite/affine-model';
import {
BookmarkStyles,
DEFAULT_NOTE_HEIGHT,
@@ -48,12 +44,9 @@ import type {
import {
compareLayer,
type GfxBlockElementModel,
type GfxCompatibleProps,
GfxControllerIdentifier,
type GfxModel,
type GfxPrimitiveElementModel,
type SerializedElement,
SortOrder,
} from '@blocksuite/block-std/gfx';
import { DisposableGroup } from '@blocksuite/global/disposable';
import { BlockSuiteError, ErrorCode } from '@blocksuite/global/exceptions';
@@ -62,23 +55,16 @@ import {
getCommonBound,
type IBound,
type IVec,
type SerializedXYWH,
Vec,
} from '@blocksuite/global/gfx';
import { assertType } from '@blocksuite/global/utils';
import {
type BlockSnapshot,
BlockSnapshotSchema,
type SliceSnapshot,
} from '@blocksuite/store';
import { type BlockSnapshot, type SliceSnapshot } from '@blocksuite/store';
import * as Y from 'yjs';
import { PageClipboard } from '../../clipboard/index.js';
import { getSortedCloneElements } from '../utils/clone-utils.js';
import { isCanvasElementWithText } from '../utils/query.js';
import { createElementsFromClipboardDataCommand } from './command.js';
import {
createNewPresentationIndexes,
edgelessElementsBoundFromRawData,
isPureFileInClipboard,
prepareClipboardData,
tryGetSvgFromClipboard,
@@ -86,7 +72,6 @@ import {
const BLOCKSUITE_SURFACE = 'blocksuite/surface';
const { GROUP, MINDMAP, CONNECTOR } = CanvasElementType;
const IMAGE_PADDING = 5; // for rotated shapes some padding is needed
interface CanvasExportOptions {
@@ -397,107 +382,6 @@ export class EdgelessClipboardController extends PageClipboard {
}
}
private _createCanvasElement(
clipboardData: SerializedElement,
context: ClipboardConfigCreationContext,
newXYWH: SerializedXYWH
): GfxPrimitiveElementModel | null {
if (clipboardData.type === GROUP) {
const yMap = new Y.Map();
const children = clipboardData.children ?? {};
for (const [key, value] of Object.entries(children)) {
const newKey = context.oldToNewIdMap.get(key);
if (!newKey) {
console.error(
`Copy failed: cannot find the copied child in group, key: ${key}`
);
return null;
}
yMap.set(newKey, value);
}
clipboardData.children = yMap;
clipboardData.xywh = newXYWH;
} else if (clipboardData.type === MINDMAP) {
const yMap = new Y.Map();
const children = clipboardData.children ?? {};
for (const [oldKey, oldValue] of Object.entries(children)) {
const newKey = context.oldToNewIdMap.get(oldKey);
const newValue = {
...oldValue,
};
if (!newKey) {
console.error(
`Copy failed: cannot find the copied node in mind map, key: ${oldKey}`
);
return null;
}
if (oldValue.parent) {
const newParent = context.oldToNewIdMap.get(oldValue.parent);
if (!newParent) {
console.error(
`Copy failed: cannot find the copied node in mind map, parent: ${oldValue.parent}`
);
return null;
}
newValue.parent = newParent;
}
yMap.set(newKey, newValue);
}
clipboardData.children = yMap;
} else if (clipboardData.type === CONNECTOR) {
const source = clipboardData.source as Connection;
const target = clipboardData.target as Connection;
const oldBound = Bound.deserialize(clipboardData.xywh);
const newBound = Bound.deserialize(newXYWH);
const offset = Vec.sub(
[newBound.x, newBound.y],
[oldBound.x, oldBound.y]
);
if (source.id) {
source.id = context.oldToNewIdMap.get(source.id) ?? source.id;
} else if (source.position) {
source.position = Vec.add(source.position, offset);
}
if (target.id) {
target.id = context.oldToNewIdMap.get(target.id) ?? target.id;
} else if (target.position) {
target.position = Vec.add(target.position, offset);
}
} else {
clipboardData.xywh = newXYWH;
}
clipboardData.lockedBySelf = false;
const id = this.crud.addElement(
clipboardData.type as CanvasElementType,
clipboardData
);
if (!id) {
return null;
}
this.std.getOptional(TelemetryProvider)?.track('CanvasElementAdded', {
control: 'canvas:paste',
page: 'whiteboard editor',
module: 'toolbar',
segment: 'toolbar',
type: clipboardData.type as string,
});
const element = this.crud.getElementById(id) as GfxPrimitiveElementModel;
if (!element) {
console.error(`Copy failed: cannot find the copied element, id: ${id}`);
return null;
}
return element;
}
private async _edgelessToCanvas(
bound: IBound,
nodes?: GfxBlockElementModel[],
@@ -680,8 +564,14 @@ export class EdgelessClipboardController extends PageClipboard {
private async _pasteShapesAndBlocks(
elementsRawData: (SerializedElement | BlockSnapshot)[]
) {
const { canvasElements, blockModels } =
await this.createElementsFromClipboardData(elementsRawData);
const [_, { createdElementsPromise }] = this.std.command.exec(
createElementsFromClipboardDataCommand,
{
elementsRawData,
}
);
if (!createdElementsPromise) return;
const { canvasElements, blockModels } = await createdElementsPromise;
this._emitSelectionChangeAfterPaste(
canvasElements.map(ele => ele.id),
blockModels.map(block => block.id)
@@ -761,51 +651,6 @@ export class EdgelessClipboardController extends PageClipboard {
});
}
private _updatePastedElementsIndex(
elements: GfxModel[],
originalIndexes: Map<string, string>
) {
function compare(a: GfxModel, b: GfxModel) {
if (a instanceof SurfaceGroupLikeModel && a.hasDescendant(b)) {
return SortOrder.BEFORE;
} else if (b instanceof SurfaceGroupLikeModel && b.hasDescendant(a)) {
return SortOrder.AFTER;
} else {
const aGroups = a.groups as SurfaceGroupLikeModel[];
const bGroups = b.groups as SurfaceGroupLikeModel[];
let i = 1;
let aGroup: GfxModel | undefined = aGroups.at(-i);
let bGroup: GfxModel | undefined = bGroups.at(-i);
while (aGroup === bGroup && aGroup) {
++i;
aGroup = aGroups.at(-i);
bGroup = bGroups.at(-i);
}
aGroup = aGroup ?? a;
bGroup = bGroup ?? b;
return originalIndexes.get(aGroup.id) === originalIndexes.get(bGroup.id)
? SortOrder.SAME
: originalIndexes.get(aGroup.id)! < originalIndexes.get(bGroup.id)!
? SortOrder.BEFORE
: SortOrder.AFTER;
}
}
const idxGenerator = this.gfx.layer.createIndexGenerator();
const sortedElements = elements.sort(compare);
sortedElements.forEach(ele => {
const newIndex = idxGenerator();
this.crud.updateElement(ele.id, {
index: newIndex,
});
});
}
copy() {
document.dispatchEvent(
new Event('copy', {
@@ -815,126 +660,6 @@ export class EdgelessClipboardController extends PageClipboard {
);
}
async createElementsFromClipboardData(
elementsRawData: (SerializedElement | BlockSnapshot)[],
pasteCenter?: IVec
) {
let oldCommonBound, pasteX, pasteY;
{
const lastMousePos = this.toolManager.lastMousePos$.peek();
pasteCenter =
pasteCenter ??
this.gfx.viewport.toModelCoord(lastMousePos.x, lastMousePos.y);
const [modelX, modelY] = pasteCenter;
oldCommonBound = edgelessElementsBoundFromRawData(elementsRawData);
pasteX = modelX - oldCommonBound.w / 2;
pasteY = modelY - oldCommonBound.h / 2;
}
const getNewXYWH = (oldXYWH: SerializedXYWH) => {
const oldBound = Bound.deserialize(oldXYWH);
return new Bound(
oldBound.x + pasteX - oldCommonBound.x,
oldBound.y + pasteY - oldCommonBound.y,
oldBound.w,
oldBound.h
).serialize();
};
// create blocks and canvas elements
const context: ClipboardConfigCreationContext = {
oldToNewIdMap: new Map<string, string>(),
originalIndexes: new Map<string, string>(),
newPresentationIndexes: createNewPresentationIndexes(
elementsRawData,
this.std
),
};
const blockModels: GfxBlockElementModel[] = [];
const canvasElements: GfxPrimitiveElementModel[] = [];
const allElements: GfxModel[] = [];
for (const data of elementsRawData) {
const { data: blockSnapshot } = BlockSnapshotSchema.safeParse(data);
if (blockSnapshot) {
const oldId = blockSnapshot.id;
const config = this.std.getOptional(
EdgelessClipboardConfigIdentifier(blockSnapshot.flavour)
);
if (!config) continue;
if (typeof blockSnapshot.props.index !== 'string') {
console.error(`Block(id: ${oldId}) does not have index property`);
continue;
}
const originalIndex = (blockSnapshot.props as GfxCompatibleProps).index;
if (typeof blockSnapshot.props.xywh !== 'string') {
console.error(`Block(id: ${oldId}) does not have xywh property`);
continue;
}
assertType<GfxCompatibleProps & unknown>(blockSnapshot.props);
blockSnapshot.props.xywh = getNewXYWH(
blockSnapshot.props.xywh as SerializedXYWH
);
blockSnapshot.props.lockedBySelf = false;
const newId = await config.createBlock(blockSnapshot, context);
if (!newId) continue;
const block = this.doc.getBlock(newId);
if (!block) continue;
assertType<GfxBlockElementModel>(block.model);
blockModels.push(block.model);
allElements.push(block.model);
context.oldToNewIdMap.set(oldId, newId);
context.originalIndexes.set(oldId, originalIndex);
} else {
assertType<SerializedElement>(data);
const oldId = data.id;
const element = this._createCanvasElement(
data,
context,
getNewXYWH(data.xywh)
);
if (!element) continue;
canvasElements.push(element);
allElements.push(element);
context.oldToNewIdMap.set(oldId, element.id);
context.originalIndexes.set(oldId, element.index);
}
}
// remap old id to new id for the original index
const oldIds = [...context.originalIndexes.keys()];
oldIds.forEach(oldId => {
const newId = context.oldToNewIdMap.get(oldId);
const originalIndex = context.originalIndexes.get(oldId);
if (newId && originalIndex) {
context.originalIndexes.set(newId, originalIndex);
context.originalIndexes.delete(oldId);
}
});
this._updatePastedElementsIndex(allElements, context.originalIndexes);
return {
canvasElements: canvasElements,
blockModels: blockModels,
};
}
override mounted() {
if (!navigator.clipboard) {
console.error(

View File

@@ -0,0 +1,220 @@
import {
type ClipboardConfigCreationContext,
EdgelessClipboardConfigIdentifier,
EdgelessCRUDIdentifier,
SurfaceGroupLikeModel,
} from '@blocksuite/affine-block-surface';
import type { BlockStdScope, Command } from '@blocksuite/block-std';
import {
type GfxBlockElementModel,
type GfxCompatibleProps,
GfxControllerIdentifier,
type GfxModel,
type GfxPrimitiveElementModel,
type SerializedElement,
SortOrder,
} from '@blocksuite/block-std/gfx';
import { Bound, type IVec, type SerializedXYWH } from '@blocksuite/global/gfx';
import { assertType } from '@blocksuite/global/utils';
import { type BlockSnapshot, BlockSnapshotSchema } from '@blocksuite/store';
import { createCanvasElement } from './canvas';
import {
createNewPresentationIndexes,
edgelessElementsBoundFromRawData,
} from './utils';
interface Input {
elementsRawData: (SerializedElement | BlockSnapshot)[];
pasteCenter?: IVec;
}
type CreatedElements = {
canvasElements: GfxPrimitiveElementModel[];
blockModels: GfxBlockElementModel[];
};
interface Output {
createdElementsPromise: Promise<CreatedElements>;
}
export const createElementsFromClipboardDataCommand: Command<Input, Output> = (
ctx,
next
) => {
const { std, elementsRawData } = ctx;
let { pasteCenter } = ctx;
const gfx = std.get(GfxControllerIdentifier);
const toolManager = gfx.tool;
const runner = async (): Promise<CreatedElements> => {
let oldCommonBound, pasteX, pasteY;
{
const lastMousePos = toolManager.lastMousePos$.peek();
pasteCenter =
pasteCenter ??
gfx.viewport.toModelCoord(lastMousePos.x, lastMousePos.y);
const [modelX, modelY] = pasteCenter;
oldCommonBound = edgelessElementsBoundFromRawData(elementsRawData);
pasteX = modelX - oldCommonBound.w / 2;
pasteY = modelY - oldCommonBound.h / 2;
}
const getNewXYWH = (oldXYWH: SerializedXYWH) => {
const oldBound = Bound.deserialize(oldXYWH);
return new Bound(
oldBound.x + pasteX - oldCommonBound.x,
oldBound.y + pasteY - oldCommonBound.y,
oldBound.w,
oldBound.h
).serialize();
};
// create blocks and canvas elements
const context: ClipboardConfigCreationContext = {
oldToNewIdMap: new Map<string, string>(),
originalIndexes: new Map<string, string>(),
newPresentationIndexes: createNewPresentationIndexes(
elementsRawData,
std
),
};
const blockModels: GfxBlockElementModel[] = [];
const canvasElements: GfxPrimitiveElementModel[] = [];
const allElements: GfxModel[] = [];
for (const data of elementsRawData) {
const { data: blockSnapshot } = BlockSnapshotSchema.safeParse(data);
if (blockSnapshot) {
const oldId = blockSnapshot.id;
const config = std.getOptional(
EdgelessClipboardConfigIdentifier(blockSnapshot.flavour)
);
if (!config) continue;
if (typeof blockSnapshot.props.index !== 'string') {
console.error(`Block(id: ${oldId}) does not have index property`);
continue;
}
const originalIndex = (blockSnapshot.props as GfxCompatibleProps).index;
if (typeof blockSnapshot.props.xywh !== 'string') {
console.error(`Block(id: ${oldId}) does not have xywh property`);
continue;
}
assertType<GfxCompatibleProps>(blockSnapshot.props);
blockSnapshot.props.xywh = getNewXYWH(
blockSnapshot.props.xywh as SerializedXYWH
);
blockSnapshot.props.lockedBySelf = false;
const newId = await config.createBlock(blockSnapshot, context);
if (!newId) continue;
const block = std.store.getBlock(newId);
if (!block) continue;
assertType<GfxBlockElementModel>(block.model);
blockModels.push(block.model);
allElements.push(block.model);
context.oldToNewIdMap.set(oldId, newId);
context.originalIndexes.set(oldId, originalIndex);
} else {
assertType<SerializedElement>(data);
const oldId = data.id;
const element = createCanvasElement(
std,
data,
context,
getNewXYWH(data.xywh)
);
if (!element) continue;
canvasElements.push(element);
allElements.push(element);
context.oldToNewIdMap.set(oldId, element.id);
context.originalIndexes.set(oldId, element.index);
}
}
// remap old id to new id for the original index
const oldIds = [...context.originalIndexes.keys()];
oldIds.forEach(oldId => {
const newId = context.oldToNewIdMap.get(oldId);
const originalIndex = context.originalIndexes.get(oldId);
if (newId && originalIndex) {
context.originalIndexes.set(newId, originalIndex);
context.originalIndexes.delete(oldId);
}
});
updatePastedElementsIndex(std, allElements, context.originalIndexes);
return {
canvasElements: canvasElements,
blockModels: blockModels,
};
};
return next({
createdElementsPromise: runner(),
});
};
function updatePastedElementsIndex(
std: BlockStdScope,
elements: GfxModel[],
originalIndexes: Map<string, string>
) {
const gfx = std.get(GfxControllerIdentifier);
const crud = std.get(EdgelessCRUDIdentifier);
function compare(a: GfxModel, b: GfxModel) {
if (a instanceof SurfaceGroupLikeModel && a.hasDescendant(b)) {
return SortOrder.BEFORE;
} else if (b instanceof SurfaceGroupLikeModel && b.hasDescendant(a)) {
return SortOrder.AFTER;
} else {
const aGroups = a.groups as SurfaceGroupLikeModel[];
const bGroups = b.groups as SurfaceGroupLikeModel[];
let i = 1;
let aGroup: GfxModel | undefined = aGroups.at(-i);
let bGroup: GfxModel | undefined = bGroups.at(-i);
while (aGroup === bGroup && aGroup) {
++i;
aGroup = aGroups.at(-i);
bGroup = bGroups.at(-i);
}
aGroup = aGroup ?? a;
bGroup = bGroup ?? b;
return originalIndexes.get(aGroup.id) === originalIndexes.get(bGroup.id)
? SortOrder.SAME
: originalIndexes.get(aGroup.id)! < originalIndexes.get(bGroup.id)!
? SortOrder.BEFORE
: SortOrder.AFTER;
}
}
const idxGenerator = gfx.layer.createIndexGenerator();
const sortedElements = elements.sort(compare);
sortedElements.forEach(ele => {
const newIndex = idxGenerator();
crud.updateElement(ele.id, {
index: newIndex,
});
});
}

View File

@@ -49,7 +49,7 @@ import { effect } from '@preact/signals-core';
import clamp from 'lodash-es/clamp';
import last from 'lodash-es/last';
import { EdgelessClipboardController } from '../clipboard/clipboard.js';
import { createElementsFromClipboardDataCommand } from '../clipboard/command.js';
import { prepareCloneData } from '../utils/clone-utils.js';
import { calPanDelta } from '../utils/panning-utils.js';
import { isCanvasElement, isEdgelessTextBlock } from '../utils/query.js';
@@ -238,18 +238,18 @@ export class DefaultTool extends BaseTool {
private async _cloneContent() {
if (!this._edgeless) return;
const clipboardController = this.std.getOptional(
EdgelessClipboardController
);
if (!clipboardController) return;
const snapshot = prepareCloneData(this._toBeMoved, this.std);
const bound = getCommonBoundWithRotation(this._toBeMoved);
const { canvasElements, blockModels } =
await clipboardController.createElementsFromClipboardData(
snapshot,
bound.center
);
const [_, { createdElementsPromise }] = this.std.command.exec(
createElementsFromClipboardDataCommand,
{
elementsRawData: snapshot,
pasteCenter: bound.center,
}
);
if (!createdElementsPromise) return;
const { canvasElements, blockModels } = await createdElementsPromise;
this._toBeMoved = [...canvasElements, ...blockModels];
this.edgelessSelectionManager.set({

View File

@@ -1,4 +1,5 @@
export * from './clipboard/clipboard';
export * from './clipboard/command';
export { EdgelessTemplatePanel } from './components/toolbar/template/template-panel.js';
export * from './components/toolbar/template/template-type.js';
export * from './edgeless-root-block.js';

View File

@@ -20,7 +20,7 @@ import {
import { getCommonBoundWithRotation } from '@blocksuite/global/gfx';
import groupBy from 'lodash-es/groupBy';
import { EdgelessClipboardController } from '../clipboard/clipboard.js';
import { createElementsFromClipboardDataCommand } from '../clipboard/command.js';
import { getSortedCloneElements, prepareCloneData } from './clone-utils.js';
import {
isEdgelessTextBlock,
@@ -34,7 +34,6 @@ export async function duplicate(
elements: GfxModel[],
select = true
) {
const edgelessClipboard = edgeless.std.get(EdgelessClipboardController);
const gfx = edgeless.std.get(GfxControllerIdentifier);
const surface = getSurfaceComponent(edgeless.std);
@@ -45,11 +44,15 @@ export async function duplicate(
totalBound.x += totalBound.w + offset;
const snapshot = prepareCloneData(copyElements, edgeless.std);
const { canvasElements, blockModels } =
await edgelessClipboard.createElementsFromClipboardData(
snapshot,
totalBound.center
);
const [_, { createdElementsPromise }] = edgeless.std.command.exec(
createElementsFromClipboardDataCommand,
{
elementsRawData: snapshot,
pasteCenter: totalBound.center,
}
);
if (!createdElementsPromise) return;
const { canvasElements, blockModels } = await createdElementsPromise;
const newElements = [...canvasElements, ...blockModels];