Files
AFFiNE-Mirror/blocksuite/affine/shared/src/utils/event.ts
doouding 99717196c5 refactor: rewrite blocksuite dnd (#9595)
### 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;
  }
})
```
2025-01-16 12:36:58 +00:00

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;
}
}