import type { PointerEventState } from '@blockeditor/block-std'; import { assertExists, assertInstanceOf } from '@blockeditor/global/utils'; import { BlockElement } from '@blockeditor/lit'; import { WidgetElement } from '@blockeditor/lit'; import { html, nothing } from 'lit'; import { customElement, state } from 'lit/decorators.js'; import { styleMap } from 'lit/directives/style-map.js'; import { BLOCK_ID_ATTR } from '../../../_common/consts.js'; import { matchFlavours } from '../../../_common/utils/model.js'; import type { DocPageBlockComponent } from '../../index.js'; import { autoScroll } from '../../text-selection/utils.js'; type Rect = { left: number; top: number; width: number; height: number; }; type BlockInfo = { element: BlockElement; rect: Rect; }; export const AFFINE_DOC_DRAGGING_AREA_WIDGET = 'affine-doc-dragging-area-widget'; @customElement(AFFINE_DOC_DRAGGING_AREA_WIDGET) export class AffineDocDraggingAreaWidget extends WidgetElement { @state() rect: Rect | null = null; private _rafID = 0; static excludeFlavours: string[] = ['affine:note', 'affine:surface']; private _lastPointerState: PointerEventState | null = null; private _dragging = false; private _initialScrollOffset: { top: number; left: number; } = { top: 0, left: 0, }; private _initialContainerOffset: { x: number; y: number; } = { x: 0, y: 0, }; private get _viewportElement() { const pageBlock = this.blockElement; assertExists(pageBlock); return pageBlock.viewportElement; } private get _viewport() { const pageBlock = this.blockElement; assertExists(pageBlock); return pageBlock.viewport; } private get _allBlocksWithRect(): BlockInfo[] { const { scrollLeft, scrollTop } = this._viewport; const getAllNodeFromTree = (): BlockElement[] => { const blockElement: BlockElement[] = []; this.host.view.walkThrough(node => { const view = node.view; if (!(view instanceof BlockElement)) { return true; } if ( view.model.role !== 'root' && !AffineDocDraggingAreaWidget.excludeFlavours.includes( view.model.flavour ) ) { blockElement.push(view); } return; }); return blockElement; }; 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 _selectBlocksByRect(userRect: Rect) { const selections = getSelectingBlockPaths( this._allBlocksWithRect, userRect ).map(blockPath => { return this.host.selection.create('block', { path: blockPath, }); }); this.host.selection.setGroup('note', selections); } private _clearRaf() { if (this._rafID) { cancelAnimationFrame(this._rafID); this._rafID = 0; } } private _updateDraggingArea = ( state: PointerEventState, shouldAutoScroll: boolean ) => { const { x, y } = state; const { x: startX, y: startY } = state.start; const { left: initScrollX, top: initScrollY } = this._initialScrollOffset; 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) { const result = autoScroll(this._viewportElement, state.raw.y); if (!result) { this._clearRaf(); return; } } }; override connectedCallback() { super.connectedCallback(); this.handleEvent( 'dragStart', ctx => { const state = ctx.get('pointerState'); const { button } = state.raw; if (button !== 0) return; if (isDragArea(state)) { 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; } return; }, { global: true } ); this.handleEvent( 'dragMove', ctx => { this._clearRaf(); if (!this._dragging) { return; } ctx.get('defaultState').event.preventDefault(); const state = ctx.get('pointerState'); 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 firstUpdated() { this._disposables.addFromEvent(this._viewportElement, 'scroll', () => { if (!this._dragging || !this._lastPointerState) return; const state = this._lastPointerState; this._rafID = requestAnimationFrame(() => { this._updateDraggingArea(state, false); }); }); } override disconnectedCallback() { this._clearRaf(); this._disposables.dispose(); super.disconnectedCallback(); } 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`
`; } } 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.childBlockElements .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 for (let i = 0; i < blocks.length; i++) { const parent = blocks[i].element.parentPath.join('|'); const isParentInBlocks = blocks.some( block => block.element.path.join('|') === parent ); if (!isParentInBlocks) { blockPaths.push(blocks[i].element.path); } } return blockPaths; } function isDragArea(e: PointerEventState) { const el = e.raw.target; assertInstanceOf(el, Element); const block = el.closest(`[${BLOCK_ID_ATTR}]`); return block && matchFlavours(block.model, ['affine:page', 'affine:note']); } declare global { interface HTMLElementTagNameMap { [AFFINE_DOC_DRAGGING_AREA_WIDGET]: AffineDocDraggingAreaWidget; } }