refactor(editor): unify directories naming (#11516)

**Directory Structure Changes**

- Renamed multiple block-related directories by removing the "block-" prefix:
  - `block-attachment` → `attachment`
  - `block-bookmark` → `bookmark`
  - `block-callout` → `callout`
  - `block-code` → `code`
  - `block-data-view` → `data-view`
  - `block-database` → `database`
  - `block-divider` → `divider`
  - `block-edgeless-text` → `edgeless-text`
  - `block-embed` → `embed`
This commit is contained in:
Saul-Mirone
2025-04-07 12:34:40 +00:00
parent e1bd2047c4
commit 1f45cc5dec
893 changed files with 439 additions and 460 deletions

View File

@@ -0,0 +1,133 @@
import { unsafeCSSVarV2 } from '@blocksuite/affine-shared/theme';
import {
EmbedIcon,
FrameIcon,
ImageIcon,
PageIcon,
ShapeIcon,
} from '@blocksuite/icons/lit';
import { css, html, LitElement } from 'lit';
import { property } from 'lit/decorators.js';
import { repeat } from 'lit/directives/repeat.js';
import { styleMap } from 'lit/directives/style-map.js';
const BLOCK_PREVIEW_ICON_MAP: Record<
string,
{
icon: typeof ShapeIcon;
name: string;
}
> = {
shape: {
icon: ShapeIcon,
name: 'Edgeless shape',
},
'affine:image': {
icon: ImageIcon,
name: 'Image block',
},
'affine:note': {
icon: PageIcon,
name: 'Note block',
},
'affine:frame': {
icon: FrameIcon,
name: 'Frame block',
},
'affine:embed-': {
icon: EmbedIcon,
name: 'Embed block',
},
};
declare global {
interface HTMLElementTagNameMap {
'edgeless-dnd-preview-element': EdgelessDndPreviewElement;
}
}
export const EDGELESS_DND_PREVIEW_ELEMENT = 'edgeless-dnd-preview-element';
export class EdgelessDndPreviewElement extends LitElement {
static override styles = css`
.edgeless-dnd-preview-container {
position: relative;
padding: 12px;
width: 264px;
height: 80px;
}
.edgeless-dnd-preview-block {
display: flex;
position: absolute;
width: 234px;
align-items: flex-start;
box-sizing: border-box;
border-radius: 8px;
background-color: ${unsafeCSSVarV2(
'layer/background/overlayPanel',
'#FBFBFC'
)};
padding: 8px 20px;
gap: 8px;
transform-origin: center;
font-family: var(--affine-font-family);
box-shadow: 0px 0px 0px 0.5px #e3e3e4 inset;
}
.edgeless-dnd-preview-block > svg {
color: ${unsafeCSSVarV2('icon/primary', '#77757D')};
}
.edgeless-dnd-preview-block > .text {
color: ${unsafeCSSVarV2('text/primary', '#121212')};
font-size: 14px;
line-height: 24px;
}
`;
@property({ type: Array })
accessor elementTypes: {
type: string;
}[] = [];
private _getPreviewIcon(type: string) {
if (BLOCK_PREVIEW_ICON_MAP[type]) {
return BLOCK_PREVIEW_ICON_MAP[type];
}
if (type.startsWith('affine:embed-')) {
return BLOCK_PREVIEW_ICON_MAP['affine:embed-'];
}
return {
icon: ShapeIcon,
name: 'Edgeless content',
};
}
override render() {
const blocks = repeat(this.elementTypes.slice(0, 3), ({ type }, index) => {
const { icon, name } = this._getPreviewIcon(type);
return html`<div
class="edgeless-dnd-preview-block"
style=${styleMap({
transform: `rotate(${index * -2}deg)`,
zIndex: 3 - index,
})}
>
${icon({ width: '24px', height: '24px' })}
<span class="text">${name}</span>
</div>`;
});
return html`<div class="edgeless-dnd-preview-container">${blocks}</div>`;
}
}

View File

@@ -0,0 +1,19 @@
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

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

View File

@@ -0,0 +1,254 @@
import { EdgelessCRUDIdentifier } from '@blocksuite/affine-block-surface';
import type { RootBlockModel } from '@blocksuite/affine-model';
import { DocModeProvider } from '@blocksuite/affine-shared/services';
import {
isInsideEdgelessEditor,
isInsidePageEditor,
} from '@blocksuite/affine-shared/utils';
import { DisposableGroup } from '@blocksuite/global/disposable';
import type { IVec, Point, Rect } from '@blocksuite/global/gfx';
import { type BlockComponent, WidgetComponent } from '@blocksuite/std';
import type { GfxModel } from '@blocksuite/std/gfx';
import { computed, type ReadonlySignal, signal } from '@preact/signals-core';
import { html, nothing } from 'lit';
import { query, state } from 'lit/decorators.js';
import { classMap } from 'lit/directives/class-map.js';
import { styleMap } from 'lit/directives/style-map.js';
import type { AFFINE_DRAG_HANDLE_WIDGET } from './consts.js';
import { RectHelper } from './helpers/rect-helper.js';
import { SelectionHelper } from './helpers/selection-helper.js';
import { styles } from './styles.js';
import { updateDragHandleClassName } from './utils.js';
import { DragEventWatcher } from './watchers/drag-event-watcher.js';
import { EdgelessWatcher } from './watchers/edgeless-watcher.js';
import { HandleEventWatcher } from './watchers/handle-event-watcher.js';
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;
/**
* Used to handle drag behavior
*/
private readonly _dragEventWatcher = new DragEventWatcher(this);
private readonly _handleEventWatcher = new HandleEventWatcher(this);
private readonly _keyboardEventWatcher = new KeyboardEventWatcher(this);
private readonly _pageWatcher = new PageWatcher(this);
private readonly _reset = () => {
this.dragging = false;
this.dragHoverRect = null;
this.anchorBlockId.value = null;
this.isDragHandleHovered = false;
this.pointerEventWatcher.reset();
};
@state()
accessor activeDragHandle: 'block' | 'gfx' | null = 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<GfxModel | null> = computed(() => {
if (!this.anchorBlockId.value) return null;
if (this.mode === 'page') return null;
const crud = this.std.get(EdgelessCRUDIdentifier);
const edgelessElement = crud.getElementById(this.anchorBlockId.value);
return edgelessElement;
});
// 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
);
lastDragPoint: Point | 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.subscribe(() => this.hide())
);
this._anchorModelDisposables.add(
blockModel.deleted.subscribe(() => this.hide())
);
};
hide = (force = false) => {
if (this.dragging && !force) return;
updateDragHandleClassName();
this.isDragHandleHovered = false;
this.anchorBlockId.value = null;
this.activeDragHandle = null;
if (this.dragHandleContainer) {
this.dragHandleContainer.removeAttribute('style');
this.dragHandleContainer.style.display = 'none';
}
if (this.dragHandleGrabber) {
this.dragHandleGrabber.removeAttribute('style');
}
if (force) {
this._reset();
}
};
isDragHandleHovered = false;
get isBlockDragHandleVisible() {
return this.activeDragHandle === 'block';
}
get isGfxDragHandleVisible() {
return this.activeDragHandle === 'gfx';
}
noteScale = signal(1);
pointerEventWatcher = new PointerEventWatcher(this);
scale = signal(1);
scaleInNote = computed(() => this.scale.value * this.noteScale.value);
selectionHelper = new SelectionHelper(this);
get dragHandleContainerOffsetParent() {
return this.dragHandleContainer.parentElement!;
}
get mode() {
return this.std.get(DocModeProvider).getEditorMode();
}
get rootComponent() {
return this.block;
}
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 && this.activeDragHandle
? {
width: `${this.dragHoverRect.width}px`,
height: `${this.dragHoverRect.height}px`,
top: `${this.dragHoverRect.top}px`,
left: `${this.dragHoverRect.left}px`,
}
: {
display: 'none',
}
);
const isGfx = this.activeDragHandle === 'gfx';
const classes = {
'affine-drag-handle-grabber': true,
dots: isGfx ? true : false,
};
return html`
<div class="affine-drag-handle-widget">
<div class="affine-drag-handle-container">
<div class=${classMap(classes)}>
${isGfx
? html`
<div class="dot"></div>
<div class="dot"></div>
<div class="dot"></div>
<div class="dot"></div>
<div class="dot"></div>
<div class="dot"></div>
`
: nothing}
</div>
</div>
<div class="affine-drag-hover-rect" style=${hoverRectStyle}></div>
</div>
`;
}
@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

