mirror of
https://github.com/zoriya/v10.git
synced 2026-08-16 02:45:09 +00:00
chore(root): prepare workspace for alpha (#276)
This commit is contained in:
@@ -0,0 +1,46 @@
|
||||
/**
|
||||
* Converts a `NamedNodeMap` to a plain object.
|
||||
*/
|
||||
export function namedNodeMapToObject(namedNodeMap: NamedNodeMap): Record<string, string> {
|
||||
const obj: Record<string, string> = {};
|
||||
|
||||
for (const attr of namedNodeMap) {
|
||||
obj[attr.name] = attr.value;
|
||||
}
|
||||
|
||||
return obj;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets multiple attributes on an element and handles boolean attributes appropriately.
|
||||
*
|
||||
* @param element - The element to set attributes on.
|
||||
* @param attributes - The attributes to set.
|
||||
*/
|
||||
export function setAttributes(element: HTMLElement, attributes: Record<string, any>): void {
|
||||
for (const [key, value] of Object.entries(attributes)) {
|
||||
if (key === 'style' && typeof value === 'object') {
|
||||
for (const [styleKey, styleValue] of Object.entries(value)) {
|
||||
if (typeof styleValue === 'string') {
|
||||
element.style.setProperty(toKebabCase(styleKey), styleValue);
|
||||
} else if (styleValue == null) {
|
||||
element.style.removeProperty(toKebabCase(styleKey));
|
||||
}
|
||||
}
|
||||
} else {
|
||||
if (typeof value === 'boolean') {
|
||||
element.toggleAttribute(key, value);
|
||||
} else if (value === undefined) {
|
||||
element.removeAttribute(key);
|
||||
} else {
|
||||
element.setAttribute(key, value);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function toKebabCase(str: string): string {
|
||||
return str
|
||||
.replace(/([A-Z])/g, '-$1')
|
||||
.toLowerCase();
|
||||
}
|
||||
@@ -0,0 +1,197 @@
|
||||
/**
|
||||
* Get the active element, accounting for Shadow DOM subtrees.
|
||||
*
|
||||
* @param root - The root node to search for the active element.
|
||||
*/
|
||||
export function activeElement(root: Document = document): Element | null {
|
||||
let element = root.activeElement;
|
||||
|
||||
while (element?.shadowRoot?.activeElement != null) {
|
||||
element = element.shadowRoot.activeElement;
|
||||
}
|
||||
|
||||
return element;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the document or shadow root of a node, not the node itself which can lead to bugs.
|
||||
* https://developer.mozilla.org/en-US/docs/Web/API/Node/getRootNode#return_value
|
||||
* @param node - The node to get the root node from.
|
||||
*/
|
||||
export function getDocumentOrShadowRoot(node: Node): Document | ShadowRoot | null {
|
||||
const rootNode = node?.getRootNode?.();
|
||||
if (rootNode instanceof ShadowRoot || rootNode instanceof Document) {
|
||||
return rootNode;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function getDocument(node?: Element | null): Document {
|
||||
return node?.ownerDocument ?? document;
|
||||
}
|
||||
|
||||
export function isElement(value: unknown): value is Element {
|
||||
if (!hasWindow()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return value instanceof Element || value instanceof getWindow(value).Element;
|
||||
}
|
||||
|
||||
export function contains(parent?: Element | null, child?: Element | null): boolean {
|
||||
if (!parent || !child) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const rootNode = child.getRootNode?.();
|
||||
|
||||
// First, attempt with faster native method
|
||||
if (parent.contains(child)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// then fallback to custom implementation with Shadow DOM support
|
||||
if (rootNode && isShadowRoot(rootNode)) {
|
||||
let next = child;
|
||||
while (next) {
|
||||
if (parent === next) {
|
||||
return true;
|
||||
}
|
||||
// @ts-expect-error - next.host is not defined in the type
|
||||
next = next.parentNode || next.host;
|
||||
}
|
||||
}
|
||||
|
||||
// Give up, the result is false
|
||||
return false;
|
||||
}
|
||||
|
||||
export function getTarget(event: Event): EventTarget | null {
|
||||
if ('composedPath' in event) {
|
||||
return event.composedPath()[0] ?? null;
|
||||
}
|
||||
|
||||
// TS thinks `event` is of type never as it assumes all browsers support
|
||||
// `composedPath()`, but browsers without shadow DOM don't.
|
||||
return (event as Event).target;
|
||||
}
|
||||
|
||||
export function isShadowRoot(value: unknown): value is ShadowRoot {
|
||||
if (!hasWindow() || typeof ShadowRoot === 'undefined') {
|
||||
return false;
|
||||
}
|
||||
|
||||
return value instanceof ShadowRoot || value instanceof getWindow(value).ShadowRoot;
|
||||
}
|
||||
|
||||
function hasWindow() {
|
||||
return typeof window !== 'undefined';
|
||||
}
|
||||
|
||||
export function getWindow(node: any): typeof window {
|
||||
return node?.ownerDocument?.defaultView || window;
|
||||
}
|
||||
|
||||
export interface FloatingNodeType {
|
||||
id: string;
|
||||
parentId: string | null;
|
||||
context: FloatingContext;
|
||||
}
|
||||
|
||||
interface FloatingContext {
|
||||
open: boolean;
|
||||
}
|
||||
|
||||
export function getNodeChildren(
|
||||
nodes: Array<FloatingNodeType>,
|
||||
id: string | undefined,
|
||||
onlyOpenChildren = true,
|
||||
): Array<FloatingNodeType> {
|
||||
const directChildren = nodes.filter(node => node.parentId === id && (!onlyOpenChildren || node.context?.open));
|
||||
return directChildren.flatMap(child => [child, ...getNodeChildren(nodes, child.id, onlyOpenChildren)]);
|
||||
}
|
||||
|
||||
export function getUntransformedBoundingRect(element: HTMLElement): DOMRect {
|
||||
let el = element;
|
||||
let left = 0;
|
||||
let top = 0;
|
||||
|
||||
do {
|
||||
left += el.offsetLeft;
|
||||
top += el.offsetTop;
|
||||
el = el.offsetParent as HTMLElement;
|
||||
} while (el);
|
||||
|
||||
return {
|
||||
x: left,
|
||||
y: top,
|
||||
left,
|
||||
top,
|
||||
bottom: top + element.offsetHeight,
|
||||
right: left + element.offsetWidth,
|
||||
width: element.offsetWidth,
|
||||
height: element.offsetHeight,
|
||||
} as DOMRect;
|
||||
}
|
||||
|
||||
export function addTranslateToBoundingRect(rect: DOMRect, element: HTMLElement): DOMRect {
|
||||
// Get translate from transform
|
||||
const style = getWindow(element).getComputedStyle(element);
|
||||
const translate = style.translate;
|
||||
if (translate && translate !== 'none') {
|
||||
const values = translate.split(' ');
|
||||
|
||||
// Parse translateX (can be px, %, etc)
|
||||
const translateX = parseTranslateValue(values[0] ?? '0', element.offsetWidth);
|
||||
const translateY = parseTranslateValue(values[1] ?? '0', element.offsetHeight);
|
||||
|
||||
return {
|
||||
...rect,
|
||||
left: rect.left + translateX,
|
||||
top: rect.top + translateY,
|
||||
};
|
||||
}
|
||||
|
||||
return rect;
|
||||
}
|
||||
|
||||
function parseTranslateValue(value: string, referenceSize: number): number {
|
||||
if (value.endsWith('%')) {
|
||||
return (Number.parseFloat(value) / 100) * referenceSize;
|
||||
}
|
||||
if (value.endsWith('px')) {
|
||||
return Number.parseFloat(value);
|
||||
}
|
||||
// Handle other units like em, rem, etc if needed
|
||||
return Number.parseFloat(value) || 0;
|
||||
}
|
||||
|
||||
export function getInBoundsAdjustments(
|
||||
popupRect: DOMRect,
|
||||
containerRect: DOMRect,
|
||||
collisionPadding: number,
|
||||
): { x: number; y: number } {
|
||||
const bounds = {
|
||||
top: containerRect.top + collisionPadding,
|
||||
right: containerRect.right - collisionPadding,
|
||||
bottom: containerRect.bottom - collisionPadding,
|
||||
left: containerRect.left + collisionPadding,
|
||||
};
|
||||
|
||||
let x = 0;
|
||||
let y = 0;
|
||||
|
||||
if (popupRect.left < bounds.left) {
|
||||
x = bounds.left - popupRect.left;
|
||||
} else if (popupRect.right > bounds.right) {
|
||||
x = bounds.right - popupRect.right;
|
||||
}
|
||||
|
||||
if (popupRect.top < bounds.top) {
|
||||
y = bounds.top - popupRect.top;
|
||||
} else if (popupRect.bottom > bounds.bottom) {
|
||||
y = bounds.bottom - popupRect.bottom;
|
||||
}
|
||||
|
||||
return { x, y };
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
export function isOutsideEvent(event: FocusEvent, container?: Element): boolean {
|
||||
const containerElement = container || (event.currentTarget as Element);
|
||||
const relatedTarget = event.relatedTarget as HTMLElement | null;
|
||||
return !relatedTarget || !containerElement.contains(relatedTarget);
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
export * from './attributes';
|
||||
export * from './element';
|
||||
export * from './event';
|
||||
export * from './safe-polygon';
|
||||
export * from './shadow-dom';
|
||||
@@ -0,0 +1,445 @@
|
||||
import type { FloatingNodeType } from './element';
|
||||
import { contains, getNodeChildren, getTarget, isElement } from './element';
|
||||
|
||||
type Point = [number, number];
|
||||
type Polygon = Point[];
|
||||
|
||||
interface Rect {
|
||||
x: number;
|
||||
y: number;
|
||||
width: number;
|
||||
height: number;
|
||||
}
|
||||
|
||||
interface HandleClose {
|
||||
(context: HandleCloseContext): (event: MouseEvent) => void;
|
||||
__options?: SafePolygonOptions;
|
||||
}
|
||||
|
||||
interface HandleCloseContext {
|
||||
x: number;
|
||||
y: number;
|
||||
placement: string;
|
||||
elements: Elements;
|
||||
onClose: () => void;
|
||||
nodeId?: string;
|
||||
tree?: {
|
||||
nodesRef: {
|
||||
current: Array<FloatingNodeType>;
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
interface Elements {
|
||||
domReference: HTMLElement;
|
||||
floating: HTMLElement;
|
||||
}
|
||||
|
||||
type Side = 'top' | 'right' | 'bottom' | 'left';
|
||||
|
||||
function clearTimeoutIfSet(timeoutRef: { current: number }) {
|
||||
if (timeoutRef.current !== -1) {
|
||||
clearTimeout(timeoutRef.current);
|
||||
timeoutRef.current = -1;
|
||||
}
|
||||
}
|
||||
|
||||
function isPointInPolygon(point: Point, polygon: Polygon) {
|
||||
const [x, y] = point;
|
||||
let isInside = false;
|
||||
const length = polygon.length;
|
||||
for (let i = 0, j = length - 1; i < length; j = i++) {
|
||||
const [xi, yi] = polygon[i] || [0, 0];
|
||||
const [xj, yj] = polygon[j] || [0, 0];
|
||||
const intersect
|
||||
= (yi >= y) !== (yj >= y) && x <= ((xj - xi) * (y - yi)) / (yj - yi) + xi;
|
||||
if (intersect) {
|
||||
isInside = !isInside;
|
||||
}
|
||||
}
|
||||
return isInside;
|
||||
}
|
||||
|
||||
function isInside(point: Point, rect: Rect) {
|
||||
return (
|
||||
point[0] >= rect.x
|
||||
&& point[0] <= rect.x + rect.width
|
||||
&& point[1] >= rect.y
|
||||
&& point[1] <= rect.y + rect.height
|
||||
);
|
||||
}
|
||||
|
||||
export interface SafePolygonOptions {
|
||||
buffer?: number;
|
||||
blockPointerEvents?: boolean;
|
||||
requireIntent?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generates a safe polygon area that the user can traverse without closing the
|
||||
* floating element once leaving the reference element.
|
||||
* @see https://floating-ui.com/docs/useHover#safepolygon
|
||||
*/
|
||||
export function safePolygon(options: SafePolygonOptions = {}): HandleClose {
|
||||
const {
|
||||
buffer = 0.5,
|
||||
blockPointerEvents = false,
|
||||
requireIntent = true,
|
||||
} = options;
|
||||
|
||||
const timeoutRef = { current: -1 };
|
||||
|
||||
let hasLanded = false;
|
||||
let lastX: number | null = null;
|
||||
let lastY: number | null = null;
|
||||
let lastCursorTime
|
||||
= typeof performance !== 'undefined' ? performance.now() : 0;
|
||||
|
||||
function getCursorSpeed(x: number, y: number): number | null {
|
||||
const currentTime = performance.now();
|
||||
const elapsedTime = currentTime - lastCursorTime;
|
||||
|
||||
if (lastX === null || lastY === null || elapsedTime === 0) {
|
||||
lastX = x;
|
||||
lastY = y;
|
||||
lastCursorTime = currentTime;
|
||||
return null;
|
||||
}
|
||||
|
||||
const deltaX = x - lastX;
|
||||
const deltaY = y - lastY;
|
||||
const distance = Math.sqrt(deltaX * deltaX + deltaY * deltaY);
|
||||
const speed = distance / elapsedTime; // px / ms
|
||||
|
||||
lastX = x;
|
||||
lastY = y;
|
||||
lastCursorTime = currentTime;
|
||||
|
||||
return speed;
|
||||
}
|
||||
|
||||
const fn: HandleClose = ({
|
||||
x,
|
||||
y,
|
||||
placement,
|
||||
elements,
|
||||
onClose,
|
||||
nodeId,
|
||||
tree,
|
||||
}) => {
|
||||
return function onMouseMove(event: MouseEvent) {
|
||||
function close() {
|
||||
clearTimeoutIfSet(timeoutRef);
|
||||
onClose();
|
||||
}
|
||||
|
||||
clearTimeoutIfSet(timeoutRef);
|
||||
|
||||
if (
|
||||
!elements.domReference
|
||||
|| !elements.floating
|
||||
|| placement == null
|
||||
|| x == null
|
||||
|| y == null
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
const { clientX, clientY } = event;
|
||||
const clientPoint: Point = [clientX, clientY];
|
||||
const target = getTarget(event) as Element | null;
|
||||
const isLeave = event.type === 'mouseleave';
|
||||
const isOverFloatingEl = contains(elements.floating, target);
|
||||
const isOverReferenceEl = contains(elements.domReference, target);
|
||||
const refRect = elements.domReference.getBoundingClientRect();
|
||||
const rect = elements.floating.getBoundingClientRect();
|
||||
const side = placement.split('-')[0] as Side;
|
||||
const cursorLeaveFromRight = x > rect.right - rect.width / 2;
|
||||
const cursorLeaveFromBottom = y > rect.bottom - rect.height / 2;
|
||||
const isOverReferenceRect = isInside(clientPoint, refRect);
|
||||
const isFloatingWider = rect.width > refRect.width;
|
||||
const isFloatingTaller = rect.height > refRect.height;
|
||||
const left = (isFloatingWider ? refRect : rect).left;
|
||||
const right = (isFloatingWider ? refRect : rect).right;
|
||||
const top = (isFloatingTaller ? refRect : rect).top;
|
||||
const bottom = (isFloatingTaller ? refRect : rect).bottom;
|
||||
|
||||
if (isOverFloatingEl) {
|
||||
hasLanded = true;
|
||||
|
||||
if (!isLeave) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (isOverReferenceEl) {
|
||||
hasLanded = false;
|
||||
}
|
||||
|
||||
if (isOverReferenceEl && !isLeave) {
|
||||
hasLanded = true;
|
||||
return;
|
||||
}
|
||||
|
||||
// Prevent overlapping floating element from being stuck in an open-close
|
||||
// loop: https://github.com/floating-ui/floating-ui/issues/1910
|
||||
if (
|
||||
isLeave
|
||||
&& isElement(event.relatedTarget)
|
||||
&& contains(elements.floating, event.relatedTarget)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
// If any nested child is open, abort.
|
||||
if (tree && getNodeChildren(tree.nodesRef.current, nodeId).length) {
|
||||
return;
|
||||
}
|
||||
|
||||
// If the pointer is leaving from the opposite side, the "buffer" logic
|
||||
// creates a point where the floating element remains open, but should be
|
||||
// ignored.
|
||||
// A constant of 1 handles floating point rounding errors.
|
||||
if (
|
||||
(side === 'top' && y >= refRect.bottom - 1)
|
||||
|| (side === 'bottom' && y <= refRect.top + 1)
|
||||
|| (side === 'left' && x >= refRect.right - 1)
|
||||
|| (side === 'right' && x <= refRect.left + 1)
|
||||
) {
|
||||
return close();
|
||||
}
|
||||
|
||||
// Ignore when the cursor is within the rectangular trough between the
|
||||
// two elements. Since the triangle is created from the cursor point,
|
||||
// which can start beyond the ref element's edge, traversing back and
|
||||
// forth from the ref to the floating element can cause it to close. This
|
||||
// ensures it always remains open in that case.
|
||||
let rectPoly: Point[] = [];
|
||||
|
||||
switch (side) {
|
||||
case 'top':
|
||||
rectPoly = [
|
||||
[left, refRect.top + 1],
|
||||
[left, rect.bottom - 1],
|
||||
[right, rect.bottom - 1],
|
||||
[right, refRect.top + 1],
|
||||
];
|
||||
break;
|
||||
case 'bottom':
|
||||
rectPoly = [
|
||||
[left, rect.top + 1],
|
||||
[left, refRect.bottom - 1],
|
||||
[right, refRect.bottom - 1],
|
||||
[right, rect.top + 1],
|
||||
];
|
||||
break;
|
||||
case 'left':
|
||||
rectPoly = [
|
||||
[rect.right - 1, bottom],
|
||||
[rect.right - 1, top],
|
||||
[refRect.left + 1, top],
|
||||
[refRect.left + 1, bottom],
|
||||
];
|
||||
break;
|
||||
case 'right':
|
||||
rectPoly = [
|
||||
[refRect.right - 1, bottom],
|
||||
[refRect.right - 1, top],
|
||||
[rect.left + 1, top],
|
||||
[rect.left + 1, bottom],
|
||||
];
|
||||
break;
|
||||
default:
|
||||
rectPoly = [];
|
||||
break;
|
||||
}
|
||||
|
||||
function getPolygon([x, y]: Point): Array<Point> {
|
||||
switch (side) {
|
||||
case 'top': {
|
||||
const cursorPointOne: Point = [
|
||||
isFloatingWider
|
||||
? x + buffer / 2
|
||||
: cursorLeaveFromRight
|
||||
? x + buffer * 4
|
||||
: x - buffer * 4,
|
||||
y + buffer + 1,
|
||||
];
|
||||
const cursorPointTwo: Point = [
|
||||
isFloatingWider
|
||||
? x - buffer / 2
|
||||
: cursorLeaveFromRight
|
||||
? x + buffer * 4
|
||||
: x - buffer * 4,
|
||||
y + buffer + 1,
|
||||
];
|
||||
const commonPoints: [Point, Point] = [
|
||||
[
|
||||
rect.left,
|
||||
cursorLeaveFromRight
|
||||
? rect.bottom - buffer
|
||||
: isFloatingWider
|
||||
? rect.bottom - buffer
|
||||
: rect.top,
|
||||
],
|
||||
[
|
||||
rect.right,
|
||||
cursorLeaveFromRight
|
||||
? isFloatingWider
|
||||
? rect.bottom - buffer
|
||||
: rect.top
|
||||
: rect.bottom - buffer,
|
||||
],
|
||||
];
|
||||
|
||||
return [cursorPointOne, cursorPointTwo, ...commonPoints];
|
||||
}
|
||||
case 'bottom': {
|
||||
const cursorPointOne: Point = [
|
||||
isFloatingWider
|
||||
? x + buffer / 2
|
||||
: cursorLeaveFromRight
|
||||
? x + buffer * 4
|
||||
: x - buffer * 4,
|
||||
y - buffer,
|
||||
];
|
||||
const cursorPointTwo: Point = [
|
||||
isFloatingWider
|
||||
? x - buffer / 2
|
||||
: cursorLeaveFromRight
|
||||
? x + buffer * 4
|
||||
: x - buffer * 4,
|
||||
y - buffer,
|
||||
];
|
||||
const commonPoints: [Point, Point] = [
|
||||
[
|
||||
rect.left,
|
||||
cursorLeaveFromRight
|
||||
? rect.top + buffer
|
||||
: isFloatingWider
|
||||
? rect.top + buffer
|
||||
: rect.bottom,
|
||||
],
|
||||
[
|
||||
rect.right,
|
||||
cursorLeaveFromRight
|
||||
? isFloatingWider
|
||||
? rect.top + buffer
|
||||
: rect.bottom
|
||||
: rect.top + buffer,
|
||||
],
|
||||
];
|
||||
|
||||
return [cursorPointOne, cursorPointTwo, ...commonPoints];
|
||||
}
|
||||
case 'left': {
|
||||
const cursorPointOne: Point = [
|
||||
x + buffer + 1,
|
||||
isFloatingTaller
|
||||
? y + buffer / 2
|
||||
: cursorLeaveFromBottom
|
||||
? y + buffer * 4
|
||||
: y - buffer * 4,
|
||||
];
|
||||
const cursorPointTwo: Point = [
|
||||
x + buffer + 1,
|
||||
isFloatingTaller
|
||||
? y - buffer / 2
|
||||
: cursorLeaveFromBottom
|
||||
? y + buffer * 4
|
||||
: y - buffer * 4,
|
||||
];
|
||||
const commonPoints: [Point, Point] = [
|
||||
[
|
||||
cursorLeaveFromBottom
|
||||
? rect.right - buffer
|
||||
: isFloatingTaller
|
||||
? rect.right - buffer
|
||||
: rect.left,
|
||||
rect.top,
|
||||
],
|
||||
[
|
||||
cursorLeaveFromBottom
|
||||
? isFloatingTaller
|
||||
? rect.right - buffer
|
||||
: rect.left
|
||||
: rect.right - buffer,
|
||||
rect.bottom,
|
||||
],
|
||||
];
|
||||
|
||||
return [...commonPoints, cursorPointOne, cursorPointTwo];
|
||||
}
|
||||
case 'right': {
|
||||
const cursorPointOne: Point = [
|
||||
x - buffer,
|
||||
isFloatingTaller
|
||||
? y + buffer / 2
|
||||
: cursorLeaveFromBottom
|
||||
? y + buffer * 4
|
||||
: y - buffer * 4,
|
||||
];
|
||||
const cursorPointTwo: Point = [
|
||||
x - buffer,
|
||||
isFloatingTaller
|
||||
? y - buffer / 2
|
||||
: cursorLeaveFromBottom
|
||||
? y + buffer * 4
|
||||
: y - buffer * 4,
|
||||
];
|
||||
const commonPoints: [Point, Point] = [
|
||||
[
|
||||
cursorLeaveFromBottom
|
||||
? rect.left + buffer
|
||||
: isFloatingTaller
|
||||
? rect.left + buffer
|
||||
: rect.right,
|
||||
rect.top,
|
||||
],
|
||||
[
|
||||
cursorLeaveFromBottom
|
||||
? isFloatingTaller
|
||||
? rect.left + buffer
|
||||
: rect.right
|
||||
: rect.left + buffer,
|
||||
rect.bottom,
|
||||
],
|
||||
];
|
||||
|
||||
return [cursorPointOne, cursorPointTwo, ...commonPoints];
|
||||
}
|
||||
default:
|
||||
return [[x, y]];
|
||||
}
|
||||
}
|
||||
|
||||
if (isPointInPolygon([clientX, clientY], rectPoly)) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (hasLanded && !isOverReferenceRect) {
|
||||
return close();
|
||||
}
|
||||
|
||||
if (!isLeave && requireIntent) {
|
||||
const cursorSpeed = getCursorSpeed(event.clientX, event.clientY);
|
||||
const cursorSpeedThreshold = 0.1;
|
||||
if (cursorSpeed !== null && cursorSpeed < cursorSpeedThreshold) {
|
||||
return close();
|
||||
}
|
||||
}
|
||||
|
||||
if (!isPointInPolygon([clientX, clientY], getPolygon([x, y]))) {
|
||||
close();
|
||||
} else if (!hasLanded && requireIntent) {
|
||||
timeoutRef.current = window.setTimeout(close, 40);
|
||||
}
|
||||
};
|
||||
};
|
||||
|
||||
fn.__options = {
|
||||
blockPointerEvents,
|
||||
};
|
||||
|
||||
return fn;
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
/**
|
||||
* Utility function to check if a root node contains a child node across shadow DOM boundaries.
|
||||
*/
|
||||
export function containsComposedNode(rootNode: Node, childNode: Node): boolean {
|
||||
if (!rootNode || !childNode) return false;
|
||||
if (rootNode?.contains(childNode)) return true;
|
||||
const childRootNode = childNode.getRootNode();
|
||||
if (childRootNode && 'host' in childRootNode && childRootNode.host) {
|
||||
return containsComposedNode(rootNode, childRootNode.host as Node);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"extends": "../../tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"composite": true,
|
||||
"lib": ["ES2020", "DOM", "DOM.Iterable"]
|
||||
},
|
||||
"include": ["./**/*.ts"]
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
export * from './shared/console';
|
||||
export * from './shared/crypto';
|
||||
export * from './shared/math';
|
||||
export * from './shared/memoize';
|
||||
export * from './shared/state';
|
||||
export * from './shared/string';
|
||||
export * from './shared/time';
|
||||
export * from './shared/unit';
|
||||
@@ -0,0 +1,34 @@
|
||||
export function printConsoleBanner(version: string): void {
|
||||
// eslint-disable-next-line no-console
|
||||
console.info(
|
||||
`%c Video.js %c v${version}`,
|
||||
`border-radius: 9999px;
|
||||
background: #393836;
|
||||
font: bold 1.5em/1.5em monospace;
|
||||
color: #ebe4c1;
|
||||
text-shadow: 1px 1px 0 #fcb116,
|
||||
2px 2px 0 #f26222,
|
||||
3px 3px 0 #ea3837,
|
||||
4px 4px 0 #a83b71`,
|
||||
`font: 1em monospace;`,
|
||||
);
|
||||
|
||||
const prereleaseType = version.includes('preview') ? 'preview' : version.includes('alpha') ? 'alpha' : null;
|
||||
if (prereleaseType) {
|
||||
console.warn(
|
||||
`%c This is a ${prereleaseType} release. Please use with caution.`,
|
||||
`color: #f26222;`,
|
||||
);
|
||||
}
|
||||
|
||||
// eslint-disable-next-line no-console
|
||||
console.info(
|
||||
'%cReport a Bug, Issue or Feature Request - https://github.com/videojs/v10/issues/new/choose',
|
||||
'color: #aaa; font-size: .9em;',
|
||||
);
|
||||
// eslint-disable-next-line no-console
|
||||
console.info(
|
||||
'%cReach out on Discord - https://discord.gg/JBqHh485uF',
|
||||
'color: #aaa; font-size: .9em;',
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
let id = 0;
|
||||
|
||||
/**
|
||||
* Generates a unique ID for an element.
|
||||
*/
|
||||
export function uniqueId(): string {
|
||||
id++;
|
||||
return `:h${id}:`;
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
export interface Point {
|
||||
x: number;
|
||||
y: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get progress ratio of a point on a line segment.
|
||||
* @param x - The x coordinate of the point.
|
||||
* @param y - The y coordinate of the point.
|
||||
* @param p1 - The first point of the line segment.
|
||||
* @param p2 - The second point of the line segment.
|
||||
*/
|
||||
export function getPointProgressOnLine(x: number, y: number, p1: Point, p2: Point): number {
|
||||
const dx = p2.x - p1.x;
|
||||
const dy = p2.y - p1.y;
|
||||
const lengthSquared = dx * dx + dy * dy;
|
||||
|
||||
if (lengthSquared === 0) return 0; // Avoid division by zero if p1 === p2
|
||||
|
||||
const projection = ((x - p1.x) * dx + (y - p1.y) * dy) / lengthSquared;
|
||||
|
||||
return Math.max(0, Math.min(1, projection)); // Clamp between 0 and 1
|
||||
}
|
||||
|
||||
export function distance(p1: Point, p2: Point): number {
|
||||
return Math.sqrt((p2.x - p1.x) ** 2 + (p2.y - p1.y) ** 2);
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
// Adapted from https://github.com/caiogondim/fast-memoize.js - MIT License
|
||||
type MemoizedFunction<T extends (...args: any[]) => any> = (
|
||||
...args: Parameters<T>
|
||||
) => ReturnType<T>;
|
||||
|
||||
interface MemoizedId {
|
||||
$m: number;
|
||||
}
|
||||
|
||||
interface MemoizedWithId {
|
||||
$m?: number;
|
||||
}
|
||||
|
||||
function isNode(x: unknown): boolean {
|
||||
if (typeof globalThis === 'undefined') return false;
|
||||
const NodeConstructor = (globalThis as { Node?: new () => unknown }).Node;
|
||||
return NodeConstructor !== undefined && x instanceof NodeConstructor;
|
||||
}
|
||||
|
||||
export function memoize<T extends (...args: any[]) => any>(
|
||||
func: T,
|
||||
): MemoizedFunction<T> {
|
||||
const cache: Record<string, ReturnType<T>> = {};
|
||||
return function (this: unknown, ...args: Parameters<T>): ReturnType<T> {
|
||||
const argsWithFuncIds = args.map((x) => {
|
||||
if (isPlainObject(x) || Array.isArray(x)) {
|
||||
const obj: Record<string, unknown> = {};
|
||||
for (const key in x) {
|
||||
obj[key] = memoizedIdFunc((x as Record<string, unknown>)[key]);
|
||||
}
|
||||
return obj;
|
||||
}
|
||||
return memoizedIdFunc(x);
|
||||
});
|
||||
|
||||
const cacheKey = JSON.stringify(argsWithFuncIds);
|
||||
const cachedValue = cache[cacheKey];
|
||||
if (cachedValue !== undefined) {
|
||||
return cachedValue;
|
||||
}
|
||||
const computedValue = func.apply(this, args);
|
||||
cache[cacheKey] = computedValue;
|
||||
return computedValue;
|
||||
};
|
||||
}
|
||||
|
||||
let id = 0;
|
||||
function memoizedIdFunc(x: unknown): unknown {
|
||||
if (typeof x === 'function' || isNode(x)) {
|
||||
const funcOrNode = x as MemoizedWithId;
|
||||
if (!funcOrNode.$m) funcOrNode.$m = ++id;
|
||||
return { $m: funcOrNode.$m } as MemoizedId;
|
||||
}
|
||||
return x;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if this is a plain object.
|
||||
* @param {object} obj - The object to inspect.
|
||||
* @return {boolean}
|
||||
*/
|
||||
function isPlainObject(obj: unknown): obj is Record<string, unknown> {
|
||||
if (typeof obj !== 'object' || obj === null) return false;
|
||||
|
||||
let proto = obj;
|
||||
while (Object.getPrototypeOf(proto) !== null) {
|
||||
proto = Object.getPrototypeOf(proto);
|
||||
}
|
||||
|
||||
return Object.getPrototypeOf(obj) === proto;
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
/**
|
||||
* Slightly modified version of React's shallowEqual, with optimizations for Arrays
|
||||
* so we may treat them specifically as unequal if they are not a) both arrays
|
||||
* or b) don't contain the same (shallowly compared) elements.
|
||||
*/
|
||||
export function shallowEqual(objA: object, objB: object): boolean {
|
||||
// Using Object.is as a first pass, as it covers a lot of the "simple" cases that are
|
||||
// more complex than strict equality and is a built-in. For discussion, see, e.g.:
|
||||
// https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Object/is#description
|
||||
if (Object.is(objA, objB)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Since we've done an Object.is() check immediately above, we can safely assume non-objects (or null-valued objects)
|
||||
// are not equal, so can early bail for those as well.
|
||||
if (typeof objA !== 'object' || objA === null || typeof objB !== 'object' || objB === null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (Array.isArray(objA)) {
|
||||
// Early "cheap" array compares
|
||||
if (!Array.isArray(objB) || objA.length !== objB.length) return false;
|
||||
// Shallow compare for arrays
|
||||
return objA.every((vVal, i) => objB[i] === vVal);
|
||||
}
|
||||
|
||||
const keysA = Object.keys(objA);
|
||||
const keysB = Object.keys(objB);
|
||||
|
||||
if (keysA.length !== keysB.length) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Test for A's keys different from B.
|
||||
for (let i = 0; i < keysA.length; i++) {
|
||||
// NOTE: Since we've already guaranteed the keys list lengths are the same, we can safely cast to string here (CJP)
|
||||
if (
|
||||
!globalThis.hasOwnProperty.call(objB, keysA[i] as string)
|
||||
|| !Object.is(objA[keysA[i] as string], objB[keysA[i] as string])
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
/**
|
||||
* Converts a string to camel case.
|
||||
*
|
||||
* @param str - The string to convert.
|
||||
* @returns The camel case string.
|
||||
*/
|
||||
export function toCamelCase(str: string): string {
|
||||
return str
|
||||
.toLowerCase()
|
||||
.replace(/[-_]([a-z])/g, (_$0, $1) => $1.toUpperCase());
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts a string to kebab case.
|
||||
*
|
||||
* @param str - The string to convert.
|
||||
* @returns The kebab case string.
|
||||
*/
|
||||
export function toKebabCase(str: string): string {
|
||||
return str
|
||||
.replace(/([A-Z])/g, '-$1')
|
||||
.toLowerCase();
|
||||
}
|
||||
@@ -0,0 +1,131 @@
|
||||
/**
|
||||
* Checks if a value is a valid number (not NaN, null, undefined, or Infinity)
|
||||
*/
|
||||
function isValidNumber(value: unknown): value is number {
|
||||
return typeof value === 'number' && Number.isFinite(value);
|
||||
}
|
||||
|
||||
const UnitLabels = [
|
||||
{
|
||||
singular: 'hour',
|
||||
plural: 'hours',
|
||||
},
|
||||
{
|
||||
singular: 'minute',
|
||||
plural: 'minutes',
|
||||
},
|
||||
{
|
||||
singular: 'second',
|
||||
plural: 'seconds',
|
||||
},
|
||||
] as const;
|
||||
|
||||
function toTimeUnitPhrase(timeUnitValue: number, unitIndex: number): string {
|
||||
const unitLabel = timeUnitValue === 1 ? UnitLabels[unitIndex]?.singular : UnitLabels[unitIndex]?.plural;
|
||||
|
||||
return `${timeUnitValue} ${unitLabel}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts numeric seconds into a human-readable phrase for accessibility.
|
||||
*
|
||||
* @param seconds - A (positive or negative) time, represented as seconds
|
||||
* @returns The time, represented as a phrase of hours, minutes, and seconds
|
||||
*
|
||||
* @example
|
||||
* formatAsTimePhrase(3661) // "1 hour, 1 minute, 1 second"
|
||||
* formatAsTimePhrase(90) // "1 minute, 30 seconds"
|
||||
* formatAsTimePhrase(-30) // "30 seconds remaining"
|
||||
*/
|
||||
export function formatAsTimePhrase(seconds: number): string {
|
||||
if (!isValidNumber(seconds)) return '';
|
||||
|
||||
const positiveSeconds = Math.abs(seconds);
|
||||
const negative = positiveSeconds !== seconds;
|
||||
const secondsDateTime = new Date(0, 0, 0, 0, 0, positiveSeconds, 0);
|
||||
const timeParts = [secondsDateTime.getHours(), secondsDateTime.getMinutes(), secondsDateTime.getSeconds()];
|
||||
|
||||
const timeString = timeParts
|
||||
// Convert non-0 values to a string of the value plus its unit
|
||||
.map((timeUnitValue, index) => timeUnitValue && toTimeUnitPhrase(timeUnitValue, index))
|
||||
// Ignore/exclude any 0 values
|
||||
.filter(x => x)
|
||||
// join into a single comma-separated string phrase
|
||||
.join(', ');
|
||||
|
||||
// If the time was negative, assume it represents some remaining amount of time/"count down".
|
||||
const negativeSuffix = negative ? ' remaining' : '';
|
||||
|
||||
return `${timeString}${negativeSuffix}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts a time, in numeric seconds, to a formatted string representation
|
||||
* of the form [HH:[MM:]]SS, where hours and minutes are optional, either
|
||||
* based on the value of `seconds` or (optionally) based on the value of `guide`.
|
||||
*
|
||||
* @param seconds - The total time you'd like formatted, in seconds
|
||||
* @param guide - A number in seconds that represents how many units you'd want
|
||||
* to show. This ensures consistent formatting between e.g. 35s and 4834s.
|
||||
* @returns A string representation of the time, with expected units
|
||||
*
|
||||
* @example
|
||||
* formatTime(90) // "1:30"
|
||||
* formatTime(3661) // "1:01:01"
|
||||
* formatTime(35, 3600) // "0:35" (guided by 1-hour duration)
|
||||
* formatTime(NaN) // "0:00"
|
||||
* formatTime(Infinity) // "0:00"
|
||||
*/
|
||||
export function formatTime(seconds: number, guide?: number): string {
|
||||
// Handle negative values
|
||||
let negative = false;
|
||||
|
||||
if (seconds < 0) {
|
||||
negative = true;
|
||||
seconds = 0 - seconds;
|
||||
}
|
||||
|
||||
seconds = seconds < 0 ? 0 : seconds;
|
||||
|
||||
let s: number | string = Math.floor(seconds % 60);
|
||||
let m: number | string = Math.floor((seconds / 60) % 60);
|
||||
let h: number | string = Math.floor(seconds / 3600);
|
||||
|
||||
const gm = guide ? Math.floor((guide / 60) % 60) : 0;
|
||||
const gh = guide ? Math.floor(guide / 3600) : 0;
|
||||
|
||||
// Handle invalid times
|
||||
if (Number.isNaN(seconds) || seconds === Infinity) {
|
||||
// '-' is false for all relational operators (e.g. <, >=) so this setting
|
||||
// will add the minimum number of fields specified by the guide
|
||||
h = m = s = '0';
|
||||
}
|
||||
|
||||
// Check if we need to show hours
|
||||
const showHours = (h as number) > 0 || gh > 0;
|
||||
const hoursString = showHours ? `${h}:` : '';
|
||||
|
||||
// If hours are showing, we may need to add a leading zero.
|
||||
// Always show at least one digit of minutes.
|
||||
const minutesString = `${(showHours || gm >= 10) && (m as number) < 10 ? `0${m}` : m}:`;
|
||||
|
||||
// Check if leading zero is needed for seconds
|
||||
const secondsString = (s as number) < 10 ? `0${s}` : s;
|
||||
|
||||
return (negative ? '-' : '') + hoursString + minutesString + secondsString;
|
||||
}
|
||||
|
||||
/**
|
||||
* Formats a time value with fallback handling for invalid values.
|
||||
*
|
||||
* @param time - The time value to format in seconds (duration, currentTime, etc.)
|
||||
* @param guide - Optional guide time for consistent formatting
|
||||
* @param fallback - Fallback text when time is invalid (default: "--:--")
|
||||
* @returns Formatted time string or fallback
|
||||
*/
|
||||
export function formatDisplayTime(time: unknown, guide?: number, fallback: string = '--:--'): string {
|
||||
if (!isValidNumber(time)) {
|
||||
return fallback;
|
||||
}
|
||||
return formatTime(time, guide);
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
export function isValidNumber(value: any): value is number {
|
||||
return typeof value === 'number' && !Number.isNaN(value) && Number.isFinite(value);
|
||||
}
|
||||
Reference in New Issue
Block a user