feat: edgeless dnd (#9988)

### Changed
- Support edgelss dnd
- Simplify the drag-handle state
This commit is contained in:
doouding
2025-02-12 12:37:06 +00:00
parent 8129434a2e
commit f89fcf82f8
22 changed files with 1041 additions and 350 deletions
@@ -4,10 +4,9 @@ import { DocModeProvider } from '@blocksuite/affine-shared/services';
import {
isInsideEdgelessEditor,
isInsidePageEditor,
isTopLevelBlock,
} from '@blocksuite/affine-shared/utils';
import { type BlockComponent, WidgetComponent } from '@blocksuite/block-std';
import type { GfxBlockElementModel } from '@blocksuite/block-std/gfx';
import type { GfxModel } from '@blocksuite/block-std/gfx';
import {
DisposableGroup,
type IVec,
@@ -15,8 +14,9 @@ import {
type Rect,
} from '@blocksuite/global/utils';
import { computed, type ReadonlySignal, signal } from '@preact/signals-core';
import { html } from 'lit';
import { html, nothing } from 'lit';
import { query, state } from 'lit/decorators.js';
import { classMap } from 'lit/directives/class-map.js';
import { styleMap } from 'lit/directives/style-map.js';
import type { AFFINE_DRAG_HANDLE_WIDGET } from './consts.js';
@@ -53,12 +53,13 @@ export class AffineDragHandleWidget extends WidgetComponent<RootBlockModel> {
this.dragHoverRect = null;
this.anchorBlockId.value = null;
this.isDragHandleHovered = false;
this.isHoverDragHandleVisible = false;
this.isTopLevelDragHandleVisible = false;
this.pointerEventWatcher.reset();
};
@state()
accessor activeDragHandle: 'block' | 'gfx' | null = null;
anchorBlockId = signal<string | null>(null);
anchorBlockComponent = computed<BlockComponent | null>(() => {
@@ -67,16 +68,14 @@ export class AffineDragHandleWidget extends WidgetComponent<RootBlockModel> {
return this.std.view.getBlock(this.anchorBlockId.value);
});
anchorEdgelessElement: ReadonlySignal<GfxBlockElementModel | null> = computed(
() => {
if (!this.anchorBlockId.value) return null;
if (this.mode === 'page') return null;
anchorEdgelessElement: ReadonlySignal<GfxModel | null> = computed(() => {
if (!this.anchorBlockId.value) return null;
if (this.mode === 'page') return null;
const crud = this.std.get(EdgelessCRUDIdentifier);
const edgelessElement = crud.getElementById(this.anchorBlockId.value);
return isTopLevelBlock(edgelessElement) ? edgelessElement : null;
}
);
const crud = this.std.get(EdgelessCRUDIdentifier);
const edgelessElement = crud.getElementById(this.anchorBlockId.value);
return edgelessElement;
});
// Single block: drag handle should show on the vertical middle of the first line of element
center: IVec = [0, 0];
@@ -115,15 +114,18 @@ export class AffineDragHandleWidget extends WidgetComponent<RootBlockModel> {
if (this.dragging && !force) return;
updateDragHandleClassName();
this.isHoverDragHandleVisible = false;
this.isTopLevelDragHandleVisible = false;
this.isDragHandleHovered = false;
this.anchorBlockId.value = null;
this.activeDragHandle = null;
if (this.dragHandleContainer) {
this.dragHandleContainer.removeAttribute('style');
this.dragHandleContainer.style.display = 'none';
}
if (this.dragHandleGrabber) {
this.dragHandleGrabber.removeAttribute('style');
}
if (force) {
this._reset();
@@ -132,9 +134,13 @@ export class AffineDragHandleWidget extends WidgetComponent<RootBlockModel> {
isDragHandleHovered = false;
isHoverDragHandleVisible = false;
get isBlockDragHandleVisible() {
return this.activeDragHandle === 'block';
}
isTopLevelDragHandleVisible = false;
get isGfxDragHandleVisible() {
return this.activeDragHandle === 'gfx';
}
noteScale = signal(1);
@@ -190,7 +196,7 @@ export class AffineDragHandleWidget extends WidgetComponent<RootBlockModel> {
override render() {
const hoverRectStyle = styleMap(
this.dragHoverRect
this.dragHoverRect && this.activeDragHandle
? {
width: `${this.dragHoverRect.width}px`,
height: `${this.dragHoverRect.height}px`,
@@ -201,11 +207,27 @@ export class AffineDragHandleWidget extends WidgetComponent<RootBlockModel> {
display: 'none',
}
);
const isGfx = this.activeDragHandle === 'gfx';
const classes = {
'affine-drag-handle-grabber': true,
dots: isGfx ? true : false,
};
return html`
<div class="affine-drag-handle-widget">
<div class="affine-drag-handle-container" draggable="true">
<div class="affine-drag-handle-grabber"></div>
<div class="affine-drag-handle-container">
<div class=${classMap(classes)}>
${isGfx
? html`
<div class="dot"></div>
<div class="dot"></div>
<div class="dot"></div>
<div class="dot"></div>
<div class="dot"></div>
<div class="dot"></div>
`
: nothing}
</div>
</div>
<div class="affine-drag-hover-rect" style=${hoverRectStyle}></div>
</div>
@@ -15,7 +15,7 @@ import {
export class RectHelper {
private readonly _getHoveredBlocks = (): BlockComponent[] => {
if (!this.widget.isHoverDragHandleVisible || !this.widget.anchorBlockId)
if (!this.widget.isBlockDragHandleVisible || !this.widget.anchorBlockId)
return [];
const hoverBlock = this.widget.anchorBlockComponent.peek();
@@ -0,0 +1,49 @@
import type { SurfaceBlockModel } from '@blocksuite/affine-block-surface';
import type { BlockStdScope } from '@blocksuite/block-std';
import { isGfxGroupCompatibleModel } from '@blocksuite/block-std/gfx';
import type { TransformerMiddleware } from '@blocksuite/store';
/**
* Used to filter out gfx elements that are not selected
* @param ids
* @param std
* @returns
*/
export const gfxBlocksFilter = (
ids: string[],
std: BlockStdScope
): TransformerMiddleware => {
const selectedIds = new Set<string>();
const store = std.store;
const surface = store.getBlocksByFlavour('affine:surface')[0]
.model as SurfaceBlockModel;
const idsToCheck = ids.slice();
for (const id of idsToCheck) {
const blockOrElem = store.getBlock(id)?.model ?? surface.getElementById(id);
if (!blockOrElem) continue;
if (isGfxGroupCompatibleModel(blockOrElem)) {
idsToCheck.push(...blockOrElem.childIds);
}
selectedIds.add(id);
}
return ({ slots, transformerConfigs }) => {
slots.beforeExport.on(payload => {
if (payload.type !== 'block') {
return;
}
if (payload.model.flavour === 'affine:surface') {
transformerConfigs.set('selectedElements', selectedIds);
payload.model.children = payload.model.children.filter(model =>
selectedIds.has(model.id)
);
return;
}
});
};
};
@@ -1,3 +1,4 @@
import { unsafeCSSVarV2 } from '@blocksuite/affine-shared/theme';
import { css } from 'lit';
import { DRAG_HANDLE_CONTAINER_WIDTH } from './config.js';
@@ -35,6 +36,32 @@ export const styles = css`
transition: width 0.25s ease;
}
.affine-drag-handle-grabber.dots {
width: 14px;
height: 26px;
box-sizing: border-box;
padding: 5px 2px;
border-radius: 4px;
gap: 2px;
display: flex;
flex-wrap: wrap;
background-color: transparent;
transform: translateX(-100%);
transition: unset;
}
.affine-drag-handle-grabber.dots:hover {
background-color: ${unsafeCSSVarV2('layer/background/hoverOverlay')};
}
.affine-drag-handle-grabber.dots > .dot {
width: 4px;
height: 4px;
border-radius: 50%;
flex: 0 0 4px;
background-color: ${unsafeCSSVarV2('icon/secondary')};
}
@media print {
.affine-drag-handle-widget {
display: none;
@@ -151,7 +151,7 @@ export const isOutOfNoteBlock = (
};
export const getParentNoteBlock = (blockComponent: BlockComponent) => {
return blockComponent.closest('affine-note') ?? null;
return blockComponent.closest('affine-note, affine-edgeless-note') ?? null;
};
export const getClosestNoteBlock = (
File diff suppressed because it is too large Load Diff
@@ -2,10 +2,7 @@ import {
EdgelessLegacySlotIdentifier,
type SurfaceBlockComponent,
} from '@blocksuite/affine-block-surface';
import {
getSelectedRect,
isTopLevelBlock,
} from '@blocksuite/affine-shared/utils';
import { getSelectedRect } from '@blocksuite/affine-shared/utils';
import {
GfxControllerIdentifier,
type GfxToolsFullOptionValue,
@@ -16,19 +13,23 @@ import { effect } from '@preact/signals-core';
import {
DRAG_HANDLE_CONTAINER_OFFSET_LEFT_TOP_LEVEL,
DRAG_HANDLE_CONTAINER_WIDTH_TOP_LEVEL,
DRAG_HANDLE_GRABBER_BORDER_RADIUS,
DRAG_HANDLE_GRABBER_WIDTH_HOVERED,
HOVER_AREA_RECT_PADDING_TOP_LEVEL,
} from '../config.js';
import type { AffineDragHandleWidget } from '../drag-handle.js';
/**
* Used to control the drag handle visibility in edgeless mode
*
* 1. Show drag handle on every block and gfx element
* 2. Multiple selection is not supported
*/
export class EdgelessWatcher {
private readonly _handleEdgelessToolUpdated = (
newTool: GfxToolsFullOptionValue
) => {
// @ts-expect-error FIXME: resolve after gfx tool refactor
if (newTool.type === 'default') {
this.checkTopLevelBlockSelection();
this.updateAnchorElement();
} else {
this.widget.hide();
}
@@ -52,17 +53,15 @@ export class EdgelessWatcher {
this.widget.center = [...center];
}
if (this.widget.isTopLevelDragHandleVisible) {
this._showDragHandleOnTopLevelBlocks().catch(console.error);
if (this.widget.isGfxDragHandleVisible) {
this._showDragHandle().catch(console.error);
this._updateDragHoverRectTopLevelBlock();
} else {
} else if (this.widget.activeDragHandle) {
this.widget.hide();
}
};
private readonly _showDragHandleOnTopLevelBlocks = async () => {
if (this.widget.mode === 'page') return;
private readonly _showDragHandle = async () => {
const surfaceModel = this.widget.doc.getBlockByFlavour('affine:surface');
const surface = this.widget.std.view.getBlock(
surfaceModel[0]!.id
@@ -75,84 +74,67 @@ export class EdgelessWatcher {
const grabber = this.widget.dragHandleGrabber;
if (!container || !grabber) return;
const area = this.hoverAreaTopLevelBlock;
const area = this.hoveredElemArea;
if (!area) return;
const height = area.height;
const posLeft = area.left;
const posTop = (area.top += area.padding);
container.style.transition = 'none';
container.style.paddingTop = `0px`;
container.style.paddingBottom = `0px`;
container.style.width = `${area.containerWidth}px`;
container.style.left = `${posLeft}px`;
container.style.top = `${posTop}px`;
container.style.left = `${area.left}px`;
container.style.top = `${area.top}px`;
container.style.display = 'flex';
container.style.height = `${height}px`;
grabber.style.width = `${DRAG_HANDLE_GRABBER_WIDTH_HOVERED * this.widget.scale.peek()}px`;
grabber.style.borderRadius = `${
DRAG_HANDLE_GRABBER_BORDER_RADIUS * this.widget.scale.peek()
}px`;
this.widget.handleAnchorModelDisposables();
this.widget.isTopLevelDragHandleVisible = true;
this.widget.activeDragHandle = 'gfx';
};
private readonly _updateDragHoverRectTopLevelBlock = () => {
if (!this.widget.dragHoverRect) return;
this.widget.dragHoverRect = this.hoverAreaRectTopLevelBlock;
this.widget.dragHoverRect = this.hoveredElemAreaRect;
};
checkTopLevelBlockSelection = () => {
if (!this.widget.isConnected) return;
get gfx() {
return this.widget.std.get(GfxControllerIdentifier);
}
updateAnchorElement = () => {
if (!this.widget.isConnected) return;
if (this.widget.doc.readonly || this.widget.mode === 'page') {
this.widget.hide();
return;
}
const { std } = this.widget;
const gfx = std.get(GfxControllerIdentifier);
const { selection } = gfx;
const { selection } = this.gfx;
const editing = selection.editing;
const selectedElements = selection.selectedElements;
if (editing || selectedElements.length !== 1) {
if (editing || selectedElements.length !== 1 || this.widget.doc.readonly) {
this.widget.hide();
return;
}
const selectedElement = selectedElements[0];
if (!isTopLevelBlock(selectedElement)) {
this.widget.hide();
return;
}
this.widget.anchorBlockId.value = selectedElement.id;
this._showDragHandleOnTopLevelBlocks().catch(console.error);
this._showDragHandle().catch(console.error);
};
get hoverAreaRectTopLevelBlock() {
const area = this.hoverAreaTopLevelBlock;
get hoveredElemAreaRect() {
const area = this.hoveredElemArea;
if (!area) return null;
return new Rect(area.left, area.top, area.right, area.bottom);
}
get hoverAreaTopLevelBlock() {
get hoveredElemArea() {
const edgelessElement = this.widget.anchorEdgelessElement.peek();
if (!edgelessElement) return null;
const { std } = this.widget;
const gfx = std.get(GfxControllerIdentifier);
const { viewport } = gfx;
const { viewport } = this.gfx;
const rect = getSelectedRect([edgelessElement]);
let [left, top] = viewport.toViewCoord(rect.left, rect.top);
const scale = this.widget.scale.peek();
@@ -186,6 +168,10 @@ export class EdgelessWatcher {
constructor(readonly widget: AffineDragHandleWidget) {}
watch() {
if (this.widget.mode === 'page') {
return;
}
const { disposables, std } = this.widget;
const gfx = std.get(GfxControllerIdentifier);
const { viewport, selection, tool } = gfx;
@@ -197,7 +183,19 @@ export class EdgelessWatcher {
disposables.add(
selection.slots.updated.on(() => {
this.checkTopLevelBlockSelection();
this.updateAnchorElement();
})
);
disposables.add(
edgelessSlots.readonlyUpdated.on(() => {
this.updateAnchorElement();
})
);
disposables.add(
edgelessSlots.elementResizeEnd.on(() => {
this.updateAnchorElement();
})
);
@@ -209,22 +207,10 @@ export class EdgelessWatcher {
})
);
disposables.add(
edgelessSlots.readonlyUpdated.on(() => {
this.checkTopLevelBlockSelection();
})
);
disposables.add(
edgelessSlots.elementResizeStart.on(() => {
this.widget.hide();
})
);
disposables.add(
edgelessSlots.elementResizeEnd.on(() => {
this.checkTopLevelBlockSelection();
})
);
}
}
@@ -7,7 +7,7 @@ import type { AffineDragHandleWidget } from '../drag-handle.js';
export class HandleEventWatcher {
private readonly _onDragHandlePointerDown = () => {
if (!this.widget.isHoverDragHandleVisible || !this.widget.anchorBlockId)
if (!this.widget.isBlockDragHandleVisible || !this.widget.anchorBlockId)
return;
this.widget.dragHoverRect = this.widget.draggingAreaRect.value;
@@ -18,7 +18,7 @@ export class HandleEventWatcher {
const grabber = this.widget.dragHandleGrabber;
if (!container || !grabber) return;
if (this.widget.isHoverDragHandleVisible && this.widget.anchorBlockId) {
if (this.widget.isBlockDragHandleVisible && this.widget.anchorBlockId) {
const block = this.widget.anchorBlockComponent;
if (!block) return;
@@ -35,9 +35,9 @@ export class HandleEventWatcher {
}px`;
this.widget.isDragHandleHovered = true;
} else if (this.widget.isTopLevelDragHandleVisible) {
} else if (this.widget.isGfxDragHandleVisible) {
this.widget.dragHoverRect =
this.widget.edgelessWatcher.hoverAreaRectTopLevelBlock;
this.widget.edgelessWatcher.hoveredElemAreaRect;
this.widget.isDragHandleHovered = true;
}
};
@@ -46,7 +46,7 @@ export class HandleEventWatcher {
this.widget.isDragHandleHovered = false;
this.widget.dragHoverRect = null;
if (this.widget.isTopLevelDragHandleVisible) return;
if (this.widget.isGfxDragHandleVisible) return;
if (this.widget.dragging) return;
@@ -54,7 +54,7 @@ export class HandleEventWatcher {
};
private readonly _onDragHandlePointerUp = () => {
if (!this.widget.isHoverDragHandleVisible) return;
if (!this.widget.isBlockDragHandleVisible) return;
this.widget.dragHoverRect = null;
};
@@ -29,6 +29,9 @@ import {
updateDragHandleClassName,
} from '../utils.js';
/**
* Used to control the drag handle visibility in page mode
*/
export class PointerEventWatcher {
private get _gfx() {
return this.widget.std.get(GfxControllerIdentifier);
@@ -51,7 +54,7 @@ export class PointerEventWatcher {
* Should clear selection if current block is the first selected block
*/
private readonly _clickHandler: UIEventHandler = ctx => {
if (!this.widget.isHoverDragHandleVisible) return;
if (!this.widget.isBlockDragHandleVisible) return;
const state = ctx.get('pointerState');
const { target } = state.raw;
@@ -152,7 +155,7 @@ export class PointerEventWatcher {
* And update hover block id and path
*/
private readonly _pointerMoveOnBlock = (state: PointerEventState) => {
if (this.widget.isTopLevelDragHandleVisible) return;
if (this.widget.isGfxDragHandleVisible) return;
const point = new Point(state.raw.x, state.raw.y);
const closestBlock = getClosestBlockByPoint(
@@ -182,7 +185,7 @@ export class PointerEventWatcher {
this.widget.anchorBlockId.peek(),
this._lastHoveredBlockId
) ||
!this.widget.isHoverDragHandleVisible) &&
!this.widget.isBlockDragHandleVisible) &&
!this.widget.isDragHandleHovered
) {
this.showDragHandleOnHoverBlock();
@@ -224,7 +227,7 @@ export class PointerEventWatcher {
this.widget.hide();
return;
}
if (this.widget.isTopLevelDragHandleVisible) return;
if (this.widget.isGfxDragHandleVisible) return;
const state = ctx.get('pointerState');
const { target } = state.raw;
@@ -263,7 +266,9 @@ export class PointerEventWatcher {
return true;
}
this.widget.hide();
if (this.widget.activeDragHandle) {
this.widget.hide();
}
return false;
},
1000 / 60
@@ -278,7 +283,7 @@ export class PointerEventWatcher {
const grabber = this.widget.dragHandleGrabber;
if (!container || !grabber) return;
this.widget.isHoverDragHandleVisible = true;
this.widget.activeDragHandle = 'block';
const draggingAreaRect = this.widget.draggingAreaRect.peek();
if (!draggingAreaRect) return;