refactor: rewrite blocksuite dnd (#9595)

### Changed

- Refactored BlockSuite drag-and-drop using @atlaskit/pragmatic-drag-and-drop/element/adapter.
- Updated block dragging to use the new drag-and-drop infrastructure.

### BlockSuite DND API

Access the BlockSuite drag-and-drop API via `std.dnd`. This is a lightweight wrapper around pragmatic-drag-and-drop, offering convenient generic types and more intuitive option names.

#### Drag payload structure
There's some constrain about drag payload. The whole drag payload looks like this:

```typescript
type DragPayload = {
  entity: {
    type: string
  },
  from: {
    at: 'blocksuite',
    docId: string
  }
}
```
- The `from` field is auto-generated—no need for manual handling.
- The `entity` field is customizable, but it must include a `type`.

All drag-and-drop methods accept a generic type for entity, ensuring more accurate payloads in event handlers.

```typescript
type BlockEntity = {
  type: 'blocks',
  blockIds: string[]
}

dnd.draggable<BlockEntity>({
  element: someElement,
  setDragData: () => {
    // the return type must satisfy the generic type
    // in this case, it's BlockEntity
    return {
      type: 'blocks',
      blockIds: []
    }
  }
});

dnd.monitor<BlockEntity>({
  // the arguments is same for other event handler
  onDrag({ source }) {
    // the type of this is BlockEntity
    source.data.entity
  }
})
```

#### Drop payload
When hover on droppable target. You can set drop payload as well. All drag-and-drop methods accept a second generic type for drop payload.

The drop payload is customizable. Additionally, the DND system will add an `edge` field to the final payload object, indicating the nearest edge of the drop target relative to the current drag position.

```typescript
type DropPayload = {
  blockId: string;
}

dnd.dropTarget<BlockEntity, DropPayload>({
  getData() {
    // the type should be DropPayload
    return {
      blockId: 'someId'
    }
  }
});

dnd.monitor<BlockEntity, DropPayload>({
  // drag over on drop target
  onDrag({ location }) {
    const target = location.current.dropTargets[0];

    // the type is DropPayload
    target.data;
    // retrieve the nearest edge of the drop target relative to the current drop position.
    target.data.edge;
  }
})
```
This commit is contained in:
doouding
2025-01-16 12:36:58 +00:00
parent 3828144849
commit 99717196c5
30 changed files with 1207 additions and 561 deletions

View File

@@ -234,7 +234,6 @@ export class AttachmentBlockComponent extends CaptionedBlockComponent<
<div
${this._whenHover ? ref(this._whenHover.setReference) : nothing}
class="affine-attachment-container"
draggable="${this.blockDraggable ? 'true' : 'false'}"
style=${this.containerStyleMap}
>
${embedView

View File

@@ -58,6 +58,8 @@ export class FileDropExtension extends LifeCycleWatcher {
point$ = signal<Point | null>(null);
private _disableIndicator = false;
closestElement$ = signal<BlockComponent | null>(null);
dropTarget$ = computed<DropTarget | null>(() => {
@@ -196,7 +198,9 @@ export class FileDropExtension extends LifeCycleWatcher {
std.event.disposables.add(
this.dropTarget$.subscribe(target => {
FileDropExtension.indicator.rect = target?.rect ?? null;
FileDropExtension.indicator.rect = this._disableIndicator
? null
: (target?.rect ?? null);
})
);
@@ -210,6 +214,17 @@ export class FileDropExtension extends LifeCycleWatcher {
this.dragging$.value = false;
})
);
std.event.disposables.add(
std.dnd.monitor({
onDragStart: () => {
this._disableIndicator = true;
},
onDrop: () => {
this._disableIndicator = false;
},
})
);
std.event.disposables.add(
std.event.add('nativeDragOver', context => {
const event = context.get('dndState').raw;

View File

@@ -1,5 +1,6 @@
import { BLOCK_ID_ATTR, type BlockComponent } from '@blocksuite/block-std';
import type { Point, Rect } from '@blocksuite/global/utils';
import type { BlockModel } from '@blocksuite/store';
import { BLOCK_CHILDREN_CONTAINER_PADDING_LEFT } from '../../consts/index.js';
import { clamp } from '../math.js';
@@ -272,18 +273,32 @@ export function getRectByBlockComponent(element: Element | BlockComponent) {
* Only keep block elements of same level.
*/
export function getBlockComponentsExcludeSubtrees(
elements: Element[] | BlockComponent[]
elements: BlockComponent[]
): BlockComponent[] {
if (elements.length <= 1) return elements as BlockComponent[];
let parent = elements[0];
return elements.filter((node, index) => {
if (index === 0) return true;
if (contains(parent, node)) {
return false;
} else {
parent = node;
return true;
const getLevel = (element: BlockComponent) => {
let level = 0;
let model: BlockModel | null = element.model;
while (model && model.role !== 'root') {
level++;
model = model.parent;
}
return level;
};
let topMostLevel = Number.POSITIVE_INFINITY;
const levels = elements.map(element => {
const level = getLevel(element);
topMostLevel = Math.min(topMostLevel, level);
return level;
});
return elements.filter((_, index) => {
return levels[index] === topMostLevel;
}) as BlockComponent[];
}

View File

@@ -180,7 +180,7 @@ export function requestConnectedFrame(
* A wrapper around `requestConnectedFrame` that only calls at most once in one frame
*/
export function requestThrottledConnectedFrame<
T extends (...args: unknown[]) => void,
T extends (...args: any[]) => void,
>(func: T, element?: HTMLElement): T {
let raqId: number | undefined = undefined;
let latestArgs: unknown[] = [];

View File

@@ -24,13 +24,17 @@ export class DropIndicator extends LitElement {
if (!this.rect) {
return null;
}
const parentRect = this.parentElement!.getBoundingClientRect();
const { left, top, width, height } = this.rect;
const style = styleMap({
width: `${width}px`,
height: `${height}px`,
top: `${top}px`,
left: `${left}px`,
top: `${top - parentRect.y}px`,
left: `${left - parentRect.x}px`,
});
return html`<div class="affine-drop-indicator" style=${style}></div>`;
}

View File

@@ -2,44 +2,29 @@ import { EdgelessCRUDIdentifier } from '@blocksuite/affine-block-surface';
import type { RootBlockModel } from '@blocksuite/affine-model';
import { DocModeProvider } from '@blocksuite/affine-shared/services';
import {
autoScroll,
calcDropTarget,
type DropPlacement,
type DropTarget,
getScrollContainer,
isInsideEdgelessEditor,
isInsidePageEditor,
isTopLevelBlock,
matchFlavours,
} from '@blocksuite/affine-shared/utils';
import {
type BlockComponent,
type DndEventState,
WidgetComponent,
} from '@blocksuite/block-std';
import { type BlockComponent, WidgetComponent } from '@blocksuite/block-std';
import type { GfxBlockElementModel } from '@blocksuite/block-std/gfx';
import type { IVec } from '@blocksuite/global/utils';
import { DisposableGroup, Point, Rect } from '@blocksuite/global/utils';
import {
DisposableGroup,
type IVec,
type Point,
type Rect,
} from '@blocksuite/global/utils';
import { computed, type ReadonlySignal, signal } from '@preact/signals-core';
import { html } from 'lit';
import { query, state } from 'lit/decorators.js';
import { styleMap } from 'lit/directives/style-map.js';
import type { DragPreview } from './components/drag-preview.js';
import type { DropIndicator } from './components/drop-indicator.js';
import type { AFFINE_DRAG_HANDLE_WIDGET } from './consts.js';
import { PreviewHelper } from './helpers/preview-helper.js';
import { RectHelper } from './helpers/rect-helper.js';
import { SelectionHelper } from './helpers/selection-helper.js';
import { styles } from './styles.js';
import {
containBlock,
containChildBlock,
getClosestBlockByPoint,
getClosestNoteBlock,
isOutOfNoteBlock,
updateDragHandleClassName,
} from './utils.js';
import { updateDragHandleClassName } from './utils.js';
import { DragEventWatcher } from './watchers/drag-event-watcher.js';
import { EdgelessWatcher } from './watchers/edgeless-watcher.js';
import { HandleEventWatcher } from './watchers/handle-event-watcher.js';
@@ -54,90 +39,13 @@ export class AffineDragHandleWidget extends WidgetComponent<RootBlockModel> {
private readonly _dragEventWatcher = new DragEventWatcher(this);
private readonly _getBlockView = (blockId: string) => {
return this.host.view.getBlock(blockId);
};
/**
* When dragging, should update indicator position and target drop block id
*/
private readonly _getDropTarget = (
state: DndEventState
): DropTarget | null => {
const point = new Point(state.raw.x, state.raw.y);
const closestBlock = getClosestBlockByPoint(
this.host,
this.rootComponent,
point
);
if (!closestBlock) return null;
const blockId = closestBlock.model.id;
const model = closestBlock.model;
const isDatabase = matchFlavours(model, ['affine:database']);
if (isDatabase) return null;
// note block can only be dropped into another note block
// prevent note block from being dropped into other blocks
const isDraggedElementNote =
this.draggingElements.length === 1 &&
matchFlavours(this.draggingElements[0].model, ['affine:note']);
if (isDraggedElementNote) {
const parent = this.std.store.getParent(closestBlock.model);
if (!parent) return null;
const parentElement = this._getBlockView(parent.id);
if (!parentElement) return null;
if (!matchFlavours(parentElement.model, ['affine:note'])) return null;
}
// Should make sure that target drop block is
// neither within the dragging elements
// nor a child-block of any dragging elements
if (
containBlock(
this.draggingElements.map(block => block.model.id),
blockId
) ||
containChildBlock(this.draggingElements, model)
) {
return null;
}
const result = calcDropTarget(
point,
model,
closestBlock,
this.draggingElements,
this.scale.peek(),
isDraggedElementNote === false
);
if (isDraggedElementNote && result?.placement === 'in') return null;
return result;
};
private readonly _handleEventWatcher = new HandleEventWatcher(this);
private readonly _keyboardEventWatcher = new KeyboardEventWatcher(this);
private readonly _pageWatcher = new PageWatcher(this);
private readonly _removeDropIndicator = () => {
if (this.dropIndicator) {
this.dropIndicator.remove();
this.dropIndicator = null;
}
};
private readonly _reset = () => {
this.draggingElements = [];
this.dropBlockId = '';
this.dropPlacement = null;
this.lastDragPointerState = null;
this.rafID = 0;
this.dragging = false;
this.dragHoverRect = null;
@@ -147,9 +55,6 @@ export class AffineDragHandleWidget extends WidgetComponent<RootBlockModel> {
this.isTopLevelDragHandleVisible = false;
this.pointerEventWatcher.reset();
this.previewHelper.removeDragPreview();
this._removeDropIndicator();
this._resetCursor();
};
@@ -157,32 +62,6 @@ export class AffineDragHandleWidget extends WidgetComponent<RootBlockModel> {
document.documentElement.classList.remove('affine-drag-preview-grabbing');
};
private readonly _resetDropTarget = () => {
this.dropBlockId = '';
this.dropPlacement = null;
if (this.dropIndicator) this.dropIndicator.rect = null;
};
private readonly _updateDropTarget = (dropTarget: DropTarget | null) => {
if (!this.dropIndicator) return;
this.dropBlockId = dropTarget?.modelState.model.id ?? '';
this.dropPlacement = dropTarget?.placement ?? null;
if (dropTarget?.rect) {
const offsetParentRect =
this.dragHandleContainerOffsetParent.getBoundingClientRect();
let { left, top } = dropTarget.rect;
left -= offsetParentRect.left;
top -= offsetParentRect.top;
const { width, height } = dropTarget.rect;
const rect = Rect.fromLWTH(left, width, top, height);
this.dropIndicator.rect = rect;
} else {
this.dropIndicator.rect = dropTarget?.rect ?? null;
}
};
anchorBlockId = signal<string | null>(null);
anchorBlockComponent = computed<BlockComponent | null>(() => {
@@ -213,15 +92,7 @@ export class AffineDragHandleWidget extends WidgetComponent<RootBlockModel> {
this.rectHelper.getDraggingAreaRect
);
draggingElements: BlockComponent[] = [];
dragPreview: DragPreview | null = null;
dropBlockId = '';
dropIndicator: DropIndicator | null = null;
dropPlacement: DropPlacement | null = null;
lastDragPoint: Point | null = null;
edgelessWatcher = new EdgelessWatcher(this);
@@ -268,74 +139,18 @@ export class AffineDragHandleWidget extends WidgetComponent<RootBlockModel> {
isTopLevelDragHandleVisible = false;
lastDragPointerState: DndEventState | null = null;
noteScale = signal(1);
pointerEventWatcher = new PointerEventWatcher(this);
previewHelper = new PreviewHelper(this);
rafID = 0;
scale = signal(1);
scaleInNote = computed(() => this.scale.value * this.noteScale.value);
selectionHelper = new SelectionHelper(this);
updateDropIndicator = (
state: DndEventState,
shouldAutoScroll: boolean = false
) => {
const point = new Point(state.raw.x, state.raw.y);
const closestNoteBlock = getClosestNoteBlock(
this.host,
this.rootComponent,
point
);
if (
!closestNoteBlock ||
isOutOfNoteBlock(this.host, closestNoteBlock, point, this.scale.peek())
) {
this._resetDropTarget();
} else {
const dropTarget = this._getDropTarget(state);
this._updateDropTarget(dropTarget);
}
this.lastDragPointerState = state;
if (this.mode === 'page') {
if (!shouldAutoScroll) return;
const scrollContainer = getScrollContainer(this.rootComponent);
const result = autoScroll(scrollContainer, state.raw.y);
if (!result) {
this.clearRaf();
return;
}
this.rafID = requestAnimationFrame(() =>
this.updateDropIndicator(state, true)
);
} else {
this.clearRaf();
}
};
updateDropIndicatorOnScroll = () => {
if (
!this.dragging ||
this.draggingElements.length === 0 ||
!this.lastDragPointerState
)
return;
const state = this.lastDragPointerState;
this.rafID = requestAnimationFrame(() =>
this.updateDropIndicator(state, false)
);
};
get dragHandleContainerOffsetParent() {
return this.dragHandleContainer.parentElement!;
}
@@ -348,13 +163,6 @@ export class AffineDragHandleWidget extends WidgetComponent<RootBlockModel> {
return this.block;
}
clearRaf() {
if (this.rafID) {
cancelAnimationFrame(this.rafID);
this.rafID = 0;
}
}
override connectedCallback() {
super.connectedCallback();

View File

@@ -61,10 +61,6 @@ export class PreviewHelper {
dragPreviewEl?: HTMLElement,
dragPreviewOffset?: Point
): DragPreview => {
if (this.widget.dragPreview) {
this.widget.dragPreview.remove();
}
let dragPreview: DragPreview;
if (dragPreviewEl) {
dragPreview = new DragPreview(dragPreviewOffset);
@@ -107,12 +103,5 @@ export class PreviewHelper {
return dragPreview;
};
removeDragPreview = () => {
if (this.widget.dragPreview) {
this.widget.dragPreview.remove();
this.widget.dragPreview = null;
}
};
constructor(readonly widget: AffineDragHandleWidget) {}
}

View File

@@ -11,7 +11,12 @@ import {
} from '@blocksuite/affine-shared/utils';
import type { BlockComponent, EditorHost } from '@blocksuite/block-std';
import { Point, Rect } from '@blocksuite/global/utils';
import type { BaseSelection, BlockModel } from '@blocksuite/store';
import type {
BaseSelection,
BlockModel,
BlockSnapshot,
SliceSnapshot,
} from '@blocksuite/store';
import {
DRAG_HANDLE_CONTAINER_HEIGHT,
@@ -70,6 +75,25 @@ export const containBlock = (blockIDs: string[], targetID: string) => {
return blockIDs.some(blockID => blockID === targetID);
};
export const extractIdsFromSnapshot = (snapshot: SliceSnapshot) => {
const ids: string[] = [];
const extractFromBlock = (block: BlockSnapshot) => {
ids.push(block.id);
if (block.children) {
for (const child of block.children) {
extractFromBlock(child);
}
}
};
for (const block of snapshot.content) {
extractFromBlock(block);
}
return ids;
};
// TODO: this is a hack, need to find a better way
export const insideDatabaseTable = (element: Element) => {
return !!element.closest('.affine-database-block-table');
@@ -118,6 +142,10 @@ export const isOutOfNoteBlock = (
: true;
};
export const getParentNoteBlock = (blockComponent: BlockComponent) => {
return blockComponent.closest('affine-note') ?? null;
};
export const getClosestNoteBlock = (
editorHost: EditorHost,
rootComponent: BlockComponent,

View File

@@ -5,43 +5,77 @@ import {
} from '@blocksuite/affine-block-surface';
import type { EmbedCardStyle, NoteBlockModel } from '@blocksuite/affine-model';
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 {
calcDropTarget,
captureEventTarget,
type DropTarget,
type DropTarget as DropResult,
getBlockComponentsExcludeSubtrees,
getClosestBlockComponentByPoint,
getRectByBlockComponent,
getScrollContainer,
matchFlavours,
SpecProvider,
} from '@blocksuite/affine-shared/utils';
import {
type BlockComponent,
BlockSelection,
type DndEventState,
BlockStdScope,
type DragFromBlockSuite,
type DragPayload,
type DropPayload,
isGfxBlockComponent,
type UIEventHandler,
type UIEventStateContext,
} from '@blocksuite/block-std';
import { GfxControllerIdentifier } from '@blocksuite/block-std/gfx';
import { Bound, Point } from '@blocksuite/global/utils';
import { Bound, last, Point, Rect } from '@blocksuite/global/utils';
import { Slice, type SliceSnapshot } from '@blocksuite/store';
import { DropIndicator } from '../components/drop-indicator.js';
import { AFFINE_DRAG_HANDLE_WIDGET } from '../consts.js';
import type { AffineDragHandleWidget } from '../drag-handle.js';
import { newIdCrossDoc } from '../middleware/new-id-cross-doc.js';
import { reorderList } from '../middleware/reorder-list';
import { surfaceRefToEmbed } from '../middleware/surface-ref-to-embed.js';
import { containBlock, includeTextSelection } from '../utils.js';
import {
containBlock,
extractIdsFromSnapshot,
getParentNoteBlock,
includeTextSelection,
isOutOfNoteBlock,
} from '../utils.js';
export type DragBlockEntity = {
type: 'blocks';
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;
get host() {
return this.widget.host;
}
get mode() {
return this.widget.mode;
}
get std() {
return this.widget.std;
}
private get _gfx() {
return this.widget.std.get(GfxControllerIdentifier);
}
@@ -72,137 +106,193 @@ export class DragEventWatcher {
};
private readonly _createDropIndicator = () => {
if (!this.widget.dropIndicator) {
this.widget.dropIndicator = new DropIndicator();
this.widget.rootComponent.append(this.widget.dropIndicator);
if (!this.dropIndicator) {
this.dropIndicator = new DropIndicator();
this.widget.ownerDocument.body.append(this.dropIndicator);
}
};
private readonly _clearDropIndicator = () => {
if (this.dropIndicator) {
this.dropIndicator.remove();
this.dropIndicator = null;
}
};
private readonly _cleanup = () => {
this.widget.previewHelper.removeDragPreview();
this.widget.clearRaf();
this._clearDropIndicator();
this.widget.hide(true);
this._std.selection.setGroup('gfx', []);
this.std.selection.setGroup('gfx', []);
};
private readonly _dragEndHandler: UIEventHandler = () => {
this._cleanup();
};
private readonly _dragMoveHandler: UIEventHandler = ctx => {
if (
this.widget.isHoverDragHandleVisible ||
this.widget.isTopLevelDragHandleVisible
) {
this.widget.hide();
}
if (!this.widget.dragging || this.widget.draggingElements.length === 0) {
return false;
}
ctx.get('defaultState').event.preventDefault();
const state = ctx.get('dndState');
// call default drag move handler if no option return true
return this._onDragMove(state);
private readonly _onDragMove = (
point: Point,
payload: DragBlockPayload,
dropPayload: DropPayload,
block: BlockComponent
) => {
this._createDropIndicator();
this._updateDropIndicator(point, payload, dropPayload, block);
};
/**
* When start dragging, should set dragging elements and create drag preview
* When dragging, should update indicator position and target drop block id
*/
private readonly _dragStartHandler: UIEventHandler = ctx => {
const state = ctx.get('dndState');
// If not click left button to start dragging, should do nothing
const { button } = state.raw;
if (button !== 0) {
return false;
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 ||
matchFlavours(model, ['affine:database'])
)
return null;
const isDropOnNoteBlock = matchFlavours(model, ['affine:note']);
const edge = dropPayload.edge;
const scale = this.widget.scale.peek();
let result: DropResult;
if (edge === 'right' && matchFlavours(dropBlock.model, ['affine:list'])) {
const domRect = getRectByBlockComponent(dropBlock);
const placement = 'in';
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 placement =
isDropOnNoteBlock &&
this.widget.doc.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 this._onDragStart(state);
return result;
};
private readonly _dropHandler = (context: UIEventStateContext) => {
const raw = context.get('dndState').raw;
const fileLength = raw.dataTransfer?.files.length ?? 0;
// If drop files, should let file drop extension handle it
if (fileLength > 0) {
return;
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);
}
this._onDrop(context);
this._cleanup();
};
private readonly _onDragMove = (state: DndEventState) => {
this.widget.clearRaf();
this.widget.rafID = requestAnimationFrame(() => {
this.widget.edgelessWatcher.updateDragPreviewPosition(state);
this.widget.updateDropIndicator(state, true);
});
return true;
private readonly _resetDropResult = () => {
if (this.dropIndicator) this.dropIndicator.rect = null;
};
private readonly _onDragStart = (state: DndEventState) => {
// Get current hover block element by path
const hoverBlock = this.widget.anchorBlockComponent.peek();
if (!hoverBlock) return false;
private readonly _updateDropResult = (dropResult: DropResult | null) => {
if (!this.dropIndicator) return;
const element = captureEventTarget(state.raw.target);
const dragByHandle = !!element?.closest(AFFINE_DRAG_HANDLE_WIDGET);
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 _getDraggedBlock = (draggedBlock: BlockComponent) => {
return this._selectAndSetDraggingBlock(draggedBlock);
};
private readonly _selectAndSetDraggingBlock = (
hoveredBlock: BlockComponent
) => {
this.std.selection.setGroup('note', [
this.std.selection.create(BlockSelection, {
blockId: hoveredBlock.blockId,
}),
]);
return {
models: [hoveredBlock.model],
snapshot: this._toSnapshot([hoveredBlock]),
};
};
private readonly _getSnapshotFromHoveredBlocks = () => {
const hoverBlock = this.widget.anchorBlockComponent.peek()!;
const isInSurface = isGfxBlockComponent(hoverBlock);
if (isInSurface && dragByHandle) {
this._startDragging([hoverBlock], state);
return true;
if (isInSurface) {
return {
models: [hoverBlock.model],
snapshot: this._toSnapshot([hoverBlock]),
};
}
const selectBlockAndStartDragging = () => {
this._std.selection.setGroup('note', [
this._std.selection.create(BlockSelection, {
blockId: hoverBlock.blockId,
}),
]);
this._startDragging([hoverBlock], state);
};
if (this.widget.draggingElements.length === 0) {
const dragByBlock =
hoverBlock.contains(element) && !hoverBlock.model.text;
const canDragByBlock =
matchFlavours(hoverBlock.model, [
'affine:attachment',
'affine:bookmark',
]) || hoverBlock.model.flavour.startsWith('affine:embed-');
if (!isInSurface && dragByBlock && canDragByBlock) {
selectBlockAndStartDragging();
return true;
}
}
// Should only start dragging when pointer down on drag handle
// And current mouse button is left button
if (!dragByHandle) {
this.widget.hide();
return false;
}
if (this.widget.draggingElements.length === 1 && !isInSurface) {
selectBlockAndStartDragging();
return true;
}
if (!this.widget.isHoverDragHandleVisible) return false;
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;
const rangeManager = this.std.range;
if (nativeSelection && nativeSelection.rangeCount > 0 && rangeManager) {
const range = nativeSelection.getRangeAt(0);
const blocks = rangeManager.getSelectedBlockComponentsByRange(range, {
@@ -224,10 +314,7 @@ export class DragEventWatcher {
this.widget.anchorBlockId.peek()!
)
) {
const block = this.widget.anchorBlockComponent.peek();
if (block) {
this.widget.selectionHelper.setSelectedBlocks([block]);
}
this.widget.selectionHelper.setSelectedBlocks([hoverBlock]);
}
const collapsedBlock: BlockComponent[] = [];
@@ -258,58 +345,73 @@ export class DragEventWatcher {
blocks
) as BlockComponent[];
if (blocksExcludingChildren.length === 0) return false;
this._startDragging(blocksExcludingChildren, state);
this.widget.hide();
return true;
return {
models: blocksExcludingChildren.map(block => block.model),
snapshot: this._toSnapshot(blocksExcludingChildren),
};
};
private readonly _onDrop = (context: UIEventStateContext) => {
const state = context.get('dndState');
private readonly _onDrop = (
dropBlock: BlockComponent,
dragPayload: DragBlockPayload,
dropPayload: DropPayload,
point: Point
) => {
const result = this._getDropResult(dropBlock, dragPayload, dropPayload);
const snapshot = dragPayload?.bsEntity?.snapshot;
const event = state.raw;
event.preventDefault();
if (!result || !snapshot || snapshot.content.length === 0) return;
const { clientX, clientY } = event;
const point = new Point(clientX, clientY);
const element = getClosestBlockComponentByPoint(point.clone());
if (!element) {
const target = captureEventTarget(event.target);
const isEdgelessContainer =
target?.classList.contains('edgeless-container');
if (!isEdgelessContainer) return;
// drop to edgeless container
this._onDropOnEdgelessCanvas(context);
return;
{
const isEdgelessContainer = dropBlock.closest('.edgeless-container');
if (isEdgelessContainer) {
// drop to edgeless container
this._onDropOnEdgelessCanvas(
dropBlock,
dragPayload,
dropPayload,
point
);
return;
}
}
const model = element.model;
const parent = this._std.store.getParent(model.id);
const model = result.modelState.model;
const parent =
result.placement === 'in' ? model : this.std.store.getParent(model);
if (!parent) return;
if (matchFlavours(parent, ['affine:surface'])) {
return;
}
const target: DropTarget | null = calcDropTarget(point, model, element);
if (!target) return;
const index =
parent.children.indexOf(model) + (target.placement === 'before' ? 0 : 1);
result.placement === 'in'
? 0
: parent.children.indexOf(model) +
(result.placement === 'before' ? 0 : 1);
if (matchFlavours(parent, ['affine:note'])) {
const snapshot = this._deserializeSnapshot(state);
if (snapshot) {
const [first] = snapshot.content;
if (first.flavour === 'affine:note') {
if (parent.id !== first.id) {
this._onDropNoteOnNote(snapshot, parent.id, index);
}
return;
const [first] = snapshot.content;
if (first.flavour === 'affine:note') {
if (parent.id !== first.id) {
this._onDropNoteOnNote(snapshot, parent.id, index);
}
return;
}
}
this._deserializeData(state, parent.id, index).catch(console.error);
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 _onDropNoteOnNote = (
@@ -320,7 +422,7 @@ export class DragEventWatcher {
const [first] = snapshot.content;
const id = first.id;
const std = this._std;
const std = this.std;
const job = this._getJob();
const snapshotWithoutNote = {
...snapshot,
@@ -337,13 +439,18 @@ export class DragEventWatcher {
.catch(console.error);
};
private readonly _onDropOnEdgelessCanvas = (context: UIEventStateContext) => {
const state = context.get('dndState');
// If drop a note, should do nothing
const snapshot = this._deserializeSnapshot(state);
private readonly _onDropOnEdgelessCanvas = (
dropBlock: BlockComponent,
dragPayload: DragBlockPayload,
dropPayload: DropPayload,
point: Point
) => {
const surfaceBlockModel = getSurfaceBlock(this.widget.doc);
const result = this._getDropResult(dropBlock, dragPayload, dropPayload);
if (!snapshot || !surfaceBlockModel) {
const snapshot = dragPayload?.bsEntity?.snapshot;
if (!result || !snapshot || !surfaceBlockModel) {
return;
}
@@ -360,7 +467,7 @@ export class DragEventWatcher {
first.props.width = width;
first.props.height = height;
const std = this._std;
const std = this.std;
const job = this._getJob();
job
.snapshotToSlice(snapshot, std.store, surfaceBlockModel.id)
@@ -376,8 +483,8 @@ export class DragEventWatcher {
const height = EMBED_CARD_HEIGHT[style];
const newBound = this._computeEdgelessBound(
state.raw.clientX,
state.raw.clientY,
point.x,
point.y,
width,
height
);
@@ -397,8 +504,8 @@ export class DragEventWatcher {
const height = Number(first.props.height || 100) * noteScale;
const newBound = this._computeEdgelessBound(
state.raw.clientX,
state.raw.clientY,
point.x,
point.y,
width,
height
);
@@ -409,10 +516,11 @@ export class DragEventWatcher {
}
}
const { left: viewportLeft, top: viewportTop } = this._gfx.viewport;
const newNoteId = addNoteAtPoint(
this._std,
new Point(state.raw.x - viewportLeft, state.raw.y - viewportTop),
this.std,
Point.from(
this._gfx.viewport.toModelCoordFromClientCoord([point.x, point.y])
),
{
scale: this.widget.noteScale.peek(),
}
@@ -433,49 +541,32 @@ export class DragEventWatcher {
},
});
this._deserializeData(state, newNoteId).catch(console.error);
this._dropToModel(snapshot, newNoteId).catch(console.error);
};
private readonly _startDragging = (
blocks: BlockComponent[],
state: DndEventState,
dragPreviewEl?: HTMLElement,
dragPreviewOffset?: Point
) => {
if (!blocks.length) {
return;
}
this.widget.draggingElements = blocks;
this.widget.dragPreview = this.widget.previewHelper.createDragPreview(
blocks,
state,
dragPreviewEl,
dragPreviewOffset
);
private readonly _toSnapshot = (blocks: BlockComponent[]) => {
const slice = Slice.fromModels(
this._std.store,
this.std.store,
blocks.map(block => block.model)
);
const job = this._getJob();
this.widget.dragging = true;
this._createDropIndicator();
this.widget.hide();
this._serializeData(slice, state);
const snapshot = job.sliceToSnapshot(slice);
if (!snapshot) return;
return snapshot;
};
private readonly _trackLinkedDocCreated = (id: string) => {
const isNewBlock = !this._std.store.hasBlock(id);
const isNewBlock = !this.std.store.hasBlock(id);
if (!isNewBlock) {
return;
}
const mode =
this._std.getOptional(DocModeProvider)?.getEditorMode() ?? 'page';
this.std.getOptional(DocModeProvider)?.getEditorMode() ?? 'page';
const telemetryService = this._std.getOptional(TelemetryProvider);
const telemetryService = this.std.getOptional(TelemetryProvider);
telemetryService?.track('LinkedDocCreated', {
control: `drop on ${mode}`,
module: 'drag and drop',
@@ -484,67 +575,38 @@ export class DragEventWatcher {
});
};
private get _dndAPI() {
return this._std.get(DndApiExtensionIdentifier);
}
private get _std() {
return this.widget.std;
}
constructor(readonly widget: AffineDragHandleWidget) {}
private async _deserializeData(
state: DndEventState,
private async _dropToModel(
snapshot: SliceSnapshot,
parent?: string,
index?: number
) {
try {
const dataTransfer = state.raw.dataTransfer;
if (!dataTransfer) throw new Error('No data transfer');
const std = this._std;
const std = this.std;
const job = this._getJob();
const snapshot = this._deserializeSnapshot(state);
if (snapshot) {
if (snapshot.content.length === 1) {
const [first] = snapshot.content;
if (first.flavour === 'affine:embed-linked-doc') {
this._trackLinkedDocCreated(first.id);
}
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;
}
return null;
} catch {
return null;
}
}
private _deserializeSnapshot(state: DndEventState) {
try {
const dataTransfer = state.raw.dataTransfer;
if (!dataTransfer) throw new Error('No data transfer');
const data = dataTransfer.getData(this._dndAPI.mimeType);
const snapshot = this._dndAPI.decodeSnapshot(data);
return snapshot;
// use snapshot
const slice = await job.snapshotToSlice(
snapshot,
std.store,
parent,
index
);
return slice;
} catch {
return null;
}
}
private _getJob() {
const std = this._std;
const std = this.std;
return std.getTransformer([
newIdCrossDoc(std),
reorderList(std),
@@ -552,17 +614,8 @@ export class DragEventWatcher {
]);
}
private _serializeData(slice: Slice, state: DndEventState) {
const dataTransfer = state.raw.dataTransfer;
if (!dataTransfer) return;
const job = this._getJob();
const snapshot = job.sliceToSnapshot(slice);
if (!snapshot) return;
const data = this._dndAPI.encodeSnapshot(snapshot);
dataTransfer.setData(this._dndAPI.mimeType, data);
private _isDropOnCurrentEditor(std?: BlockStdScope) {
return std === this.std;
}
watch() {
@@ -591,17 +644,247 @@ export class DragEventWatcher {
return;
});
this.widget.handleEvent('nativeDragStart', this._dragStartHandler, {
global: true,
const widget = this.widget;
const std = this.std;
const disposables = widget.disposables;
const scrollable = getScrollContainer(this.host);
disposables.add(
std.dnd.draggable<DragBlockEntity>({
element: this.widget,
canDrag: () => {
const hoverBlock = this.widget.anchorBlockComponent.peek();
return hoverBlock ? true : false;
},
onDragStart: () => {
this.widget.dragging = true;
},
onDrop: () => {
this._cleanup();
},
setDragPreview: ({ source, container }) => {
if (!source.data?.bsEntity?.modelIds.length) {
return;
}
const query = widget.previewHelper['_calculateQuery'](
source.data?.bsEntity?.modelIds as string[]
);
const store = widget.doc.doc.getStore({ query });
const previewSpec =
SpecProvider.getInstance().getSpec('page:preview');
const previewStd = new BlockStdScope({
store,
extensions: previewSpec.value,
});
const previewTemplate = previewStd.render();
const noteBlock = this.widget.host.querySelector('affine-note');
container.style.width = `${noteBlock?.offsetWidth ?? noteBlock?.clientWidth ?? 500}px`;
container.append(previewTemplate);
},
setDragData: () => {
const { snapshot } = this._getSnapshotFromHoveredBlocks();
return {
type: 'blocks',
modelIds: snapshot ? extractIdsFromSnapshot(snapshot) : [],
snapshot,
};
},
})
);
if (scrollable) {
disposables.add(
std.dnd.autoScroll<DragBlockEntity>({
element: scrollable,
canScroll: ({ source }) => {
return source.data?.bsEntity?.type === 'blocks';
},
})
);
}
// used to handle drag move and drop
disposables.add(
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
);
},
})
);
let dropTargetCleanUps: Map<string, (() => void)[]> = new Map();
const makeBlockComponentDropTarget = (view: BlockComponent) => {
if (view.model.role !== 'content' && view.model.role !== 'hub') {
return;
}
const cleanups: (() => void)[] = [];
cleanups.push(
std.dnd.dropTarget<
DragBlockEntity,
{
modelId: string;
}
>({
element: view,
getIsSticky: () => true,
canDrop: ({ source }) => {
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;
},
setDropData: () => {
return {
modelId: view.model.id,
};
},
})
);
if (matchFlavours(view.model, ['affine:attachment', 'affine:bookmark'])) {
cleanups.push(
std.dnd.draggable<DragBlockEntity>({
element: view,
canDrag: () => {
return !isGfxBlockComponent(view);
},
onDragStart: () => {
this.widget.dragging = true;
},
onDrop: () => {
this._cleanup();
},
setDragPreview: ({ source, container }) => {
if (!source.data?.bsEntity?.modelIds.length) {
return;
}
const query = widget.previewHelper['_calculateQuery'](
source.data?.bsEntity?.modelIds as string[]
);
const store = widget.doc.doc.getStore({ query });
const previewSpec =
SpecProvider.getInstance().getSpec('page:preview');
const previewStd = new BlockStdScope({
store,
extensions: previewSpec.value,
});
const previewTemplate = previewStd.render();
container.style.width = `${std.host.clientWidth || std.host.offsetWidth || 500}px`;
container.append(previewTemplate);
},
setDragData: () => {
const { snapshot } = this._getDraggedBlock(view);
return {
type: 'blocks',
modelIds: snapshot ? extractIdsFromSnapshot(snapshot) : [],
snapshot,
};
},
})
);
}
dropTargetCleanUps.set(view.model.id, cleanups);
};
disposables.add(
std.view.viewUpdated.on(payload => {
if (payload.type === 'add') {
makeBlockComponentDropTarget(payload.view);
} else if (
payload.type === 'delete' &&
dropTargetCleanUps.has(payload.id)
) {
dropTargetCleanUps.get(payload.id)!.forEach(clean => clean());
dropTargetCleanUps.delete(payload.id);
}
})
);
std.view.views.forEach(block => {
makeBlockComponentDropTarget(block);
});
this.widget.handleEvent('nativeDragMove', this._dragMoveHandler, {
global: true,
});
this.widget.handleEvent('nativeDragEnd', this._dragEndHandler, {
global: true,
});
this.widget.handleEvent('nativeDrop', this._dropHandler, {
global: true,
disposables.add(() => {
dropTargetCleanUps.forEach(cleanUps => cleanUps.forEach(fn => fn()));
dropTargetCleanUps.clear();
});
}
}

View File

@@ -6,7 +6,6 @@ import {
getSelectedRect,
isTopLevelBlock,
} from '@blocksuite/affine-shared/utils';
import type { DndEventState } from '@blocksuite/block-std';
import {
GfxControllerIdentifier,
type GfxToolsFullOptionValue,
@@ -44,7 +43,6 @@ export class EdgelessWatcher {
}) => {
if (this.widget.scale.peek() !== zoom) {
this.widget.scale.value = zoom;
this._updateDragPreviewOnViewportUpdate();
}
if (
@@ -52,7 +50,6 @@ export class EdgelessWatcher {
this.widget.center[1] !== center[1]
) {
this.widget.center = [...center];
this.widget.updateDropIndicatorOnScroll();
}
if (this.widget.isTopLevelDragHandleVisible) {
@@ -112,12 +109,6 @@ export class EdgelessWatcher {
this.widget.dragHoverRect = this.hoverAreaRectTopLevelBlock;
};
private readonly _updateDragPreviewOnViewportUpdate = () => {
if (this.widget.dragPreview && this.widget.lastDragPointerState) {
this.updateDragPreviewPosition(this.widget.lastDragPointerState);
}
};
checkTopLevelBlockSelection = () => {
if (!this.widget.isConnected) return;
@@ -147,24 +138,6 @@ export class EdgelessWatcher {
this._showDragHandleOnTopLevelBlocks().catch(console.error);
};
updateDragPreviewPosition = (state: DndEventState) => {
if (!this.widget.dragPreview) return;
const offsetParentRect =
this.widget.dragHandleContainerOffsetParent.getBoundingClientRect();
const dragPreviewOffset = this.widget.dragPreview.offset;
const posX = state.raw.x - dragPreviewOffset.x - offsetParentRect.left;
const posY = state.raw.y - dragPreviewOffset.y - offsetParentRect.top;
this.widget.dragPreview.style.transform = `translate(${posX}px, ${posY}px) scale(${this.widget.scaleInNote.peek()})`;
const altKey = state.raw.altKey;
this.widget.dragPreview.style.opacity = altKey ? '1' : '0.5';
};
get hoverAreaRectTopLevelBlock() {
const area = this.hoverAreaTopLevelBlock;
if (!area) return null;

View File

@@ -4,7 +4,7 @@ import type { AffineDragHandleWidget } from '../drag-handle.js';
export class KeyboardEventWatcher {
private readonly _keyboardHandler: UIEventHandler = ctx => {
if (!this.widget.dragging || !this.widget.dragPreview) {
if (!this.widget.dragging) {
return;
}
@@ -12,9 +12,6 @@ export class KeyboardEventWatcher {
const event = state.event as KeyboardEvent;
event.preventDefault();
event.stopPropagation();
const altKey = event.key === 'Alt' && event.altKey;
this.widget.dragPreview.style.opacity = altKey ? '1' : '0.5';
};
constructor(readonly widget: AffineDragHandleWidget) {}

View File

@@ -1,5 +1,4 @@
import { PageViewportService } from '@blocksuite/affine-shared/services';
import { getScrollContainer } from '@blocksuite/affine-shared/utils';
import type { AffineDragHandleWidget } from '../drag-handle.js';
@@ -12,7 +11,6 @@ export class PageWatcher {
watch() {
const { disposables } = this.widget;
const scrollContainer = getScrollContainer(this.widget.rootComponent);
disposables.add(
this.widget.doc.slots.blockUpdated.on(() => this.widget.hide())
@@ -21,16 +19,7 @@ export class PageWatcher {
disposables.add(
this.pageViewportService.on(() => {
this.widget.hide();
if (this.widget.dropIndicator) {
this.widget.dropIndicator.rect = null;
}
})
);
disposables.addFromEvent(
scrollContainer,
'scrollend',
this.widget.updateDropIndicatorOnScroll
);
}
}