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,201 @@
import { DisposableGroup } from '@blocksuite/global/utils';
import type { ReactiveController, ReactiveElement } from 'lit';
import {
type AdvancedPortalOptions,
createLitPortal,
} from '../portal/index.js';
import type { HoverOptions } from './types.js';
import { whenHover } from './when-hover.js';
type OptionsParams = Omit<
ReturnType<typeof whenHover>,
'setFloating' | 'dispose'
> & {
abortController: AbortController;
};
type HoverPortalOptions = Omit<AdvancedPortalOptions, 'abortController'>;
const DEFAULT_HOVER_OPTIONS: HoverOptions = {
transition: {
duration: 100,
in: {
opacity: '1',
transition: 'opacity 0.1s ease-in-out',
},
out: {
opacity: '0',
transition: 'opacity 0.1s ease-in-out',
},
},
setPortalAsFloating: true,
allowMultiple: false,
};
const abortHoverPortal = ({
portal,
hoverOptions,
abortController,
}: {
portal: HTMLDivElement | undefined;
hoverOptions: HoverOptions;
abortController: AbortController;
}) => {
if (!portal || !hoverOptions.transition) {
abortController.abort();
return;
}
// Transition out
Object.assign(portal.style, hoverOptions.transition.out);
portal.addEventListener(
'transitionend',
() => {
abortController.abort();
},
{ signal: abortController.signal }
);
portal.addEventListener(
'transitioncancel',
() => {
abortController.abort();
},
{ signal: abortController.signal }
);
// Make sure the portal is aborted after the transition ends
setTimeout(() => abortController.abort(), hoverOptions.transition.duration);
};
export class HoverController implements ReactiveController {
static globalAbortController?: AbortController;
private _abortController?: AbortController;
private readonly _hoverOptions: HoverOptions;
private _isHovering = false;
private readonly _onHover: (
options: OptionsParams
) => HoverPortalOptions | null;
private _portal?: HTMLDivElement;
private _setReference: (element?: Element | undefined) => void = () => {
console.error('setReference is not ready');
};
protected _disposables = new DisposableGroup();
host: ReactiveElement;
/**
* Callback when the portal needs to be aborted.
*/
onAbort = () => {
this.abort();
};
/**
* Whether the host is currently hovering.
*
* This property is unreliable when the floating element disconnect from the DOM suddenly.
*/
get isHovering() {
return this._isHovering;
}
get portal() {
return this._portal;
}
get setReference() {
return this._setReference;
}
constructor(
host: ReactiveElement,
onHover: (options: OptionsParams) => HoverPortalOptions | null,
hoverOptions?: Partial<HoverOptions>
) {
this._hoverOptions = { ...DEFAULT_HOVER_OPTIONS, ...hoverOptions };
(this.host = host).addController(this);
this._onHover = onHover;
}
abort(force = false) {
if (!this._abortController) return;
if (force) {
this._abortController.abort();
return;
}
abortHoverPortal({
portal: this._portal,
hoverOptions: this._hoverOptions,
abortController: this._abortController,
});
}
hostConnected() {
if (this._disposables.disposed) {
this._disposables = new DisposableGroup();
}
// Start a timer when the host is connected
const { setReference, setFloating, dispose } = whenHover(isHover => {
if (!this.host.isConnected) {
return;
}
this._isHovering = isHover;
if (!isHover) {
this.onAbort();
return;
}
if (this._abortController) {
return;
}
this._abortController = new AbortController();
this._abortController.signal.addEventListener('abort', () => {
this._abortController = undefined;
});
if (!this._hoverOptions.allowMultiple) {
HoverController.globalAbortController?.abort();
HoverController.globalAbortController = this._abortController;
}
const portalOptions = this._onHover({
setReference,
abortController: this._abortController,
});
if (!portalOptions) {
// Sometimes the portal is not ready to show
this._abortController.abort();
return;
}
this._portal = createLitPortal({
...portalOptions,
abortController: this._abortController,
});
const transition = this._hoverOptions.transition;
if (transition) {
Object.assign(this._portal.style, transition.in);
}
if (this._hoverOptions.setPortalAsFloating) {
setFloating(this._portal);
}
}, this._hoverOptions);
this._setReference = setReference;
this._disposables.add(dispose);
}
hostDisconnected() {
this._abortController?.abort();
this._disposables.dispose();
}
}

