mirror of
https://github.com/toeverything/AFFiNE.git
synced 2026-02-14 13:25:12 +00:00
62
blocksuite/framework/std/src/event/base.ts
Normal file
62
blocksuite/framework/std/src/event/base.ts
Normal 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;
|
||||
}
|
||||
56
blocksuite/framework/std/src/event/control/clipboard.ts
Normal file
56
blocksuite/framework/std/src/event/control/clipboard.ts
Normal 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 readonly _copy = (event: ClipboardEvent) => {
|
||||
const clipboardEventState = new ClipboardEventState({
|
||||
event,
|
||||
});
|
||||
this._dispatcher.run(
|
||||
'copy',
|
||||
this._createContext(event, clipboardEventState)
|
||||
);
|
||||
};
|
||||
|
||||
private readonly _cut = (event: ClipboardEvent) => {
|
||||
const clipboardEventState = new ClipboardEventState({
|
||||
event,
|
||||
});
|
||||
this._dispatcher.run(
|
||||
'cut',
|
||||
this._createContext(event, clipboardEventState)
|
||||
);
|
||||
};
|
||||
|
||||
private readonly _paste = (event: ClipboardEvent) => {
|
||||
const clipboardEventState = new ClipboardEventState({
|
||||
event,
|
||||
});
|
||||
|
||||
this._dispatcher.run(
|
||||
'paste',
|
||||
this._createContext(event, clipboardEventState)
|
||||
);
|
||||
};
|
||||
|
||||
constructor(private readonly _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);
|
||||
}
|
||||
}
|
||||
129
blocksuite/framework/std/src/event/control/keyboard.ts
Normal file
129
blocksuite/framework/std/src/event/control/keyboard.ts
Normal file
@@ -0,0 +1,129 @@
|
||||
import { DisposableGroup } from '@blocksuite/global/disposable';
|
||||
import { IS_ANDROID, IS_MAC } from '@blocksuite/global/env';
|
||||
|
||||
import {
|
||||
type UIEventHandler,
|
||||
UIEventState,
|
||||
UIEventStateContext,
|
||||
} from '../base.js';
|
||||
import type { EventOptions, UIEventDispatcher } from '../dispatcher.js';
|
||||
import { androidBindKeymapPatch, bindKeymap } from '../keymap.js';
|
||||
import { KeyboardEventState } from '../state/index.js';
|
||||
import { EventScopeSourceType, EventSourceState } from '../state/source.js';
|
||||
|
||||
export class KeyboardControl {
|
||||
private readonly _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 readonly _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 readonly _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 readonly _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) {
|
||||
const disposables = new DisposableGroup();
|
||||
if (IS_ANDROID) {
|
||||
disposables.add(
|
||||
this._dispatcher.add(
|
||||
'beforeInput',
|
||||
ctx => {
|
||||
if (this.composition) return false;
|
||||
const binding = androidBindKeymapPatch(keymap);
|
||||
return binding(ctx);
|
||||
},
|
||||
options
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
disposables.add(
|
||||
this._dispatcher.add(
|
||||
'keyDown',
|
||||
ctx => {
|
||||
if (this.composition) return false;
|
||||
const binding = bindKeymap(keymap);
|
||||
return binding(ctx);
|
||||
},
|
||||
options
|
||||
)
|
||||
);
|
||||
return () => disposables.dispose();
|
||||
}
|
||||
|
||||
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,
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
626
blocksuite/framework/std/src/event/control/pointer.ts
Normal file
626
blocksuite/framework/std/src/event/control/pointer.ts
Normal file
@@ -0,0 +1,626 @@
|
||||
import { IS_IPAD } from '@blocksuite/global/env';
|
||||
import { Vec } from '@blocksuite/global/gfx';
|
||||
import { nextTick } 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 readonly _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 readonly _lastStates = new Map<PointerId, PointerEventState>();
|
||||
|
||||
private readonly _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 readonly _startStates = new Map<PointerId, PointerEventState>();
|
||||
|
||||
private readonly _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 readonly _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 readonly _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 readonly _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 readonly _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 readonly _nativeDragEnd = (event: DragEvent) => {
|
||||
this._nativeDragging = false;
|
||||
const dndEventState = new DndEventState({ event });
|
||||
this._dispatcher.run(
|
||||
'nativeDragEnd',
|
||||
this._createContext(event, dndEventState)
|
||||
);
|
||||
};
|
||||
|
||||
private _nativeDragging = false;
|
||||
|
||||
private readonly _nativeDragMove = (event: DragEvent) => {
|
||||
const dndEventState = new DndEventState({ event });
|
||||
this._dispatcher.run(
|
||||
'nativeDragMove',
|
||||
this._createContext(event, dndEventState)
|
||||
);
|
||||
};
|
||||
|
||||
private readonly _nativeDragStart = (event: DragEvent) => {
|
||||
this._reset();
|
||||
this._nativeDragging = true;
|
||||
const dndEventState = new DndEventState({ event });
|
||||
this._dispatcher.run(
|
||||
'nativeDragStart',
|
||||
this._createContext(event, dndEventState)
|
||||
);
|
||||
};
|
||||
|
||||
private readonly _nativeDragOver = (event: DragEvent) => {
|
||||
// prevent default to allow drop in editor
|
||||
event.preventDefault();
|
||||
const dndEventState = new DndEventState({ event });
|
||||
this._dispatcher.run(
|
||||
'nativeDragOver',
|
||||
this._createContext(event, dndEventState)
|
||||
);
|
||||
};
|
||||
|
||||
private readonly _nativeDragLeave = (event: DragEvent) => {
|
||||
const dndEventState = new DndEventState({ event });
|
||||
this._dispatcher.run(
|
||||
'nativeDragLeave',
|
||||
this._createContext(event, dndEventState)
|
||||
);
|
||||
};
|
||||
|
||||
private readonly _nativeDrop = (event: DragEvent) => {
|
||||
this._reset();
|
||||
this._nativeDragging = false;
|
||||
const dndEventState = new DndEventState({ event });
|
||||
this._dispatcher.run(
|
||||
'nativeDrop',
|
||||
this._createContext(event, dndEventState)
|
||||
);
|
||||
};
|
||||
|
||||
private readonly _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 readonly _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.add(
|
||||
host.std.dnd.monitor({
|
||||
onDragStart: () => {
|
||||
this._nativeDragging = true;
|
||||
},
|
||||
onDrop: () => {
|
||||
this._nativeDragging = false;
|
||||
},
|
||||
})
|
||||
);
|
||||
|
||||
disposables.addFromEvent(host, 'dragstart', this._nativeDragStart);
|
||||
disposables.addFromEvent(host, 'dragend', this._nativeDragEnd);
|
||||
disposables.addFromEvent(host, 'drag', this._nativeDragMove);
|
||||
disposables.addFromEvent(host, 'drop', this._nativeDrop);
|
||||
disposables.addFromEvent(host, 'dragover', this._nativeDragOver);
|
||||
disposables.addFromEvent(host, 'dragleave', this._nativeDragLeave);
|
||||
}
|
||||
}
|
||||
|
||||
abstract class DualDragControllerBase extends PointerControllerBase {
|
||||
private readonly _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 readonly _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 readonly _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 readonly _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 readonly _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 readonly controllers: PointerControllerBase[];
|
||||
|
||||
constructor(private readonly _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());
|
||||
}
|
||||
}
|
||||
156
blocksuite/framework/std/src/event/control/range.ts
Normal file
156
blocksuite/framework/std/src/event/control/range.ts
Normal 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 readonly _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 readonly _compositionEnd = (event: Event) => {
|
||||
const scope = this._buildScope('compositionEnd');
|
||||
|
||||
this._dispatcher.run('compositionEnd', this._createContext(event), scope);
|
||||
};
|
||||
|
||||
private readonly _compositionStart = (event: Event) => {
|
||||
const scope = this._buildScope('compositionStart');
|
||||
|
||||
this._dispatcher.run('compositionStart', this._createContext(event), scope);
|
||||
};
|
||||
|
||||
private readonly _compositionUpdate = (event: Event) => {
|
||||
const scope = this._buildScope('compositionUpdate');
|
||||
|
||||
this._dispatcher.run(
|
||||
'compositionUpdate',
|
||||
this._createContext(event),
|
||||
scope
|
||||
);
|
||||
};
|
||||
|
||||
private _prev: Range | null = null;
|
||||
|
||||
private readonly _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 readonly _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
|
||||
);
|
||||
}
|
||||
}
|
||||
428
blocksuite/framework/std/src/event/dispatcher.ts
Normal file
428
blocksuite/framework/std/src/event/dispatcher.ts
Normal file
@@ -0,0 +1,428 @@
|
||||
import { DisposableGroup } from '@blocksuite/global/disposable';
|
||||
import { BlockSuiteError, ErrorCode } from '@blocksuite/global/exceptions';
|
||||
import { signal } from '@preact/signals-core';
|
||||
|
||||
import { LifeCycleWatcher } from '../extension/index.js';
|
||||
import { KeymapIdentifier } from '../identifier.js';
|
||||
import type { BlockStdScope } from '../scope/index.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',
|
||||
'nativeDragOver',
|
||||
'nativeDragLeave',
|
||||
|
||||
...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 readonly _active = signal(false);
|
||||
|
||||
private readonly _clipboardControl: ClipboardControl;
|
||||
|
||||
private _handlersMap = Object.fromEntries(
|
||||
eventNames.map((name): [EventName, Array<EventHandlerRunner>] => [name, []])
|
||||
) as Record<EventName, Array<EventHandlerRunner>>;
|
||||
|
||||
private readonly _keyboardControl: KeyboardControl;
|
||||
|
||||
private readonly _pointerControl: PointerControl;
|
||||
|
||||
private readonly _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.peek();
|
||||
}
|
||||
|
||||
get active$() {
|
||||
return this._active;
|
||||
}
|
||||
|
||||
get host() {
|
||||
return this.std.host;
|
||||
}
|
||||
|
||||
constructor(std: BlockStdScope) {
|
||||
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', () => {
|
||||
_dragging = true;
|
||||
this._setActive(true);
|
||||
});
|
||||
this.disposables.addFromEvent(this.host, 'dragenter', () => {
|
||||
_dragging = true;
|
||||
this._setActive(true);
|
||||
});
|
||||
this.disposables.addFromEvent(this.host, 'dragstart', () => {
|
||||
_dragging = true;
|
||||
this._setActive(true);
|
||||
});
|
||||
this.disposables.addFromEvent(this.host, 'dragend', () => {
|
||||
_dragging = false;
|
||||
});
|
||||
this.disposables.addFromEvent(this.host, 'drop', () => {
|
||||
_dragging = false;
|
||||
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.value = false;
|
||||
}
|
||||
UIEventDispatcher._activeDispatcher = this;
|
||||
}
|
||||
this._active.value = true;
|
||||
} else {
|
||||
if (UIEventDispatcher._activeDispatcher === this) {
|
||||
UIEventDispatcher._activeDispatcher = null;
|
||||
}
|
||||
this._active.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
set active(active: boolean) {
|
||||
if (active === this._active.peek()) return;
|
||||
this._setActive(active);
|
||||
}
|
||||
|
||||
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.store.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.store.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();
|
||||
}
|
||||
}
|
||||
4
blocksuite/framework/std/src/event/index.ts
Normal file
4
blocksuite/framework/std/src/event/index.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
export * from './base.js';
|
||||
export * from './dispatcher.js';
|
||||
export * from './keymap.js';
|
||||
export * from './state/index.js';
|
||||
127
blocksuite/framework/std/src/event/keymap.ts
Normal file
127
blocksuite/framework/std/src/event/keymap.ts
Normal file
@@ -0,0 +1,127 @@
|
||||
import { IS_MAC } from '@blocksuite/global/env';
|
||||
import { BlockSuiteError, ErrorCode } from '@blocksuite/global/exceptions';
|
||||
import { base, keyName } from 'w3c-keyname';
|
||||
|
||||
import type { UIEventHandler } from './base.js';
|
||||
|
||||
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 (IS_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;
|
||||
};
|
||||
}
|
||||
|
||||
// In Android, the keypress event dose not contain
|
||||
// the information about what key is pressed. See
|
||||
// https://stackoverflow.com/a/68188679
|
||||
// https://stackoverflow.com/a/66724830
|
||||
export function androidBindKeymapPatch(
|
||||
bindings: Record<string, UIEventHandler>
|
||||
): UIEventHandler {
|
||||
return ctx => {
|
||||
const event = ctx.get('defaultState').event;
|
||||
if (!(event instanceof InputEvent)) return;
|
||||
|
||||
if (
|
||||
event.inputType === 'deleteContentBackward' &&
|
||||
'Backspace' in bindings
|
||||
) {
|
||||
return bindings['Backspace'](ctx);
|
||||
}
|
||||
|
||||
return false;
|
||||
};
|
||||
}
|
||||
23
blocksuite/framework/std/src/event/state/clipboard.ts
Normal file
23
blocksuite/framework/std/src/event/state/clipboard.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
23
blocksuite/framework/std/src/event/state/dnd.ts
Normal file
23
blocksuite/framework/std/src/event/state/dnd.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
5
blocksuite/framework/std/src/event/state/index.ts
Normal file
5
blocksuite/framework/std/src/event/state/index.ts
Normal 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';
|
||||
27
blocksuite/framework/std/src/event/state/keyboard.ts
Normal file
27
blocksuite/framework/std/src/event/state/keyboard.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
83
blocksuite/framework/std/src/event/state/pointer.ts
Normal file
83
blocksuite/framework/std/src/event/state/pointer.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
31
blocksuite/framework/std/src/event/state/source.ts
Normal file
31
blocksuite/framework/std/src/event/state/source.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
17
blocksuite/framework/std/src/event/utils.ts
Normal file
17
blocksuite/framework/std/src/event/utils.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
import type { IPoint } from '@blocksuite/global/gfx';
|
||||
|
||||
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>;
|
||||
Reference in New Issue
Block a user