@@ -0,0 +1,14 @@
import {
EDGELESS_DND_PREVIEW_ELEMENT,
EdgelessDndPreviewElement,
} from './components/edgeless-preview/preview';
import { AFFINE_DRAG_HANDLE_WIDGET } from './consts';
import { AffineDragHandleWidget } from './drag-handle';
export function effects() {
customElements.define(AFFINE_DRAG_HANDLE_WIDGET, AffineDragHandleWidget);
customElements.define(
EDGELESS_DND_PREVIEW_ELEMENT,
EdgelessDndPreviewElement
);
}

View File

@@ -0,0 +1,205 @@
import {
DocModeExtension,
DocModeProvider,
EditorSettingExtension,
EditorSettingProvider,
} from '@blocksuite/affine-shared/services';
import { SpecProvider } from '@blocksuite/affine-shared/utils';
import { BlockStdScope, BlockViewIdentifier } from '@blocksuite/std';
import type {
BlockModel,
BlockViewType,
ExtensionType,
Query,
SliceSnapshot,
} from '@blocksuite/store';
import { signal } from '@preact/signals-core';
import { literal } from 'lit/static-html.js';
import { EdgelessDndPreviewElement } from '../components/edgeless-preview/preview.js';
import type { AffineDragHandleWidget } from '../drag-handle.js';
export class PreviewHelper {
private readonly _calculateQuery = (selectedIds: string[]): Query => {
const ids: Array<{ id: string; viewType: BlockViewType }> = selectedIds.map(
id => ({
id,
viewType: '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: 'bypass', id: parent });
}
parent = this.widget.doc.getParent(parent)?.id ?? null;
} while (parent && !ids.map(({ id }) => id).includes(parent));
});
// The children of the selected blocks should be rendered as Display
const addChildren = (id: string) => {
const model = this.widget.doc.getBlock(id)?.model;
if (!model) {
return;
}
const children = model.children ?? [];
children.forEach(child => {
ids.push({ viewType: 'display', id: child.id });
addChildren(child.id);
});
};
selectedIds.forEach(addChildren);
return {
match: ids,
mode: 'strict',
};
};
getPreviewStd = (blockIds: string[]) => {
const widget = this.widget;
const std = widget.std;
blockIds = blockIds.slice();
const docModeService = std.get(DocModeProvider);
const editorSetting = std.get(EditorSettingProvider).peek();
const query = this._calculateQuery(blockIds as string[]);
const store = widget.doc.doc.getStore({ query });
const previewSpec = SpecProvider._.getSpec('preview:page');
const settingSignal = signal({ ...editorSetting });
const extensions = [
DocModeExtension(docModeService),
EditorSettingExtension(settingSignal),
{
setup(di) {
di.override(
BlockViewIdentifier('affine:database'),
() => literal`affine-dnd-preview-database`
);
},
} as ExtensionType,
{
setup(di) {
di.override(BlockViewIdentifier('affine:image'), () => {
return (model: BlockModel) => {
const parent = model.doc.getParent(model.id);
if (parent?.flavour === 'affine:surface') {
return literal`affine-edgeless-placeholder-preview-image`;
}
return literal`affine-placeholder-preview-image`;
};
});
},
} as ExtensionType,
];
previewSpec.extend(extensions);
settingSignal.value = {
...settingSignal.value,
edgelessDisableScheduleUpdate: true,
};
const previewStd = new BlockStdScope({
store,
extensions: previewSpec.value,
});
let width: number = 500;
let height;
const noteBlock = this.widget.host.querySelector('affine-note');
width = noteBlock?.offsetWidth ?? noteBlock?.clientWidth ?? 500;
return {
previewStd,
width,
height,
};
};
private _extractBlockTypes(snapshot: SliceSnapshot) {
const blockTypes: {
type: string;
}[] = [];
snapshot.content.forEach(block => {
if (block.flavour === 'affine:surface') {
Object.values(
block.props.elements as Record<string, { id: string; type: string }>
).forEach(elem => {
blockTypes.push({
type: elem.type,
});
});
} else {
blockTypes.push({
type: block.flavour,
});
}
});
return blockTypes;
}
getPreviewElement = (options: {
blockIds: string[];
snapshot: SliceSnapshot;
mode: 'block' | 'gfx';
}) => {
const { blockIds, snapshot, mode } = options;
if (mode === 'block') {
const { previewStd, width, height } = this.getPreviewStd(blockIds);
const previewTemplate = previewStd.render();
return {
width,
height,
element: previewTemplate,
};
} else {
const blockTypes = this._extractBlockTypes(snapshot);
const edgelessPreview = new EdgelessDndPreviewElement();
edgelessPreview.elementTypes = blockTypes;
return {
left: 12,
top: 12,
element: edgelessPreview,
};
}
};
renderDragPreview = (options: {
blockIds: string[];
snapshot: SliceSnapshot;
container: HTMLElement;
mode: 'block' | 'gfx';
}): { x: number; y: number } => {
const { container } = options;
const { width, height, element, left, top } =
this.getPreviewElement(options);
container.style.position = 'absolute';
container.style.left = left ? `${left}px` : '';
container.style.top = top ? `${top}px` : '';
container.style.width = width ? `${width}px` : '';
container.style.height = height ? `${height}px` : '';
container.append(element);
return {
x: left ?? 0,
y: top ?? 0,
};
};
constructor(readonly widget: AffineDragHandleWidget) {}
}