View File

@@ -0,0 +1,3 @@
export { HoverController } from './controller.js';
export type * from './types.js';
export { whenHover } from './when-hover.js';

View File

@@ -0,0 +1,79 @@
import { sleep } from '@blocksuite/global/utils';
import type { HoverMiddleware } from '../types.js';
/**
* When the mouse is hovering in, the `mouseover` event will be fired multiple times.
* This middleware will filter out the duplicated events.
*/
export const dedupe = (keepWhenFloatingNotReady = true): HoverMiddleware => {
const SKIP = false;
const KEEP = true;
let hoverState = false;
return ({ event, floatingElement }) => {
const curState = hoverState;
if (event.type === 'mouseover') {
// hover in
hoverState = true;
if (curState !== hoverState)
// state changed, so we should keep the event
return KEEP;
if (
keepWhenFloatingNotReady &&
(!floatingElement || !floatingElement.isConnected)
) {
// Already hovered
// But the floating element is not ready
// so we should not skip the event
return KEEP;
}
return SKIP;
}
if (event.type === 'mouseleave') {
// hover out
hoverState = false;
if (curState !== hoverState) return KEEP;
if (keepWhenFloatingNotReady && floatingElement?.isConnected) {
// Already hover out
// But the floating element is still showing
// so we should not skip the event
return KEEP;
}
return SKIP;
}
console.warn('Unknown event type in hover middleware', event);
return KEEP;
};
};
/**
* Wait some time before emitting the `mouseover` event.
*/
export const delayShow = (delay: number): HoverMiddleware => {
let abortController = new AbortController();
return async ({ event }) => {
abortController.abort();
const newAbortController = new AbortController();
abortController = newAbortController;
if (event.type !== 'mouseover') return true;
if (delay <= 0) return true;
await sleep(delay, newAbortController.signal);
return !newAbortController.signal.aborted;
};
};
/**
* Wait some time before emitting the `mouseleave` event.
*/
export const delayHide = (delay: number): HoverMiddleware => {
let abortController = new AbortController();
return async ({ event }) => {
abortController.abort();
const newAbortController = new AbortController();
abortController = newAbortController;
if (event.type !== 'mouseleave') return true;
if (delay <= 0) return true;
await sleep(delay, newAbortController.signal);
return !newAbortController.signal.aborted;
};
};

View File

