mirror of
https://github.com/toeverything/AFFiNE.git
synced 2026-02-13 12:55:00 +00:00
chore: merge blocksuite source code (#9213)
This commit is contained in:
201
blocksuite/affine/components/src/hover/controller.ts
Normal file
201
blocksuite/affine/components/src/hover/controller.ts
Normal 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();
|
||||
}
|
||||
}
|
||||
3
blocksuite/affine/components/src/hover/index.ts
Normal file
3
blocksuite/affine/components/src/hover/index.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export { HoverController } from './controller.js';
|
||||
export type * from './types.js';
|
||||
export { whenHover } from './when-hover.js';
|
||||
79
blocksuite/affine/components/src/hover/middlewares/basic.ts
Normal file
79
blocksuite/affine/components/src/hover/middlewares/basic.ts
Normal 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;
|
||||
};
|
||||
};
|
||||
361
blocksuite/affine/components/src/hover/middlewares/safe-area.ts
Normal file
361
blocksuite/affine/components/src/hover/middlewares/safe-area.ts
Normal 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;
|
||||
};
|
||||
};
|
||||
65
blocksuite/affine/components/src/hover/types.ts
Normal file
65
blocksuite/affine/components/src/hover/types.ts
Normal 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;
|
||||
142
blocksuite/affine/components/src/hover/when-hover.ts
Normal file
142
blocksuite/affine/components/src/hover/when-hover.ts
Normal 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 };
|
||||
Reference in New Issue
Block a user