mirror of
https://github.com/toeverything/AFFiNE.git
synced 2026-02-14 13:25:12 +00:00
### Changed
- Refactored BlockSuite drag-and-drop using @atlaskit/pragmatic-drag-and-drop/element/adapter.
- Updated block dragging to use the new drag-and-drop infrastructure.
### BlockSuite DND API
Access the BlockSuite drag-and-drop API via `std.dnd`. This is a lightweight wrapper around pragmatic-drag-and-drop, offering convenient generic types and more intuitive option names.
#### Drag payload structure
There's some constrain about drag payload. The whole drag payload looks like this:
```typescript
type DragPayload = {
entity: {
type: string
},
from: {
at: 'blocksuite',
docId: string
}
}
```
- The `from` field is auto-generated—no need for manual handling.
- The `entity` field is customizable, but it must include a `type`.
All drag-and-drop methods accept a generic type for entity, ensuring more accurate payloads in event handlers.
```typescript
type BlockEntity = {
type: 'blocks',
blockIds: string[]
}
dnd.draggable<BlockEntity>({
element: someElement,
setDragData: () => {
// the return type must satisfy the generic type
// in this case, it's BlockEntity
return {
type: 'blocks',
blockIds: []
}
}
});
dnd.monitor<BlockEntity>({
// the arguments is same for other event handler
onDrag({ source }) {
// the type of this is BlockEntity
source.data.entity
}
})
```
#### Drop payload
When hover on droppable target. You can set drop payload as well. All drag-and-drop methods accept a second generic type for drop payload.
The drop payload is customizable. Additionally, the DND system will add an `edge` field to the final payload object, indicating the nearest edge of the drop target relative to the current drag position.
```typescript
type DropPayload = {
blockId: string;
}
dnd.dropTarget<BlockEntity, DropPayload>({
getData() {
// the type should be DropPayload
return {
blockId: 'someId'
}
}
});
dnd.monitor<BlockEntity, DropPayload>({
// drag over on drop target
onDrag({ location }) {
const target = location.current.dropTargets[0];
// the type is DropPayload
target.data;
// retrieve the nearest edge of the drop target relative to the current drop position.
target.data.edge;
}
})
```
377 lines
8.6 KiB
TypeScript
377 lines
8.6 KiB
TypeScript
import type { Palette } from '@blocksuite/affine-model';
|
|
import { IS_IOS, IS_MAC } from '@blocksuite/global/env';
|
|
|
|
export function isTouchPadPinchEvent(e: WheelEvent) {
|
|
// two finger pinches on touch pad, ctrlKey is always true.
|
|
// https://bugs.chromium.org/p/chromium/issues/detail?id=397027
|
|
if (IS_IOS || IS_MAC) {
|
|
return e.ctrlKey || e.metaKey;
|
|
}
|
|
return e.ctrlKey;
|
|
}
|
|
|
|
// See https://developer.mozilla.org/en-US/docs/Web/API/MouseEvent/buttons
|
|
export enum MOUSE_BUTTONS {
|
|
AUXILIARY = 4,
|
|
FIFTH = 16,
|
|
FORTH = 8,
|
|
NO_BUTTON = 0,
|
|
PRIMARY = 1,
|
|
SECONDARY = 2,
|
|
}
|
|
|
|
export enum MOUSE_BUTTON {
|
|
AUXILIARY = 1,
|
|
FIFTH = 4,
|
|
FORTH = 3,
|
|
MAIN = 0,
|
|
SECONDARY = 2,
|
|
}
|
|
|
|
export function isMiddleButtonPressed(e: MouseEvent) {
|
|
return (MOUSE_BUTTONS.AUXILIARY & e.buttons) === MOUSE_BUTTONS.AUXILIARY;
|
|
}
|
|
|
|
export function isRightButtonPressed(e: MouseEvent) {
|
|
return (MOUSE_BUTTONS.SECONDARY & e.buttons) === MOUSE_BUTTONS.SECONDARY;
|
|
}
|
|
|
|
export function stopPropagation(event: Event) {
|
|
event.stopPropagation();
|
|
}
|
|
|
|
export function isControlledKeyboardEvent(e: KeyboardEvent) {
|
|
return e.ctrlKey || e.metaKey || e.altKey;
|
|
}
|
|
|
|
export function isNewTabTrigger(event?: MouseEvent) {
|
|
return event
|
|
? (event.ctrlKey || event.metaKey || event.button === 1) && !event.altKey
|
|
: false;
|
|
}
|
|
|
|
export function isNewViewTrigger(event?: MouseEvent) {
|
|
return event ? (event.ctrlKey || event.metaKey) && event.altKey : false;
|
|
}
|
|
|
|
export function on<
|
|
T extends HTMLElement,
|
|
K extends keyof M,
|
|
M = HTMLElementEventMap,
|
|
>(
|
|
element: T,
|
|
event: K,
|
|
handler: (ev: M[K]) => void,
|
|
options?: boolean | AddEventListenerOptions
|
|
): () => void;
|
|
export function on<T extends HTMLElement>(
|
|
element: T,
|
|
event: string,
|
|
handler: (ev: Event) => void,
|
|
options?: boolean | AddEventListenerOptions
|
|
): () => void;
|
|
export function on<T extends Document, K extends keyof M, M = DocumentEventMap>(
|
|
element: T,
|
|
event: K,
|
|
handler: (ev: M[K]) => void,
|
|
options?: boolean | AddEventListenerOptions
|
|
): () => void;
|
|
export function on<
|
|
T extends HTMLElement | Document,
|
|
K extends keyof HTMLElementEventMap,
|
|
>(
|
|
element: T,
|
|
event: K,
|
|
handler: (ev: HTMLElementEventMap[K]) => void,
|
|
options?: boolean | AddEventListenerOptions
|
|
) {
|
|
const dispose = () => {
|
|
element.removeEventListener(
|
|
event as string,
|
|
handler as unknown as EventListenerObject,
|
|
options
|
|
);
|
|
};
|
|
|
|
element.addEventListener(
|
|
event as string,
|
|
handler as unknown as EventListenerObject,
|
|
options
|
|
);
|
|
|
|
return dispose;
|
|
}
|
|
|
|
export function once<
|
|
T extends HTMLElement,
|
|
K extends keyof M,
|
|
M = HTMLElementEventMap,
|
|
>(
|
|
element: T,
|
|
event: K,
|
|
handler: (ev: M[K]) => void,
|
|
options?: boolean | AddEventListenerOptions
|
|
): () => void;
|
|
export function once<T extends HTMLElement>(
|
|
element: T,
|
|
event: string,
|
|
handler: (ev: Event) => void,
|
|
options?: boolean | AddEventListenerOptions
|
|
): () => void;
|
|
export function once<
|
|
T extends Document,
|
|
K extends keyof M,
|
|
M = DocumentEventMap,
|
|
>(
|
|
element: T,
|
|
event: K,
|
|
handler: (ev: M[K]) => void,
|
|
options?: boolean | AddEventListenerOptions
|
|
): () => void;
|
|
export function once<
|
|
T extends HTMLElement,
|
|
K extends keyof HTMLElementEventMap,
|
|
>(
|
|
element: T,
|
|
event: K,
|
|
handler: (ev: HTMLElementEventMap[K]) => void,
|
|
options?: boolean | AddEventListenerOptions
|
|
) {
|
|
const onceHandler = (e: HTMLElementEventMap[K]) => {
|
|
dispose();
|
|
handler(e);
|
|
};
|
|
const dispose = () => {
|
|
element.removeEventListener(event, onceHandler, options);
|
|
};
|
|
|
|
element.addEventListener(event, onceHandler, options);
|
|
|
|
return dispose;
|
|
}
|
|
|
|
export function delayCallback(callback: () => void, delay: number = 0) {
|
|
const timeoutId = setTimeout(callback, delay);
|
|
|
|
return () => clearTimeout(timeoutId);
|
|
}
|
|
|
|
/**
|
|
* A wrapper around `requestAnimationFrame` that only calls the callback if the
|
|
* element is still connected to the DOM.
|
|
*/
|
|
export function requestConnectedFrame(
|
|
callback: () => void,
|
|
element?: HTMLElement
|
|
) {
|
|
return requestAnimationFrame(() => {
|
|
// If element is not provided, fallback to `requestAnimationFrame`
|
|
if (element === undefined) {
|
|
callback();
|
|
return;
|
|
}
|
|
|
|
// Only calls callback if element is still connected to the DOM
|
|
if (element.isConnected) callback();
|
|
});
|
|
}
|
|
|
|
/**
|
|
* A wrapper around `requestConnectedFrame` that only calls at most once in one frame
|
|
*/
|
|
export function requestThrottledConnectedFrame<
|
|
T extends (...args: any[]) => void,
|
|
>(func: T, element?: HTMLElement): T {
|
|
let raqId: number | undefined = undefined;
|
|
let latestArgs: unknown[] = [];
|
|
|
|
return ((...args: unknown[]) => {
|
|
latestArgs = args;
|
|
|
|
if (raqId) return;
|
|
|
|
raqId = requestConnectedFrame(() => {
|
|
raqId = undefined;
|
|
func(...latestArgs);
|
|
}, element);
|
|
}) as T;
|
|
}
|
|
|
|
export const captureEventTarget = (target: EventTarget | null) => {
|
|
const isElementOrNode = target instanceof Element || target instanceof Node;
|
|
return isElementOrNode
|
|
? target instanceof Element
|
|
? target
|
|
: target.parentElement
|
|
: null;
|
|
};
|
|
|
|
interface ObserverParams {
|
|
target: HTMLElement;
|
|
signal: AbortSignal;
|
|
onInput?: (isComposition: boolean) => void;
|
|
onDelete?: () => void;
|
|
onMove?: (step: 1 | -1) => void;
|
|
onConfirm?: () => void;
|
|
onAbort?: () => void;
|
|
onPaste?: () => void;
|
|
interceptor?: (e: KeyboardEvent, next: () => void) => void;
|
|
}
|
|
|
|
/**
|
|
* @deprecated don't use this, use event dispatch instead
|
|
*/
|
|
export const createKeydownObserver = ({
|
|
target,
|
|
signal,
|
|
onInput,
|
|
onDelete,
|
|
onMove,
|
|
onConfirm,
|
|
onAbort,
|
|
onPaste,
|
|
interceptor = (_, next) => next(),
|
|
}: ObserverParams) => {
|
|
const keyDownListener = (e: KeyboardEvent) => {
|
|
if (e.key === 'Process' || e.isComposing) return;
|
|
|
|
if (e.defaultPrevented) return;
|
|
|
|
if (isControlledKeyboardEvent(e)) {
|
|
const isOnlyCmd = (e.ctrlKey || e.metaKey) && !e.altKey && !e.shiftKey;
|
|
// Ctrl/Cmd + alphabet key
|
|
if (isOnlyCmd && e.key.length === 1) {
|
|
switch (e.key) {
|
|
// Previous command
|
|
case 'p': {
|
|
onMove?.(-1);
|
|
e.stopPropagation();
|
|
e.preventDefault();
|
|
return;
|
|
}
|
|
// Next command
|
|
case 'n': {
|
|
onMove?.(1);
|
|
e.stopPropagation();
|
|
e.preventDefault();
|
|
return;
|
|
}
|
|
// Paste command
|
|
case 'v': {
|
|
onPaste?.();
|
|
return;
|
|
}
|
|
}
|
|
}
|
|
|
|
// Pressing **only** modifier key is allowed and will be ignored
|
|
// Because we don't know the user's intention
|
|
// Aborting here will cause the above hotkeys to not work
|
|
if (e.key === 'Control' || e.key === 'Meta' || e.key === 'Alt') {
|
|
e.stopPropagation();
|
|
return;
|
|
}
|
|
|
|
// Abort when press modifier key + any other key to avoid weird behavior
|
|
// e.g. press ctrl + a to select all
|
|
onAbort?.();
|
|
return;
|
|
}
|
|
|
|
e.stopPropagation();
|
|
|
|
if (
|
|
// input abc, 123, etc.
|
|
!isControlledKeyboardEvent(e) &&
|
|
e.key.length === 1
|
|
) {
|
|
onInput?.(false);
|
|
return;
|
|
}
|
|
|
|
switch (e.key) {
|
|
case 'Backspace': {
|
|
onDelete?.();
|
|
return;
|
|
}
|
|
case 'Enter': {
|
|
if (e.shiftKey) {
|
|
onAbort?.();
|
|
return;
|
|
}
|
|
onConfirm?.();
|
|
e.preventDefault();
|
|
return;
|
|
}
|
|
case 'Tab': {
|
|
if (e.shiftKey) {
|
|
onMove?.(-1);
|
|
} else {
|
|
onMove?.(1);
|
|
}
|
|
e.preventDefault();
|
|
return;
|
|
}
|
|
case 'ArrowUp': {
|
|
if (e.shiftKey) {
|
|
onAbort?.();
|
|
return;
|
|
}
|
|
onMove?.(-1);
|
|
e.preventDefault();
|
|
return;
|
|
}
|
|
case 'ArrowDown': {
|
|
if (e.shiftKey) {
|
|
onAbort?.();
|
|
return;
|
|
}
|
|
onMove?.(1);
|
|
e.preventDefault();
|
|
return;
|
|
}
|
|
case 'Escape':
|
|
case 'ArrowLeft':
|
|
case 'ArrowRight': {
|
|
onAbort?.();
|
|
return;
|
|
}
|
|
default:
|
|
// Other control keys
|
|
return;
|
|
}
|
|
};
|
|
|
|
target.addEventListener(
|
|
'keydown',
|
|
(e: KeyboardEvent) => interceptor(e, () => keyDownListener(e)),
|
|
{
|
|
// Workaround: Use capture to prevent the event from triggering the keyboard bindings action
|
|
capture: true,
|
|
signal,
|
|
}
|
|
);
|
|
|
|
// Fix paste input
|
|
target.addEventListener('paste', () => onDelete?.(), { signal });
|
|
|
|
// Fix composition input
|
|
target.addEventListener('compositionend', () => onInput?.(true), { signal });
|
|
};
|
|
|
|
export class ColorEvent extends Event {
|
|
detail: Palette;
|
|
|
|
constructor(
|
|
type: string,
|
|
{
|
|
detail,
|
|
composed,
|
|
bubbles,
|
|
}: { detail: Palette; composed: boolean; bubbles: boolean }
|
|
) {
|
|
super(type, { bubbles, composed });
|
|
this.detail = detail;
|
|
}
|
|
}
|