@@ -0,0 +1,361 @@
import type { HoverMiddleware } from '../types.js';
export type SafeTriangleOptions = {
zIndex: number;
buffer: number;
/**
* abort triangle guard if the mouse not move for some time
*/
idleTimeout: number;
debug?: boolean;
};
/**
* Returns true if the line from (a,b)->(c,d) intersects with (p,q)->(r,s)
*
* See https://stackoverflow.com/questions/9043805/test-if-two-lines-intersect-javascript-function
*/
function hasIntersection(
{ x: a, y: b }: { x: number; y: number },
{ x: c, y: d }: { x: number; y: number },
{ x: p, y: q }: { x: number; y: number },
{ x: r, y: s }: { x: number; y: number }
) {
const det = (c - a) * (s - q) - (r - p) * (d - b);
if (det === 0) {
return false;
} else {
const lambda = ((s - q) * (r - a) + (p - r) * (s - b)) / det;
const gamma = ((b - d) * (r - a) + (c - a) * (s - b)) / det;
return 0 < lambda && lambda < 1 && 0 < gamma && gamma < 1;
}
}
function isInside(
{ x, y }: { x: number; y: number },
rect: DOMRect,
buffer = 0
) {
return (
x >= rect.left - buffer &&
x <= rect.right + buffer &&
y >= rect.top - buffer &&
y <= rect.bottom - buffer
);
}
const getNearestSide = (
point: { x: number; y: number },
rect: DOMRect
):
| [
'top' | 'bottom' | 'left' | 'right',
{ x: number; y: number },
{ x: number; y: number },
]
| null => {
const centerPoint = {
x: rect.x + rect.width / 2,
y: rect.y + rect.height / 2,
};
const topLeft = { x: rect.x, y: rect.y };
const topRight = { x: rect.right, y: rect.y };
const bottomLeft = { x: rect.x, y: rect.bottom };
const bottomRight = { x: rect.right, y: rect.bottom };
if (hasIntersection(point, centerPoint, bottomLeft, bottomRight)) {
return ['top', bottomLeft, bottomRight];
}
if (hasIntersection(point, centerPoint, topLeft, topRight)) {
return ['bottom', topLeft, topRight];
}
if (hasIntersection(point, centerPoint, topLeft, bottomLeft)) {
return ['right', topLeft, bottomLeft];
}
if (hasIntersection(point, centerPoint, topRight, bottomRight)) {
return ['left', topRight, bottomRight];
}
return null;
};
/**
* Part of the code is ported from https://github.com/floating-ui/floating-ui/blob/master/packages/react/src/safePolygon.ts
* Licensed under MIT.
*/
export const safeTriangle = ({
zIndex = 10000,
buffer = 2,
idleTimeout = 40,
debug = false,
}: Partial<SafeTriangleOptions> = {}): HoverMiddleware => {
let abortController = new AbortController();
return async ({ event, referenceElement, floatingElement }) => {
abortController.abort();
const newAbortController = new AbortController();
abortController = newAbortController;
const isLeave = event.type === 'mouseleave';
if (!isLeave || event.target !== referenceElement) return true;
if (!(event instanceof MouseEvent)) {
console.warn('Unknown event type in hover middleware', event);
return true;
}
if (!floatingElement) return true;
const mouseX = event.x;
const mouseY = event.y;
const refRect = referenceElement.getBoundingClientRect();
const rect = floatingElement.getBoundingClientRect();
// If the mouse leaves from inside the referenceElement element,
// we should ignore the event.
const leaveFromInside = isInside({ x: mouseX, y: mouseY }, refRect);
if (leaveFromInside) return true;
// what side is the floating element on
const floatingData = getNearestSide({ x: mouseX, y: mouseY }, rect);
if (!floatingData) return true;
const floatingSide = floatingData[0];
// If the pointer is leaving from the opposite side, no need to show the triangle.
// A constant of 1 handles floating point rounding errors.
if (
(floatingSide === 'top' && mouseY >= refRect.bottom - 1) ||
(floatingSide === 'bottom' && mouseY <= refRect.top + 1) ||
(floatingSide === 'left' && mouseX >= refRect.right - 1) ||
(floatingSide === 'right' && mouseX <= refRect.left + 1)
) {
return true;
}
const svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
const path = document.createElementNS('http://www.w3.org/2000/svg', 'path');
const updateSafeTriangle = (mouseX: number, mouseY: number) => {
if (newAbortController.signal.aborted) return;
// If the mouse is inside the floating element, we should ignore the event.
if (
mouseX >= rect.left &&
mouseX <= rect.right &&
mouseY >= rect.top &&
mouseY <= rect.bottom
)
newAbortController.abort();
const p1 = { x: mouseX, y: mouseY };
// Assume the floating element is still in the same position.
const p2 = floatingData[1];
const p3 = floatingData[2];
// The base point is the top left corner of the three points.
const basePoint = {
x: Math.min(p1.x, p2.x, p3.x) - buffer,
y: Math.min(p1.y, p2.y, p3.y) - buffer,
};
const areaHeight = Math.max(
Math.abs(p1.y - p2.y),
Math.abs(p1.y - p3.y),
Math.abs(p2.y - p3.y)
);
const areaWidth = Math.max(
Math.abs(p1.x - p2.x),
Math.abs(p1.x - p3.x),
Math.abs(p2.x - p3.x)
);
Object.assign(svg.style, {
position: 'fixed',
pointerEvents: 'none',
width: areaWidth + buffer * 2,
height: areaHeight + buffer * 2,
zIndex,
top: 0,
left: 0,
transform: `translate(${basePoint.x}px, ${basePoint.y}px)`,
});
path.setAttributeNS(
null,
'd',
`M${p1.x - basePoint.x} ${p1.y - basePoint.y} ${p2.x - basePoint.x} ${
p2.y - basePoint.y
} ${p3.x - basePoint.x} ${p3.y - basePoint.y} z`
);
};
path.setAttributeNS(null, 'pointer-events', 'auto');
path.setAttributeNS(null, 'fill', 'transparent');
path.setAttributeNS(null, 'stroke-width', buffer.toString());
path.setAttributeNS(null, 'stroke', 'transparent');
if (debug) {
path.setAttributeNS(null, 'stroke', 'red');
}
updateSafeTriangle(mouseX, mouseY);
svg.append(path);
document.body.append(svg);
abortController.signal.addEventListener('abort', () => svg.remove());
let frameId = 0;
let idleId = window.setTimeout(
() => newAbortController.abort(),
idleTimeout
);
svg.addEventListener(
'mousemove',
e => {
clearTimeout(idleId);
idleId = window.setTimeout(
() => newAbortController.abort(),
idleTimeout
);
cancelAnimationFrame(frameId);
// prevent unexpected mouseleave
frameId = requestAnimationFrame(() =>
updateSafeTriangle(e.clientX, e.clientY)
);
},
{ signal: newAbortController.signal }
);
await new Promise<void>(res => {
if (newAbortController.signal.aborted) res();
newAbortController.signal.addEventListener('abort', () => res());
svg.addEventListener('mouseleave', () => newAbortController.abort(), {
signal: newAbortController.signal,
});
});
return true;
};
};
export type SafeBridgeOptions = { debug: boolean; idleTimeout: number };
/**
* Create a virtual rectangular bridge between the reference element and the floating element.
*
* Part of the code is ported from https://github.com/floating-ui/floating-ui/blob/master/packages/react/src/safePolygon.ts
* Licensed under MIT.
*/
export const safeBridge = ({
debug = false,
idleTimeout = 500,
}: Partial<SafeBridgeOptions> = {}): HoverMiddleware => {
let abortController = new AbortController();
return async ({ event, referenceElement, floatingElement }) => {
abortController.abort();
const newAbortController = new AbortController();
abortController = newAbortController;
const isLeave = event.type === 'mouseleave';
if (!isLeave || event.target !== referenceElement) return true;
if (!(event instanceof MouseEvent)) {
console.warn('Unknown event type in hover middleware', event);
return true;
}
if (!floatingElement) return true;
const checkInside = (mouseX: number, mouseY: number) => {
if (newAbortController.signal.aborted) return false;
const point = { x: mouseX, y: mouseY };
const refRect = referenceElement.getBoundingClientRect();
const rect = floatingElement.getBoundingClientRect();
// what side is the floating element on
const floatingData = getNearestSide(point, rect);
if (!floatingData) return false;
const floatingSide = floatingData[0];
// If the pointer is leaving from the other side, no need to show the bridge.
// A constant of 1 handles floating point rounding errors.
if (
(floatingSide === 'top' && mouseY > refRect.top + 1) ||
(floatingSide === 'bottom' && mouseY < refRect.bottom - 1) ||
(floatingSide === 'left' && mouseX > refRect.left + 1) ||
(floatingSide === 'right' && mouseX < refRect.right - 1)
)
return false;
let rectRect: DOMRect;
switch (floatingSide) {
case 'top': {
rectRect = new DOMRect(
Math.max(rect.left, refRect.left),
rect.bottom,
Math.min(rect.right, refRect.right) -
Math.max(rect.left, refRect.left),
refRect.top - rect.bottom
);
break;
}
case 'bottom': {
rectRect = new DOMRect(
Math.max(rect.left, refRect.left),
refRect.bottom,
Math.min(rect.right, refRect.right) -
Math.max(rect.left, refRect.left),
rect.top - refRect.bottom
);
break;
}
case 'left': {
rectRect = new DOMRect(
rect.right,
Math.max(rect.top, refRect.top),
refRect.left - rect.right,
Math.min(rect.bottom, refRect.bottom) -
Math.max(rect.top, refRect.top)
);
break;
}
case 'right': {
rectRect = new DOMRect(
refRect.right,
Math.max(rect.top, refRect.top),
rect.left - refRect.right,
Math.min(rect.bottom, refRect.bottom) -
Math.max(rect.top, refRect.top)
);
break;
}
default:
return false;
}
const inside = isInside(point, rectRect, 1);
if (inside && debug) {
const debugId = 'debug-rectangle-bridge-rect';
const rectDom =
document.querySelector<HTMLDivElement>(`#${debugId}`) ??
document.createElement('div');
rectDom.id = debugId;
Object.assign(rectDom.style, {
position: 'fixed',
pointerEvents: 'none',
background: 'aqua',
opacity: '0.3',
top: rectRect.top + 'px',
left: rectRect.left + 'px',
width: rectRect.width + 'px',
height: rectRect.height + 'px',
});
document.body.append(rectDom);
newAbortController.signal.addEventListener('abort', () =>
rectDom.remove()
);
}
return inside;
};
if (!checkInside(event.x, event.y)) return true;
await new Promise<void>(res => {
if (newAbortController.signal.aborted) res();
newAbortController.signal.addEventListener('abort', () => res());
let idleId = window.setTimeout(
() => newAbortController.abort(),
idleTimeout
);
document.addEventListener(
'mousemove',
e => {
clearTimeout(idleId);
idleId = window.setTimeout(
() => newAbortController.abort(),
idleTimeout
);
if (!checkInside(e.clientX, e.clientY)) newAbortController.abort();
},
{
signal: newAbortController.signal,
}
);
});
return true;
};
};