View File

@@ -0,0 +1,95 @@
import { getCurrentNativeRange } from '@blocksuite/affine-shared/utils';
import { Rect } from '@blocksuite/global/gfx';
import type { BlockComponent } from '@blocksuite/std';
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.isBlockDragHandleVisible || !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

@@ -0,0 +1,67 @@
import { findNoteBlockModel } from '@blocksuite/affine-shared/utils';
import {
type BlockComponent,
BlockSelection,
SurfaceSelection,
TextSelection,
} from '@blocksuite/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(BlockSelection, {
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(
SurfaceSelection,
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(TextSelection)
? selection.filter(TextSelection)
: selection.filter(BlockSelection);
}
get selection() {
return this.widget.std.selection;
}
constructor(readonly widget: AffineDragHandleWidget) {}
}

View File

@@ -0,0 +1,15 @@
import { WidgetViewExtension } from '@blocksuite/std';
import { literal, unsafeStatic } from 'lit/static-html.js';
import { AFFINE_DRAG_HANDLE_WIDGET } from './consts';
export * from './consts';
export * from './drag-handle';
export * from './utils';
export type { DragBlockPayload } from './watchers/drag-event-watcher';
export const dragHandleWidget = WidgetViewExtension(
'affine:page',
AFFINE_DRAG_HANDLE_WIDGET,
literal`${unsafeStatic(AFFINE_DRAG_HANDLE_WIDGET)}`
);

