mirror of
https://github.com/toeverything/AFFiNE.git
synced 2026-02-23 01:07:12 +08:00
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:
@@ -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>`;
|
||||
}
|
||||
}
|
||||
19
blocksuite/affine/widgets/drag-handle/src/config.ts
Normal file
19
blocksuite/affine/widgets/drag-handle/src/config.ts
Normal 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;
|
||||
1
blocksuite/affine/widgets/drag-handle/src/consts.ts
Normal file
1
blocksuite/affine/widgets/drag-handle/src/consts.ts
Normal file
@@ -0,0 +1 @@
|
||||
export const AFFINE_DRAG_HANDLE_WIDGET = 'affine-drag-handle-widget';
|
||||
254
blocksuite/affine/widgets/drag-handle/src/drag-handle.ts
Normal file
254
blocksuite/affine/widgets/drag-handle/src/drag-handle.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
14
blocksuite/affine/widgets/drag-handle/src/effects.ts
Normal file
14
blocksuite/affine/widgets/drag-handle/src/effects.ts
Normal 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
|
||||
);
|
||||
}
|
||||
@@ -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) {}
|
||||
}
|
||||
@@ -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) {}
|
||||
}
|
||||
@@ -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) {}
|
||||
}
|
||||
15
blocksuite/affine/widgets/drag-handle/src/index.ts
Normal file
15
blocksuite/affine/widgets/drag-handle/src/index.ts
Normal 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)}`
|
||||
);
|
||||
@@ -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]
|
||||
),
|
||||
};
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
};
|
||||
};
|
||||
@@ -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;
|
||||
}
|
||||
});
|
||||
};
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
};
|
||||
86
blocksuite/affine/widgets/drag-handle/src/styles.ts
Normal file
86
blocksuite/affine/widgets/drag-handle/src/styles.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
`;
|
||||
338
blocksuite/affine/widgets/drag-handle/src/utils.ts
Normal file
338
blocksuite/affine/widgets/drag-handle/src/utils.ts
Normal 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
@@ -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);
|
||||
}
|
||||
})
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
})
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user