View File

@@ -0,0 +1,65 @@
import type { StyleInfo } from 'lit/directives/style-map.js';
import type {
SafeBridgeOptions,
SafeTriangleOptions,
} from './middlewares/safe-area.js';
export type WhenHoverOptions = {
enterDelay?: number;
leaveDelay?: number;
/**
* When already hovered to the reference element,
* but the floating element is not ready,
* the callback will still be executed if the `alwayRunWhenNoFloating` is true.
*
* It is useful when the floating element is removed just before by a user's action,
* and the user's mouse is still hovering over the reference element.
*
* @default true
*/
alwayRunWhenNoFloating?: boolean;
safeTriangle?: boolean | SafeTriangleOptions;
/**
* Create a virtual rectangular bridge between the reference element and the floating element.
*/
safeBridge?: boolean | SafeBridgeOptions;
};
export type HoverMiddleware = (ctx: {
event: Event;
referenceElement?: Element;
floatingElement?: Element;
}) => boolean | Promise<boolean>;
export type HoverOptions = {
/**
* Transition style when the portal is shown or hidden.
*/
transition: {
/**
* Specifies the length of the transition in ms.
*
* You only need to specify the transition end duration actually.
*
* ---
*
* Why is the duration required?
*
* The transition event is not reliable, and it may not be triggered in some cases.
*
* See also https://github.com/w3c/csswg-drafts/issues/3043 https://github.com/toeverything/blocksuite/pull/7248/files#r1631375330
*
* Take a look at solutions from other projects: https://floating-ui.com/docs/useTransition#duration
*/
duration: number;
in: StyleInfo;
out: StyleInfo;
} | null;
/**
* Set the portal as hover element automatically.
* @default true
*/
setPortalAsFloating: boolean;
allowMultiple?: boolean;
} & WhenHoverOptions;

