refactor(editor): extract drag handle widget (#9415)

This commit is contained in:
Saul-Mirone
2024-12-29 06:51:48 +00:00
parent b96a03b283
commit cec4a4b2c0
47 changed files with 277 additions and 92 deletions

View File

@@ -16,6 +16,7 @@ import {
type TelemetryEventMap,
TelemetryProvider,
} from '@blocksuite/affine-shared/services';
import { getDropResult } from '@blocksuite/affine-widget-drag-handle';
import { RANGE_SYNC_EXCLUDE_ATTR } from '@blocksuite/block-std';
import {
createRecordDetail,
@@ -46,7 +47,6 @@ import { computed, signal } from '@preact/signals-core';
import { css, html, nothing, unsafeCSS } from 'lit';
import { EdgelessRootBlockComponent } from '../root-block/index.js';
import { getDropResult } from '../root-block/widgets/drag-handle/utils.js';
import { popSideDetail } from './components/layout.js';
import type { DatabaseOptionsConfig } from './config.js';
import { HostContextKey } from './context/host-context.js';

View File

@@ -26,6 +26,7 @@ import { SmoothCorner } from '@blocksuite/affine-components/smooth-corner';
import { effects as componentToggleButtonEffects } from '@blocksuite/affine-components/toggle-button';
import { ToggleSwitch } from '@blocksuite/affine-components/toggle-switch';
import { effects as componentToolbarEffects } from '@blocksuite/affine-components/toolbar';
import { effects as widgetDragHandleEffects } from '@blocksuite/affine-widget-drag-handle/effects';
import { effects as widgetRemoteSelectionEffects } from '@blocksuite/affine-widget-remote-selection/effects';
import { effects as widgetScrollAnchoringEffects } from '@blocksuite/affine-widget-scroll-anchoring/effects';
import type { BlockComponent } from '@blocksuite/block-std';
@@ -148,7 +149,6 @@ import {
AFFINE_EMBED_CARD_TOOLBAR_WIDGET,
AFFINE_FORMAT_BAR_WIDGET,
AffineAIPanelWidget,
AffineDragHandleWidget,
AffineEdgelessZoomToolbarWidget,
AffineFormatBarWidget,
AffineImageToolbarWidget,
@@ -176,9 +176,6 @@ import {
AIPanelGenerating,
AIPanelInput,
} from './root-block/widgets/ai-panel/components/index.js';
import { DragPreview } from './root-block/widgets/drag-handle/components/drag-preview.js';
import { DropIndicator } from './root-block/widgets/drag-handle/components/drop-indicator.js';
import { AFFINE_DRAG_HANDLE_WIDGET } from './root-block/widgets/drag-handle/consts.js';
import {
AFFINE_EDGELESS_AUTO_CONNECT_WIDGET,
EdgelessAutoConnectWidget,
@@ -259,6 +256,7 @@ export function effects() {
widgetFrameTitleEffects();
widgetEdgelessElementToolbarEffects();
widgetRemoteSelectionEffects();
widgetDragHandleEffects();
dataViewEffects();
customElements.define('affine-database-title', DatabaseTitle);
@@ -433,7 +431,6 @@ export function effects() {
'edgeless-group-title-editor',
EdgelessGroupTitleEditor
);
customElements.define('affine-drag-preview', DragPreview);
customElements.define(EDGELESS_TOOLBAR_WIDGET, EdgelessToolbarWidget);
customElements.define('edgeless-shape-style-panel', EdgelessShapeStylePanel);
customElements.define(
@@ -446,7 +443,6 @@ export function effects() {
);
customElements.define('edgeless-text-editor', EdgelessTextEditor);
customElements.define('affine-image-toolbar', AffineImageToolbar);
customElements.define('affine-drop-indicator', DropIndicator);
customElements.define('mini-mindmap-root-block', MindmapRootBlock);
customElements.define('affine-block-selection', BlockSelection);
customElements.define('edgeless-slide-menu', EdgelessSlideMenu);
@@ -463,7 +459,6 @@ export function effects() {
AFFINE_PAGE_DRAGGING_AREA_WIDGET,
AffinePageDraggingAreaWidget
);
customElements.define(AFFINE_DRAG_HANDLE_WIDGET, AffineDragHandleWidget);
customElements.define(AFFINE_EDGELESS_COPILOT_WIDGET, EdgelessCopilotWidget);
customElements.define(AFFINE_IMAGE_TOOLBAR_WIDGET, AffineImageToolbarWidget);

View File

@@ -31,7 +31,6 @@ export {
export { CopilotTool } from './root-block/edgeless/gfx-tool/copilot-tool.js';
export * from './root-block/edgeless/gfx-tool/index.js';
export { EditPropsMiddlewareBuilder } from './root-block/edgeless/middlewares/base.js';
export * from './root-block/edgeless/utils/common.js';
export { EdgelessSnapManager } from './root-block/edgeless/utils/snap-manager.js';
export * from './root-block/index.js';
export * from './schemas.js';

View File

@@ -4,8 +4,10 @@ import {
DocDisplayMetaService,
DocModeService,
EmbedOptionService,
PageViewportServiceExtension,
ThemeService,
} from '@blocksuite/affine-shared/services';
import { AFFINE_DRAG_HANDLE_WIDGET } from '@blocksuite/affine-widget-drag-handle';
import {
AFFINE_DOC_REMOTE_SELECTION_WIDGET,
AFFINE_EDGELESS_REMOTE_SELECTION_WIDGET,
@@ -25,7 +27,6 @@ import { literal, unsafeStatic } from 'lit/static-html.js';
import { ExportManagerExtension } from '../../_common/export-manager/export-manager.js';
import { RootBlockAdapterExtensions } from '../adapters/extension.js';
import { commands } from '../commands/index.js';
import { AFFINE_DRAG_HANDLE_WIDGET } from '../widgets/drag-handle/consts.js';
import { AFFINE_EDGELESS_AUTO_CONNECT_WIDGET } from '../widgets/edgeless-auto-connect/edgeless-auto-connect.js';
import { AFFINE_EDGELESS_ZOOM_TOOLBAR_WIDGET } from '../widgets/edgeless-zoom-toolbar/index.js';
import { EDGELESS_ELEMENT_TOOLBAR_WIDGET } from '../widgets/element-toolbar/index.js';
@@ -97,6 +98,7 @@ const EdgelessCommonExtension: ExtensionType[] = [
ExportManagerExtension,
ToolController,
DNDAPIExtension,
PageViewportServiceExtension,
DocDisplayMetaService,
RootBlockAdapterExtensions,
FileDropExtension,

View File

@@ -1,4 +1,5 @@
import type { SurfaceBlockComponent } from '@blocksuite/affine-block-surface';
import { addNote } from '@blocksuite/affine-block-surface';
import {
DEFAULT_NOTE_HEIGHT,
DEFAULT_NOTE_WIDTH,
@@ -11,7 +12,6 @@ import { Point } from '@blocksuite/global/utils';
import { effect } from '@preact/signals-core';
import { hasClassNameInList } from '../../../_common/utils/index.js';
import { addNote } from '../utils/common.js';
import { EXCLUDING_MOUSE_OUT_CLASS_LIST } from '../utils/consts.js';
import { DraggingNoteOverlay, NoteOverlay } from '../utils/tool-overlay.js';

View File

@@ -1,135 +0,0 @@
import { EdgelessCRUDIdentifier } from '@blocksuite/affine-block-surface';
import { focusTextModel } from '@blocksuite/affine-components/rich-text';
import {
DEFAULT_NOTE_HEIGHT,
DEFAULT_NOTE_WIDTH,
NOTE_MIN_HEIGHT,
type NoteBlockModel,
NoteDisplayMode,
} from '@blocksuite/affine-model';
import { TelemetryProvider } from '@blocksuite/affine-shared/services';
import type { NoteChildrenFlavour } from '@blocksuite/affine-shared/types';
import { handleNativeRangeAtPoint } from '@blocksuite/affine-shared/utils';
import type { BlockStdScope } from '@blocksuite/block-std';
import { GfxControllerIdentifier } from '@blocksuite/block-std/gfx';
import {
type IPoint,
type Point,
serializeXYWH,
} from '@blocksuite/global/utils';
import { DEFAULT_NOTE_OFFSET_X, DEFAULT_NOTE_OFFSET_Y } from './consts.js';
export function addNoteAtPoint(
std: BlockStdScope,
/**
* The point is in browser coordinate
*/
point: IPoint,
options: {
width?: number;
height?: number;
parentId?: string;
noteIndex?: number;
offsetX?: number;
offsetY?: number;
scale?: number;
} = {}
) {
const gfx = std.get(GfxControllerIdentifier);
const crud = std.get(EdgelessCRUDIdentifier);
const {
width = DEFAULT_NOTE_WIDTH,
height = DEFAULT_NOTE_HEIGHT,
offsetX = DEFAULT_NOTE_OFFSET_X,
offsetY = DEFAULT_NOTE_OFFSET_Y,
parentId = gfx.doc.root?.id,
noteIndex,
scale = 1,
} = options;
const [x, y] = gfx.viewport.toModelCoord(point.x, point.y);
const blockId = crud.addBlock(
'affine:note',
{
xywh: serializeXYWH(
x - offsetX * scale,
y - offsetY * scale,
width,
height
),
displayMode: NoteDisplayMode.EdgelessOnly,
},
parentId,
noteIndex
);
std.getOptional(TelemetryProvider)?.track('CanvasElementAdded', {
control: 'canvas:draw',
page: 'whiteboard editor',
module: 'toolbar',
segment: 'toolbar',
type: 'note',
});
return blockId;
}
type NoteOptions = {
childFlavour: NoteChildrenFlavour;
childType: string | null;
collapse: boolean;
};
export function addNote(
std: BlockStdScope,
point: Point,
options: NoteOptions,
width = DEFAULT_NOTE_WIDTH,
height = DEFAULT_NOTE_HEIGHT
) {
const noteId = addNoteAtPoint(std, point, {
width,
height,
});
const gfx = std.get(GfxControllerIdentifier);
const doc = std.doc;
const blockId = doc.addBlock(
options.childFlavour,
{ type: options.childType },
noteId
);
if (options.collapse && height > NOTE_MIN_HEIGHT) {
const note = doc.getBlockById(noteId) as NoteBlockModel;
doc.updateBlock(note, () => {
note.edgeless.collapse = true;
note.edgeless.collapsedHeight = height;
});
}
gfx.tool.setTool('default');
// Wait for edgelessTool updated
requestAnimationFrame(() => {
const blocks =
(doc.root?.children.filter(
child => child.flavour === 'affine:note'
) as BlockSuite.EdgelessBlockModelType[]) ?? [];
const element = blocks.find(b => b.id === noteId);
if (element) {
gfx.selection.set({
elements: [element.id],
editing: true,
});
// Waiting dom updated, `note mask` is removed
if (blockId) {
focusTextModel(gfx.std, blockId);
} else {
// Cannot reuse `handleNativeRangeClick` directly here,
// since `retargetClick` will re-target to pervious editor
handleNativeRangeAtPoint(point.x, point.y);
}
}
});
}

View File

@@ -6,8 +6,6 @@ import {
StrokeStyle,
} from '@blocksuite/affine-model';
export const DEFAULT_NOTE_OFFSET_X = 30;
export const DEFAULT_NOTE_OFFSET_Y = 40;
export const NOTE_OVERLAY_OFFSET_X = 6;
export const NOTE_OVERLAY_OFFSET_Y = 6;
export const NOTE_OVERLAY_WIDTH = 100;

View File

@@ -1,6 +1,7 @@
import { focusTextModel } from '@blocksuite/affine-components/rich-text';
import type { NoteBlockModel, RootBlockModel } from '@blocksuite/affine-model';
import { NoteDisplayMode } from '@blocksuite/affine-model';
import { PageViewportService } from '@blocksuite/affine-shared/services';
import type { Viewport } from '@blocksuite/affine-shared/types';
import {
focusTitle,
@@ -141,10 +142,6 @@ export class PageRootBlockComponent extends BlockComponent<
return getScrollContainer(this);
}
get slots() {
return this.service.slots;
}
get viewport(): Viewport | null {
if (!this.viewportElement) {
return null;
@@ -172,9 +169,9 @@ export class PageRootBlockComponent extends BlockComponent<
get viewportElement(): HTMLDivElement | null {
if (this._viewportElement) return this._viewportElement;
this._viewportElement = this.host.closest(
this._viewportElement = this.host.closest<HTMLDivElement>(
'.affine-page-viewport'
) as HTMLDivElement | null;
);
return this._viewportElement;
}
@@ -198,12 +195,14 @@ export class PageRootBlockComponent extends BlockComponent<
if (!viewport || !viewportElement) {
return;
}
const viewportService = this.std.get(PageViewportService);
// when observe viewportElement resize, emit viewport update event
const resizeObserver = new ResizeObserver(
(entries: ResizeObserverEntry[]) => {
for (const { target } of entries) {
if (target === viewportElement) {
this.slots.viewportUpdated.emit(viewport);
viewportService.emit(viewport);
break;
}
}

View File

@@ -4,8 +4,10 @@ import {
DocDisplayMetaService,
DocModeService,
EmbedOptionService,
PageViewportServiceExtension,
ThemeService,
} from '@blocksuite/affine-shared/services';
import { AFFINE_DRAG_HANDLE_WIDGET } from '@blocksuite/affine-widget-drag-handle';
import { AFFINE_DOC_REMOTE_SELECTION_WIDGET } from '@blocksuite/affine-widget-remote-selection';
import { AFFINE_SCROLL_ANCHORING_WIDGET } from '@blocksuite/affine-widget-scroll-anchoring';
import {
@@ -20,7 +22,6 @@ import { literal, unsafeStatic } from 'lit/static-html.js';
import { ExportManagerExtension } from '../../_common/export-manager/export-manager.js';
import { RootBlockAdapterExtensions } from '../adapters/extension.js';
import { commands } from '../commands/index.js';
import { AFFINE_DRAG_HANDLE_WIDGET } from '../widgets/drag-handle/consts.js';
import { AFFINE_EMBED_CARD_TOOLBAR_WIDGET } from '../widgets/embed-card-toolbar/embed-card-toolbar.js';
import { AFFINE_FORMAT_BAR_WIDGET } from '../widgets/format-bar/format-bar.js';
import { AFFINE_INNER_MODAL_WIDGET } from '../widgets/inner-modal/inner-modal.js';
@@ -74,6 +75,7 @@ export const PageRootBlockSpec: ExtensionType[] = [
WidgetViewMapExtension('affine:page', pageRootWidgetViewMap),
ExportManagerExtension,
DNDAPIExtension,
PageViewportServiceExtension,
DocDisplayMetaService,
RootBlockAdapterExtensions,
FileDropExtension,

View File

@@ -1,30 +0,0 @@
export function autoScroll(
viewportElement: HTMLElement,
y: number,
threshold = 50
): boolean {
const { scrollHeight, clientHeight, scrollTop } = viewportElement;
let _scrollTop = scrollTop;
const max = scrollHeight - clientHeight;
let d = 0;
let flag = false;
if (Math.ceil(scrollTop) < max && clientHeight - y < threshold) {
// ↓
d = threshold - (clientHeight - y);
flag = Math.ceil(_scrollTop) < max;
} else if (scrollTop > 0 && y < threshold) {
// ↑
d = y - threshold;
flag = _scrollTop > 0;
}
_scrollTop += d * 0.25;
if (flag && scrollTop !== _scrollTop) {
viewportElement.scrollTop = _scrollTop;
return true;
}
return false;
}

View File

@@ -1,3 +1,4 @@
import type { AFFINE_DRAG_HANDLE_WIDGET } from '@blocksuite/affine-widget-drag-handle';
import type {
AFFINE_DOC_REMOTE_SELECTION_WIDGET,
AFFINE_EDGELESS_REMOTE_SELECTION_WIDGET,
@@ -5,7 +6,6 @@ import type {
import type { EdgelessRootBlockComponent } from './edgeless/edgeless-root-block.js';
import type { PageRootBlockComponent } from './page/page-root-block.js';
import type { AFFINE_DRAG_HANDLE_WIDGET } from './widgets/drag-handle/consts.js';
import type { AFFINE_EDGELESS_ZOOM_TOOLBAR_WIDGET } from './widgets/edgeless-zoom-toolbar/index.js';
import type { EDGELESS_ELEMENT_TOOLBAR_WIDGET } from './widgets/element-toolbar/index.js';
import type { AFFINE_EMBED_CARD_TOOLBAR_WIDGET } from './widgets/embed-card-toolbar/embed-card-toolbar.js';

View File

@@ -1,63 +0,0 @@
import type { EditorHost } from '@blocksuite/block-std';
import { ShadowlessElement } from '@blocksuite/block-std';
import { Point } from '@blocksuite/global/utils';
import { baseTheme } from '@toeverything/theme';
import type { TemplateResult } from 'lit';
import { html } from 'lit';
import { property } from 'lit/decorators.js';
export class DragPreview extends ShadowlessElement {
offset: Point;
constructor(offset?: Point) {
super();
this.offset = offset ?? new Point(0, 0);
}
override disconnectedCallback() {
if (this.onRemove) {
this.onRemove();
}
super.disconnectedCallback();
}
override render() {
return html`<style>
affine-drag-preview {
box-sizing: border-box;
position: absolute;
display: block;
height: auto;
font-family: ${baseTheme.fontSansFamily};
font-size: var(--affine-font-base);
line-height: var(--affine-line-height);
color: var(--affine-text-primary-color);
font-weight: 400;
top: 0;
left: 0;
transform-origin: 0 0;
opacity: 0.5;
user-select: none;
pointer-events: none;
caret-color: transparent;
z-index: 3;
}
.affine-drag-preview-grabbing * {
cursor: grabbing !important;
}</style
>${this.template}`;
}
@property({ attribute: false })
accessor onRemove: (() => void) | null = null;
@property({ attribute: false })
accessor template: TemplateResult | EditorHost | null = null;
}
declare global {
interface HTMLElementTagNameMap {
'affine-drag-preview': DragPreview;
}
}

View File

@@ -1,45 +0,0 @@
import type { Rect } from '@blocksuite/global/utils';
import { css, html, LitElement } from 'lit';
import { property } from 'lit/decorators.js';
import { styleMap } from 'lit/directives/style-map.js';
export class DropIndicator extends LitElement {
static override styles = css`
.affine-drop-indicator {
position: absolute;
top: 0;
left: 0;
background: var(--affine-primary-color);
transition-property: height, transform;
transition-duration: 100ms;
transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1);
transition-delay: 0s;
transform-origin: 0 0;
pointer-events: none;
z-index: 2;
}
`;
override render() {
if (!this.rect) {
return null;
}
const { left, top, width, height } = this.rect;
const style = styleMap({
width: `${width}px`,
height: `${height}px`,
top: `${top}px`,
left: `${left}px`,
});
return html`<div class="affine-drop-indicator" style=${style}></div>`;
}
@property({ attribute: false })
accessor rect: Rect | null = null;
}
declare global {
interface HTMLElementTagNameMap {
'affine-drop-indicator': DropIndicator;
}
}

View File

@@ -1,19 +0,0 @@
export const DRAG_HANDLE_CONTAINER_HEIGHT = 24;
export const DRAG_HANDLE_CONTAINER_WIDTH = 16;
export const DRAG_HANDLE_CONTAINER_WIDTH_TOP_LEVEL = 8;
export const DRAG_HANDLE_CONTAINER_OFFSET_LEFT = 2;
export const DRAG_HANDLE_CONTAINER_OFFSET_LEFT_LIST = 18;
export const DRAG_HANDLE_CONTAINER_OFFSET_LEFT_TOP_LEVEL = 5;
export const DRAG_HANDLE_CONTAINER_PADDING = 8;
export const DRAG_HANDLE_GRABBER_HEIGHT = 12;
export const DRAG_HANDLE_GRABBER_WIDTH = 4;
export const DRAG_HANDLE_GRABBER_WIDTH_HOVERED = 2;
export const DRAG_HANDLE_GRABBER_BORDER_RADIUS = 4;
export const DRAG_HANDLE_GRABBER_MARGIN = 4;
export const HOVER_AREA_RECT_PADDING_TOP_LEVEL = 6;
export const NOTE_CONTAINER_PADDING = 24;
export const EDGELESS_NOTE_EXTRA_PADDING = 20;
export const DRAG_HOVER_RECT_PADDING = 4;

View File

@@ -1 +0,0 @@
export const AFFINE_DRAG_HANDLE_WIDGET = 'affine-drag-handle-widget';

View File

@@ -1,431 +0,0 @@
import { EdgelessCRUDIdentifier } from '@blocksuite/affine-block-surface';
import type { RootBlockModel } from '@blocksuite/affine-model';
import { DocModeProvider } from '@blocksuite/affine-shared/services';
import {
calcDropTarget,
type DroppingType,
type DropResult,
getScrollContainer,
isInsideEdgelessEditor,
isInsidePageEditor,
isTopLevelBlock,
matchFlavours,
} from '@blocksuite/affine-shared/utils';
import {
type BlockComponent,
type DndEventState,
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 { 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 { autoScroll } from '../../../root-block/text-selection/utils.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 { DragEventWatcher } from './watchers/drag-event-watcher.js';
import { EdgelessWatcher } from './watchers/edgeless-watcher.js';
import { HandleEventWatcher } from './watchers/handle-event-watcher.js';
import { KeyboardEventWatcher } from './watchers/keyboard-event-watcher.js';
import { PageWatcher } from './watchers/page-watcher.js';
import { PointerEventWatcher } from './watchers/pointer-event-watcher.js';
export class AffineDragHandleWidget extends WidgetComponent<RootBlockModel> {
static override styles = styles;
private _anchorModelDisposables: DisposableGroup | null = null;
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 _getDropResult = (
state: DndEventState
): DropResult | 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.doc.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?.type === '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.dropType = null;
this.lastDragPointerState = null;
this.rafID = 0;
this.dragging = false;
this.dragHoverRect = null;
this.anchorBlockId.value = null;
this.isDragHandleHovered = false;
this.isHoverDragHandleVisible = false;
this.isTopLevelDragHandleVisible = false;
this.pointerEventWatcher.reset();
this.previewHelper.removeDragPreview();
this._removeDropIndicator();
this._resetCursor();
};
private readonly _resetCursor = () => {
document.documentElement.classList.remove('affine-drag-preview-grabbing');
};
private readonly _resetDropResult = () => {
this.dropBlockId = '';
this.dropType = null;
if (this.dropIndicator) this.dropIndicator.rect = null;
};
private readonly _updateDropResult = (dropResult: DropResult | null) => {
if (!this.dropIndicator) return;
this.dropBlockId = dropResult?.modelState.model.id ?? '';
this.dropType = dropResult?.type ?? null;
if (dropResult?.rect) {
const offsetParentRect =
this.dragHandleContainerOffsetParent.getBoundingClientRect();
let { left, top } = dropResult.rect;
left -= offsetParentRect.left;
top -= offsetParentRect.top;
const { width, height } = dropResult.rect;
const rect = Rect.fromLWTH(left, width, top, height);
this.dropIndicator.rect = rect;
} else {
this.dropIndicator.rect = dropResult?.rect ?? null;
}
};
anchorBlockId = signal<string | null>(null);
anchorBlockComponent = computed<BlockComponent | null>(() => {
if (!this.anchorBlockId.value) return null;
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;
const crud = this.std.get(EdgelessCRUDIdentifier);
const edgelessElement = crud.getElementById(this.anchorBlockId.value);
return isTopLevelBlock(edgelessElement) ? edgelessElement : null;
}
);
// Single block: drag handle should show on the vertical middle of the first line of element
center: IVec = [0, 0];
dragging = false;
rectHelper = new RectHelper(this);
draggingAreaRect: ReadonlySignal<Rect | null> = computed(
this.rectHelper.getDraggingAreaRect
);
draggingElements: BlockComponent[] = [];
dragPreview: DragPreview | null = null;
dropBlockId = '';
dropIndicator: DropIndicator | null = null;
dropType: DroppingType | null = null;
edgelessWatcher = new EdgelessWatcher(this);
handleAnchorModelDisposables = () => {
const block = this.anchorBlockComponent.peek();
if (!block) return;
const blockModel = block.model;
if (this._anchorModelDisposables) {
this._anchorModelDisposables.dispose();
this._anchorModelDisposables = null;
}
this._anchorModelDisposables = new DisposableGroup();
this._anchorModelDisposables.add(
blockModel.propsUpdated.on(() => this.hide())
);
this._anchorModelDisposables.add(blockModel.deleted.on(() => this.hide()));
};
hide = (force = false) => {
if (this.dragging && !force) return;
updateDragHandleClassName();
this.isHoverDragHandleVisible = false;
this.isTopLevelDragHandleVisible = false;
this.isDragHandleHovered = false;
this.anchorBlockId.value = null;
if (this.dragHandleContainer) {
this.dragHandleContainer.style.display = 'none';
}
if (force) {
this._reset();
}
};
isDragHandleHovered = false;
isHoverDragHandleVisible = false;
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._resetDropResult();
} else {
const dropResult = this._getDropResult(state);
this._updateDropResult(dropResult);
}
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!;
}
get mode() {
return this.std.get(DocModeProvider).getEditorMode();
}
get rootComponent() {
return this.block;
}
clearRaf() {
if (this.rafID) {
cancelAnimationFrame(this.rafID);
this.rafID = 0;
}
}
override connectedCallback() {
super.connectedCallback();
this.pointerEventWatcher.watch();
this._keyboardEventWatcher.watch();
this._dragEventWatcher.watch();
}
override disconnectedCallback() {
this.hide(true);
this._disposables.dispose();
this._anchorModelDisposables?.dispose();
super.disconnectedCallback();
}
override firstUpdated() {
this.hide(true);
this._disposables.addFromEvent(this.host, 'pointerleave', () => {
this.hide();
});
this._handleEventWatcher.watch();
if (isInsidePageEditor(this.host)) {
this._pageWatcher.watch();
} else if (isInsideEdgelessEditor(this.host)) {
this.edgelessWatcher.watch();
}
}
override render() {
const hoverRectStyle = styleMap(
this.dragHoverRect
? {
width: `${this.dragHoverRect.width}px`,
height: `${this.dragHoverRect.height}px`,
top: `${this.dragHoverRect.top}px`,
left: `${this.dragHoverRect.left}px`,
}
: {
display: 'none',
}
);
return html`
<div class="affine-drag-handle-widget">
<div class="affine-drag-handle-container" draggable="true">
<div class="affine-drag-handle-grabber"></div>
</div>
<div class="affine-drag-hover-rect" style=${hoverRectStyle}></div>
</div>
`;
}
@query('.affine-drag-handle-container')
accessor dragHandleContainer!: HTMLDivElement;
@query('.affine-drag-handle-grabber')
accessor dragHandleGrabber!: HTMLDivElement;
@state()
accessor dragHoverRect: {
width: number;
height: number;
left: number;
top: number;
} | null = null;
}
declare global {
interface HTMLElementTagNameMap {
[AFFINE_DRAG_HANDLE_WIDGET]: AffineDragHandleWidget;
}
}

View File

@@ -1,118 +0,0 @@
import { SpecProvider } from '@blocksuite/affine-shared/utils';
import {
type BlockComponent,
BlockStdScope,
type DndEventState,
} from '@blocksuite/block-std';
import { Point } from '@blocksuite/global/utils';
import { BlockViewType, type Query } from '@blocksuite/store';
import { DragPreview } from '../components/drag-preview.js';
import type { AffineDragHandleWidget } from '../drag-handle.js';
export class PreviewHelper {
private readonly _calculatePreviewOffset = (
blocks: BlockComponent[],
state: DndEventState
) => {
const { top, left } = blocks[0].getBoundingClientRect();
const previewOffset = new Point(state.raw.x - left, state.raw.y - top);
return previewOffset;
};
private readonly _calculateQuery = (selectedIds: string[]): Query => {
const ids: Array<{ id: string; viewType: BlockViewType }> = selectedIds.map(
id => ({
id,
viewType: BlockViewType.Display,
})
);
// The ancestors of the selected blocks should be rendered as Bypass
selectedIds.forEach(block => {
let parent: string | null = block;
do {
if (!selectedIds.includes(parent)) {
ids.push({ viewType: BlockViewType.Bypass, id: parent });
}
parent = this.widget.doc.blockCollection.crud.getParent(parent);
} while (parent && !ids.map(({ id }) => id).includes(parent));
});
// The children of the selected blocks should be rendered as Display
const addChildren = (id: string) => {
const children = this.widget.doc.getBlock(id)?.model.children ?? [];
children.forEach(child => {
ids.push({ viewType: BlockViewType.Display, id: child.id });
addChildren(child.id);
});
};
selectedIds.forEach(addChildren);
return {
match: ids,
mode: 'strict',
};
};
createDragPreview = (
blocks: BlockComponent[],
state: DndEventState,
dragPreviewEl?: HTMLElement,
dragPreviewOffset?: Point
): DragPreview => {
if (this.widget.dragPreview) {
this.widget.dragPreview.remove();
}
let dragPreview: DragPreview;
if (dragPreviewEl) {
dragPreview = new DragPreview(dragPreviewOffset);
dragPreview.append(dragPreviewEl);
} else {
let width = 0;
blocks.forEach(element => {
width = Math.max(width, element.getBoundingClientRect().width);
});
const selectedIds = blocks.map(block => block.model.id);
const query = this._calculateQuery(selectedIds);
const doc = this.widget.doc.blockCollection.getDoc({ query });
const previewSpec = SpecProvider.getInstance().getSpec('page:preview');
const previewStd = new BlockStdScope({
doc,
extensions: previewSpec.value,
});
const previewTemplate = previewStd.render();
const offset = this._calculatePreviewOffset(blocks, state);
const posX = state.raw.x - offset.x;
const posY = state.raw.y - offset.y;
const altKey = state.raw.altKey;
dragPreview = new DragPreview(offset);
dragPreview.template = previewTemplate;
dragPreview.onRemove = () => {
this.widget.doc.blockCollection.clearQuery(query);
};
dragPreview.style.width = `${width / this.widget.scaleInNote.peek()}px`;
dragPreview.style.transform = `translate(${posX}px, ${posY}px) scale(${this.widget.scaleInNote.peek()})`;
dragPreview.style.opacity = altKey ? '1' : '0.5';
}
this.widget.rootComponent.append(dragPreview);
return dragPreview;
};
removeDragPreview = () => {
if (this.widget.dragPreview) {
this.widget.dragPreview.remove();
this.widget.dragPreview = null;
}
};
constructor(readonly widget: AffineDragHandleWidget) {}
}

View File

@@ -1,95 +0,0 @@
import { getCurrentNativeRange } from '@blocksuite/affine-shared/utils';
import type { BlockComponent } from '@blocksuite/block-std';
import { Rect } from '@blocksuite/global/utils';
import {
DRAG_HANDLE_CONTAINER_WIDTH,
DRAG_HOVER_RECT_PADDING,
} from '../config.js';
import type { AffineDragHandleWidget } from '../drag-handle.js';
import {
containBlock,
getDragHandleLeftPadding,
includeTextSelection,
} from '../utils.js';
export class RectHelper {
private readonly _getHoveredBlocks = (): BlockComponent[] => {
if (!this.widget.isHoverDragHandleVisible || !this.widget.anchorBlockId)
return [];
const hoverBlock = this.widget.anchorBlockComponent.peek();
if (!hoverBlock) return [];
const selections = this.widget.selectionHelper.selectedBlocks;
let blocks: BlockComponent[] = [];
// When current selection is TextSelection, should cover all the blocks in native range
if (selections.length > 0 && includeTextSelection(selections)) {
const range = getCurrentNativeRange();
if (!range) return [];
const rangeManager = this.widget.std.range;
if (!rangeManager) return [];
blocks = rangeManager.getSelectedBlockComponentsByRange(range, {
match: el => el.model.role === 'content',
mode: 'highest',
});
} else {
blocks = this.widget.selectionHelper.selectedBlockComponents;
}
if (
containBlock(
blocks.map(block => block.blockId),
this.widget.anchorBlockId.peek()!
)
) {
return blocks;
}
return [hoverBlock];
};
getDraggingAreaRect = (): Rect | null => {
const block = this.widget.anchorBlockComponent.value;
if (!block) return null;
// When hover block is in selected blocks, should show hover rect on the selected blocks
// Top: the top of the first selected block
// Left: the left of the first selected block
// Right: the largest right of the selected blocks
// Bottom: the bottom of the last selected block
let { left, top, right, bottom } = block.getBoundingClientRect();
const blocks = this._getHoveredBlocks();
blocks.forEach(block => {
left = Math.min(left, block.getBoundingClientRect().left);
top = Math.min(top, block.getBoundingClientRect().top);
right = Math.max(right, block.getBoundingClientRect().right);
bottom = Math.max(bottom, block.getBoundingClientRect().bottom);
});
const offsetLeft = getDragHandleLeftPadding(blocks);
const offsetParentRect =
this.widget.dragHandleContainerOffsetParent.getBoundingClientRect();
if (!offsetParentRect) return null;
left -= offsetParentRect.left;
right -= offsetParentRect.left;
top -= offsetParentRect.top;
bottom -= offsetParentRect.top;
const scaleInNote = this.widget.scaleInNote.value;
// Add padding to hover rect
left -= (DRAG_HANDLE_CONTAINER_WIDTH + offsetLeft) * scaleInNote;
top -= DRAG_HOVER_RECT_PADDING * scaleInNote;
right += DRAG_HOVER_RECT_PADDING * scaleInNote;
bottom += DRAG_HOVER_RECT_PADDING * scaleInNote;
return new Rect(left, top, right, bottom);
};
constructor(readonly widget: AffineDragHandleWidget) {}
}

View File

@@ -1,62 +0,0 @@
import { findNoteBlockModel } from '@blocksuite/affine-shared/utils';
import type { BlockComponent } from '@blocksuite/block-std';
import type { AffineDragHandleWidget } from '../drag-handle.js';
export class SelectionHelper {
/** Check if given block component is selected */
isBlockSelected = (block?: BlockComponent) => {
if (!block) return false;
return this.selectedBlocks.some(
selection => selection.blockId === block.model.id
);
};
setSelectedBlocks = (blocks: BlockComponent[], noteId?: string) => {
const { selection } = this;
const selections = blocks.map(block =>
selection.create('block', {
blockId: block.blockId,
})
);
// When current page is edgeless page
// We need to remain surface selection and set editing as true
if (this.widget.mode === 'edgeless') {
const surfaceElementId = noteId
? noteId
: findNoteBlockModel(blocks[0].model)?.id;
if (!surfaceElementId) return;
const surfaceSelection = selection.create(
'surface',
blocks[0]!.blockId,
[surfaceElementId],
true
);
selections.push(surfaceSelection);
}
selection.set(selections);
};
get selectedBlockComponents() {
return this.selectedBlocks
.map(block => this.widget.std.view.getBlock(block.blockId))
.filter((block): block is BlockComponent => !!block);
}
get selectedBlocks() {
const selection = this.selection;
return selection.find('text')
? selection.filter('text')
: selection.filter('block');
}
get selection() {
return this.widget.std.selection;
}
constructor(readonly widget: AffineDragHandleWidget) {}
}

View File

@@ -1,16 +0,0 @@
import type { BlockStdScope } from '@blocksuite/block-std';
import type { JobMiddleware } from '@blocksuite/store';
export const newIdCrossDoc =
(std: BlockStdScope): JobMiddleware =>
({ slots, collection }) => {
let samePage = false;
slots.beforeImport.on(payload => {
if (payload.type === 'slice') {
samePage = payload.snapshot.pageId === std.doc.id;
}
if (payload.type === 'block' && !samePage) {
payload.snapshot.id = collection.idGenerator();
}
});
};

View File

@@ -1,24 +0,0 @@
import { correctNumberedListsOrderToPrev } from '@blocksuite/affine-block-list';
import { matchFlavours } from '@blocksuite/affine-shared/utils';
import type { BlockStdScope } from '@blocksuite/block-std';
import type { JobMiddleware } from '@blocksuite/store';
export const reorderList =
(std: BlockStdScope): JobMiddleware =>
({ slots }) => {
slots.afterImport.on(payload => {
if (payload.type === 'block') {
const model = payload.model;
if (
matchFlavours(model, ['affine:list']) &&
model.type === 'numbered'
) {
const next = std.doc.getNext(model);
correctNumberedListsOrderToPrev(std.doc, model);
if (next) {
correctNumberedListsOrderToPrev(std.doc, next);
}
}
}
});
};

View File

@@ -1,29 +0,0 @@
import type { BlockStdScope } from '@blocksuite/block-std';
import type { JobMiddleware } from '@blocksuite/store';
export const surfaceRefToEmbed =
(std: BlockStdScope): JobMiddleware =>
({ slots, collection }) => {
let pageId: string | null = null;
slots.beforeImport.on(payload => {
if (payload.type === 'slice') {
pageId = payload.snapshot.pageId;
}
});
slots.beforeImport.on(payload => {
if (
pageId &&
payload.type === 'block' &&
payload.snapshot.flavour === 'affine:surface-ref' &&
!std.doc.hasBlock(payload.snapshot.id)
) {
const id = payload.snapshot.id;
payload.snapshot.id = collection.idGenerator();
payload.snapshot.flavour = 'affine:embed-linked-doc';
payload.snapshot.props = {
blockId: id,
pageId,
};
}
});
};

View File

@@ -1,59 +0,0 @@
import { css } from 'lit';
import { DRAG_HANDLE_CONTAINER_WIDTH } from './config.js';
export const styles = css`
.affine-drag-handle-widget {
display: flex;
position: absolute;
left: 0;
top: 0;
contain: size layout;
}
.affine-drag-handle-container {
top: 0;
left: 0;
position: absolute;
display: flex;
justify-content: center;
width: ${DRAG_HANDLE_CONTAINER_WIDTH}px;
min-height: 12px;
pointer-events: auto;
user-select: none;
box-sizing: border-box;
}
.affine-drag-handle-container:hover {
cursor: grab;
}
.affine-drag-handle-grabber {
width: 4px;
height: 100%;
border-radius: 1px;
background: var(--affine-placeholder-color);
transition: width 0.25s ease;
}
@media print {
.affine-drag-handle-widget {
display: none;
}
}
.affine-drag-hover-rect {
position: absolute;
top: 0;
left: 0;
border-radius: 6px;
background: var(--affine-hover-color);
pointer-events: none;
z-index: 2;
animation: expand 0.25s forwards;
}
@keyframes expand {
0% {
width: 0;
height: 0;
}
}
`;

View File

@@ -1,247 +0,0 @@
import { ParagraphBlockComponent } from '@blocksuite/affine-block-paragraph';
import type { ParagraphBlockModel } from '@blocksuite/affine-model';
import { DocModeProvider } from '@blocksuite/affine-shared/services';
import {
calcDropTarget,
type DropResult,
findClosestBlockComponent,
getBlockProps,
getClosestBlockComponentByPoint,
matchFlavours,
} from '@blocksuite/affine-shared/utils';
import type {
BaseSelection,
BlockComponent,
EditorHost,
} from '@blocksuite/block-std';
import { Point, Rect } from '@blocksuite/global/utils';
import type { BlockModel } from '@blocksuite/store';
import {
DRAG_HANDLE_CONTAINER_HEIGHT,
DRAG_HANDLE_CONTAINER_OFFSET_LEFT,
DRAG_HANDLE_CONTAINER_OFFSET_LEFT_LIST,
EDGELESS_NOTE_EXTRA_PADDING,
NOTE_CONTAINER_PADDING,
} from './config.js';
const heightMap: Record<string, number> = {
text: 23,
h1: 40,
h2: 36,
h3: 32,
h4: 32,
h5: 28,
h6: 26,
quote: 46,
list: 24,
database: 28,
image: 28,
divider: 36,
};
export const getDragHandleContainerHeight = (model: BlockModel) => {
const flavour = model.flavour;
const index = flavour.indexOf(':');
let key = flavour.slice(index + 1);
if (key === 'paragraph' && (model as ParagraphBlockModel).type) {
key = (model as ParagraphBlockModel).type;
}
const height = heightMap[key] ?? DRAG_HANDLE_CONTAINER_HEIGHT;
return height;
};
// To check if the block is a child block of the selected blocks
export const containChildBlock = (
blocks: BlockComponent[],
childModel: BlockModel
) => {
return blocks.some(block => {
let currentBlock: BlockModel | null = childModel;
while (currentBlock) {
if (currentBlock.id === block.model.id) {
return true;
}
currentBlock = block.doc.getParent(currentBlock.id);
}
return false;
});
};
export const containBlock = (blockIDs: string[], targetID: string) => {
return blockIDs.some(blockID => blockID === targetID);
};
// TODO: this is a hack, need to find a better way
export const insideDatabaseTable = (element: Element) => {
return !!element.closest('.affine-database-block-table');
};
export const includeTextSelection = (selections: BaseSelection[]) => {
return selections.some(selection => selection.type === 'text');
};
/**
* Check if the path of two blocks are equal
*/
export const isBlockIdEqual = (
id1: string | null | undefined,
id2: string | null | undefined
) => {
if (!id1 || !id2) {
return false;
}
return id1 === id2;
};
export const isOutOfNoteBlock = (
editorHost: EditorHost,
noteBlock: Element,
point: Point,
scale: number
) => {
// TODO: need to find a better way to check if the point is out of note block
const rect = noteBlock.getBoundingClientRect();
const insidePageEditor =
editorHost.std.get(DocModeProvider).getEditorMode() === 'page';
const padding =
(NOTE_CONTAINER_PADDING +
(insidePageEditor ? 0 : EDGELESS_NOTE_EXTRA_PADDING)) *
scale;
return rect
? insidePageEditor
? point.y < rect.top ||
point.y > rect.bottom ||
point.x > rect.right + padding
: point.y < rect.top ||
point.y > rect.bottom ||
point.x < rect.left - padding ||
point.x > rect.right + padding
: true;
};
export const getClosestNoteBlock = (
editorHost: EditorHost,
rootComponent: BlockComponent,
point: Point
) => {
const isInsidePageEditor =
editorHost.std.get(DocModeProvider).getEditorMode() === 'page';
return isInsidePageEditor
? findClosestBlockComponent(rootComponent, point, 'affine-note')
: getHoveringNote(point)?.closest('affine-edgeless-note');
};
export const getClosestBlockByPoint = (
editorHost: EditorHost,
rootComponent: BlockComponent,
point: Point
) => {
const closestNoteBlock = getClosestNoteBlock(
editorHost,
rootComponent,
point
);
if (!closestNoteBlock || closestNoteBlock.closest('.affine-surface-ref')) {
return null;
}
const noteRect = Rect.fromDOM(closestNoteBlock);
const block = getClosestBlockComponentByPoint(point, {
container: closestNoteBlock,
rect: noteRect,
}) as BlockComponent | null;
const blockSelector =
'.affine-note-block-container > .affine-block-children-container > [data-block-id]';
const closestBlock = (
block && containChildBlock([closestNoteBlock], block.model)
? block
: findClosestBlockComponent(
closestNoteBlock as BlockComponent,
point.clone(),
blockSelector
)
) as BlockComponent;
if (!closestBlock || !!closestBlock.closest('.surface-ref-note-portal')) {
return null;
}
return closestBlock;
};
export const getDropResult = (
event: MouseEvent,
scale: number = 1
): DropResult | null => {
let dropIndicator = null;
const point = new Point(event.x, event.y);
const closestBlock = getClosestBlockComponentByPoint(point) as BlockComponent;
if (!closestBlock) {
return dropIndicator;
}
const model = closestBlock.model;
const isDatabase = matchFlavours(model, ['affine:database']);
if (isDatabase) {
return dropIndicator;
}
const result = calcDropTarget(point, model, closestBlock, [], scale);
if (result) {
dropIndicator = result;
}
return dropIndicator;
};
export function getDragHandleLeftPadding(blocks: BlockComponent[]) {
const hasToggleList = blocks.some(
block =>
(matchFlavours(block.model, ['affine:list']) &&
block.model.children.length > 0) ||
(block instanceof ParagraphBlockComponent &&
block.model.type.startsWith('h') &&
block.collapsedSiblings.length > 0)
);
const offsetLeft = hasToggleList
? DRAG_HANDLE_CONTAINER_OFFSET_LEFT_LIST
: DRAG_HANDLE_CONTAINER_OFFSET_LEFT;
return offsetLeft;
}
let previousEle: BlockComponent[] = [];
export function updateDragHandleClassName(blocks: BlockComponent[] = []) {
const className = 'with-drag-handle';
previousEle.forEach(block => block.classList.remove(className));
previousEle = blocks;
blocks.forEach(block => block.classList.add(className));
}
export function getDuplicateBlocks(blocks: BlockModel[]) {
const duplicateBlocks = blocks.map(block => ({
flavour: block.flavour,
blockProps: getBlockProps(block),
}));
return duplicateBlocks;
}
/**
* Get hovering note with given a point in edgeless mode.
*/
function getHoveringNote(point: Point) {
return (
document.elementsFromPoint(point.x, point.y).find(isEdgelessChildNote) ||
null
);
}
function isEdgelessChildNote({ classList }: Element) {
return classList.contains('note-background');
}

View File

@@ -1,605 +0,0 @@
import { ParagraphBlockComponent } from '@blocksuite/affine-block-paragraph';
import type { EmbedCardStyle, NoteBlockModel } from '@blocksuite/affine-model';
import {
EMBED_CARD_HEIGHT,
EMBED_CARD_WIDTH,
} from '@blocksuite/affine-shared/consts';
import {
DndApiExtensionIdentifier,
DocModeProvider,
TelemetryProvider,
} from '@blocksuite/affine-shared/services';
import {
calcDropTarget,
captureEventTarget,
type DropResult,
getBlockComponentsExcludeSubtrees,
getClosestBlockComponentByPoint,
matchFlavours,
} from '@blocksuite/affine-shared/utils';
import {
type BlockComponent,
type DndEventState,
isGfxBlockComponent,
type UIEventHandler,
type UIEventStateContext,
} from '@blocksuite/block-std';
import { GfxControllerIdentifier } from '@blocksuite/block-std/gfx';
import { Bound, Point } from '@blocksuite/global/utils';
import { Job, Slice, type SliceSnapshot } from '@blocksuite/store';
import type { EdgelessRootBlockComponent } from '../../../edgeless/index.js';
import { addNoteAtPoint } from '../../../edgeless/utils/common.js';
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';
export class DragEventWatcher {
private readonly _computeEdgelessBound = (
x: number,
y: number,
width: number,
height: number
) => {
const controller = this._std.get(GfxControllerIdentifier);
const border = 2;
const noteScale = this.widget.noteScale.peek();
const { viewport } = controller;
const { left: viewportLeft, top: viewportTop } = viewport;
const currentViewBound = new Bound(
x - viewportLeft,
y - viewportTop,
width + border / noteScale,
height + border / noteScale
);
const currentModelBound = viewport.toModelBound(currentViewBound);
return new Bound(
currentModelBound.x,
currentModelBound.y,
width * noteScale,
height * noteScale
);
};
private readonly _createDropIndicator = () => {
if (!this.widget.dropIndicator) {
this.widget.dropIndicator = new DropIndicator();
this.widget.rootComponent.append(this.widget.dropIndicator);
}
};
private readonly _cleanup = () => {
this.widget.previewHelper.removeDragPreview();
this.widget.clearRaf();
this.widget.hide(true);
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);
};
/**
* When start dragging, should set dragging elements and create drag preview
*/
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;
}
return this._onDragStart(state);
};
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;
}
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 _onDragStart = (state: DndEventState) => {
// Get current hover block element by path
const hoverBlock = this.widget.anchorBlockComponent.peek();
if (!hoverBlock) return false;
const element = captureEventTarget(state.raw.target);
const dragByHandle = !!element?.closest(AFFINE_DRAG_HANDLE_WIDGET);
const isInSurface = isGfxBlockComponent(hoverBlock);
if (isInSurface && dragByHandle) {
this._startDragging([hoverBlock], state);
return true;
}
const selectBlockAndStartDragging = () => {
this._std.selection.setGroup('note', [
this._std.selection.create('block', {
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;
if (nativeSelection && nativeSelection.rangeCount > 0 && rangeManager) {
const range = nativeSelection.getRangeAt(0);
const blocks = rangeManager.getSelectedBlockComponentsByRange(range, {
match: el => el.model.role === 'content',
mode: 'highest',
});
this.widget.selectionHelper.setSelectedBlocks(blocks);
selections = this.widget.selectionHelper.selectedBlocks;
}
}
// When there is no selected blocks
// Or selected blocks not including current hover block
// Set current hover block as selected
if (
selections.length === 0 ||
!containBlock(
selections.map(selection => selection.blockId),
this.widget.anchorBlockId.peek()!
)
) {
const block = this.widget.anchorBlockComponent.peek();
if (block) {
this.widget.selectionHelper.setSelectedBlocks([block]);
}
}
const collapsedBlock: BlockComponent[] = [];
const blocks = this.widget.selectionHelper.selectedBlockComponents.flatMap(
block => {
// filter out collapsed siblings
if (collapsedBlock.includes(block)) return [];
// if block is toggled heading, should select all siblings
if (
block instanceof ParagraphBlockComponent &&
block.model.type.startsWith('h') &&
block.model.collapsed
) {
const collapsedSiblings = block.collapsedSiblings.flatMap(
sibling => this.widget.host.view.getBlock(sibling.id) ?? []
);
collapsedBlock.push(...collapsedSiblings);
return [block, ...collapsedSiblings];
}
return [block];
}
);
// This could be skipped if we can ensure that all selected blocks are on the same level
// Which means not selecting parent block and child block at the same time
const blocksExcludingChildren = getBlockComponentsExcludeSubtrees(
blocks
) as BlockComponent[];
if (blocksExcludingChildren.length === 0) return false;
this._startDragging(blocksExcludingChildren, state);
this.widget.hide();
return true;
};
private readonly _onDrop = (context: UIEventStateContext) => {
const state = context.get('dndState');
const event = state.raw;
event.preventDefault();
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 model = element.model;
const parent = this._std.doc.getParent(model.id);
if (!parent) return;
if (matchFlavours(parent, ['affine:surface'])) {
return;
}
const result: DropResult | null = calcDropTarget(point, model, element);
if (!result) return;
const index =
parent.children.indexOf(model) + (result.type === '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;
}
}
}
this._deserializeData(state, parent.id, index).catch(console.error);
};
private readonly _onDropNoteOnNote = (
snapshot: SliceSnapshot,
parent?: string,
index?: number
) => {
const [first] = snapshot.content;
const id = first.id;
const std = this._std;
const job = this._getJob();
const snapshotWithoutNote = {
...snapshot,
content: first.children,
};
job
.snapshotToSlice(snapshotWithoutNote, std.doc, parent, index)
.then(() => {
const block = std.doc.getBlock(id)?.model;
if (block) {
std.doc.deleteBlock(block);
}
})
.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);
const edgelessRoot = this.widget
.rootComponent as EdgelessRootBlockComponent;
if (!snapshot) {
return;
}
const [first] = snapshot.content;
if (first.flavour === 'affine:note') return;
if (snapshot.content.length === 1) {
const importToSurface = (
width: number,
height: number,
newBound: Bound
) => {
first.props.xywh = newBound.serialize();
first.props.width = width;
first.props.height = height;
const std = this._std;
const job = this._getJob();
job
.snapshotToSlice(snapshot, std.doc, edgelessRoot.surfaceBlockModel.id)
.catch(console.error);
};
if (
['affine:attachment', 'affine:bookmark'].includes(first.flavour) ||
first.flavour.startsWith('affine:embed-')
) {
const style = (first.props.style ?? 'horizontal') as EmbedCardStyle;
const width = EMBED_CARD_WIDTH[style];
const height = EMBED_CARD_HEIGHT[style];
const newBound = this._computeEdgelessBound(
state.raw.clientX,
state.raw.clientY,
width,
height
);
if (!newBound) return;
if (first.flavour === 'affine:embed-linked-doc') {
this._trackLinkedDocCreated(first.id);
}
importToSurface(width, height, newBound);
return;
}
if (first.flavour === 'affine:image') {
const noteScale = this.widget.noteScale.peek();
const width = Number(first.props.width || 100) * noteScale;
const height = Number(first.props.height || 100) * noteScale;
const newBound = this._computeEdgelessBound(
state.raw.clientX,
state.raw.clientY,
width,
height
);
if (!newBound) return;
importToSurface(width, height, newBound);
return;
}
}
const { left: viewportLeft, top: viewportTop } = edgelessRoot.viewport;
const newNoteId = addNoteAtPoint(
edgelessRoot.std,
new Point(state.raw.x - viewportLeft, state.raw.y - viewportTop),
{
scale: this.widget.noteScale.peek(),
}
);
const newNoteBlock = this.widget.doc.getBlock(newNoteId)?.model as
| NoteBlockModel
| undefined;
if (!newNoteBlock) return;
const bound = Bound.deserialize(newNoteBlock.xywh);
bound.h *= this.widget.noteScale.peek();
bound.w *= this.widget.noteScale.peek();
this.widget.doc.updateBlock(newNoteBlock, {
xywh: bound.serialize(),
edgeless: {
...newNoteBlock.edgeless,
scale: this.widget.noteScale.peek(),
},
});
this._deserializeData(state, 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
);
const slice = Slice.fromModels(
this._std.doc,
blocks.map(block => block.model)
);
this.widget.dragging = true;
this._createDropIndicator();
this.widget.hide();
this._serializeData(slice, state);
};
private readonly _trackLinkedDocCreated = (id: string) => {
const isNewBlock = !this._std.doc.hasBlock(id);
if (!isNewBlock) {
return;
}
const mode =
this._std.getOptional(DocModeProvider)?.getEditorMode() ?? 'page';
const telemetryService = this._std.getOptional(TelemetryProvider);
telemetryService?.track('LinkedDocCreated', {
control: `drop on ${mode}`,
module: 'drag and drop',
type: 'doc',
other: 'new doc',
});
};
private get _dndAPI() {
return this._std.get(DndApiExtensionIdentifier);
}
private get _std() {
return this.widget.std;
}
constructor(readonly widget: AffineDragHandleWidget) {}
private async _deserializeData(
state: DndEventState,
parent?: string,
index?: number
) {
try {
const dataTransfer = state.raw.dataTransfer;
if (!dataTransfer) throw new Error('No data transfer');
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);
}
}
// use snapshot
const slice = await job.snapshotToSlice(
snapshot,
std.doc,
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;
} catch {
return null;
}
}
private _getJob() {
const std = this._std;
return new Job({
collection: std.collection,
middlewares: [
newIdCrossDoc(std),
reorderList(std),
surfaceRefToEmbed(std),
],
});
}
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);
}
watch() {
this.widget.handleEvent('pointerDown', ctx => {
const state = ctx.get('pointerState');
const event = state.raw;
const target = captureEventTarget(event.target);
if (!target) return;
if (this.widget.contains(target)) {
return true;
}
return;
});
this.widget.handleEvent('dragStart', ctx => {
const state = ctx.get('pointerState');
const event = state.raw;
const target = captureEventTarget(event.target);
if (!target) return;
if (this.widget.contains(target)) {
return true;
}
return;
});
this.widget.handleEvent('nativeDragStart', this._dragStartHandler, {
global: true,
});
this.widget.handleEvent('nativeDragMove', this._dragMoveHandler, {
global: true,
});
this.widget.handleEvent('nativeDragEnd', this._dragEndHandler, {
global: true,
});
this.widget.handleEvent('nativeDrop', this._dropHandler, {
global: true,
});
}
}

View File

@@ -1,256 +0,0 @@
import {
EdgelessLegacySlotIdentifier,
type SurfaceBlockComponent,
} from '@blocksuite/affine-block-surface';
import {
getSelectedRect,
isTopLevelBlock,
} from '@blocksuite/affine-shared/utils';
import type { DndEventState } from '@blocksuite/block-std';
import {
GfxControllerIdentifier,
type GfxToolsFullOptionValue,
} from '@blocksuite/block-std/gfx';
import { type IVec, Rect } from '@blocksuite/global/utils';
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';
export class EdgelessWatcher {
private readonly _handleEdgelessToolUpdated = (
newTool: GfxToolsFullOptionValue
) => {
if (newTool.type === 'default') {
this.checkTopLevelBlockSelection();
} else {
this.widget.hide();
}
};
private readonly _handleEdgelessViewPortUpdated = ({
zoom,
center,
}: {
zoom: number;
center: IVec;
}) => {
if (this.widget.scale.peek() !== zoom) {
this.widget.scale.value = zoom;
this._updateDragPreviewOnViewportUpdate();
}
if (
this.widget.center[0] !== center[0] &&
this.widget.center[1] !== center[1]
) {
this.widget.center = [...center];
this.widget.updateDropIndicatorOnScroll();
}
if (this.widget.isTopLevelDragHandleVisible) {
this._showDragHandleOnTopLevelBlocks().catch(console.error);
this._updateDragHoverRectTopLevelBlock();
} else {
this.widget.hide();
}
};
private readonly _showDragHandleOnTopLevelBlocks = async () => {
if (this.widget.mode === 'page') return;
const surfaceModel = this.widget.doc.getBlockByFlavour('affine:surface');
const surface = this.widget.std.view.getBlock(
surfaceModel[0]!.id
) as SurfaceBlockComponent;
await surface.updateComplete;
if (!this.widget.anchorBlockId) return;
const container = this.widget.dragHandleContainer;
const grabber = this.widget.dragHandleGrabber;
if (!container || !grabber) return;
const area = this.hoverAreaTopLevelBlock;
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.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;
};
private readonly _updateDragHoverRectTopLevelBlock = () => {
if (!this.widget.dragHoverRect) return;
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;
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 editing = selection.editing;
const selectedElements = selection.selectedElements;
if (editing || selectedElements.length !== 1) {
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);
};
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;
return new Rect(area.left, area.top, area.right, area.bottom);
}
get hoverAreaTopLevelBlock() {
const edgelessElement = this.widget.anchorEdgelessElement.peek();
if (!edgelessElement) return null;
const { std } = this.widget;
const gfx = std.get(GfxControllerIdentifier);
const { viewport } = gfx;
const rect = getSelectedRect([edgelessElement]);
let [left, top] = viewport.toViewCoord(rect.left, rect.top);
const scale = this.widget.scale.peek();
const width = rect.width * scale;
const height = rect.height * scale;
let [right, bottom] = [left + width, top + height];
const padding = HOVER_AREA_RECT_PADDING_TOP_LEVEL * scale;
const containerWidth = DRAG_HANDLE_CONTAINER_WIDTH_TOP_LEVEL * scale;
const offsetLeft = DRAG_HANDLE_CONTAINER_OFFSET_LEFT_TOP_LEVEL * scale;
left -= containerWidth + offsetLeft;
top -= padding;
right += padding;
bottom += padding;
return {
left,
top,
right,
bottom,
width,
height,
padding,
containerWidth,
};
}
constructor(readonly widget: AffineDragHandleWidget) {}
watch() {
const { disposables, std } = this.widget;
const gfx = std.get(GfxControllerIdentifier);
const { viewport, selection, tool } = gfx;
const edgelessSlots = std.get(EdgelessLegacySlotIdentifier);
disposables.add(
viewport.viewportUpdated.on(this._handleEdgelessViewPortUpdated)
);
disposables.add(
selection.slots.updated.on(() => {
this.checkTopLevelBlockSelection();
})
);
disposables.add(
effect(() => {
const value = tool.currentToolOption$.value;
value && this._handleEdgelessToolUpdated(value);
})
);
disposables.add(
edgelessSlots.readonlyUpdated.on(() => {
this.checkTopLevelBlockSelection();
})
);
disposables.add(
edgelessSlots.elementResizeStart.on(() => {
this.widget.hide();
})
);
disposables.add(
edgelessSlots.elementResizeEnd.on(() => {
this.checkTopLevelBlockSelection();
})
);
}
}

View File

@@ -1,93 +0,0 @@
import {
DRAG_HANDLE_CONTAINER_PADDING,
DRAG_HANDLE_GRABBER_BORDER_RADIUS,
DRAG_HANDLE_GRABBER_WIDTH_HOVERED,
} from '../config.js';
import type { AffineDragHandleWidget } from '../drag-handle.js';
export class HandleEventWatcher {
private readonly _onDragHandlePointerDown = () => {
if (!this.widget.isHoverDragHandleVisible || !this.widget.anchorBlockId)
return;
this.widget.dragHoverRect = this.widget.draggingAreaRect.value;
};
private readonly _onDragHandlePointerEnter = () => {
const container = this.widget.dragHandleContainer;
const grabber = this.widget.dragHandleGrabber;
if (!container || !grabber) return;
if (this.widget.isHoverDragHandleVisible && this.widget.anchorBlockId) {
const block = this.widget.anchorBlockComponent;
if (!block) return;
const padding = DRAG_HANDLE_CONTAINER_PADDING * this.widget.scale.peek();
container.style.paddingTop = `${padding}px`;
container.style.paddingBottom = `${padding}px`;
container.style.transition = `padding 0.25s ease`;
grabber.style.width = `${
DRAG_HANDLE_GRABBER_WIDTH_HOVERED * this.widget.scaleInNote.peek()
}px`;
grabber.style.borderRadius = `${
DRAG_HANDLE_GRABBER_BORDER_RADIUS * this.widget.scaleInNote.peek()
}px`;
this.widget.isDragHandleHovered = true;
} else if (this.widget.isTopLevelDragHandleVisible) {
this.widget.dragHoverRect =
this.widget.edgelessWatcher.hoverAreaRectTopLevelBlock;
this.widget.isDragHandleHovered = true;
}
};
private readonly _onDragHandlePointerLeave = () => {
this.widget.isDragHandleHovered = false;
this.widget.dragHoverRect = null;
if (this.widget.isTopLevelDragHandleVisible) return;
if (this.widget.dragging) return;
this.widget.pointerEventWatcher.showDragHandleOnHoverBlock();
};
private readonly _onDragHandlePointerUp = () => {
if (!this.widget.isHoverDragHandleVisible) return;
this.widget.dragHoverRect = null;
};
constructor(readonly widget: AffineDragHandleWidget) {}
watch() {
const { dragHandleContainer, disposables } = this.widget;
// When pointer enter drag handle grabber
// Extend drag handle grabber to the height of the hovered block
disposables.addFromEvent(
dragHandleContainer,
'pointerenter',
this._onDragHandlePointerEnter
);
disposables.addFromEvent(
dragHandleContainer,
'pointerdown',
this._onDragHandlePointerDown
);
disposables.addFromEvent(
dragHandleContainer,
'pointerup',
this._onDragHandlePointerUp
);
// When pointer leave drag handle grabber, should reset drag handle grabber style
disposables.addFromEvent(
dragHandleContainer,
'pointerleave',
this._onDragHandlePointerLeave
);
}
}

View File

@@ -1,27 +0,0 @@
import type { UIEventHandler } from '@blocksuite/block-std';
import type { AffineDragHandleWidget } from '../drag-handle.js';
export class KeyboardEventWatcher {
private readonly _keyboardHandler: UIEventHandler = ctx => {
if (!this.widget.dragging || !this.widget.dragPreview) {
return;
}
const state = ctx.get('defaultState');
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) {}
watch() {
this.widget.handleEvent('beforeInput', () => this.widget.hide());
this.widget.handleEvent('keyDown', this._keyboardHandler, { global: true });
this.widget.handleEvent('keyUp', this._keyboardHandler, { global: true });
}
}

View File

@@ -1,37 +0,0 @@
import { getScrollContainer } from '@blocksuite/affine-shared/utils';
import type { PageRootBlockComponent } from '../../../page/page-root-block.js';
import type { AffineDragHandleWidget } from '../drag-handle.js';
export class PageWatcher {
get pageRoot() {
return this.widget.rootComponent as PageRootBlockComponent;
}
constructor(readonly widget: AffineDragHandleWidget) {}
watch() {
const { pageRoot } = this;
const { disposables } = this.widget;
const scrollContainer = getScrollContainer(pageRoot);
disposables.add(
this.widget.doc.slots.blockUpdated.on(() => this.widget.hide())
);
disposables.add(
pageRoot.slots.viewportUpdated.on(() => {
this.widget.hide();
if (this.widget.dropIndicator) {
this.widget.dropIndicator.rect = null;
}
})
);
disposables.addFromEvent(
scrollContainer,
'scrollend',
this.widget.updateDropIndicatorOnScroll
);
}
}

View File

@@ -1,339 +0,0 @@
import type { NoteBlockComponent } from '@blocksuite/affine-block-note';
import { captureEventTarget } from '@blocksuite/affine-shared/utils';
import {
BLOCK_ID_ATTR,
type BlockComponent,
type PointerEventState,
type UIEventHandler,
} from '@blocksuite/block-std';
import { Point, throttle } from '@blocksuite/global/utils';
import { computed } from '@preact/signals-core';
import type { EdgelessRootBlockComponent } from '../../../edgeless/index.js';
import {
DRAG_HANDLE_CONTAINER_WIDTH,
DRAG_HANDLE_GRABBER_BORDER_RADIUS,
DRAG_HANDLE_GRABBER_HEIGHT,
DRAG_HANDLE_GRABBER_WIDTH,
} from '../config.js';
import { AFFINE_DRAG_HANDLE_WIDGET } from '../consts.js';
import type { AffineDragHandleWidget } from '../drag-handle.js';
import {
getClosestBlockByPoint,
getClosestNoteBlock,
getDragHandleContainerHeight,
includeTextSelection,
insideDatabaseTable,
isBlockIdEqual,
isOutOfNoteBlock,
updateDragHandleClassName,
} from '../utils.js';
export class PointerEventWatcher {
private readonly _canEditing = (noteBlock: BlockComponent) => {
if (noteBlock.doc.id !== this.widget.doc.id) return false;
if (this.widget.mode === 'page') return true;
const edgelessRoot = this.widget
.rootComponent as EdgelessRootBlockComponent;
const noteBlockId = noteBlock.model.id;
return (
edgelessRoot.service.selection.editing &&
edgelessRoot.service.selection.selectedIds[0] === noteBlockId
);
};
/**
* When click on drag handle
* Should select the block and show slash menu if current block is not selected
* Should clear selection if current block is the first selected block
*/
private readonly _clickHandler: UIEventHandler = ctx => {
if (!this.widget.isHoverDragHandleVisible) return;
const state = ctx.get('pointerState');
const { target } = state.raw;
const element = captureEventTarget(target);
const insideDragHandle = !!element?.closest(AFFINE_DRAG_HANDLE_WIDGET);
if (!insideDragHandle) return;
const anchorBlockId = this.widget.anchorBlockId.peek();
if (!anchorBlockId) return;
const { selection } = this.widget.std;
const selectedBlocks = this.widget.selectionHelper.selectedBlocks;
// Should clear selection if current block is the first selected block
if (
selectedBlocks.length > 0 &&
!includeTextSelection(selectedBlocks) &&
selectedBlocks[0].blockId === anchorBlockId
) {
selection.clear(['block']);
this.widget.dragHoverRect = null;
this.showDragHandleOnHoverBlock();
return;
}
// Should select the block if current block is not selected
const block = this.widget.anchorBlockComponent.peek();
if (!block) return;
if (selectedBlocks.length > 1) {
this.showDragHandleOnHoverBlock();
}
this.widget.selectionHelper.setSelectedBlocks([block]);
};
// Need to consider block padding and scale
private readonly _getTopWithBlockComponent = (block: BlockComponent) => {
const computedStyle = getComputedStyle(block);
const { top } = block.getBoundingClientRect();
const paddingTop =
parseInt(computedStyle.paddingTop) * this.widget.scale.peek();
return (
top +
paddingTop -
this.widget.dragHandleContainerOffsetParent.getBoundingClientRect().top
);
};
private readonly _containerStyle = computed(() => {
const draggingAreaRect = this.widget.draggingAreaRect.value;
if (!draggingAreaRect) return null;
const block = this.widget.anchorBlockComponent.value;
if (!block) return null;
const containerHeight = getDragHandleContainerHeight(block.model);
const posTop = this._getTopWithBlockComponent(block);
const scaleInNote = this.widget.scaleInNote.value;
const rowPaddingY =
((containerHeight - DRAG_HANDLE_GRABBER_HEIGHT) / 2 + 2) * scaleInNote;
// use padding to control grabber's height
const paddingTop = rowPaddingY + posTop - draggingAreaRect.top;
const paddingBottom =
draggingAreaRect.height -
paddingTop -
DRAG_HANDLE_GRABBER_HEIGHT * scaleInNote;
return {
paddingTop: `${paddingTop}px`,
paddingBottom: `${paddingBottom}px`,
width: `${DRAG_HANDLE_CONTAINER_WIDTH * scaleInNote}px`,
left: `${draggingAreaRect.left}px`,
top: `${draggingAreaRect.top}px`,
height: `${draggingAreaRect.height}px`,
};
});
private readonly _grabberStyle = computed(() => {
const scaleInNote = this.widget.scaleInNote.value;
return {
width: `${DRAG_HANDLE_GRABBER_WIDTH * scaleInNote}px`,
borderRadius: `${DRAG_HANDLE_GRABBER_BORDER_RADIUS * scaleInNote}px`,
};
});
private _lastHoveredBlockId: string | null = null;
private _lastShowedBlock: { id: string; el: BlockComponent } | null = null;
/**
* When pointer move on block, should show drag handle
* And update hover block id and path
*/
private readonly _pointerMoveOnBlock = (state: PointerEventState) => {
if (this.widget.isTopLevelDragHandleVisible) return;
const point = new Point(state.raw.x, state.raw.y);
const closestBlock = getClosestBlockByPoint(
this.widget.host,
this.widget.rootComponent,
point
);
if (!closestBlock) {
this.widget.anchorBlockId.value = null;
return;
}
const blockId = closestBlock.getAttribute(BLOCK_ID_ATTR);
if (!blockId) return;
this.widget.anchorBlockId.value = blockId;
if (insideDatabaseTable(closestBlock) || this.widget.doc.readonly) {
this.widget.hide();
return;
}
// If current block is not the last hovered block, show drag handle beside the hovered block
if (
(!this._lastHoveredBlockId ||
!isBlockIdEqual(
this.widget.anchorBlockId.peek(),
this._lastHoveredBlockId
) ||
!this.widget.isHoverDragHandleVisible) &&
!this.widget.isDragHandleHovered
) {
this.showDragHandleOnHoverBlock();
this._lastHoveredBlockId = this.widget.anchorBlockId.peek();
}
};
private readonly _pointerOutHandler: UIEventHandler = ctx => {
const state = ctx.get('pointerState');
state.raw.preventDefault();
const { target } = state.raw;
const element = captureEventTarget(target);
if (!element) return;
const { relatedTarget } = state.raw;
// TODO: when pointer out of page viewport, should hide drag handle
// But the pointer out event is not as expected
// Need to be optimized
const relatedElement = captureEventTarget(relatedTarget);
const outOfPageViewPort = element.classList.contains(
'affine-page-viewport'
);
const inPage = !!relatedElement?.closest('.affine-page-viewport');
const inDragHandle = !!relatedElement?.closest(AFFINE_DRAG_HANDLE_WIDGET);
if (outOfPageViewPort && !inDragHandle && !inPage) {
this.widget.hide();
}
};
private readonly _throttledPointerMoveHandler = throttle<UIEventHandler>(
ctx => {
if (
this.widget.doc.readonly ||
this.widget.dragging ||
!this.widget.isConnected
) {
this.widget.hide();
return;
}
if (this.widget.isTopLevelDragHandleVisible) return;
const state = ctx.get('pointerState');
const { target } = state.raw;
const element = captureEventTarget(target);
// When pointer not on block or on dragging, should do nothing
if (!element) return;
// When pointer on drag handle, should do nothing
if (element.closest('.affine-drag-handle-container')) return;
// When pointer out of note block hover area or inside database, should hide drag handle
const point = new Point(state.raw.x, state.raw.y);
const closestNoteBlock = getClosestNoteBlock(
this.widget.host,
this.widget.rootComponent,
point
) as NoteBlockComponent | null;
this.widget.noteScale.value =
this.widget.mode === 'page'
? 1
: (closestNoteBlock?.model.edgeless.scale ?? 1);
if (
closestNoteBlock &&
this._canEditing(closestNoteBlock) &&
!isOutOfNoteBlock(
this.widget.host,
closestNoteBlock,
point,
this.widget.scaleInNote.peek()
)
) {
this._pointerMoveOnBlock(state);
return true;
}
this.widget.hide();
return false;
},
1000 / 60
);
// Multiple blocks: drag handle should show on the vertical middle of all blocks
showDragHandleOnHoverBlock = () => {
const block = this.widget.anchorBlockComponent.peek();
if (!block) return;
const container = this.widget.dragHandleContainer;
const grabber = this.widget.dragHandleGrabber;
if (!container || !grabber) return;
this.widget.isHoverDragHandleVisible = true;
const draggingAreaRect = this.widget.draggingAreaRect.peek();
if (!draggingAreaRect) return;
// Ad-hoc solution for list with toggle icon
updateDragHandleClassName([block]);
// End of ad-hoc solution
const applyStyle = (transition?: boolean) => {
const containerStyle = this._containerStyle.value;
if (!containerStyle) return;
container.style.transition = transition ? 'padding 0.25s ease' : 'none';
Object.assign(container.style, containerStyle);
container.style.display = 'flex';
};
if (isBlockIdEqual(block.blockId, this._lastShowedBlock?.id)) {
applyStyle(true);
} else if (this.widget.selectionHelper.selectedBlocks.length) {
if (this.widget.selectionHelper.isBlockSelected(block))
applyStyle(
this.widget.isDragHandleHovered &&
this.widget.selectionHelper.isBlockSelected(
this._lastShowedBlock?.el
)
);
else applyStyle(false);
} else {
applyStyle(false);
}
const grabberStyle = this._grabberStyle.value;
Object.assign(grabber.style, grabberStyle);
this.widget.handleAnchorModelDisposables();
if (!isBlockIdEqual(block.blockId, this._lastShowedBlock?.id)) {
this._lastShowedBlock = {
id: block.blockId,
el: block,
};
}
};
constructor(readonly widget: AffineDragHandleWidget) {}
reset() {
this._lastHoveredBlockId = null;
this._lastShowedBlock = null;
}
watch() {
this.widget.handleEvent('click', this._clickHandler);
this.widget.handleEvent('pointerMove', this._throttledPointerMoveHandler);
this.widget.handleEvent('pointerOut', this._pointerOutHandler);
}
}

View File

@@ -6,7 +6,6 @@ export {
type AffineAIPanelState,
type AffineAIPanelWidgetConfig,
} from './ai-panel/type.js';
export { AffineDragHandleWidget } from './drag-handle/drag-handle.js';
export {
AFFINE_EDGELESS_COPILOT_WIDGET,
EdgelessCopilotWidget,
@@ -53,7 +52,3 @@ export {
type AffineSlashSubMenu,
} from './slash-menu/index.js';
export { AffineSurfaceRefToolbar } from './surface-ref-toolbar/surface-ref-toolbar.js';
export {
AffineDocRemoteSelectionWidget,
EdgelessRemoteSelectionWidget,
} from '@blocksuite/affine-widget-remote-selection';

View File

@@ -1,5 +1,6 @@
import type { RootBlockModel } from '@blocksuite/affine-model';
import {
autoScroll,
getScrollContainer,
matchFlavours,
} from '@blocksuite/affine-shared/utils';
@@ -15,7 +16,6 @@ import { state } from 'lit/decorators.js';
import { styleMap } from 'lit/directives/style-map.js';
import type { PageRootBlockComponent } from '../../index.js';
import { autoScroll } from '../../text-selection/utils.js';
type Rect = {
left: number;