View File

@@ -0,0 +1,114 @@
import type { SurfaceBlockModel } from '@blocksuite/affine-block-surface';
import type { ConnectorElementModel } from '@blocksuite/affine-model';
import type { IVec, SerializedXYWH } from '@blocksuite/global/gfx';
import { assertType } from '@blocksuite/global/utils';
import type { BlockStdScope } from '@blocksuite/std';
import {
GfxController,
type GfxModel,
isGfxGroupCompatibleModel,
} from '@blocksuite/std/gfx';
import type { TransformerMiddleware } from '@blocksuite/store';
/**
* Used to filter out gfx elements that are not selected
* @param ids
* @param std
* @returns
*/
export const gfxBlocksFilter = (
ids: string[],
std: BlockStdScope
): TransformerMiddleware => {
const selectedIds = new Set<string>();
const store = std.store;
const surface = store.getBlocksByFlavour('affine:surface')[0]
.model as SurfaceBlockModel;
const idsToCheck = ids.slice();
const gfx = std.get(GfxController);
for (const id of idsToCheck) {
const blockOrElem = store.getBlock(id)?.model ?? surface.getElementById(id);
if (!blockOrElem) continue;
if (isGfxGroupCompatibleModel(blockOrElem)) {
idsToCheck.push(...blockOrElem.childIds);
}
selectedIds.add(id);
}
return ({ slots, transformerConfigs }) => {
slots.beforeExport.subscribe(payload => {
if (payload.type !== 'block') {
return;
}
if (payload.model.flavour === 'affine:surface') {
transformerConfigs.set('selectedElements', selectedIds);
payload.model.children = payload.model.children.filter(model =>
selectedIds.has(model.id)
);
return;
}
});
slots.afterExport.subscribe(payload => {
if (payload.type !== 'block') {
return;
}
if (payload.model.flavour === 'affine:surface') {
const { snapshot } = payload;
const elementsMap = snapshot.props.elements as Record<
string,
{ type: string }
>;
Object.entries(elementsMap).forEach(([elementId, val]) => {
if (val.type === 'connector') {
assertType<{
type: 'connector';
source: { position: IVec; id?: string };
target: { position: IVec; id?: string };
xywh: SerializedXYWH;
}>(val);
const connectorElem = gfx.getElementById(
elementId
) as ConnectorElementModel;
if (!connectorElem) {
delete elementsMap[elementId];
return;
}
// should be deleted during the import process
val.xywh = connectorElem.xywh;
['source', 'target'].forEach(key => {
const endpoint = val[key as 'source' | 'target'];
if (endpoint.id && !selectedIds.has(endpoint.id)) {
const endElem = gfx.getElementById(endpoint.id);
if (!endElem) {
delete elementsMap[elementId];
return;
}
const endElemBound = (endElem as GfxModel).elementBound;
val[key as 'source' | 'target'] = {
position: endElemBound.getRelativePoint(
endpoint.position ?? [0.5, 0.5]
),
};
}
});
}
});
}
});
};
};

