Signal-Desktop/ts/components/fun/base/FunLightbox.tsx

159 lines
3.9 KiB
TypeScript

// Copyright 2025 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import type { ReactNode, RefObject } from 'react';
import React, { createContext, useContext, useEffect, useState } from 'react';
import { createPortal } from 'react-dom';
import { strictAssert } from '../../../util/assert';
/**
* Tracks the current `data-key` that has a long-press/long-focus
*/
const FunLightboxKeyContext = createContext<string | null>(null);
export function useFunLightboxKey(): string | null {
return useContext(FunLightboxKeyContext);
}
/**
* Provider
*/
export type FunLightboxProviderProps = Readonly<{
containerRef: RefObject<HTMLDivElement>;
children: ReactNode;
}>;
export function FunLightboxProvider(
props: FunLightboxProviderProps
): JSX.Element {
const [lightboxKey, setLightboxKey] = useState<string | null>(null);
useEffect(() => {
strictAssert(props.containerRef.current, 'Missing container ref');
const container = props.containerRef.current;
let isLongPressed = false;
let lastLongPress: number | null = null;
let currentKey: string | null;
let timer: NodeJS.Timeout | undefined;
function lookupKey(event: Event): string | null {
if (!(event.target instanceof HTMLElement)) {
return null;
}
const closest = event.target.closest('[data-key]');
if (!(closest instanceof HTMLElement)) {
return null;
}
const { key } = closest.dataset;
strictAssert(key, 'Must have key');
return key;
}
function update() {
if (isLongPressed && currentKey != null) {
setLightboxKey(currentKey);
} else {
setLightboxKey(null);
}
}
function onMouseDown(event: MouseEvent) {
currentKey = lookupKey(event);
timer = setTimeout(() => {
isLongPressed = true;
update();
}, 500);
}
function onMouseUp(event: MouseEvent) {
clearTimeout(timer);
if (isLongPressed) {
lastLongPress = event.timeStamp;
isLongPressed = false;
currentKey = null;
update();
}
}
function onMouseMove(event: MouseEvent) {
const foundKey = lookupKey(event);
if (foundKey != null) {
currentKey = lookupKey(event);
update();
}
}
function onClick(event: MouseEvent) {
if (event.timeStamp === lastLongPress) {
event.stopImmediatePropagation();
}
}
container.addEventListener('mousedown', onMouseDown);
container.addEventListener('mousemove', onMouseMove);
window.addEventListener('mouseup', onMouseUp);
window.addEventListener('click', onClick, { capture: true });
return () => {
container.removeEventListener('mousedown', onMouseDown);
container.removeEventListener('mousemove', onMouseMove);
window.removeEventListener('mouseup', onMouseUp);
window.addEventListener('click', onClick, { capture: true });
};
}, [props.containerRef]);
return (
<FunLightboxKeyContext.Provider value={lightboxKey}>
{props.children}
</FunLightboxKeyContext.Provider>
);
}
/**
* Portal
*/
export type FunLightboxPortalProps = Readonly<{
children: ReactNode;
}>;
export function FunLightboxPortal(props: FunLightboxPortalProps): JSX.Element {
return createPortal(props.children, document.body);
}
/**
* Backdrop
*/
export type FunLightboxBackdropProps = Readonly<{
children: ReactNode;
}>;
export function FunLightboxBackdrop(
props: FunLightboxBackdropProps
): JSX.Element {
return <div className="FunLightbox__Backdrop">{props.children}</div>;
}
/**
* Dialog
*/
export type FunLightboxDialogProps = Readonly<{
'aria-label': string;
children: ReactNode;
}>;
export function FunLightboxDialog(props: FunLightboxDialogProps): JSX.Element {
return (
<div
role="dialog"
className="FunLightbox__Dialog"
aria-label={props['aria-label']}
>
{props.children}
</div>
);
}