chore: merge blocksuite source code (#9213)

This commit is contained in:
Mirone
2024-12-20 15:38:06 +08:00
committed by GitHub
parent 2c9ef916f4
commit 30200ff86d
2031 changed files with 238888 additions and 229 deletions

View File

@@ -0,0 +1,62 @@
import { BlockSuiteError, ErrorCode } from '@blocksuite/global/exceptions';
type MatchEvent<T extends string> = T extends UIEventStateType
? BlockSuiteUIEventState[T]
: UIEventState;
export class UIEventState {
/** when extends, override it with pattern `xxxState` */
type = 'defaultState';
constructor(public event: Event) {}
}
export class UIEventStateContext {
private _map: Record<string, UIEventState> = {};
add = <State extends UIEventState = UIEventState>(state: State) => {
const name = state.type;
if (this._map[name]) {
console.warn('UIEventStateContext: state name duplicated', name);
}
this._map[name] = state;
};
get = <Type extends UIEventStateType = UIEventStateType>(
type: Type
): MatchEvent<Type> => {
const state = this._map[type];
if (!state) {
throw new BlockSuiteError(
ErrorCode.EventDispatcherError,
`UIEventStateContext: state ${type} not found`
);
}
return state as MatchEvent<Type>;
};
has = (type: UIEventStateType) => {
return !!this._map[type];
};
static from(...states: UIEventState[]) {
const context = new UIEventStateContext();
states.forEach(state => {
context.add(state);
});
return context;
}
}
export type UIEventHandler = (
context: UIEventStateContext
) => boolean | null | undefined | void;
declare global {
interface BlockSuiteUIEventState {
defaultState: UIEventState;
}
type UIEventStateType = keyof BlockSuiteUIEventState;
}

View File

@@ -0,0 +1,56 @@
import { UIEventState, UIEventStateContext } from '../base.js';
import type { UIEventDispatcher } from '../dispatcher.js';
import { ClipboardEventState } from '../state/clipboard.js';
import { EventScopeSourceType, EventSourceState } from '../state/source.js';
export class ClipboardControl {
private _copy = (event: ClipboardEvent) => {
const clipboardEventState = new ClipboardEventState({
event,
});
this._dispatcher.run(
'copy',
this._createContext(event, clipboardEventState)
);
};
private _cut = (event: ClipboardEvent) => {
const clipboardEventState = new ClipboardEventState({
event,
});
this._dispatcher.run(
'cut',
this._createContext(event, clipboardEventState)
);
};
private _paste = (event: ClipboardEvent) => {
const clipboardEventState = new ClipboardEventState({
event,
});
this._dispatcher.run(
'paste',
this._createContext(event, clipboardEventState)
);
};
constructor(private _dispatcher: UIEventDispatcher) {}
private _createContext(event: Event, clipboardState: ClipboardEventState) {
return UIEventStateContext.from(
new UIEventState(event),
new EventSourceState({
event,
sourceType: EventScopeSourceType.Selection,
}),
clipboardState
);
}
listen() {
this._dispatcher.disposables.addFromEvent(document, 'cut', this._cut);
this._dispatcher.disposables.addFromEvent(document, 'copy', this._copy);
this._dispatcher.disposables.addFromEvent(document, 'paste', this._paste);
}
}

View File

@@ -0,0 +1,112 @@
import { IS_MAC } from '@blocksuite/global/env';
import {
type UIEventHandler,
UIEventState,
UIEventStateContext,
} from '../base.js';
import type { EventOptions, UIEventDispatcher } from '../dispatcher.js';
import { bindKeymap } from '../keymap.js';
import { KeyboardEventState } from '../state/index.js';
import { EventScopeSourceType, EventSourceState } from '../state/source.js';
export class KeyboardControl {
private _down = (event: KeyboardEvent) => {
if (!this._shouldTrigger(event)) {
return;
}
const keyboardEventState = new KeyboardEventState({
event,
composing: this.composition,
});
this._dispatcher.run(
'keyDown',
this._createContext(event, keyboardEventState)
);
};
private _shouldTrigger = (event: KeyboardEvent) => {
if (event.isComposing) {
return false;
}
const mod = IS_MAC ? event.metaKey : event.ctrlKey;
if (
['c', 'v', 'x'].includes(event.key) &&
mod &&
!event.shiftKey &&
!event.altKey
) {
return false;
}
return true;
};
private _up = (event: KeyboardEvent) => {
if (!this._shouldTrigger(event)) {
return;
}
const keyboardEventState = new KeyboardEventState({
event,
composing: this.composition,
});
this._dispatcher.run(
'keyUp',
this._createContext(event, keyboardEventState)
);
};
private composition = false;
constructor(private _dispatcher: UIEventDispatcher) {}
private _createContext(event: Event, keyboardState: KeyboardEventState) {
return UIEventStateContext.from(
new UIEventState(event),
new EventSourceState({
event,
sourceType: EventScopeSourceType.Selection,
}),
keyboardState
);
}
bindHotkey(keymap: Record<string, UIEventHandler>, options?: EventOptions) {
return this._dispatcher.add(
'keyDown',
ctx => {
if (this.composition) {
return false;
}
const binding = bindKeymap(keymap);
return binding(ctx);
},
options
);
}
listen() {
this._dispatcher.disposables.addFromEvent(document, 'keydown', this._down);
this._dispatcher.disposables.addFromEvent(document, 'keyup', this._up);
this._dispatcher.disposables.addFromEvent(
document,
'compositionstart',
() => {
this.composition = true;
},
{
capture: true,
}
);
this._dispatcher.disposables.addFromEvent(
document,
'compositionend',
() => {
this.composition = false;
},
{
capture: true,
}
);
}
}

View File

@@ -0,0 +1,594 @@
import { IS_IPAD } from '@blocksuite/global/env';
import { nextTick, Vec } from '@blocksuite/global/utils';
import { UIEventState, UIEventStateContext } from '../base.js';
import type { UIEventDispatcher } from '../dispatcher.js';
import {
DndEventState,
MultiPointerEventState,
PointerEventState,
} from '../state/index.js';
import { EventScopeSourceType, EventSourceState } from '../state/source.js';
import { isFarEnough } from '../utils.js';
type PointerId = typeof PointerEvent.prototype.pointerId;
function createContext(
event: Event,
state: PointerEventState | MultiPointerEventState
) {
return UIEventStateContext.from(
new UIEventState(event),
new EventSourceState({
event,
sourceType: EventScopeSourceType.Target,
}),
state
);
}
const POLL_INTERVAL = 1000;
abstract class PointerControllerBase {
constructor(
protected _dispatcher: UIEventDispatcher,
protected _getRect: () => DOMRect
) {}
abstract listen(): void;
}
class PointerEventForward extends PointerControllerBase {
private _down = (event: PointerEvent) => {
const { pointerId } = event;
const pointerState = new PointerEventState({
event,
rect: this._getRect(),
startX: -Infinity,
startY: -Infinity,
last: null,
});
this._startStates.set(pointerId, pointerState);
this._lastStates.set(pointerId, pointerState);
this._dispatcher.run('pointerDown', createContext(event, pointerState));
};
private _lastStates = new Map<PointerId, PointerEventState>();
private _move = (event: PointerEvent) => {
const { pointerId } = event;
const start = this._startStates.get(pointerId) ?? null;
const last = this._lastStates.get(pointerId) ?? null;
const state = new PointerEventState({
event,
rect: this._getRect(),
startX: start?.x ?? -Infinity,
startY: start?.y ?? -Infinity,
last,
});
this._lastStates.set(pointerId, state);
this._dispatcher.run('pointerMove', createContext(event, state));
};
private _startStates = new Map<PointerId, PointerEventState>();
private _upOrOut = (up: boolean) => (event: PointerEvent) => {
const { pointerId } = event;
const start = this._startStates.get(pointerId) ?? null;
const last = this._lastStates.get(pointerId) ?? null;
const state = new PointerEventState({
event,
rect: this._getRect(),
startX: start?.x ?? -Infinity,
startY: start?.y ?? -Infinity,
last,
});
this._startStates.delete(pointerId);
this._lastStates.delete(pointerId);
this._dispatcher.run(
up ? 'pointerUp' : 'pointerOut',
createContext(event, state)
);
};
listen() {
const { host, disposables } = this._dispatcher;
disposables.addFromEvent(host, 'pointerdown', this._down);
disposables.addFromEvent(host, 'pointermove', this._move);
disposables.addFromEvent(host, 'pointerup', this._upOrOut(true));
disposables.addFromEvent(host, 'pointerout', this._upOrOut(false));
}
}
class ClickController extends PointerControllerBase {
private _down = (event: PointerEvent) => {
// disable for secondary pointer
if (event.isPrimary === false) return;
if (
this._downPointerState &&
event.pointerId === this._downPointerState.raw.pointerId &&
event.timeStamp - this._downPointerState.raw.timeStamp < 500 &&
!isFarEnough(event, this._downPointerState.raw)
) {
this._pointerDownCount++;
} else {
this._pointerDownCount = 1;
}
this._downPointerState = new PointerEventState({
event,
rect: this._getRect(),
startX: -Infinity,
startY: -Infinity,
last: null,
});
};
private _downPointerState: PointerEventState | null = null;
private _pointerDownCount = 0;
private _up = (event: PointerEvent) => {
if (!this._downPointerState) return;
if (isFarEnough(this._downPointerState.raw, event)) {
this._pointerDownCount = 0;
this._downPointerState = null;
return;
}
const state = new PointerEventState({
event,
rect: this._getRect(),
startX: -Infinity,
startY: -Infinity,
last: null,
});
const context = createContext(event, state);
const run = () => {
this._dispatcher.run('click', context);
if (this._pointerDownCount === 2) {
this._dispatcher.run('doubleClick', context);
}
if (this._pointerDownCount === 3) {
this._dispatcher.run('tripleClick', context);
}
};
run();
};
listen() {
const { host, disposables } = this._dispatcher;
disposables.addFromEvent(host, 'pointerdown', this._down);
disposables.addFromEvent(host, 'pointerup', this._up);
}
}
class DragController extends PointerControllerBase {
private _down = (event: PointerEvent) => {
if (this._nativeDragging) return;
if (!event.isPrimary) {
if (this._dragging && this._lastPointerState) {
this._up(this._lastPointerState.raw);
}
this._reset();
return;
}
const pointerState = new PointerEventState({
event,
rect: this._getRect(),
startX: -Infinity,
startY: -Infinity,
last: null,
});
this._startPointerState = pointerState;
this._dispatcher.disposables.addFromEvent(
document,
'pointermove',
this._move
);
this._dispatcher.disposables.addFromEvent(document, 'pointerup', this._up);
};
private _dragging = false;
private _lastPointerState: PointerEventState | null = null;
private _move = (event: PointerEvent) => {
if (
this._startPointerState === null ||
this._startPointerState.raw.pointerId !== event.pointerId
)
return;
const start = this._startPointerState;
const last = this._lastPointerState ?? start;
const state = new PointerEventState({
event,
rect: this._getRect(),
startX: start.x,
startY: start.y,
last,
});
this._lastPointerState = state;
if (
!this._nativeDragging &&
!this._dragging &&
isFarEnough(event, this._startPointerState.raw)
) {
this._dragging = true;
this._dispatcher.run('dragStart', createContext(event, start));
}
if (this._dragging) {
this._dispatcher.run('dragMove', createContext(event, state));
}
};
private _nativeDragEnd = (event: DragEvent) => {
this._nativeDragging = false;
const dndEventState = new DndEventState({ event });
this._dispatcher.run(
'nativeDragEnd',
this._createContext(event, dndEventState)
);
};
private _nativeDragging = false;
private _nativeDragMove = (event: DragEvent) => {
const dndEventState = new DndEventState({ event });
this._dispatcher.run(
'nativeDragMove',
this._createContext(event, dndEventState)
);
};
private _nativeDragStart = (event: DragEvent) => {
this._reset();
this._nativeDragging = true;
const dndEventState = new DndEventState({ event });
this._dispatcher.run(
'nativeDragStart',
this._createContext(event, dndEventState)
);
};
private _nativeDrop = (event: DragEvent) => {
this._reset();
this._nativeDragging = false;
const dndEventState = new DndEventState({ event });
this._dispatcher.run(
'nativeDrop',
this._createContext(event, dndEventState)
);
};
private _reset = () => {
this._dragging = false;
this._startPointerState = null;
this._lastPointerState = null;
document.removeEventListener('pointermove', this._move);
document.removeEventListener('pointerup', this._up);
};
private _startPointerState: PointerEventState | null = null;
private _up = (event: PointerEvent) => {
if (
!this._startPointerState ||
this._startPointerState.raw.pointerId !== event.pointerId
)
return;
const start = this._startPointerState;
const last = this._lastPointerState;
const state = new PointerEventState({
event,
rect: this._getRect(),
startX: start.x,
startY: start.y,
last,
});
if (this._dragging) {
this._dispatcher.run('dragEnd', createContext(event, state));
}
this._reset();
};
// https://mikepk.com/2020/10/iOS-safari-scribble-bug/
private _applyScribblePatch() {
if (!IS_IPAD) return;
const { host, disposables } = this._dispatcher;
disposables.addFromEvent(host, 'touchmove', (event: TouchEvent) => {
if (
this._dragging &&
this._startPointerState &&
this._startPointerState.raw.pointerType === 'pen'
) {
event.preventDefault();
}
});
}
private _createContext(event: Event, dndState: DndEventState) {
return UIEventStateContext.from(
new UIEventState(event),
new EventSourceState({
event,
sourceType: EventScopeSourceType.Target,
}),
dndState
);
}
listen() {
const { host, disposables } = this._dispatcher;
disposables.addFromEvent(host, 'pointerdown', this._down);
this._applyScribblePatch();
disposables.addFromEvent(host, 'dragstart', this._nativeDragStart);
disposables.addFromEvent(host, 'dragend', this._nativeDragEnd);
disposables.addFromEvent(host, 'drag', this._nativeDragMove);
disposables.addFromEvent(host, 'drop', this._nativeDrop);
}
}
abstract class DualDragControllerBase extends PointerControllerBase {
private _down = (event: PointerEvent) => {
// Another pointer down
if (
this._startPointerStates.primary !== null &&
this._startPointerStates.secondary !== null
) {
this._reset();
}
if (this._startPointerStates.primary === null && !event.isPrimary) {
return;
}
const state = new PointerEventState({
event,
rect: this._getRect(),
startX: -Infinity,
startY: -Infinity,
last: null,
});
if (event.isPrimary) {
this._startPointerStates.primary = state;
} else {
this._startPointerStates.secondary = state;
}
};
private _lastPointerStates: {
primary: PointerEventState | null;
secondary: PointerEventState | null;
} = {
primary: null,
secondary: null,
};
private _move = (event: PointerEvent) => {
if (
this._startPointerStates.primary === null ||
this._startPointerStates.secondary === null
) {
return;
}
const { isPrimary } = event;
const startPrimaryState = this._startPointerStates.primary;
let lastPrimaryState = this._lastPointerStates.primary;
const startSecondaryState = this._startPointerStates.secondary;
let lastSecondaryState = this._lastPointerStates.secondary;
if (isPrimary) {
lastPrimaryState = new PointerEventState({
event,
rect: this._getRect(),
startX: startPrimaryState.x,
startY: startPrimaryState.y,
last: lastPrimaryState,
});
} else {
lastSecondaryState = new PointerEventState({
event,
rect: this._getRect(),
startX: startSecondaryState.x,
startY: startSecondaryState.y,
last: lastSecondaryState,
});
}
const multiPointerState = new MultiPointerEventState(event, [
lastPrimaryState ?? startPrimaryState,
lastSecondaryState ?? startSecondaryState,
]);
this._handleMove(event, multiPointerState);
this._lastPointerStates = {
primary: lastPrimaryState,
secondary: lastSecondaryState,
};
};
private _reset = () => {
this._startPointerStates = {
primary: null,
secondary: null,
};
this._lastPointerStates = {
primary: null,
secondary: null,
};
};
private _startPointerStates: {
primary: PointerEventState | null;
secondary: PointerEventState | null;
} = {
primary: null,
secondary: null,
};
private _upOrOut = (event: PointerEvent) => {
const { pointerId } = event;
if (
pointerId === this._startPointerStates.primary?.raw.pointerId ||
pointerId === this._startPointerStates.secondary?.raw.pointerId
) {
this._reset();
}
};
abstract _handleMove(
event: PointerEvent,
state: MultiPointerEventState
): void;
override listen(): void {
const { host, disposables } = this._dispatcher;
disposables.addFromEvent(host, 'pointerdown', this._down);
disposables.addFromEvent(host, 'pointermove', this._move);
disposables.addFromEvent(host, 'pointerup', this._upOrOut);
disposables.addFromEvent(host, 'pointerout', this._upOrOut);
}
}
class PinchController extends DualDragControllerBase {
override _handleMove(event: PointerEvent, state: MultiPointerEventState) {
if (event.pointerType !== 'touch') return;
const deltaFirstPointer = state.pointers[0].delta;
const deltaSecondPointer = state.pointers[1].delta;
const deltaFirstPointerVec = Vec.toVec(deltaFirstPointer);
const deltaSecondPointerVec = Vec.toVec(deltaSecondPointer);
const deltaFirstPointerValue = Vec.len(deltaFirstPointerVec);
const deltaSecondPointerValue = Vec.len(deltaSecondPointerVec);
const deltaDotProduct = Vec.dpr(
deltaFirstPointerVec,
deltaSecondPointerVec
);
const deltaValueThreshold = 0.1;
// the changes of distance between two pointers is not far enough
if (
!isFarEnough(deltaFirstPointer, deltaSecondPointer) ||
deltaDotProduct > 0 ||
deltaFirstPointerValue < deltaValueThreshold ||
deltaSecondPointerValue < deltaValueThreshold
)
return;
this._dispatcher.run('pinch', createContext(event, state));
}
}
class PanController extends DualDragControllerBase {
override _handleMove(event: PointerEvent, state: MultiPointerEventState) {
if (event.pointerType !== 'touch') return;
const deltaFirstPointer = state.pointers[0].delta;
const deltaSecondPointer = state.pointers[1].delta;
const deltaDotProduct = Vec.dpr(
Vec.toVec(deltaFirstPointer),
Vec.toVec(deltaSecondPointer)
);
// the center move distance is not far enough
if (
!isFarEnough(deltaFirstPointer, deltaSecondPointer) &&
deltaDotProduct < 0
)
return;
this._dispatcher.run('pan', createContext(event, state));
}
}
export class PointerControl {
private _cachedRect: DOMRect | null = null;
private _getRect = () => {
if (this._cachedRect === null) {
this._updateRect();
}
return this._cachedRect as DOMRect;
};
// XXX: polling is used instead of MutationObserver
// due to potential performance issues
private _pollingInterval: number | null = null;
private controllers: PointerControllerBase[];
constructor(private _dispatcher: UIEventDispatcher) {
this.controllers = [
new PointerEventForward(_dispatcher, this._getRect),
new ClickController(_dispatcher, this._getRect),
new DragController(_dispatcher, this._getRect),
new PanController(_dispatcher, this._getRect),
new PinchController(_dispatcher, this._getRect),
];
}
private _startPolling() {
const poll = () => {
nextTick()
.then(() => this._updateRect())
.catch(console.error);
};
this._pollingInterval = window.setInterval(poll, POLL_INTERVAL);
poll();
}
protected _updateRect() {
if (!this._dispatcher.host) return;
this._cachedRect = this._dispatcher.host.getBoundingClientRect();
}
dispose() {
if (this._pollingInterval !== null) {
clearInterval(this._pollingInterval);
this._pollingInterval = null;
}
}
listen() {
this._startPolling();
this.controllers.forEach(controller => controller.listen());
}
}

View File

@@ -0,0 +1,156 @@
import type { BlockComponent } from '../../view/index.js';
import { UIEventState, UIEventStateContext } from '../base.js';
import type {
EventHandlerRunner,
EventName,
UIEventDispatcher,
} from '../dispatcher.js';
import { EventScopeSourceType, EventSourceState } from '../state/source.js';
export class RangeControl {
private _buildScope = (eventName: EventName) => {
let scope: EventHandlerRunner[] | undefined;
const selection = document.getSelection();
if (selection && selection.rangeCount > 0) {
const range = selection.getRangeAt(0);
scope = this._buildEventScopeByNativeRange(eventName, range);
this._prev = range;
} else if (this._prev !== null) {
scope = this._buildEventScopeByNativeRange(eventName, this._prev);
this._prev = null;
}
return scope;
};
private _compositionEnd = (event: Event) => {
const scope = this._buildScope('compositionEnd');
this._dispatcher.run('compositionEnd', this._createContext(event), scope);
};
private _compositionStart = (event: Event) => {
const scope = this._buildScope('compositionStart');
this._dispatcher.run('compositionStart', this._createContext(event), scope);
};
private _compositionUpdate = (event: Event) => {
const scope = this._buildScope('compositionUpdate');
this._dispatcher.run(
'compositionUpdate',
this._createContext(event),
scope
);
};
private _prev: Range | null = null;
private _selectionChange = (event: Event) => {
const selection = document.getSelection();
if (!selection) return;
if (!selection.containsNode(this._dispatcher.host, true)) return;
if (selection.containsNode(this._dispatcher.host)) return;
const scope = this._buildScope('selectionChange');
this._dispatcher.run('selectionChange', this._createContext(event), scope);
};
constructor(private _dispatcher: UIEventDispatcher) {}
private _buildEventScopeByNativeRange(name: EventName, range: Range) {
const blockIds = this._findBlockComponentPath(range);
return this._dispatcher.buildEventScope(name, blockIds);
}
private _createContext(event: Event) {
return UIEventStateContext.from(
new UIEventState(event),
new EventSourceState({
event,
sourceType: EventScopeSourceType.Selection,
})
);
}
private _findBlockComponentPath(range: Range): string[] {
const start = range.startContainer;
const end = range.endContainer;
const ancestor = range.commonAncestorContainer;
const getBlockView = (node: Node): BlockComponent | null => {
const el = node instanceof Element ? node : node.parentElement;
// TODO(mirone/#6534): find a better way to get block element from a node
return el?.closest<BlockComponent>('[data-block-id]') ?? null;
};
if (ancestor.nodeType === Node.TEXT_NODE) {
const leaf = getBlockView(ancestor);
if (leaf) {
return [leaf.blockId];
}
}
const nodes = new Set<Node>();
let startRecorded = false;
const dfsDOMSearch = (current: Node | null, ancestor: Node) => {
if (!current) {
return;
}
if (current === ancestor) {
return;
}
if (current === end) {
nodes.add(current);
startRecorded = false;
return;
}
if (current === start) {
startRecorded = true;
}
// eslint-disable-next-line sonarjs/no-collapsible-if
if (startRecorded) {
if (
current.nodeType === Node.TEXT_NODE ||
current.nodeType === Node.ELEMENT_NODE
) {
nodes.add(current);
}
}
dfsDOMSearch(current.firstChild, ancestor);
dfsDOMSearch(current.nextSibling, ancestor);
};
dfsDOMSearch(ancestor.firstChild, ancestor);
const blocks = new Set<string>();
nodes.forEach(node => {
const blockView = getBlockView(node);
if (!blockView) {
return;
}
if (blocks.has(blockView.blockId)) {
return;
}
blocks.add(blockView.blockId);
});
return Array.from(blocks);
}
listen() {
const { host, disposables } = this._dispatcher;
disposables.addFromEvent(
document,
'selectionchange',
this._selectionChange
);
disposables.addFromEvent(host, 'compositionstart', this._compositionStart);
disposables.addFromEvent(host, 'compositionend', this._compositionEnd);
disposables.addFromEvent(
host,
'compositionupdate',
this._compositionUpdate
);
}
}

View File

@@ -0,0 +1,405 @@
import { BlockSuiteError, ErrorCode } from '@blocksuite/global/exceptions';
import { DisposableGroup } from '@blocksuite/global/utils';
import { LifeCycleWatcher } from '../extension/index.js';
import { KeymapIdentifier } from '../identifier.js';
import { type BlockComponent, EditorHost } from '../view/index.js';
import {
type UIEventHandler,
UIEventState,
UIEventStateContext,
} from './base.js';
import { ClipboardControl } from './control/clipboard.js';
import { KeyboardControl } from './control/keyboard.js';
import { PointerControl } from './control/pointer.js';
import { RangeControl } from './control/range.js';
import { EventScopeSourceType, EventSourceState } from './state/source.js';
import { toLowerCase } from './utils.js';
const bypassEventNames = [
'beforeInput',
'blur',
'focus',
'contextMenu',
'wheel',
] as const;
const eventNames = [
'click',
'doubleClick',
'tripleClick',
'pointerDown',
'pointerMove',
'pointerUp',
'pointerOut',
'dragStart',
'dragMove',
'dragEnd',
'pinch',
'pan',
'keyDown',
'keyUp',
'selectionChange',
'compositionStart',
'compositionUpdate',
'compositionEnd',
'cut',
'copy',
'paste',
'nativeDragStart',
'nativeDragMove',
'nativeDragEnd',
'nativeDrop',
...bypassEventNames,
] as const;
export type EventName = (typeof eventNames)[number];
export type EventOptions = {
flavour?: string;
blockId?: string;
};
export type EventHandlerRunner = {
fn: UIEventHandler;
flavour?: string;
blockId?: string;
};
export class UIEventDispatcher extends LifeCycleWatcher {
private static _activeDispatcher: UIEventDispatcher | null = null;
static override readonly key = 'UIEventDispatcher';
private _active = false;
private _clipboardControl: ClipboardControl;
private _handlersMap = Object.fromEntries(
eventNames.map((name): [EventName, Array<EventHandlerRunner>] => [name, []])
) as Record<EventName, Array<EventHandlerRunner>>;
private _keyboardControl: KeyboardControl;
private _pointerControl: PointerControl;
private _rangeControl: RangeControl;
bindHotkey = (...args: Parameters<KeyboardControl['bindHotkey']>) =>
this._keyboardControl.bindHotkey(...args);
disposables = new DisposableGroup();
private get _currentSelections() {
return this.std.selection.value;
}
get active() {
return this._active;
}
get host() {
return this.std.host;
}
constructor(std: BlockSuite.Std) {
super(std);
this._pointerControl = new PointerControl(this);
this._keyboardControl = new KeyboardControl(this);
this._rangeControl = new RangeControl(this);
this._clipboardControl = new ClipboardControl(this);
this.disposables.add(this._pointerControl);
}
private _bindEvents() {
bypassEventNames.forEach(eventName => {
this.disposables.addFromEvent(
this.host,
toLowerCase(eventName),
event => {
this.run(
eventName,
UIEventStateContext.from(
new UIEventState(event),
new EventSourceState({
event,
sourceType: EventScopeSourceType.Selection,
})
)
);
},
eventName === 'wheel'
? {
passive: false,
}
: undefined
);
});
this._pointerControl.listen();
this._keyboardControl.listen();
this._rangeControl.listen();
this._clipboardControl.listen();
let _dragging = false;
this.disposables.addFromEvent(this.host, 'pointerdown', () => {
_dragging = true;
this._setActive(true);
});
this.disposables.addFromEvent(this.host, 'pointerup', () => {
_dragging = false;
});
this.disposables.addFromEvent(this.host, 'click', () => {
this._setActive(true);
});
this.disposables.addFromEvent(this.host, 'focusin', () => {
this._setActive(true);
});
this.disposables.addFromEvent(this.host, 'focusout', e => {
if (e.relatedTarget && !this.host.contains(e.relatedTarget as Node)) {
this._setActive(false);
}
});
this.disposables.addFromEvent(this.host, 'blur', () => {
if (_dragging) {
return;
}
this._setActive(false);
});
this.disposables.addFromEvent(this.host, 'dragover', () => {
this._setActive(true);
});
this.disposables.addFromEvent(this.host, 'dragend', () => {
this._setActive(false);
});
this.disposables.addFromEvent(this.host, 'drop', () => {
this._setActive(true);
});
this.disposables.addFromEvent(this.host, 'pointerenter', () => {
if (this._isActiveElementOutsideHost()) {
return;
}
this._setActive(true);
});
this.disposables.addFromEvent(this.host, 'pointerleave', () => {
if (
(document.activeElement &&
this.host.contains(document.activeElement)) ||
_dragging
) {
return;
}
this._setActive(false);
});
}
private _buildEventScopeBySelection(name: EventName) {
const handlers = this._handlersMap[name];
if (!handlers) return;
const selections = this._currentSelections;
const ids = selections.map(selection => selection.blockId);
return this.buildEventScope(name, ids);
}
private _buildEventScopeByTarget(name: EventName, target: Node) {
const handlers = this._handlersMap[name];
if (!handlers) return;
// TODO(mirone/#6534): find a better way to get block element from a node
const el = target instanceof Element ? target : target.parentElement;
const block = el?.closest<BlockComponent>('[data-block-id]');
const blockId = block?.blockId;
if (!blockId) {
return this._buildEventScopeBySelection(name);
}
return this.buildEventScope(name, [blockId]);
}
private _getDeepActiveElement(): Element | null {
let active = document.activeElement;
while (active && active.shadowRoot && active.shadowRoot.activeElement) {
active = active.shadowRoot.activeElement;
}
return active;
}
private _getEventScope(name: EventName, state: EventSourceState) {
const handlers = this._handlersMap[name];
if (!handlers) return;
let output: EventHandlerRunner[] | undefined;
switch (state.sourceType) {
case EventScopeSourceType.Selection: {
output = this._buildEventScopeBySelection(name);
break;
}
case EventScopeSourceType.Target: {
output = this._buildEventScopeByTarget(
name,
state.event.target as Node
);
break;
}
default: {
throw new BlockSuiteError(
ErrorCode.EventDispatcherError,
`Unknown event scope source: ${state.sourceType}`
);
}
}
return output;
}
private _isActiveElementOutsideHost(): boolean {
const activeElement = this._getDeepActiveElement();
return (
activeElement !== null &&
this._isEditableElementActive(activeElement) &&
!this.host.contains(activeElement)
);
}
private _isEditableElementActive(element: Element | null): boolean {
if (!element) return false;
return (
element instanceof HTMLInputElement ||
element instanceof HTMLTextAreaElement ||
(element instanceof EditorHost && !element.doc.readonly) ||
(element as HTMLElement).isContentEditable
);
}
private _setActive(active: boolean) {
if (active) {
if (UIEventDispatcher._activeDispatcher !== this) {
if (UIEventDispatcher._activeDispatcher) {
UIEventDispatcher._activeDispatcher._active = false;
}
UIEventDispatcher._activeDispatcher = this;
}
this._active = true;
} else {
if (UIEventDispatcher._activeDispatcher === this) {
UIEventDispatcher._activeDispatcher = null;
}
this._active = false;
}
}
add(name: EventName, handler: UIEventHandler, options?: EventOptions) {
const runner: EventHandlerRunner = {
fn: handler,
flavour: options?.flavour,
blockId: options?.blockId,
};
this._handlersMap[name].unshift(runner);
return () => {
if (this._handlersMap[name].includes(runner)) {
this._handlersMap[name] = this._handlersMap[name].filter(
x => x !== runner
);
}
};
}
buildEventScope(
name: EventName,
blocks: string[]
): EventHandlerRunner[] | undefined {
const handlers = this._handlersMap[name];
if (!handlers) return;
const globalEvents = handlers.filter(
handler => handler.flavour === undefined && handler.blockId === undefined
);
let blockIds: string[] = blocks;
const events: EventHandlerRunner[] = [];
const flavourSeen: Record<string, boolean> = {};
while (blockIds.length > 0) {
const idHandlers = handlers.filter(
handler => handler.blockId && blockIds.includes(handler.blockId)
);
const flavourHandlers = blockIds
.map(blockId => this.std.doc.getBlock(blockId)?.flavour)
.filter((flavour): flavour is string => {
if (!flavour) return false;
if (flavourSeen[flavour]) return false;
flavourSeen[flavour] = true;
return true;
})
.flatMap(flavour => {
return handlers.filter(handler => handler.flavour === flavour);
});
events.push(...idHandlers, ...flavourHandlers);
blockIds = blockIds
.map(blockId => {
const parent = this.std.doc.getParent(blockId);
return parent?.id;
})
.filter((id): id is string => !!id);
}
return events.concat(globalEvents);
}
override mounted() {
if (this.disposables.disposed) {
this.disposables = new DisposableGroup();
}
this._bindEvents();
const std = this.std;
this.std.provider
.getAll(KeymapIdentifier)
.forEach(({ getter, options }) => {
this.bindHotkey(getter(std), options);
});
}
run(
name: EventName,
context: UIEventStateContext,
runners?: EventHandlerRunner[]
) {
if (!this.active) return;
const sourceState = context.get('sourceState');
if (!runners) {
runners = this._getEventScope(name, sourceState);
if (!runners) {
return;
}
}
for (const runner of runners) {
const { fn } = runner;
const result = fn(context);
if (result) {
context.get('defaultState').event.stopPropagation();
return;
}
}
}
override unmounted() {
this.disposables.dispose();
}
}

View File

@@ -0,0 +1,4 @@
export * from './base.js';
export * from './dispatcher.js';
export * from './keymap.js';
export * from './state/index.js';

View File

@@ -0,0 +1,109 @@
import { BlockSuiteError, ErrorCode } from '@blocksuite/global/exceptions';
import { base, keyName } from 'w3c-keyname';
import type { UIEventHandler } from './base.js';
const mac =
typeof navigator !== 'undefined'
? /Mac|iP(hone|[oa]d)/.test(navigator.platform)
: false;
function normalizeKeyName(name: string) {
const parts = name.split(/-(?!$)/);
let result = parts.at(-1);
if (result === 'Space') {
result = ' ';
}
let alt, ctrl, shift, meta;
parts.slice(0, -1).forEach(mod => {
if (/^(cmd|meta|m)$/i.test(mod)) {
meta = true;
return;
}
if (/^a(lt)?$/i.test(mod)) {
alt = true;
return;
}
if (/^(c|ctrl|control)$/i.test(mod)) {
ctrl = true;
return;
}
if (/^s(hift)?$/i.test(mod)) {
shift = true;
return;
}
if (/^mod$/i.test(mod)) {
if (mac) {
meta = true;
} else {
ctrl = true;
}
return;
}
throw new BlockSuiteError(
ErrorCode.EventDispatcherError,
'Unrecognized modifier name: ' + mod
);
});
if (alt) result = 'Alt-' + result;
if (ctrl) result = 'Ctrl-' + result;
if (meta) result = 'Meta-' + result;
if (shift) result = 'Shift-' + result;
return result as string;
}
function modifiers(name: string, event: KeyboardEvent, shift = true) {
if (event.altKey) name = 'Alt-' + name;
if (event.ctrlKey) name = 'Ctrl-' + name;
if (event.metaKey) name = 'Meta-' + name;
if (shift && event.shiftKey) name = 'Shift-' + name;
return name;
}
function normalize(map: Record<string, UIEventHandler>) {
const copy: Record<string, UIEventHandler> = Object.create(null);
for (const prop in map) copy[normalizeKeyName(prop)] = map[prop];
return copy;
}
export function bindKeymap(
bindings: Record<string, UIEventHandler>
): UIEventHandler {
const map = normalize(bindings);
return ctx => {
const state = ctx.get('keyboardState');
const event = state.raw;
const name = keyName(event);
const direct = map[modifiers(name, event)];
if (direct && direct(ctx)) {
return true;
}
if (name.length !== 1 || name === ' ') {
return false;
}
if (event.shiftKey) {
const noShift = map[modifiers(name, event, false)];
if (noShift && noShift(ctx)) {
return true;
}
}
// none standard keyboard, fallback to keyCode
const special =
event.shiftKey ||
event.altKey ||
event.metaKey ||
name.charCodeAt(0) > 127;
const baseName = base[event.keyCode];
if (special && baseName && baseName !== name) {
const fromCode = map[modifiers(baseName, event)];
if (fromCode && fromCode(ctx)) {
return true;
}
}
return false;
};
}

View File

@@ -0,0 +1,23 @@
import { UIEventState } from '../base.js';
type ClipboardEventStateOptions = {
event: ClipboardEvent;
};
export class ClipboardEventState extends UIEventState {
raw: ClipboardEvent;
override type = 'clipboardState';
constructor({ event }: ClipboardEventStateOptions) {
super(event);
this.raw = event;
}
}
declare global {
interface BlockSuiteUIEventState {
clipboardState: ClipboardEventState;
}
}

View File

@@ -0,0 +1,23 @@
import { UIEventState } from '../base.js';
type DndEventStateOptions = {
event: DragEvent;
};
export class DndEventState extends UIEventState {
raw: DragEvent;
override type = 'dndState';
constructor({ event }: DndEventStateOptions) {
super(event);
this.raw = event;
}
}
declare global {
interface BlockSuiteUIEventState {
dndState: DndEventState;
}
}

View File

@@ -0,0 +1,5 @@
export * from './clipboard.js';
export * from './dnd.js';
export * from './keyboard.js';
export * from './pointer.js';
export * from './source.js';

View File

@@ -0,0 +1,27 @@
import { UIEventState } from '../base.js';
type KeyboardEventStateOptions = {
event: KeyboardEvent;
composing: boolean;
};
export class KeyboardEventState extends UIEventState {
composing: boolean;
raw: KeyboardEvent;
override type = 'keyboardState';
constructor({ event, composing }: KeyboardEventStateOptions) {
super(event);
this.raw = event;
this.composing = composing;
}
}
declare global {
interface BlockSuiteUIEventState {
keyboardState: KeyboardEventState;
}
}

View File

@@ -0,0 +1,83 @@
import { UIEventState } from '../base.js';
type PointerEventStateOptions = {
event: PointerEvent;
rect: DOMRect;
startX: number;
startY: number;
last: PointerEventState | null;
};
type Point = { x: number; y: number };
export class PointerEventState extends UIEventState {
button: number;
containerOffset: Point;
delta: Point;
keys: {
shift: boolean;
cmd: boolean;
alt: boolean;
};
point: Point;
pressure: number;
raw: PointerEvent;
start: Point;
override type = 'pointerState';
get x() {
return this.point.x;
}
get y() {
return this.point.y;
}
constructor({ event, rect, startX, startY, last }: PointerEventStateOptions) {
super(event);
const offsetX = event.clientX - rect.left;
const offsetY = event.clientY - rect.top;
this.raw = event;
this.point = { x: offsetX, y: offsetY };
this.containerOffset = { x: rect.left, y: rect.top };
this.start = { x: startX, y: startY };
this.delta = last
? { x: offsetX - last.point.x, y: offsetY - last.point.y }
: { x: 0, y: 0 };
this.keys = {
shift: event.shiftKey,
cmd: event.metaKey || event.ctrlKey,
alt: event.altKey,
};
this.button = last?.button || event.button;
this.pressure = event.pressure;
}
}
export class MultiPointerEventState extends UIEventState {
pointers: PointerEventState[];
override type = 'multiPointerState';
constructor(event: PointerEvent, pointers: PointerEventState[]) {
super(event);
this.pointers = pointers;
}
}
declare global {
interface BlockSuiteUIEventState {
pointerState: PointerEventState;
multiPointerState: MultiPointerEventState;
}
}

View File

@@ -0,0 +1,31 @@
import { UIEventState } from '../base.js';
export enum EventScopeSourceType {
// The event scope should be built by selection path
Selection = 'selection',
// The event scope should be built by event target
Target = 'target',
}
export type EventSourceStateOptions = {
event: Event;
sourceType: EventScopeSourceType;
};
export class EventSourceState extends UIEventState {
readonly sourceType: EventScopeSourceType;
override type = 'sourceState';
constructor({ event, sourceType }: EventSourceStateOptions) {
super(event);
this.sourceType = sourceType;
}
}
declare global {
interface BlockSuiteUIEventState {
sourceState: EventSourceState;
}
}

View File

@@ -0,0 +1,17 @@
import type { IPoint } from '@blocksuite/global/utils';
export function isFarEnough(a: IPoint, b: IPoint) {
const dx = a.x - b.x;
const dy = a.y - b.y;
return Math.pow(dx, 2) + Math.pow(dy, 2) > 4;
}
export function center(a: IPoint, b: IPoint) {
return {
x: (a.x + b.x) / 2,
y: (a.y + b.y) / 2,
};
}
export const toLowerCase = <T extends string>(str: T): Lowercase<T> =>
str.toLowerCase() as Lowercase<T>;