mirror of
https://github.com/toeverything/AFFiNE.git
synced 2026-08-23 21:06:22 +08:00
1651 lines
46 KiB
TypeScript
1651 lines
46 KiB
TypeScript
import { ParagraphBlockComponent } from '@blocksuite/affine-block-paragraph';
|
|
import { DropIndicator } from '@blocksuite/affine-components/drop-indicator';
|
|
import {
|
|
AttachmentBlockModel,
|
|
BookmarkBlockModel,
|
|
DatabaseBlockModel,
|
|
DEFAULT_NOTE_HEIGHT,
|
|
DEFAULT_NOTE_WIDTH,
|
|
type EmbedCardStyle,
|
|
ListBlockModel,
|
|
NoteBlockModel,
|
|
RootBlockModel,
|
|
} from '@blocksuite/affine-model';
|
|
import { surfaceRefToEmbed } from '@blocksuite/affine-shared/adapters';
|
|
import {
|
|
BLOCK_CHILDREN_CONTAINER_PADDING_LEFT,
|
|
EMBED_CARD_HEIGHT,
|
|
EMBED_CARD_WIDTH,
|
|
} from '@blocksuite/affine-shared/consts';
|
|
import {
|
|
DndApiExtensionIdentifier,
|
|
DocModeProvider,
|
|
TelemetryProvider,
|
|
} from '@blocksuite/affine-shared/services';
|
|
import {
|
|
captureEventTarget,
|
|
type DropTarget as DropResult,
|
|
getBlockComponentsExcludeSubtrees,
|
|
getRectByBlockComponent,
|
|
getScrollContainer,
|
|
matchModels,
|
|
} from '@blocksuite/affine-shared/utils';
|
|
import {
|
|
BlockComponent,
|
|
type BlockStdScope,
|
|
type DragFromBlockSuite,
|
|
type DragPayload,
|
|
type DropPayload,
|
|
} from '@blocksuite/block-std';
|
|
import {
|
|
GfxBlockElementModel,
|
|
GfxControllerIdentifier,
|
|
GfxGroupLikeElementModel,
|
|
type GfxModel,
|
|
GfxPrimitiveElementModel,
|
|
isGfxGroupCompatibleModel,
|
|
SURFACE_YMAP_UNIQ_IDENTIFIER,
|
|
SurfaceBlockModel,
|
|
} from '@blocksuite/block-std/gfx';
|
|
import {
|
|
assertType,
|
|
Bound,
|
|
groupBy,
|
|
type IVec,
|
|
last,
|
|
Point,
|
|
Rect,
|
|
type SerializedXYWH,
|
|
} from '@blocksuite/global/utils';
|
|
import {
|
|
type BlockModel,
|
|
type BlockSnapshot,
|
|
internalPrimitives,
|
|
Slice,
|
|
type SliceSnapshot,
|
|
toDraftModel,
|
|
} from '@blocksuite/store';
|
|
|
|
import type { AffineDragHandleWidget } from '../drag-handle.js';
|
|
import { PreviewHelper } from '../helpers/preview-helper.js';
|
|
import { gfxBlocksFilter } from '../middleware/blocks-filter.js';
|
|
import { newIdCrossDoc } from '../middleware/new-id-cross-doc.js';
|
|
import { reorderList } from '../middleware/reorder-list';
|
|
import {
|
|
containBlock,
|
|
extractIdsFromSnapshot,
|
|
getParentNoteBlock,
|
|
getSnapshotRect,
|
|
includeTextSelection,
|
|
isOutOfNoteBlock,
|
|
} from '../utils.js';
|
|
|
|
export type DragBlockEntity = {
|
|
type: 'blocks';
|
|
/**
|
|
* The mode that the blocks are dragged from
|
|
*/
|
|
fromMode?: 'block' | 'gfx';
|
|
snapshot?: SliceSnapshot;
|
|
modelIds: string[];
|
|
};
|
|
|
|
export type DragBlockPayload = DragPayload<DragBlockEntity, DragFromBlockSuite>;
|
|
|
|
declare module '@blocksuite/block-std' {
|
|
interface DNDEntity {
|
|
blocks: DragBlockPayload;
|
|
}
|
|
}
|
|
export class DragEventWatcher {
|
|
dropIndicator: null | DropIndicator = null;
|
|
|
|
previewHelper = new PreviewHelper(this.widget);
|
|
|
|
dropTargetCleanUps: Map<string, (() => void)[]> = new Map();
|
|
|
|
resetOpacityCallbacks: (() => void)[] = [];
|
|
|
|
get host() {
|
|
return this.widget.host;
|
|
}
|
|
|
|
get mode() {
|
|
return this.widget.mode;
|
|
}
|
|
|
|
get std() {
|
|
return this.widget.std;
|
|
}
|
|
|
|
get gfx() {
|
|
return this.widget.std.get(GfxControllerIdentifier);
|
|
}
|
|
|
|
private readonly _createDropIndicator = () => {
|
|
let topElement: HTMLElement = this.widget;
|
|
const body = this.widget.ownerDocument.body;
|
|
|
|
while (topElement && topElement.parentElement !== body) {
|
|
topElement = topElement.parentElement!;
|
|
}
|
|
const zIndex = topElement
|
|
? (parseInt(window.getComputedStyle(topElement).zIndex) || 1) + 1
|
|
: 2;
|
|
|
|
if (!this.dropIndicator) {
|
|
this.dropIndicator = new DropIndicator();
|
|
this.dropIndicator.zIndex = zIndex;
|
|
this.widget.ownerDocument.body.append(this.dropIndicator);
|
|
}
|
|
};
|
|
|
|
private readonly _clearDropIndicator = () => {
|
|
if (this.dropIndicator) {
|
|
this.dropIndicator.remove();
|
|
this.dropIndicator = null;
|
|
}
|
|
};
|
|
|
|
private readonly _cleanup = () => {
|
|
this._clearDropIndicator();
|
|
this.widget.hide(true);
|
|
this.std.selection.setGroup('gfx', []);
|
|
this.resetOpacityCallbacks.forEach(callback => callback());
|
|
};
|
|
|
|
private readonly _onDragMove = (
|
|
point: Point,
|
|
payload: DragBlockPayload,
|
|
dropPayload: DropPayload,
|
|
block: BlockComponent
|
|
) => {
|
|
this._createDropIndicator();
|
|
this._updateDropIndicator(point, payload, dropPayload, block);
|
|
};
|
|
|
|
private readonly _getFallbackInsertPlace = (block: BlockModel) => {
|
|
const store = this.std.store;
|
|
let curBlock: BlockModel | null = block;
|
|
|
|
while (curBlock) {
|
|
const parent = store.getParent(curBlock);
|
|
|
|
if (parent && matchModels(parent, [NoteBlockModel])) {
|
|
return curBlock;
|
|
}
|
|
|
|
curBlock = parent;
|
|
}
|
|
|
|
return null;
|
|
};
|
|
|
|
/**
|
|
* When dragging, should update indicator position and target drop block id
|
|
*/
|
|
private readonly _getDropResult = (
|
|
dropBlock: BlockComponent,
|
|
dragPayload: DragBlockPayload,
|
|
dropPayload: DropPayload
|
|
): DropResult | null => {
|
|
const model = dropBlock.model;
|
|
|
|
const snapshot = dragPayload?.bsEntity?.snapshot;
|
|
if (
|
|
!snapshot ||
|
|
snapshot.content.length === 0 ||
|
|
!dragPayload?.from ||
|
|
matchModels(model, [DatabaseBlockModel])
|
|
)
|
|
return null;
|
|
|
|
const isDropOnNoteBlock = matchModels(model, [NoteBlockModel]);
|
|
|
|
const schema = this.std.store.schema;
|
|
const edge = dropPayload.edge;
|
|
const scale = this.widget.scale.peek();
|
|
let result: DropResult | null = null;
|
|
|
|
if (edge === 'right' && matchModels(dropBlock.model, [ListBlockModel])) {
|
|
const domRect = getRectByBlockComponent(dropBlock);
|
|
const placement = 'in';
|
|
|
|
if (
|
|
snapshot.content.every(block =>
|
|
schema.safeValidate(block.flavour, 'affine:list')
|
|
)
|
|
) {
|
|
const rect = Rect.fromLWTH(
|
|
domRect.left + BLOCK_CHILDREN_CONTAINER_PADDING_LEFT,
|
|
domRect.width - BLOCK_CHILDREN_CONTAINER_PADDING_LEFT,
|
|
domRect.top + domRect.height,
|
|
3 * scale
|
|
);
|
|
|
|
result = {
|
|
placement,
|
|
rect,
|
|
modelState: {
|
|
model: dropBlock.model,
|
|
rect: domRect,
|
|
element: dropBlock,
|
|
},
|
|
};
|
|
} else {
|
|
const fallbackModel = this._getFallbackInsertPlace(dropBlock.model);
|
|
|
|
if (fallbackModel) {
|
|
const fallbackModelView = this.std.view.getBlock(fallbackModel.id)!;
|
|
const domRect = fallbackModelView?.getBoundingClientRect();
|
|
|
|
result = {
|
|
placement: 'after',
|
|
rect: Rect.fromLWTH(
|
|
domRect.left,
|
|
domRect.width,
|
|
domRect.top + domRect.height,
|
|
3 * scale
|
|
),
|
|
modelState: {
|
|
model: fallbackModel,
|
|
rect: domRect,
|
|
element: fallbackModelView,
|
|
},
|
|
};
|
|
}
|
|
}
|
|
} else {
|
|
const placement =
|
|
isDropOnNoteBlock &&
|
|
schema.safeValidate(snapshot.content[0].flavour, 'affine:note')
|
|
? 'in'
|
|
: edge === 'top'
|
|
? 'before'
|
|
: 'after';
|
|
const domRect = getRectByBlockComponent(dropBlock);
|
|
const y =
|
|
placement === 'after'
|
|
? domRect.top + domRect.height
|
|
: domRect.top - 3 * scale;
|
|
|
|
result = {
|
|
placement,
|
|
rect: Rect.fromLWTH(domRect.left, domRect.width, y, 3 * scale),
|
|
modelState: {
|
|
model,
|
|
rect: domRect,
|
|
element: dropBlock,
|
|
},
|
|
};
|
|
}
|
|
|
|
return result;
|
|
};
|
|
|
|
private readonly _updateDropIndicator = (
|
|
point: Point,
|
|
dragPayload: DragBlockPayload,
|
|
dropPayload: DropPayload,
|
|
dropBlock: BlockComponent
|
|
) => {
|
|
const closestNoteBlock = dropBlock && getParentNoteBlock(dropBlock);
|
|
|
|
if (
|
|
!closestNoteBlock ||
|
|
isOutOfNoteBlock(
|
|
this.host,
|
|
closestNoteBlock,
|
|
point,
|
|
this.widget.scale.peek()
|
|
)
|
|
) {
|
|
this._resetDropResult();
|
|
} else {
|
|
const dropResult = this._getDropResult(
|
|
dropBlock,
|
|
dragPayload,
|
|
dropPayload
|
|
);
|
|
this._updateDropResult(dropResult);
|
|
}
|
|
};
|
|
|
|
private readonly _resetDropResult = () => {
|
|
if (this.dropIndicator) this.dropIndicator.rect = null;
|
|
};
|
|
|
|
private readonly _updateDropResult = (dropResult: DropResult | null) => {
|
|
if (!this.dropIndicator) return;
|
|
|
|
if (dropResult?.rect) {
|
|
const { left, top, width, height } = dropResult.rect;
|
|
const rect = Rect.fromLWTH(left, width, top, height);
|
|
|
|
this.dropIndicator.rect = rect;
|
|
} else {
|
|
this.dropIndicator.rect = dropResult?.rect ?? null;
|
|
}
|
|
};
|
|
|
|
private readonly _getSnapshotFromSelectedGfxElms = () => {
|
|
const selectedElmId = this.widget.anchorBlockId.peek()!;
|
|
const selectedElm = this.gfx.getElementById(selectedElmId);
|
|
|
|
if (!selectedElm) {
|
|
return {
|
|
snapshot: undefined,
|
|
};
|
|
}
|
|
|
|
const getElementsInContainer = (
|
|
elem: GfxModel,
|
|
selectedElements: string[] = []
|
|
) => {
|
|
selectedElements.push(elem.id);
|
|
|
|
if (isGfxGroupCompatibleModel(elem)) {
|
|
elem.childElements.forEach(child => {
|
|
getElementsInContainer(child, selectedElements);
|
|
});
|
|
}
|
|
|
|
return selectedElements;
|
|
};
|
|
const toSnapshotRequiredBlocks = (selectedModels: string[]) => {
|
|
let surfaceAdded = false;
|
|
const blocks: BlockModel[] = [];
|
|
const blocksUnderSurface: BlockModel[] = [];
|
|
|
|
selectedModels.forEach(id => {
|
|
const model = this.gfx.getElementById(id);
|
|
|
|
if (!model) {
|
|
return;
|
|
}
|
|
|
|
if (model instanceof GfxPrimitiveElementModel) {
|
|
if (surfaceAdded) return;
|
|
surfaceAdded = true;
|
|
blocks.push(this.gfx.surface!);
|
|
} else {
|
|
const parentModel = this.std.store.getParent(model);
|
|
|
|
if (matchModels(parentModel, [SurfaceBlockModel])) {
|
|
blocksUnderSurface.push(model);
|
|
} else {
|
|
blocks.push(model);
|
|
}
|
|
}
|
|
});
|
|
|
|
if (surfaceAdded) {
|
|
// surface children are included, so no need to add the blocksUnderSurface
|
|
return blocks;
|
|
} else {
|
|
return blocks.concat(blocksUnderSurface);
|
|
}
|
|
};
|
|
|
|
const selectedElements = getElementsInContainer(
|
|
selectedElm as GfxModel,
|
|
[]
|
|
);
|
|
const blocksOfSnapshot = toSnapshotRequiredBlocks(selectedElements);
|
|
|
|
return {
|
|
snapshot: this._toSnapshot(blocksOfSnapshot, [selectedElmId]),
|
|
};
|
|
};
|
|
|
|
private readonly _getDraggedSnapshot = () => {
|
|
const { snapshot } =
|
|
this.widget.activeDragHandle === 'block'
|
|
? this._getSnapshotFromHoveredBlocks()
|
|
: this._getSnapshotFromSelectedGfxElms();
|
|
|
|
return {
|
|
fromMode: this.widget.activeDragHandle!,
|
|
snapshot,
|
|
};
|
|
};
|
|
|
|
private readonly _getSnapshotFromHoveredBlocks = () => {
|
|
const hoverBlock = this.widget.anchorBlockComponent.peek()!;
|
|
|
|
let selections = this.widget.selectionHelper.selectedBlocks;
|
|
|
|
// When current selection is TextSelection
|
|
// Should set BlockSelection for the blocks in native range
|
|
if (selections.length > 0 && includeTextSelection(selections)) {
|
|
const nativeSelection = document.getSelection();
|
|
const rangeManager = this.std.range;
|
|
|
|
if (nativeSelection && nativeSelection.rangeCount > 0 && rangeManager) {
|
|
const range = nativeSelection.getRangeAt(0);
|
|
const blocks = rangeManager.getSelectedBlockComponentsByRange(range, {
|
|
match: el => el.model.role === 'content',
|
|
mode: 'highest',
|
|
});
|
|
this.widget.selectionHelper.setSelectedBlocks(blocks);
|
|
selections = this.widget.selectionHelper.selectedBlocks;
|
|
}
|
|
}
|
|
|
|
// When there is no selected blocks
|
|
// Or selected blocks not including current hover block
|
|
// Set current hover block as selected
|
|
if (
|
|
selections.length === 0 ||
|
|
!containBlock(
|
|
selections.map(selection => selection.blockId),
|
|
this.widget.anchorBlockId.peek()!
|
|
)
|
|
) {
|
|
this.widget.selectionHelper.setSelectedBlocks([hoverBlock]);
|
|
}
|
|
|
|
const collapsedBlock: BlockComponent[] = [];
|
|
const blocks = this.widget.selectionHelper.selectedBlockComponents.flatMap(
|
|
block => {
|
|
// filter out collapsed siblings
|
|
if (collapsedBlock.includes(block)) return [];
|
|
|
|
// if block is toggled heading, should select all siblings
|
|
if (
|
|
block instanceof ParagraphBlockComponent &&
|
|
block.model.type.startsWith('h') &&
|
|
block.model.collapsed
|
|
) {
|
|
const collapsedSiblings = block.collapsedSiblings.flatMap(
|
|
sibling => this.widget.host.view.getBlock(sibling.id) ?? []
|
|
);
|
|
collapsedBlock.push(...collapsedSiblings);
|
|
return [block, ...collapsedSiblings];
|
|
}
|
|
return [block];
|
|
}
|
|
);
|
|
|
|
// This could be skipped if we can ensure that all selected blocks are on the same level
|
|
// Which means not selecting parent block and child block at the same time
|
|
const blocksExcludingChildren = getBlockComponentsExcludeSubtrees(
|
|
blocks
|
|
) as BlockComponent[];
|
|
|
|
return {
|
|
snapshot: this._toSnapshot(blocksExcludingChildren),
|
|
};
|
|
};
|
|
|
|
private readonly _onEdgelessDrop = (
|
|
dropBlock: BlockComponent,
|
|
dragPayload: DragBlockPayload,
|
|
dropPayload: DropPayload,
|
|
point: Point
|
|
) => {
|
|
/**
|
|
* When drag gfx elements from edgeless editor to other editor, there's some limitation:
|
|
* - when drop on the place other than note block, edgeless content can't be drag and drop within the same doc
|
|
* - when drop on note block, handle the drop data in the same way as it in page editor,
|
|
* and it will filter out the block that can't be placed under note block
|
|
* - drop data will be wrapped in a note block if it can't be placed under surface block or root block
|
|
*/
|
|
if (matchModels(dropBlock.model, [RootBlockModel])) {
|
|
// can't drop edgeless content on the same doc
|
|
if (
|
|
dragPayload.bsEntity?.fromMode === 'gfx' &&
|
|
dragPayload.from?.docId === this.widget.doc.id
|
|
) {
|
|
return;
|
|
}
|
|
|
|
const surfaceBlockModel = this.gfx.surface;
|
|
const snapshot = dragPayload?.bsEntity?.snapshot;
|
|
|
|
if (!snapshot || !surfaceBlockModel) {
|
|
return;
|
|
}
|
|
|
|
if (dragPayload.bsEntity?.fromMode === 'gfx') {
|
|
this._mergeSnapshotToCurDoc(snapshot, point).catch(console.error);
|
|
} else {
|
|
this._dropAsGfxBlock(snapshot, point);
|
|
}
|
|
} else {
|
|
this._onPageDrop(dropBlock, dragPayload, dropPayload, point);
|
|
}
|
|
};
|
|
|
|
private readonly _onPageDrop = (
|
|
dropBlock: BlockComponent,
|
|
dragPayload: DragBlockPayload,
|
|
dropPayload: DropPayload,
|
|
_: Point
|
|
) => {
|
|
const result = this._getDropResult(dropBlock, dragPayload, dropPayload);
|
|
const snapshot = dragPayload?.bsEntity?.snapshot;
|
|
|
|
if (!result || !snapshot || snapshot.content.length === 0) return;
|
|
|
|
const store = this.std.store;
|
|
const schema = store.schema;
|
|
const model = result.modelState.model;
|
|
const parent =
|
|
result.placement === 'in' ? model : this.std.store.getParent(model)!;
|
|
const index =
|
|
result.placement === 'in'
|
|
? 0
|
|
: parent.children.indexOf(model) +
|
|
(result.placement === 'before' ? 0 : 1);
|
|
|
|
if (!parent) return;
|
|
|
|
if (dragPayload.bsEntity?.fromMode === 'gfx') {
|
|
if (!matchModels(parent, [NoteBlockModel])) {
|
|
return;
|
|
}
|
|
|
|
// if not all blocks can be dropped in note block, merge the snapshot to the current doc
|
|
if (
|
|
!snapshot.content.every(block =>
|
|
schema.safeValidate(block.flavour, 'affine:note')
|
|
) &&
|
|
// if all blocks are note blocks, merge it to the current parent note
|
|
!snapshot.content.every(block => block.flavour === 'affine:note')
|
|
) {
|
|
// merge the snapshot to the current doc if the snapshot comes from other doc
|
|
if (dragPayload.from?.docId !== this.widget.doc.id) {
|
|
this._mergeSnapshotToCurDoc(snapshot)
|
|
.then(idRemap => {
|
|
let largestElem!: {
|
|
size: number;
|
|
id: string;
|
|
};
|
|
|
|
idRemap.forEach(val => {
|
|
const gfxElement = this.gfx.getElementById(val) as GfxModel;
|
|
|
|
if (gfxElement?.elementBound) {
|
|
const elemBound = gfxElement.elementBound;
|
|
largestElem =
|
|
(largestElem?.size ?? 0) < elemBound.w * elemBound.h
|
|
? { size: elemBound.w * elemBound.h, id: val }
|
|
: largestElem;
|
|
}
|
|
});
|
|
|
|
if (!largestElem) {
|
|
store.addBlock(
|
|
'affine:embed-linked-doc',
|
|
{
|
|
pageId: store.doc.id,
|
|
},
|
|
parent.id,
|
|
index
|
|
);
|
|
} else {
|
|
store.addBlock(
|
|
'affine:surface-ref',
|
|
{
|
|
reference: largestElem.id,
|
|
},
|
|
parent.id,
|
|
index
|
|
);
|
|
}
|
|
})
|
|
.catch(console.error);
|
|
}
|
|
// otherwise, just to create a surface-ref block
|
|
else {
|
|
let largestElem!: {
|
|
size: number;
|
|
id: string;
|
|
};
|
|
|
|
const walk = (block: BlockSnapshot) => {
|
|
if (block.flavour === 'affine:surface') {
|
|
Object.values(
|
|
block.props.elements as Record<
|
|
string,
|
|
{ id: string; xywh: SerializedXYWH }
|
|
>
|
|
).forEach(elem => {
|
|
if (elem.xywh) {
|
|
const bound = Bound.deserialize(elem.xywh);
|
|
const size = bound.w * bound.h;
|
|
if ((largestElem?.size ?? 0) < size) {
|
|
largestElem = { size, id: elem.id };
|
|
}
|
|
}
|
|
});
|
|
block.children.forEach(walk);
|
|
} else {
|
|
if (block.props.xywh) {
|
|
const bound = Bound.deserialize(
|
|
block.props.xywh as SerializedXYWH
|
|
);
|
|
const size = bound.w * bound.h;
|
|
if ((largestElem?.size ?? 0) < size) {
|
|
largestElem = { size, id: block.id };
|
|
}
|
|
}
|
|
}
|
|
};
|
|
|
|
snapshot.content.forEach(walk);
|
|
|
|
if (largestElem) {
|
|
store.addBlock(
|
|
'affine:surface-ref',
|
|
{
|
|
reference: largestElem.id,
|
|
},
|
|
parent.id,
|
|
index
|
|
);
|
|
} else {
|
|
store.addBlock(
|
|
'affine:embed-linked-doc',
|
|
{
|
|
pageId: store.doc.id,
|
|
},
|
|
parent.id,
|
|
index
|
|
);
|
|
}
|
|
}
|
|
|
|
return;
|
|
}
|
|
}
|
|
|
|
// drop a note on other note
|
|
if (matchModels(parent, [NoteBlockModel])) {
|
|
const [first] = snapshot.content;
|
|
if (first.flavour === 'affine:note') {
|
|
if (parent.id !== first.id) {
|
|
this._onDropNoteOnNote(snapshot, parent.id, index);
|
|
}
|
|
return;
|
|
}
|
|
}
|
|
|
|
// drop on the same place, do nothing
|
|
if (
|
|
(dragPayload.from?.docId === this.widget.doc.id &&
|
|
result.placement === 'after' &&
|
|
parent.children[index]?.id === snapshot.content[0].id) ||
|
|
(result.placement === 'before' &&
|
|
parent.children[index - 1]?.id === last(snapshot.content)!.id)
|
|
) {
|
|
return;
|
|
}
|
|
|
|
this._dropToModel(snapshot, parent.id, index).catch(console.error);
|
|
};
|
|
|
|
private readonly _onDrop = (
|
|
dropBlock: BlockComponent,
|
|
dragPayload: DragBlockPayload,
|
|
dropPayload: DropPayload,
|
|
point: Point
|
|
) => {
|
|
if (this.mode === 'edgeless') {
|
|
this._onEdgelessDrop(dropBlock, dragPayload, dropPayload, point);
|
|
} else {
|
|
this._onPageDrop(dropBlock, dragPayload, dropPayload, point);
|
|
}
|
|
};
|
|
|
|
private readonly _onDropNoteOnNote = (
|
|
snapshot: SliceSnapshot,
|
|
parent?: string,
|
|
index?: number
|
|
) => {
|
|
const [first] = snapshot.content;
|
|
const id = first.id;
|
|
|
|
const std = this.std;
|
|
const job = this._getJob();
|
|
const snapshotWithoutNote = {
|
|
...snapshot,
|
|
content: first.children,
|
|
};
|
|
job
|
|
.snapshotToSlice(snapshotWithoutNote, std.store, parent, index)
|
|
.then(() => {
|
|
const block = std.store.getBlock(id)?.model;
|
|
if (block) {
|
|
std.store.deleteBlock(block);
|
|
}
|
|
})
|
|
.catch(console.error);
|
|
};
|
|
|
|
/**
|
|
* Merge the snapshot into the current existing surface model and page model.
|
|
* This method does the following:
|
|
* 1. Analyze the snapshot to build the container dependency tree
|
|
* 2. Merge the snapshot in the correct order to make sure all containers are created after their children
|
|
* @param snapshot
|
|
* @param point
|
|
*/
|
|
private readonly _mergeSnapshotToCurDoc = async (
|
|
snapshot: SliceSnapshot,
|
|
point?: Point
|
|
) => {
|
|
if (!point) {
|
|
const bound = this.gfx.elementsBound;
|
|
point = new Point(bound.x + bound.w, bound.y + bound.h / 2);
|
|
} else {
|
|
point = Point.from(
|
|
this.gfx.viewport.toModelCoordFromClientCoord([point.x, point.y])
|
|
);
|
|
}
|
|
|
|
this._rewriteSnapshotXYWH(snapshot, point);
|
|
|
|
const surface = this.gfx.surface!;
|
|
const root = this.std.store.root!;
|
|
const schema = this.std.store.schema;
|
|
const containerTree: Record<string, Set<string>> = { root: new Set() };
|
|
const idRemap = new Map<string, string>();
|
|
let elemMap: Record<
|
|
string,
|
|
{
|
|
type: string;
|
|
xywh?: SerializedXYWH;
|
|
children?: { json: Record<string, unknown> };
|
|
}
|
|
> = {};
|
|
const blockMap: Record<
|
|
string,
|
|
{
|
|
surfaceChild: boolean;
|
|
snapshot: BlockSnapshot;
|
|
}
|
|
> = {};
|
|
|
|
const isGroupLikeElem = (elem: { type: string }) => {
|
|
const constructor = surface.getConstructor(elem.type);
|
|
const isGroup = Object.isPrototypeOf.call(
|
|
GfxGroupLikeElementModel.prototype,
|
|
constructor.prototype
|
|
);
|
|
|
|
return isGroup;
|
|
};
|
|
const isGroupLikeBlock = (flavour: string) => {
|
|
const blockModel = schema.get(flavour)?.model.toModel?.();
|
|
|
|
return blockModel && isGfxGroupCompatibleModel(blockModel);
|
|
};
|
|
|
|
// walk through the snapshot to build the container dependency tree
|
|
const buildContainerTree = (block: BlockSnapshot) => {
|
|
if (block.flavour === 'affine:surface') {
|
|
elemMap = (block.props.elements as typeof elemMap) ?? {};
|
|
Object.entries(elemMap).forEach(([elemId, elem]) => {
|
|
if (
|
|
Object.values(containerTree).every(
|
|
childSet => !childSet.has(elemId)
|
|
)
|
|
) {
|
|
containerTree['root'].add(elemId);
|
|
}
|
|
|
|
if (isGroupLikeElem(elem)) {
|
|
Object.keys(elem.children?.json ?? {}).forEach(childId => {
|
|
containerTree[elemId] = containerTree[elemId] ?? new Set();
|
|
containerTree[elemId].add(childId);
|
|
// if the child was already added to the root, remove it
|
|
containerTree['root'].delete(childId);
|
|
});
|
|
return;
|
|
} else if (elem.type === 'connector') {
|
|
assertType<{
|
|
type: 'connector';
|
|
source: { position: IVec; id?: string };
|
|
target: { position: IVec; id?: string };
|
|
}>(elem);
|
|
|
|
if (elem.source.id) {
|
|
containerTree[elemId] = containerTree[elemId] ?? new Set();
|
|
containerTree[elemId].add(elem.source.id);
|
|
containerTree['root'].delete(elem.source.id);
|
|
}
|
|
|
|
if (elem.target.id) {
|
|
containerTree[elemId] = containerTree[elemId] ?? new Set();
|
|
containerTree[elemId].add(elem.target.id);
|
|
containerTree['root'].delete(elem.target.id);
|
|
}
|
|
}
|
|
});
|
|
|
|
block.children?.forEach(buildContainerTree);
|
|
} else {
|
|
const isSurfaceChild = schema.safeValidate(
|
|
block.flavour,
|
|
'affine:surface'
|
|
);
|
|
blockMap[block.id] = {
|
|
surfaceChild: isSurfaceChild,
|
|
snapshot: block,
|
|
};
|
|
|
|
if (
|
|
Object.values(containerTree).every(
|
|
childSet => !childSet.has(block.id)
|
|
)
|
|
) {
|
|
containerTree['root'].add(block.id);
|
|
}
|
|
|
|
if (isGroupLikeBlock(block.flavour)) {
|
|
Object.keys(block.props.childElementIds ?? {}).forEach(childId => {
|
|
containerTree[block.id] = containerTree[block.id] ?? new Set();
|
|
containerTree[block.id].add(childId);
|
|
// if the child was already added to the root, remove it
|
|
containerTree['root'].delete(childId);
|
|
});
|
|
}
|
|
}
|
|
};
|
|
|
|
snapshot.content.forEach(buildContainerTree);
|
|
|
|
const addInDependencyOrder = async (id: string) => {
|
|
if (containerTree[id]) {
|
|
for (const childId of containerTree[id]) {
|
|
await addInDependencyOrder(childId);
|
|
}
|
|
}
|
|
|
|
if (blockMap[id]) {
|
|
const { surfaceChild, snapshot: blockSnapshot } = blockMap[id];
|
|
|
|
if (isGroupLikeBlock(blockSnapshot.flavour)) {
|
|
Object.keys(blockSnapshot.props.childElementIds ?? {}).forEach(
|
|
childId => {
|
|
assertType<Record<string, unknown>>(
|
|
blockSnapshot.props.childElementIds
|
|
);
|
|
|
|
if (idRemap.has(childId)) {
|
|
const remappedId = idRemap.get(childId)!;
|
|
blockSnapshot.props.childElementIds[remappedId] =
|
|
blockSnapshot.props.childElementIds[childId];
|
|
delete blockSnapshot.props.childElementIds[childId];
|
|
} else {
|
|
delete blockSnapshot.props.childElementIds[childId];
|
|
}
|
|
}
|
|
);
|
|
}
|
|
|
|
const slices = await this._dropToModel(
|
|
{
|
|
...snapshot,
|
|
content: [blockSnapshot],
|
|
},
|
|
surfaceChild ? surface.id : root.id
|
|
);
|
|
|
|
if (slices) {
|
|
idRemap.set(id, slices.content[0].id);
|
|
}
|
|
} else if (elemMap[id]) {
|
|
const elem = elemMap[id];
|
|
|
|
Object.entries(elem).forEach(([_, val]) => {
|
|
if (
|
|
val instanceof Object &&
|
|
Reflect.has(val, SURFACE_YMAP_UNIQ_IDENTIFIER)
|
|
) {
|
|
const childJson = Reflect.get(val, 'json') as Record<
|
|
string,
|
|
unknown
|
|
>;
|
|
|
|
Object.keys(childJson).forEach(oldChildId => {
|
|
if (idRemap.has(oldChildId)) {
|
|
const remappedId = idRemap.get(oldChildId)!;
|
|
const val = structuredClone(childJson[oldChildId]);
|
|
|
|
if (elem.type === 'mindmap') {
|
|
assertType<{ parent?: string }>(val);
|
|
if (val.parent) {
|
|
val.parent = idRemap.get(val.parent);
|
|
}
|
|
}
|
|
childJson[remappedId] = val;
|
|
delete childJson[oldChildId];
|
|
} else {
|
|
delete childJson[oldChildId];
|
|
}
|
|
});
|
|
}
|
|
});
|
|
|
|
if (elem.type === 'connector') {
|
|
assertType<{
|
|
type: 'connector';
|
|
source: { position: IVec; id?: string };
|
|
target: { position: IVec; id?: string };
|
|
}>(elem);
|
|
|
|
(['source', 'target'] as const).forEach(key => {
|
|
const endpoint = elem[key];
|
|
if (endpoint.id) {
|
|
if (idRemap.get(endpoint.id)) {
|
|
endpoint.id = idRemap.get(endpoint.id);
|
|
} else {
|
|
delete endpoint.id;
|
|
}
|
|
}
|
|
});
|
|
}
|
|
|
|
const newId = surface.addElement(elem);
|
|
idRemap.set(id, newId);
|
|
}
|
|
};
|
|
|
|
for (const id of containerTree['root']) {
|
|
await addInDependencyOrder(id);
|
|
}
|
|
|
|
return idRemap;
|
|
};
|
|
|
|
/**
|
|
* Rewrite the xywh of the snapshot to make the top left corner of the snapshot align with the point
|
|
* @param snapshot
|
|
* @param point the point in model coordinate
|
|
* @returns
|
|
*/
|
|
private readonly _rewriteSnapshotXYWH = (
|
|
snapshot: SliceSnapshot,
|
|
point: Point,
|
|
ignoreOriginalPos: boolean = false
|
|
) => {
|
|
const rect = getSnapshotRect(snapshot) ?? new Bound(0, 0, 0, 0);
|
|
const { x: modelX, y: modelY } = point;
|
|
|
|
const rewrite = (block: BlockSnapshot) => {
|
|
if (block.flavour === 'affine:surface') {
|
|
if (block.props.elements) {
|
|
Object.values(
|
|
block.props.elements as Record<
|
|
string,
|
|
{ type: string; xywh?: SerializedXYWH }
|
|
>
|
|
).forEach(elem => {
|
|
if (elem.type === 'connector') {
|
|
assertType<{
|
|
type: 'connector';
|
|
xywh?: SerializedXYWH;
|
|
source: { position: IVec; id?: string };
|
|
target: { position: IVec; id?: string };
|
|
}>(elem);
|
|
|
|
const connectorBound = elem.xywh
|
|
? Bound.deserialize(elem.xywh)
|
|
: new Bound(0, 0, 0, 0);
|
|
|
|
delete elem.xywh;
|
|
|
|
(['source', 'target'] as const).forEach(key => {
|
|
const endpoint = elem[key];
|
|
if (!endpoint.id) {
|
|
const originalPos = endpoint.position;
|
|
|
|
elem[key] = {
|
|
position: ignoreOriginalPos
|
|
? [
|
|
originalPos[0] - connectorBound.x + modelX,
|
|
originalPos[1] - connectorBound.y + modelY,
|
|
]
|
|
: [
|
|
originalPos[0] - rect.x + modelX,
|
|
originalPos[1] - rect.y + modelY,
|
|
],
|
|
};
|
|
}
|
|
});
|
|
}
|
|
|
|
if (elem.xywh) {
|
|
const elemBound = Bound.deserialize(elem.xywh);
|
|
|
|
if (ignoreOriginalPos) {
|
|
elemBound.x = modelX;
|
|
elemBound.y = modelY;
|
|
elem.xywh = elemBound.serialize();
|
|
} else {
|
|
elem.xywh = elemBound
|
|
.moveDelta(-rect.x + modelX, -rect.y + modelY)
|
|
.serialize();
|
|
}
|
|
}
|
|
});
|
|
}
|
|
block.children.forEach(rewrite);
|
|
} else {
|
|
const schema = this.std.store.schema.get(block.flavour);
|
|
const isGfxModel =
|
|
schema?.model.toModel?.() instanceof GfxBlockElementModel;
|
|
|
|
if (!isGfxModel) {
|
|
return;
|
|
}
|
|
|
|
if (!block.props.xywh) {
|
|
block.props.xywh =
|
|
schema?.model.props?.(internalPrimitives).xywh ?? '[0,0,100,100]';
|
|
}
|
|
|
|
const blockBound =
|
|
Bound.deserialize(block.props.xywh as SerializedXYWH) ??
|
|
new Bound(0, 0, 0, 0);
|
|
|
|
if (
|
|
block.flavour === 'affine:attachment' ||
|
|
block.flavour === 'affine:bookmark' ||
|
|
block.flavour.startsWith('affine:embed-')
|
|
) {
|
|
const style = 'vertical' as EmbedCardStyle;
|
|
block.props.style = style;
|
|
|
|
blockBound.w = EMBED_CARD_WIDTH[style];
|
|
blockBound.h = EMBED_CARD_HEIGHT[style];
|
|
}
|
|
|
|
if (block.flavour === 'affine:image') {
|
|
assertType<{ width: number; height: number }>(block.props);
|
|
blockBound.w = blockBound.w || block.props.width || 100;
|
|
blockBound.h = blockBound.h || block.props.height || 100;
|
|
}
|
|
|
|
if (ignoreOriginalPos) {
|
|
blockBound.x = modelX;
|
|
blockBound.y = modelY;
|
|
block.props.xywh = blockBound.serialize();
|
|
} else {
|
|
block.props.xywh = blockBound
|
|
.moveDelta(-rect.x + modelX, -rect.y + modelY)
|
|
.serialize();
|
|
}
|
|
}
|
|
};
|
|
|
|
snapshot.content.forEach(rewrite);
|
|
};
|
|
|
|
get dndExtension() {
|
|
return this.std.getOptional(DndApiExtensionIdentifier);
|
|
}
|
|
|
|
/**
|
|
* This method will try to drop the snapshot as gfx block directly if all blocks can be dropped as gfx block.
|
|
* Otherwise, it will create a linked doc to reference the original doc.
|
|
* @param snapshot
|
|
* @param point
|
|
*/
|
|
private readonly _dropAsGfxBlock = (
|
|
snapshot: SliceSnapshot,
|
|
point: Point
|
|
) => {
|
|
const store = this.widget.std.store;
|
|
const schema = store.schema;
|
|
|
|
point = Point.from(
|
|
this.gfx.viewport.toModelCoordFromClientCoord([point.x, point.y])
|
|
);
|
|
|
|
// check if all blocks can be dropped as gfx block
|
|
const groupByParent = groupBy(snapshot.content, block =>
|
|
schema.safeValidate(block.flavour, 'affine:surface')
|
|
? 'affine:surface'
|
|
: schema.safeValidate(block.flavour, 'affine:page')
|
|
? 'affine:page'
|
|
: // if the parent is not surface or page, it can't be dropped as gfx block
|
|
// mark it as empty
|
|
'empty'
|
|
);
|
|
|
|
// empty means all blocks can be dropped as gfx block
|
|
if (!groupByParent.empty?.length) {
|
|
// drop as children of surface or page
|
|
|
|
if (groupByParent['affine:surface']) {
|
|
const content = groupByParent['affine:surface'];
|
|
const surfaceSnapshot = {
|
|
...snapshot,
|
|
content,
|
|
};
|
|
|
|
this._rewriteSnapshotXYWH(surfaceSnapshot, point, true);
|
|
this._dropToModel(surfaceSnapshot, this.gfx.surface!.id)
|
|
.then(slices => {
|
|
slices?.content.forEach((block, idx) => {
|
|
if (
|
|
block.id === content[idx].id &&
|
|
(block.flavour === 'affine:image' ||
|
|
block.flavour === 'affine:attachment' ||
|
|
block.flavour === 'affine:bookmark' ||
|
|
block.flavour.startsWith('affine:embed-'))
|
|
) {
|
|
store.updateBlock(block.id, {
|
|
xywh: content[idx].props.xywh,
|
|
style: content[idx].props.style,
|
|
});
|
|
}
|
|
});
|
|
})
|
|
.catch(console.error);
|
|
}
|
|
|
|
if (groupByParent['affine:page']) {
|
|
const content = groupByParent['affine:page'];
|
|
const pageSnapshot = {
|
|
...snapshot,
|
|
content,
|
|
};
|
|
|
|
this._rewriteSnapshotXYWH(pageSnapshot, point, true);
|
|
this._dropToModel(pageSnapshot, this.widget.doc.root!.id)
|
|
.then(slices => {
|
|
slices?.content.forEach((block, idx) => {
|
|
if (
|
|
block.flavour === 'affine:attachment' ||
|
|
block.flavour.startsWith('affine:embed-')
|
|
) {
|
|
store.updateBlock(block.id, {
|
|
xywh: content[idx].props.xywh,
|
|
style: content[idx].props.style,
|
|
});
|
|
}
|
|
});
|
|
})
|
|
.catch(console.error);
|
|
}
|
|
} else {
|
|
const dndExtApi = this.dndExtension;
|
|
const content = snapshot.content.filter(block =>
|
|
schema.safeValidate(block.flavour, 'affine:note')
|
|
);
|
|
const sourceDocId = snapshot.pageId;
|
|
|
|
if (
|
|
dndExtApi &&
|
|
this.std.store.workspace.docs.has(sourceDocId) &&
|
|
this.gfx.surface &&
|
|
snapshot.pageId !== this.std.store.doc.id
|
|
) {
|
|
const style = 'vertical' as EmbedCardStyle;
|
|
const linkedDocSnapshot = dndExtApi.fromEntity({
|
|
docId: sourceDocId,
|
|
props: {
|
|
blockIds: content.map(block => block.id),
|
|
style: 'vertical',
|
|
xywh: new Bound(
|
|
point.x,
|
|
point.y,
|
|
EMBED_CARD_WIDTH[style],
|
|
EMBED_CARD_HEIGHT[style]
|
|
).serialize(),
|
|
},
|
|
});
|
|
|
|
if (linkedDocSnapshot) {
|
|
this._dropToModel(linkedDocSnapshot, this.gfx.surface.id).catch(
|
|
console.error
|
|
);
|
|
}
|
|
} else {
|
|
// create note to wrap the snapshot
|
|
const noteId = store.addBlock(
|
|
'affine:note',
|
|
{
|
|
xywh: new Bound(
|
|
point.x,
|
|
point.y,
|
|
DEFAULT_NOTE_WIDTH,
|
|
DEFAULT_NOTE_HEIGHT
|
|
).serialize(),
|
|
},
|
|
this.widget.doc.root!
|
|
);
|
|
|
|
this._dropToModel(
|
|
{
|
|
...snapshot,
|
|
content,
|
|
},
|
|
noteId
|
|
).catch(console.error);
|
|
}
|
|
}
|
|
};
|
|
|
|
private readonly _toSnapshot = (
|
|
blocks: (BlockComponent | BlockModel)[],
|
|
selectedGfxElms?: string[]
|
|
) => {
|
|
const slice = Slice.fromModels(
|
|
this.std.store,
|
|
blocks.map(block =>
|
|
toDraftModel(block instanceof BlockComponent ? block.model : block)
|
|
)
|
|
);
|
|
const job = this._getJob(selectedGfxElms);
|
|
|
|
const snapshot = job.sliceToSnapshot(slice);
|
|
if (!snapshot) return;
|
|
|
|
return snapshot;
|
|
};
|
|
|
|
private readonly _trackLinkedDocCreated = (id: string) => {
|
|
const isNewBlock = !this.std.store.hasBlock(id);
|
|
if (!isNewBlock) {
|
|
return;
|
|
}
|
|
|
|
const mode =
|
|
this.std.getOptional(DocModeProvider)?.getEditorMode() ?? 'page';
|
|
|
|
const telemetryService = this.std.getOptional(TelemetryProvider);
|
|
telemetryService?.track('LinkedDocCreated', {
|
|
control: `drop on ${mode}`,
|
|
module: 'drag and drop',
|
|
type: 'doc',
|
|
other: 'new doc',
|
|
});
|
|
};
|
|
|
|
private readonly _setOpacityOfDraggedBlocks = (snapshot: SliceSnapshot) => {
|
|
const OPACITY = 0.7;
|
|
const gfx = this.gfx;
|
|
const resetCallbacks: (() => void)[] = [];
|
|
|
|
const traverse = (block: BlockSnapshot) => {
|
|
if (block.flavour === 'affine:surface') {
|
|
block.children.forEach(traverse);
|
|
Object.keys(block.props.elements as Record<string, unknown>).forEach(
|
|
elemId => {
|
|
const element = gfx.getElementById(
|
|
elemId
|
|
) as GfxPrimitiveElementModel;
|
|
|
|
if (element) {
|
|
const originalOpacity = element.opacity;
|
|
element.opacity = OPACITY;
|
|
resetCallbacks.push(() => {
|
|
element.opacity = originalOpacity;
|
|
});
|
|
}
|
|
}
|
|
);
|
|
} else {
|
|
const blockView = this.std.view.getBlock(block.id);
|
|
|
|
if (blockView) {
|
|
const originalOpacity = blockView.style.opacity;
|
|
blockView.style.opacity = `${OPACITY}`;
|
|
resetCallbacks.push(() => {
|
|
if (originalOpacity) {
|
|
blockView.style.opacity = originalOpacity;
|
|
} else {
|
|
blockView.style.removeProperty('opacity');
|
|
}
|
|
});
|
|
}
|
|
}
|
|
};
|
|
|
|
snapshot.content.forEach(traverse);
|
|
this.resetOpacityCallbacks = resetCallbacks;
|
|
};
|
|
|
|
constructor(readonly widget: AffineDragHandleWidget) {}
|
|
|
|
private async _dropToModel(
|
|
snapshot: SliceSnapshot,
|
|
parent?: string,
|
|
index?: number
|
|
) {
|
|
try {
|
|
const std = this.std;
|
|
const job = this._getJob();
|
|
|
|
if (snapshot.content.length === 1) {
|
|
const [first] = snapshot.content;
|
|
if (first.flavour === 'affine:embed-linked-doc') {
|
|
this._trackLinkedDocCreated(first.id);
|
|
}
|
|
}
|
|
// use snapshot
|
|
const slice = await job.snapshotToSlice(
|
|
snapshot,
|
|
std.store,
|
|
parent,
|
|
index
|
|
);
|
|
return slice;
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
private _getJob(selectedIds?: string[]) {
|
|
const std = this.std;
|
|
const middlewares = [
|
|
newIdCrossDoc(std),
|
|
reorderList(std),
|
|
surfaceRefToEmbed(std),
|
|
];
|
|
|
|
if (selectedIds) {
|
|
middlewares.push(gfxBlocksFilter(selectedIds, std));
|
|
}
|
|
|
|
return std.store.getTransformer(middlewares);
|
|
}
|
|
|
|
private _isDropOnCurrentEditor(std?: BlockStdScope) {
|
|
return std === this.std;
|
|
}
|
|
|
|
private _isUnderNoteBlock(model: BlockModel) {
|
|
let isUnderNote = false;
|
|
const store = this.std.store;
|
|
|
|
{
|
|
let curModel = store.getParent(model);
|
|
|
|
while (curModel) {
|
|
if (matchModels(curModel, [NoteBlockModel])) {
|
|
isUnderNote = true;
|
|
break;
|
|
}
|
|
|
|
curModel = store.getParent(curModel)!;
|
|
}
|
|
}
|
|
|
|
return isUnderNote;
|
|
}
|
|
|
|
private _makeDraggable(target: HTMLElement) {
|
|
const std = this.std;
|
|
|
|
return std.dnd.draggable<DragBlockEntity>({
|
|
element: target,
|
|
canDrag: () => (this.widget.anchorBlockId.peek() ? true : false),
|
|
onDragStart: () => {
|
|
this.widget.dragging = true;
|
|
},
|
|
onDrop: () => {
|
|
this._cleanup();
|
|
},
|
|
setDragPreview: ({ source, container, setOffset }) => {
|
|
if (
|
|
!source.data?.bsEntity?.modelIds.length ||
|
|
!source.data.bsEntity.snapshot
|
|
) {
|
|
return;
|
|
}
|
|
|
|
const { snapshot, fromMode } = source.data.bsEntity;
|
|
|
|
this.previewHelper.renderDragPreview({
|
|
blockIds: source.data?.bsEntity?.modelIds,
|
|
snapshot,
|
|
container,
|
|
mode: fromMode ?? 'block',
|
|
});
|
|
|
|
setOffset({ x: 0, y: 0 });
|
|
},
|
|
setDragData: () => {
|
|
const { fromMode, snapshot } = this._getDraggedSnapshot();
|
|
|
|
snapshot && this._setOpacityOfDraggedBlocks(snapshot);
|
|
|
|
return {
|
|
type: 'blocks',
|
|
fromMode,
|
|
modelIds: snapshot ? extractIdsFromSnapshot(snapshot) : [],
|
|
snapshot,
|
|
};
|
|
},
|
|
});
|
|
}
|
|
|
|
private _makeDropTarget(view: BlockComponent) {
|
|
const isUnderNote = this._isUnderNoteBlock(view.model);
|
|
const isNote = matchModels(view.model, [NoteBlockModel]);
|
|
|
|
if (
|
|
// affine:surface block can't be drop target in any modes
|
|
matchModels(view.model, [SurfaceBlockModel]) ||
|
|
// in page mode, blocks other than root block can be drop target
|
|
(this.mode === 'page' && view.model.role === 'root') ||
|
|
// in edgeless mode, only root and note block can be drop target
|
|
(this.mode === 'edgeless' &&
|
|
!matchModels(view.model, [NoteBlockModel]) &&
|
|
view.model.role !== 'root' &&
|
|
!isUnderNote)
|
|
) {
|
|
return;
|
|
}
|
|
|
|
const widget = this.widget;
|
|
const cleanups: (() => void)[] = [];
|
|
|
|
cleanups.push(
|
|
this.std.dnd.dropTarget<
|
|
DragBlockEntity,
|
|
{
|
|
modelId: string;
|
|
}
|
|
>({
|
|
element: view,
|
|
getIsSticky: () => {
|
|
const result = this.mode === 'page' || isUnderNote;
|
|
return result;
|
|
},
|
|
canDrop: ({ source }) => {
|
|
/**
|
|
* general rules:
|
|
* 1. can't drop on the same block or its children
|
|
*/
|
|
if (source.data.bsEntity?.type === 'blocks') {
|
|
return (
|
|
source.data.from?.docId !== widget.doc.id ||
|
|
source.data.bsEntity.modelIds.every(id => id !== view.model.id)
|
|
);
|
|
}
|
|
|
|
return false;
|
|
},
|
|
onDragEnter: () => {
|
|
if (isNote && 'hideMask' in view) {
|
|
view.hideMask = true;
|
|
}
|
|
},
|
|
onDragLeave: () => {
|
|
if (isNote && 'hideMask' in view) {
|
|
view.hideMask = false;
|
|
}
|
|
},
|
|
setDropData: () => {
|
|
return {
|
|
modelId: view.model.id,
|
|
};
|
|
},
|
|
})
|
|
);
|
|
|
|
if (matchModels(view.model, [AttachmentBlockModel, BookmarkBlockModel])) {
|
|
cleanups.push(this._makeDraggable(view));
|
|
}
|
|
|
|
if (this.dropTargetCleanUps.has(view.model.id)) {
|
|
this.dropTargetCleanUps.get(view.model.id)!.forEach(clean => clean());
|
|
}
|
|
|
|
this.dropTargetCleanUps.set(view.model.id, cleanups);
|
|
}
|
|
|
|
private _monitorBlockDrag() {
|
|
return this.std.dnd.monitor<DragBlockEntity>({
|
|
canMonitor: ({ source }) => {
|
|
const entity = source.data?.bsEntity;
|
|
|
|
return entity?.type === 'blocks' && !!entity.snapshot;
|
|
},
|
|
onDropTargetChange: ({ location }) => {
|
|
this._clearDropIndicator();
|
|
|
|
if (
|
|
!this._isDropOnCurrentEditor(
|
|
(location.current.dropTargets[0]?.element as BlockComponent)?.std
|
|
)
|
|
) {
|
|
return;
|
|
}
|
|
},
|
|
onDrop: ({ location, source }) => {
|
|
this._clearDropIndicator();
|
|
|
|
if (
|
|
!this._isDropOnCurrentEditor(
|
|
(location.current.dropTargets[0]?.element as BlockComponent)?.std
|
|
)
|
|
) {
|
|
return;
|
|
}
|
|
|
|
const target = location.current.dropTargets[0];
|
|
const point = new Point(
|
|
location.current.input.clientX,
|
|
location.current.input.clientY
|
|
);
|
|
const dragPayload = source.data;
|
|
const dropPayload = target.data;
|
|
|
|
this._onDrop(
|
|
target.element as BlockComponent,
|
|
dragPayload,
|
|
dropPayload,
|
|
point
|
|
);
|
|
},
|
|
onDrag: ({ location, source }) => {
|
|
if (
|
|
!this._isDropOnCurrentEditor(
|
|
(location.current.dropTargets[0]?.element as BlockComponent)?.std
|
|
) ||
|
|
!location.current.dropTargets[0]
|
|
) {
|
|
return;
|
|
}
|
|
|
|
const target = location.current.dropTargets[0];
|
|
const point = new Point(
|
|
location.current.input.clientX,
|
|
location.current.input.clientY
|
|
);
|
|
const dragPayload = source.data;
|
|
const dropPayload = target.data;
|
|
|
|
this._onDragMove(
|
|
point,
|
|
dragPayload,
|
|
dropPayload,
|
|
target.element as BlockComponent
|
|
);
|
|
},
|
|
});
|
|
}
|
|
|
|
watch() {
|
|
this.widget.handleEvent('pointerDown', ctx => {
|
|
const state = ctx.get('pointerState');
|
|
const event = state.raw;
|
|
const target = captureEventTarget(event.target);
|
|
if (!target) return;
|
|
|
|
if (this.widget.contains(target)) {
|
|
return true;
|
|
}
|
|
|
|
return;
|
|
});
|
|
|
|
this.widget.handleEvent('dragStart', ctx => {
|
|
const state = ctx.get('pointerState');
|
|
const event = state.raw;
|
|
const target = captureEventTarget(event.target);
|
|
if (!target) return;
|
|
|
|
if (this.widget.contains(target)) {
|
|
return true;
|
|
}
|
|
|
|
return;
|
|
});
|
|
|
|
const widget = this.widget;
|
|
const std = this.std;
|
|
const disposables = widget.disposables;
|
|
const scrollable = getScrollContainer(this.host);
|
|
|
|
if (scrollable && this.mode === 'page') {
|
|
disposables.add(
|
|
std.dnd.autoScroll<DragBlockEntity>({
|
|
element: scrollable,
|
|
canScroll: ({ source }) => {
|
|
return source.data?.bsEntity?.type === 'blocks';
|
|
},
|
|
})
|
|
);
|
|
}
|
|
|
|
disposables.add(this._makeDraggable(this.widget));
|
|
|
|
// used to handle drag move and drop
|
|
disposables.add(this._monitorBlockDrag());
|
|
|
|
disposables.add(
|
|
std.view.viewUpdated.on(payload => {
|
|
if (payload.type !== 'block') {
|
|
return;
|
|
}
|
|
if (payload.method === 'add') {
|
|
this._makeDropTarget(payload.view);
|
|
} else if (
|
|
payload.method === 'delete' &&
|
|
this.dropTargetCleanUps.has(payload.id)
|
|
) {
|
|
this.dropTargetCleanUps.get(payload.id)!.forEach(clean => clean());
|
|
this.dropTargetCleanUps.delete(payload.id);
|
|
}
|
|
})
|
|
);
|
|
|
|
std.view.views.forEach(block => this._makeDropTarget(block));
|
|
|
|
disposables.add(() => {
|
|
this.dropTargetCleanUps.forEach(cleanUps => cleanUps.forEach(fn => fn()));
|
|
this.dropTargetCleanUps.clear();
|
|
});
|
|
}
|
|
}
|