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:
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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[];
|
||||
}
|
||||
|
||||
|
||||
@@ -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[] = [];
|
||||
|
||||
@@ -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>`;
|
||||
}
|
||||
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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) {}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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) {}
|
||||
|
||||
@@ -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
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user