mirror of
https://github.com/toeverything/AFFiNE.git
synced 2026-02-15 05:37:32 +00:00
feat(editor): add page dragging area widget extension (#12045)
Closes: BS-3364 <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit - **New Features** - Introduced a new "Page Dragging Area" widget, enabling enhanced block selection and drag area detection within the user interface. - Added utilities for more precise block selection based on rectangular selection areas. - **Improvements** - Integrated the new widget into the view extension system for consistent behavior across supported views. - Enhanced clipboard handling with comprehensive adapter configurations for various data types. - **Refactor** - Streamlined widget registration and block selection logic for improved maintainability and modularity. - Removed legacy widget exports and registrations to centralize widget management. - **Chores** - Updated workspace and TypeScript configurations to support the new widget module. <!-- end of auto-generated comment: release notes by coderabbit.ai -->
This commit is contained in:
@@ -21,15 +21,10 @@ import {
|
||||
PageRootBlockComponent,
|
||||
PreviewRootBlockComponent,
|
||||
} from './index.js';
|
||||
import {
|
||||
AFFINE_PAGE_DRAGGING_AREA_WIDGET,
|
||||
AffinePageDraggingAreaWidget,
|
||||
} from './widgets/page-dragging-area/page-dragging-area.js';
|
||||
|
||||
export function effects() {
|
||||
// Register components by category
|
||||
registerRootComponents();
|
||||
registerWidgets();
|
||||
registerEdgelessToolbarComponents();
|
||||
registerMiscComponents();
|
||||
}
|
||||
@@ -44,13 +39,6 @@ function registerRootComponents() {
|
||||
);
|
||||
}
|
||||
|
||||
function registerWidgets() {
|
||||
customElements.define(
|
||||
AFFINE_PAGE_DRAGGING_AREA_WIDGET,
|
||||
AffinePageDraggingAreaWidget
|
||||
);
|
||||
}
|
||||
|
||||
function registerEdgelessToolbarComponents() {
|
||||
// Tool buttons
|
||||
customElements.define('edgeless-link-tool-button', EdgelessLinkToolButton);
|
||||
|
||||
@@ -16,7 +16,6 @@ export * from './preview/preview-root-block.js';
|
||||
export { RootService } from './root-service.js';
|
||||
export * from './types.js';
|
||||
export * from './utils/index.js';
|
||||
export * from './widgets/index.js';
|
||||
|
||||
declare type _GLOBAL_ =
|
||||
| typeof PointerEffect
|
||||
|
||||
@@ -1,23 +1,15 @@
|
||||
import { ViewportElementExtension } from '@blocksuite/affine-shared/services';
|
||||
import { BlockViewExtension, WidgetViewExtension } from '@blocksuite/std';
|
||||
import { BlockViewExtension } from '@blocksuite/std';
|
||||
import type { ExtensionType } from '@blocksuite/store';
|
||||
import { literal, unsafeStatic } from 'lit/static-html.js';
|
||||
import { literal } from 'lit/static-html.js';
|
||||
|
||||
import { PageClipboard } from '../clipboard/page-clipboard.js';
|
||||
import { CommonSpecs } from '../common-specs/index.js';
|
||||
import { AFFINE_PAGE_DRAGGING_AREA_WIDGET } from '../widgets/page-dragging-area/page-dragging-area.js';
|
||||
import { PageRootService } from './page-root-service.js';
|
||||
|
||||
export const pageDraggingAreaWidget = WidgetViewExtension(
|
||||
'affine:page',
|
||||
AFFINE_PAGE_DRAGGING_AREA_WIDGET,
|
||||
literal`${unsafeStatic(AFFINE_PAGE_DRAGGING_AREA_WIDGET)}`
|
||||
);
|
||||
|
||||
const PageCommonExtension: ExtensionType[] = [
|
||||
CommonSpecs,
|
||||
PageRootService,
|
||||
pageDraggingAreaWidget,
|
||||
ViewportElementExtension('.affine-page-viewport'),
|
||||
].flat();
|
||||
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
export { AffinePageDraggingAreaWidget } from './page-dragging-area/page-dragging-area.js';
|
||||
@@ -1,454 +0,0 @@
|
||||
import { NoteBlockModel, RootBlockModel } from '@blocksuite/affine-model';
|
||||
import { ViewportElementProvider } from '@blocksuite/affine-shared/services';
|
||||
import {
|
||||
autoScroll,
|
||||
getScrollContainer,
|
||||
matchModels,
|
||||
} from '@blocksuite/affine-shared/utils';
|
||||
import {
|
||||
BLOCK_ID_ATTR,
|
||||
BlockComponent,
|
||||
BlockSelection,
|
||||
type PointerEventState,
|
||||
WidgetComponent,
|
||||
} from '@blocksuite/std';
|
||||
import { html, nothing } from 'lit';
|
||||
import { state } from 'lit/decorators.js';
|
||||
import { styleMap } from 'lit/directives/style-map.js';
|
||||
|
||||
type Rect = {
|
||||
left: number;
|
||||
top: number;
|
||||
width: number;
|
||||
height: number;
|
||||
};
|
||||
|
||||
type BlockInfo = {
|
||||
element: BlockComponent;
|
||||
rect: Rect;
|
||||
};
|
||||
|
||||
export const AFFINE_PAGE_DRAGGING_AREA_WIDGET =
|
||||
'affine-page-dragging-area-widget';
|
||||
|
||||
export class AffinePageDraggingAreaWidget extends WidgetComponent<RootBlockModel> {
|
||||
static excludeFlavours: string[] = ['affine:note', 'affine:surface'];
|
||||
|
||||
private _dragging = false;
|
||||
|
||||
private _initialContainerOffset: {
|
||||
x: number;
|
||||
y: number;
|
||||
} = {
|
||||
x: 0,
|
||||
y: 0,
|
||||
};
|
||||
|
||||
private _initialScrollOffset: {
|
||||
top: number;
|
||||
left: number;
|
||||
} = {
|
||||
top: 0,
|
||||
left: 0,
|
||||
};
|
||||
|
||||
private _lastPointerState: PointerEventState | null = null;
|
||||
|
||||
private _rafID = 0;
|
||||
|
||||
private readonly _updateDraggingArea = (
|
||||
state: PointerEventState,
|
||||
shouldAutoScroll: boolean
|
||||
) => {
|
||||
const { x, y } = state;
|
||||
const { x: startX, y: startY } = state.start;
|
||||
|
||||
const { left: initScrollX, top: initScrollY } = this._initialScrollOffset;
|
||||
if (!this._viewport) {
|
||||
return;
|
||||
}
|
||||
const { scrollLeft, scrollTop, scrollWidth, scrollHeight } = this._viewport;
|
||||
|
||||
const { x: initConX, y: initConY } = this._initialContainerOffset;
|
||||
const { x: conX, y: conY } = state.containerOffset;
|
||||
|
||||
const { left: viewportLeft, top: viewportTop } = this._viewport;
|
||||
let left = Math.min(
|
||||
startX + initScrollX + initConX - viewportLeft,
|
||||
x + scrollLeft + conX - viewportLeft
|
||||
);
|
||||
let right = Math.max(
|
||||
startX + initScrollX + initConX - viewportLeft,
|
||||
x + scrollLeft + conX - viewportLeft
|
||||
);
|
||||
let top = Math.min(
|
||||
startY + initScrollY + initConY - viewportTop,
|
||||
y + scrollTop + conY - viewportTop
|
||||
);
|
||||
let bottom = Math.max(
|
||||
startY + initScrollY + initConY - viewportTop,
|
||||
y + scrollTop + conY - viewportTop
|
||||
);
|
||||
|
||||
left = Math.max(left, conX - viewportLeft);
|
||||
right = Math.min(right, scrollWidth);
|
||||
top = Math.max(top, conY - viewportTop);
|
||||
bottom = Math.min(bottom, scrollHeight);
|
||||
|
||||
const userRect = {
|
||||
left,
|
||||
top,
|
||||
width: right - left,
|
||||
height: bottom - top,
|
||||
};
|
||||
this.rect = userRect;
|
||||
this._selectBlocksByRect({
|
||||
left: userRect.left + viewportLeft,
|
||||
top: userRect.top + viewportTop,
|
||||
width: userRect.width,
|
||||
height: userRect.height,
|
||||
});
|
||||
this._lastPointerState = state;
|
||||
|
||||
if (shouldAutoScroll && this.scrollContainer) {
|
||||
const rect = this.scrollContainer.getBoundingClientRect();
|
||||
const result = autoScroll(this.scrollContainer, state.raw.y - rect.top);
|
||||
if (!result) {
|
||||
this._clearRaf();
|
||||
return;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
private get _allBlocksWithRect(): BlockInfo[] {
|
||||
if (!this._viewport) {
|
||||
return [];
|
||||
}
|
||||
const { scrollLeft, scrollTop } = this._viewport;
|
||||
|
||||
const getAllNodeFromTree = (): BlockComponent[] => {
|
||||
const blocks: BlockComponent[] = [];
|
||||
this.host.view.walkThrough(node => {
|
||||
const view = node;
|
||||
if (!(view instanceof BlockComponent)) {
|
||||
return true;
|
||||
}
|
||||
if (
|
||||
view.model.role !== 'root' &&
|
||||
!AffinePageDraggingAreaWidget.excludeFlavours.includes(
|
||||
view.model.flavour
|
||||
)
|
||||
) {
|
||||
blocks.push(view);
|
||||
}
|
||||
return;
|
||||
});
|
||||
return blocks;
|
||||
};
|
||||
|
||||
const elements = getAllNodeFromTree();
|
||||
|
||||
return elements.map(element => {
|
||||
const bounding = element.getBoundingClientRect();
|
||||
return {
|
||||
element,
|
||||
rect: {
|
||||
left: bounding.left + scrollLeft,
|
||||
top: bounding.top + scrollTop,
|
||||
width: bounding.width,
|
||||
height: bounding.height,
|
||||
},
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
private get _viewport() {
|
||||
return this.std.get(ViewportElementProvider).viewport;
|
||||
}
|
||||
|
||||
private get scrollContainer() {
|
||||
if (!this.block) {
|
||||
return null;
|
||||
}
|
||||
return getScrollContainer(this.block);
|
||||
}
|
||||
|
||||
private _clearRaf() {
|
||||
if (this._rafID) {
|
||||
cancelAnimationFrame(this._rafID);
|
||||
this._rafID = 0;
|
||||
}
|
||||
}
|
||||
|
||||
private _selectBlocksByRect(userRect: Rect) {
|
||||
const selections = getSelectingBlockPaths(
|
||||
this._allBlocksWithRect,
|
||||
userRect
|
||||
).map(blockPath => {
|
||||
return this.host.selection.create(BlockSelection, {
|
||||
blockId: blockPath,
|
||||
});
|
||||
});
|
||||
|
||||
this.host.selection.setGroup('note', selections);
|
||||
}
|
||||
|
||||
override connectedCallback() {
|
||||
super.connectedCallback();
|
||||
|
||||
this.handleEvent(
|
||||
'dragStart',
|
||||
ctx => {
|
||||
const state = ctx.get('pointerState');
|
||||
const { button } = state.raw;
|
||||
if (button !== 0) return;
|
||||
if (!isDragArea(state)) return;
|
||||
if (!this._viewport) return;
|
||||
|
||||
this._dragging = true;
|
||||
const { scrollLeft, scrollTop } = this._viewport;
|
||||
this._initialScrollOffset = {
|
||||
left: scrollLeft,
|
||||
top: scrollTop,
|
||||
};
|
||||
this._initialContainerOffset = {
|
||||
x: state.containerOffset.x,
|
||||
y: state.containerOffset.y,
|
||||
};
|
||||
|
||||
return true;
|
||||
},
|
||||
{ global: true }
|
||||
);
|
||||
|
||||
this.handleEvent(
|
||||
'dragMove',
|
||||
ctx => {
|
||||
this._clearRaf();
|
||||
if (!this._dragging) {
|
||||
return;
|
||||
}
|
||||
|
||||
const state = ctx.get('pointerState');
|
||||
// TODO(@L-Sun) support drag area for touch device
|
||||
if (state.raw.pointerType === 'touch') return;
|
||||
|
||||
ctx.get('defaultState').event.preventDefault();
|
||||
|
||||
this._rafID = requestAnimationFrame(() => {
|
||||
this._updateDraggingArea(state, true);
|
||||
});
|
||||
|
||||
return true;
|
||||
},
|
||||
{ global: true }
|
||||
);
|
||||
|
||||
this.handleEvent(
|
||||
'dragEnd',
|
||||
() => {
|
||||
this._clearRaf();
|
||||
this._dragging = false;
|
||||
this.rect = null;
|
||||
this._initialScrollOffset = {
|
||||
top: 0,
|
||||
left: 0,
|
||||
};
|
||||
this._initialContainerOffset = {
|
||||
x: 0,
|
||||
y: 0,
|
||||
};
|
||||
this._lastPointerState = null;
|
||||
},
|
||||
{
|
||||
global: true,
|
||||
}
|
||||
);
|
||||
|
||||
this.handleEvent(
|
||||
'pointerMove',
|
||||
ctx => {
|
||||
if (this._dragging) {
|
||||
const state = ctx.get('pointerState');
|
||||
state.raw.preventDefault();
|
||||
}
|
||||
},
|
||||
{
|
||||
global: true,
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
override disconnectedCallback() {
|
||||
this._clearRaf();
|
||||
this._disposables.dispose();
|
||||
super.disconnectedCallback();
|
||||
}
|
||||
|
||||
override firstUpdated() {
|
||||
this._disposables.addFromEvent(this.scrollContainer, 'scroll', () => {
|
||||
if (!this._dragging || !this._lastPointerState) return;
|
||||
|
||||
const state = this._lastPointerState;
|
||||
this._rafID = requestAnimationFrame(() => {
|
||||
this._updateDraggingArea(state, false);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
override render() {
|
||||
const rect = this.rect;
|
||||
if (!rect) return nothing;
|
||||
|
||||
const style = {
|
||||
left: rect.left + 'px',
|
||||
top: rect.top + 'px',
|
||||
width: rect.width + 'px',
|
||||
height: rect.height + 'px',
|
||||
};
|
||||
return html`
|
||||
<style>
|
||||
.affine-page-dragging-area {
|
||||
position: absolute;
|
||||
background: var(--affine-hover-color);
|
||||
z-index: 1;
|
||||
pointer-events: none;
|
||||
}
|
||||
</style>
|
||||
<div class="affine-page-dragging-area" style=${styleMap(style)}></div>
|
||||
`;
|
||||
}
|
||||
|
||||
@state()
|
||||
accessor rect: Rect | null = null;
|
||||
}
|
||||
|
||||
function rectIntersects(a: Rect, b: Rect) {
|
||||
return (
|
||||
a.left < b.left + b.width &&
|
||||
a.left + a.width > b.left &&
|
||||
a.top < b.top + b.height &&
|
||||
a.top + a.height > b.top
|
||||
);
|
||||
}
|
||||
|
||||
function rectIncludesTopAndBottom(a: Rect, b: Rect) {
|
||||
return a.top <= b.top && a.top + a.height >= b.top + b.height;
|
||||
}
|
||||
|
||||
function filterBlockInfos(blockInfos: BlockInfo[], userRect: Rect) {
|
||||
const results: BlockInfo[] = [];
|
||||
for (const blockInfo of blockInfos) {
|
||||
const rect = blockInfo.rect;
|
||||
if (userRect.top + userRect.height < rect.top) break;
|
||||
|
||||
results.push(blockInfo);
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
function filterBlockInfosByParent(
|
||||
parentInfos: BlockInfo,
|
||||
userRect: Rect,
|
||||
filteredBlockInfos: BlockInfo[]
|
||||
) {
|
||||
const targetBlock = parentInfos.element;
|
||||
let results = [parentInfos];
|
||||
if (targetBlock.childElementCount > 0) {
|
||||
const childBlockInfos = targetBlock.childBlocks
|
||||
.map(el =>
|
||||
filteredBlockInfos.find(
|
||||
blockInfo => blockInfo.element.model.id === el.model.id
|
||||
)
|
||||
)
|
||||
.filter(block => block) as BlockInfo[];
|
||||
const firstIndex = childBlockInfos.findIndex(
|
||||
bl => rectIntersects(bl.rect, userRect) && bl.rect.top < userRect.top
|
||||
);
|
||||
const lastIndex = childBlockInfos.findIndex(
|
||||
bl =>
|
||||
rectIntersects(bl.rect, userRect) &&
|
||||
bl.rect.top + bl.rect.height > userRect.top + userRect.height
|
||||
);
|
||||
|
||||
if (firstIndex !== -1 && lastIndex !== -1) {
|
||||
results = childBlockInfos.slice(firstIndex, lastIndex + 1);
|
||||
}
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
function getSelectingBlockPaths(blockInfos: BlockInfo[], userRect: Rect) {
|
||||
const filteredBlockInfos = filterBlockInfos(blockInfos, userRect);
|
||||
const len = filteredBlockInfos.length;
|
||||
const blockPaths: string[] = [];
|
||||
let singleTargetParentBlock: BlockInfo | null = null;
|
||||
let blocks: BlockInfo[] = [];
|
||||
if (len === 0) return blockPaths;
|
||||
|
||||
// To get the single target parent block info
|
||||
for (const block of filteredBlockInfos) {
|
||||
const rect = block.rect;
|
||||
|
||||
if (
|
||||
rectIntersects(userRect, rect) &&
|
||||
rectIncludesTopAndBottom(rect, userRect)
|
||||
) {
|
||||
singleTargetParentBlock = block;
|
||||
}
|
||||
}
|
||||
|
||||
if (singleTargetParentBlock) {
|
||||
blocks = filterBlockInfosByParent(
|
||||
singleTargetParentBlock,
|
||||
userRect,
|
||||
filteredBlockInfos
|
||||
);
|
||||
} else {
|
||||
// If there is no block contains the top and bottom of the userRect
|
||||
// Then get all the blocks that intersect with the userRect
|
||||
for (const block of filteredBlockInfos) {
|
||||
if (rectIntersects(userRect, block.rect)) {
|
||||
blocks.push(block);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Filter out the blocks which parent is in the blocks
|
||||
// eslint-disable-next-line @typescript-eslint/prefer-for-of
|
||||
for (let i = 0; i < blocks.length; i++) {
|
||||
const block = blocks[i];
|
||||
const parent = blocks[i].element.doc.getParent(block.element.model);
|
||||
const parentId = parent?.id;
|
||||
if (parentId) {
|
||||
const isParentInBlocks = blocks.some(
|
||||
block => block.element.model.id === parentId
|
||||
);
|
||||
if (!isParentInBlocks) {
|
||||
blockPaths.push(blocks[i].element.blockId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return blockPaths;
|
||||
}
|
||||
|
||||
function isDragArea(e: PointerEventState) {
|
||||
const el = e.raw.target;
|
||||
if (!(el instanceof Element)) {
|
||||
return false;
|
||||
}
|
||||
const block = el.closest<BlockComponent>(`[${BLOCK_ID_ATTR}]`);
|
||||
if (!block) {
|
||||
return false;
|
||||
}
|
||||
return matchModels(block.model, [RootBlockModel, NoteBlockModel]);
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
[AFFINE_PAGE_DRAGGING_AREA_WIDGET]: AffinePageDraggingAreaWidget;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user