View File

@@ -0,0 +1,44 @@
import { DatabaseBlockModel } from '@blocksuite/affine-model';
import { matchModels } from '@blocksuite/affine-shared/utils';
import type { BlockStdScope } from '@blocksuite/std';
import type { TransformerMiddleware } from '@blocksuite/store';
export const newIdCrossDoc =
(std: BlockStdScope): TransformerMiddleware =>
({ slots }) => {
let samePage = false;
const oldToNewIdMap = new Map<string, string>();
slots.beforeImport.subscribe(payload => {
if (payload.type === 'slice') {
samePage = payload.snapshot.pageId === std.store.id;
}
if (payload.type === 'block' && !samePage) {
const newId = std.workspace.idGenerator();
oldToNewIdMap.set(payload.snapshot.id, newId);
payload.snapshot.id = newId;
}
});
slots.afterImport.subscribe(payload => {
if (
!samePage &&
payload.type === 'block' &&
matchModels(payload.model, [DatabaseBlockModel])
) {
const originalCells = payload.model.props.cells;
const newCells = {
...originalCells,
};
Object.keys(originalCells).forEach(cellId => {
if (oldToNewIdMap.has(cellId)) {
newCells[oldToNewIdMap.get(cellId)!] = originalCells[cellId];
}
});
payload.model.props.cells$.value = newCells;
}
});
};

View File

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

View File

@@ -0,0 +1,86 @@
import { unsafeCSSVarV2 } from '@blocksuite/affine-shared/theme';
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;
}
.affine-drag-handle-grabber.dots {
width: 14px;
height: 26px;
box-sizing: border-box;
padding: 5px 2px;
border-radius: 4px;
gap: 2px;
display: flex;
flex-wrap: wrap;
background-color: transparent;
transform: translateX(-100%);
transition: unset;
}
.affine-drag-handle-grabber.dots:hover {
background-color: ${unsafeCSSVarV2('layer/background/hoverOverlay')};
}
.affine-drag-handle-grabber.dots > .dot {
width: 4px;
height: 4px;
border-radius: 50%;
flex: 0 0 4px;
background-color: ${unsafeCSSVarV2('icon/secondary')};
}
@media print {
.affine-drag-handle-widget {
display: none;
}
}
.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