View File

@@ -0,0 +1,142 @@
import { dedupe, delayHide, delayShow } from './middlewares/basic.js';
import { safeBridge, safeTriangle } from './middlewares/safe-area.js';
import type { HoverMiddleware, WhenHoverOptions } from './types.js';
/**
* Call the `whenHoverChange` callback when the element is hovered.
*
* After the mouse leaves the element, there is a 300ms delay by default.
*
* Note: The callback may be called multiple times when the mouse is hovering or hovering out.
*
* See also https://floating-ui.com/docs/useHover
*
* @example
* ```ts
* private _setReference: RefOrCallback;
*
* connectedCallback() {
* let hoverTip: HTMLElement | null = null;
* const { setReference, setFloating } = whenHover(isHover => {
* if (!isHover) {
* hoverTips?.remove();
* return;
* }
* hoverTip = document.createElement('div');
* document.body.append(hoverTip);
* setFloating(hoverTip);
* }, { hoverDelay: 500 });
* this._setReference = setReference;
* }
*
* render() {
* return html`
* <div ref=${this._setReference}></div>
* `;
* }
* ```
*/
export const whenHover = (
whenHoverChange: (isHover: boolean, event?: Event) => void,
{
enterDelay = 0,
leaveDelay = 250,
alwayRunWhenNoFloating = true,
safeTriangle: triangleOptions = false,
safeBridge: bridgeOptions = true,
}: WhenHoverOptions = {}
) => {
/**
* The event listener will be removed when the signal is aborted.
*/
const abortController = new AbortController();
let referenceElement: Element | undefined;
let floatingElement: Element | undefined;
const middlewares: HoverMiddleware[] = [
dedupe(alwayRunWhenNoFloating),
triangleOptions &&
safeTriangle(
typeof triangleOptions === 'boolean' ? undefined : triangleOptions
),
bridgeOptions &&
safeBridge(
typeof bridgeOptions === 'boolean' ? undefined : bridgeOptions
),
delayShow(enterDelay),
delayHide(leaveDelay),
].filter(v => typeof v !== 'boolean') as HoverMiddleware[];
let currentEvent: Event | null = null;
const onHoverChange = (async (e: Event) => {
currentEvent = e;
for (const middleware of middlewares) {
const go = await middleware({
event: e,
floatingElement,
referenceElement,
});
if (!go) return;
}
// ignore expired event
if (e !== currentEvent) return;
const isHover = e.type === 'mouseover' ? true : false;
whenHoverChange(isHover, e);
}) as (e: Event) => void;
const addHoverListener = (element?: Element) => {
if (!element) return;
// see https://stackoverflow.com/questions/14795099/pure-javascript-to-check-if-something-has-hover-without-setting-on-mouseover-ou
const alreadyHover = element.matches(':hover');
if (alreadyHover && !abortController.signal.aborted) {
// When the element is already hovered, we need to trigger the callback manually
onHoverChange(new MouseEvent('mouseover'));
}
element.addEventListener('mouseover', onHoverChange, {
capture: true,
signal: abortController.signal,
});
element.addEventListener('mouseleave', onHoverChange, {
// Please refrain use `capture: true` here.
// It will cause the `mouseleave` trigger incorrectly when the pointer is still within the element.
// The issue is detailed in https://github.com/toeverything/blocksuite/issues/6241
//
// The `mouseleave` does not **bubble**.
// This means that `mouseleave` is fired when the pointer has exited the element and all of its descendants,
// If `capture` is used, all `mouseleave` events will be received when the pointer leaves the element or leaves one of the element's descendants (even if the pointer is still within the element).
//
// capture: true,
signal: abortController.signal,
});
};
const removeHoverListener = (element?: Element) => {
if (!element) return;
element.removeEventListener('mouseover', onHoverChange);
element.removeEventListener('mouseleave', onHoverChange);
};
const setReference = (element?: Element) => {
// Clean previous listeners
removeHoverListener(referenceElement);
addHoverListener(element);
referenceElement = element;
};
const setFloating = (element?: Element) => {
// Clean previous listeners
removeHoverListener(floatingElement);
addHoverListener(element);
floatingElement = element;
};
return {
setReference,
setFloating,
dispose: () => {
abortController.abort();
},
};
};
export type { WhenHoverOptions };