@@ -0,0 +1,338 @@
import { type CalloutBlockComponent } from '@blocksuite/affine-block-callout';
import {
AFFINE_EDGELESS_NOTE,
type EdgelessNoteBlockComponent,
} from '@blocksuite/affine-block-note';
import { ParagraphBlockComponent } from '@blocksuite/affine-block-paragraph';
import {
DatabaseBlockModel,
ListBlockModel,
ParagraphBlockModel,
} from '@blocksuite/affine-model';
import { DocModeProvider } from '@blocksuite/affine-shared/services';
import {
calcDropTarget,
type DropTarget,
findClosestBlockComponent,
getBlockProps,
getClosestBlockComponentByPoint,
matchModels,
} from '@blocksuite/affine-shared/utils';
import {
Bound,
Point,
Rect,
type SerializedXYWH,
} from '@blocksuite/global/gfx';
import type { BlockComponent, EditorHost } from '@blocksuite/std';
import type {
BaseSelection,
BlockModel,
BlockSnapshot,
SliceSnapshot,
} 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).props.type) {
key = (model as ParagraphBlockModel).props.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);
};
export const extractIdsFromSnapshot = (snapshot: SliceSnapshot) => {
const ids: string[] = [];
const extractFromBlock = (block: BlockSnapshot) => {
ids.push(block.id);
if (block.children) {
for (const child of block.children) {
extractFromBlock(child);
}
}
};
for (const block of snapshot.content) {
extractFromBlock(block);
}
return ids;
};
// TODO: this is a hack, need to find a better way
export const insideDatabaseTable = (element: Element) => {
return !!element.closest('.affine-database-block-table');
};
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 getParentNoteBlock = (blockComponent: BlockComponent) => {
return blockComponent.closest('affine-note, affine-edgeless-note') ?? null;
};
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);
};
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;
}
if (matchModels(closestBlock.model, [ParagraphBlockModel])) {
const callout =
closestBlock.closest<CalloutBlockComponent>('affine-callout');
if (callout) {
return callout;
}
}
return closestBlock;
};
export const getDropResult = (
event: MouseEvent,
scale: number = 1
): DropTarget | 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 = matchModels(model, [DatabaseBlockModel]);
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 =>
(matchModels(block.model, [ListBlockModel]) &&
block.model.children.length > 0) ||
(block instanceof ParagraphBlockComponent &&
block.model.props.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(
(e): e is EdgelessNoteBlockComponent =>
e.tagName.toLowerCase() === AFFINE_EDGELESS_NOTE
) || null
);
}
export function getSnapshotRect(snapshot: SliceSnapshot): Bound | null {
let bound: Bound | null = null;
const getBound = (block: BlockSnapshot) => {
if (block.flavour === 'affine:surface') {
if (block.props.elements) {
Object.values(
block.props.elements as Record<
string,
{ type: string; xywh: SerializedXYWH }
>
).forEach(elem => {
if (elem.xywh) {
bound = bound
? bound.unite(Bound.deserialize(elem.xywh))
: Bound.deserialize(elem.xywh);
}
if (elem.type === 'connector') {
let connectorBound: Bound | undefined;
if (elem.xywh) {
connectorBound = Bound.deserialize(elem.xywh);
}
if (connectorBound) {
bound = bound ? bound.unite(connectorBound) : connectorBound;
}
}
});
}
block.children.forEach(getBound);
} else if (block.props.xywh) {
bound = bound
? bound.unite(Bound.deserialize(block.props.xywh as SerializedXYWH))
: Bound.deserialize(block.props.xywh as SerializedXYWH);
}
};
snapshot.content.forEach(getBound);
return bound;
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,225 @@
import {
EdgelessLegacySlotIdentifier,
type SurfaceBlockComponent,
} from '@blocksuite/affine-block-surface';
import { getSelectedRect } from '@blocksuite/affine-shared/utils';
import { type IVec, Rect } from '@blocksuite/global/gfx';
import {
GfxControllerIdentifier,
type GfxToolsFullOptionValue,
} from '@blocksuite/std/gfx';
import { effect } from '@preact/signals-core';
import {
DRAG_HANDLE_CONTAINER_OFFSET_LEFT_TOP_LEVEL,
DRAG_HANDLE_CONTAINER_WIDTH_TOP_LEVEL,
HOVER_AREA_RECT_PADDING_TOP_LEVEL,
} from '../config.js';
import type { AffineDragHandleWidget } from '../drag-handle.js';
/**
* Used to control the drag handle visibility in edgeless mode
*
* 1. Show drag handle on every block and gfx element
* 2. Multiple selection is not supported
*/
export class EdgelessWatcher {
private readonly _handleEdgelessToolUpdated = (
newTool: GfxToolsFullOptionValue
) => {
// @ts-expect-error GfxToolsFullOptionValue is extended in other packages
if (newTool.type === 'default') {
this.updateAnchorElement();
} else {
this.widget.hide();
}
};
private readonly _handleEdgelessViewPortUpdated = ({
zoom,
center,
}: {
zoom: number;
center: IVec;
}) => {
if (this.widget.scale.peek() !== zoom) {
this.widget.scale.value = zoom;
}
if (
this.widget.center[0] !== center[0] &&
this.widget.center[1] !== center[1]
) {
this.widget.center = [...center];
}
if (this.widget.isGfxDragHandleVisible) {
this._showDragHandle().catch(console.error);
this._updateDragHoverRectTopLevelBlock();
} else if (this.widget.activeDragHandle) {
this.widget.hide();
}
};
private readonly _showDragHandle = async () => {
const surfaceModel = this.widget.doc.getModelsByFlavour('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.hoveredElemArea;
if (!area) return;
container.style.transition = 'none';
container.style.paddingTop = `0px`;
container.style.paddingBottom = `0px`;
container.style.left = `${area.left}px`;
container.style.top = `${area.top}px`;
container.style.display = 'flex';
this.widget.handleAnchorModelDisposables();
this.widget.activeDragHandle = 'gfx';
};
private readonly _updateDragHoverRectTopLevelBlock = () => {
if (!this.widget.dragHoverRect) return;
this.widget.dragHoverRect = this.hoveredElemAreaRect;
};
get gfx() {
return this.widget.std.get(GfxControllerIdentifier);
}
updateAnchorElement = () => {
if (!this.widget.isConnected) return;
if (this.widget.doc.readonly || this.widget.mode === 'page') {
this.widget.hide();
return;
}
const { selection } = this.gfx;
const editing = selection.editing;
const selectedElements = selection.selectedElements;
if (editing || selectedElements.length !== 1 || this.widget.doc.readonly) {
this.widget.hide();
return;
}
const selectedElement = selectedElements[0];
this.widget.anchorBlockId.value = selectedElement.id;
this._showDragHandle().catch(console.error);
};
get hoveredElemAreaRect() {
const area = this.hoveredElemArea;
if (!area) return null;
return new Rect(area.left, area.top, area.right, area.bottom);
}
get hoveredElemArea() {
const edgelessElement = this.widget.anchorEdgelessElement.peek();
if (!edgelessElement) return null;
const { viewport } = this.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;
left -= containerWidth + offsetLeft;
right += padding;
bottom += padding;
return {
left,
top,
right,
bottom,
width,
height,
padding,
containerWidth,
};
}
constructor(readonly widget: AffineDragHandleWidget) {}
watch() {
if (this.widget.mode === 'page') {
return;
}
const { disposables, std } = this.widget;
const gfx = std.get(GfxControllerIdentifier);
const { viewport, selection, tool, surface } = gfx;
const edgelessSlots = std.get(EdgelessLegacySlotIdentifier);
disposables.add(
viewport.viewportUpdated.subscribe(this._handleEdgelessViewPortUpdated)
);
disposables.add(
selection.slots.updated.subscribe(() => {
this.updateAnchorElement();
})
);
disposables.add(
edgelessSlots.readonlyUpdated.subscribe(() => {
this.updateAnchorElement();
})
);
disposables.add(
edgelessSlots.elementResizeEnd.subscribe(() => {
this.updateAnchorElement();
})
);
disposables.add(
effect(() => {
const value = tool.currentToolOption$.value;
value && this._handleEdgelessToolUpdated(value);
})
);
disposables.add(
edgelessSlots.elementResizeStart.subscribe(() => {
this.widget.hide();
})
);
if (surface) {
disposables.add(
surface.elementUpdated.subscribe(() => {
if (this.widget.isGfxDragHandleVisible) {
this._showDragHandle().catch(console.error);
}
})
);
}
}
}

View File

@@ -0,0 +1,93 @@
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.isBlockDragHandleVisible || !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.isBlockDragHandleVisible && 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.isGfxDragHandleVisible) {
this.widget.dragHoverRect =
this.widget.edgelessWatcher.hoveredElemAreaRect;
this.widget.isDragHandleHovered = true;
}
};
private readonly _onDragHandlePointerLeave = () => {
this.widget.isDragHandleHovered = false;
this.widget.dragHoverRect = null;
if (this.widget.isGfxDragHandleVisible) return;
if (this.widget.dragging) return;
this.widget.pointerEventWatcher.showDragHandleOnHoverBlock();
};
private readonly _onDragHandlePointerUp = () => {
if (!this.widget.isBlockDragHandleVisible) 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

@@ -0,0 +1,24 @@
import type { UIEventHandler } from '@blocksuite/std';
import type { AffineDragHandleWidget } from '../drag-handle.js';
export class KeyboardEventWatcher {
private readonly _keyboardHandler: UIEventHandler = ctx => {
if (!this.widget.dragging) {
return;
}
const state = ctx.get('defaultState');
const event = state.event as KeyboardEvent;
event.preventDefault();
event.stopPropagation();
};
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

@@ -0,0 +1,25 @@
import { PageViewportService } from '@blocksuite/affine-shared/services';
import type { AffineDragHandleWidget } from '../drag-handle.js';
export class PageWatcher {
get pageViewportService() {
return this.widget.std.get(PageViewportService);
}
constructor(readonly widget: AffineDragHandleWidget) {}
watch() {
const { disposables } = this.widget;
disposables.add(
this.widget.doc.slots.blockUpdated.subscribe(() => this.widget.hide())
);
disposables.add(
this.pageViewportService.subscribe(() => {
this.widget.hide();
})
);
}
}

View File

@@ -0,0 +1,366 @@
import type { NoteBlockComponent } from '@blocksuite/affine-block-note';
import { captureEventTarget } from '@blocksuite/affine-shared/utils';
import { Point } from '@blocksuite/global/gfx';
import {
BLOCK_ID_ATTR,
type BlockComponent,
type PointerEventState,
type UIEventHandler,
} from '@blocksuite/std';
import { GfxControllerIdentifier } from '@blocksuite/std/gfx';
import { computed } from '@preact/signals-core';
import throttle from 'lodash-es/throttle';
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';
/**
* Used to control the drag handle visibility in page mode
*/
export class PointerEventWatcher {
private _isPointerDown = false;
private get _gfx() {
return this.widget.std.get(GfxControllerIdentifier);
}
private readonly _canEditing = (noteBlock: BlockComponent) => {
if (noteBlock.doc.id !== this.widget.doc.id) return false;
if (this.widget.mode === 'page') return true;
const selection = this._gfx.selection;
const noteBlockId = noteBlock.model.id;
return selection.editing && 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.isBlockDragHandleVisible) 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.isGfxDragHandleVisible) return;
const point = new Point(state.raw.x, state.raw.y);
if (!this.widget.rootComponent) return;
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.isBlockDragHandleVisible) &&
!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._isPointerDown) return;
if (
this.widget.doc.readonly ||
this.widget.dragging ||
!this.widget.isConnected
) {
this.widget.hide();
return;
}
if (this.widget.isGfxDragHandleVisible) return;
const state = ctx.get('pointerState');
// When pointer is moving, should do nothing
if (state.delta.x !== 0 && state.delta.y !== 0) return;
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;
if (!this.widget.rootComponent) 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.props.edgeless.scale ?? 1);
if (
closestNoteBlock &&
this._canEditing(closestNoteBlock) &&
!isOutOfNoteBlock(
this.widget.host,
closestNoteBlock,
point,
this.widget.scaleInNote.peek()
)
) {
this._pointerMoveOnBlock(state);
return true;
}
if (this.widget.activeDragHandle) {
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.activeDragHandle = 'block';
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,
};
}
};
private readonly _pointerDownHandler: UIEventHandler = () => {
this._isPointerDown = true;
};
private readonly _pointerUpHandler: UIEventHandler = () => {
this._isPointerDown = false;
};
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);
this.widget.handleEvent('pointerDown', this._pointerDownHandler);
this.widget.handleEvent('pointerUp', this._pointerUpHandler);
}
}