obsidian-visualiser/shared/floating.util.ts

416 lines
16 KiB
TypeScript

import * as FloatingUI from "@floating-ui/dom";
import { cancelPropagation, dom, svg, text, type Class, type NodeChildren, type RedrawableHTML } from "./dom.util";
import { button } from "./components.util";
import type { Reactive } from "./reactive";
export interface FloatingProperties
{
placement?: FloatingUI.Placement;
offset?: FloatingUI.OffsetOptions;
arrow?: boolean;
class?: Class;
style?: Record<string, string | undefined | boolean | number> | string;
viewport?: RedrawableHTML;
cover?: 'width' | 'height' | 'all' | 'none';
persistant?: boolean;
}
export interface FollowerProperties extends FloatingProperties
{
blur?: () => void;
priority?: boolean;
}
export type FloatState = 'shown' | 'showing' | 'hidden' | 'hiding' | 'pinned';
export interface PopperProperties extends FloatingProperties
{
content?: Reactive<NodeChildren>;
delay?: number;
events?: {
show: Array<keyof HTMLElementEventMap>;
hide: Array<keyof HTMLElementEventMap>;
onshow?: (state: FloatState) => boolean;
onhide?: (state: FloatState) => boolean;
};
}
export interface ModalProperties
{
priority?: boolean;
class?: { blocker?: Class, popup?: Class },
closeWhenOutside?: boolean;
onClose?: () => boolean | void;
}
type ModalInternals = {
container: RedrawableHTML;
content: RedrawableHTML;
stop: Function;
start: Function;
show: Function;
hide: Function;
persistant: boolean;
};
export let teleport: RedrawableHTML, minimizeBox: RedrawableHTML, cache: ModalInternals[] = [], hook: VoidFunction = () => {};
export function init()
{
dispose();
teleport = dom('div', { attributes: { id: 'popper-container' }, class: 'absolute top-0 left-0 z-40' });
minimizeBox = dom('div', { attributes: { id: 'minimize-container' }, class: 'absolute bottom-0 left-0 flex flex-row px-4 gap-4 z-40 h-[21px]' });
cache = [];
document.body.appendChild(teleport);
document.body.appendChild(minimizeBox);
hook = useRouter().afterEach(clear);
}
export function dispose()
{
teleport?.remove();
minimizeBox?.remove();
cache.length = 0;
hook();
}
function clear()
{
cache = cache.filter(e => !(!e.persistant && e.content.remove()));
}
export function popper(container: RedrawableHTML, properties?: PopperProperties)
{
let state: FloatState = 'hidden', timeout: Timer;
const arrow = svg('svg', { class: ' group-data-[pinned]:hidden absolute fill-light-35 dark:fill-dark-35', attributes: { width: "12", height: "8", viewBox: "0 0 20 10" } }, [svg('polygon', { attributes: { points: "0,0 20,0 10,10" } })]);
const content = dom('div', { class: properties?.class, style: properties?.style });
const floater = dom('div', { class: 'fixed hidden group', attributes: { 'data-state': 'closed' } }, [ content, properties?.arrow ? arrow : undefined ]);
const rect = properties?.viewport?.getBoundingClientRect() ?? 'viewport';
function update()
{
FloatingUI.computePosition(container, floater, {
placement: properties?.placement,
strategy: 'fixed',
middleware: [
properties?.offset ? FloatingUI.offset(properties?.offset) : undefined,
FloatingUI.hide({ rootBoundary: rect }),
FloatingUI.shift({ rootBoundary: rect }),
FloatingUI.flip({ rootBoundary: rect }),
properties?.cover && properties?.cover !== 'none' && FloatingUI.size({ rootBoundary: rect, apply: ({ availableWidth, availableHeight }) => {
if(properties?.cover === 'width' || properties?.cover === 'all')
floater.style.maxWidth = `${availableWidth}px`;
if(properties?.cover === 'height' || properties?.cover === 'all')
floater.style.maxHeight = `${availableHeight}px`;
} }),
properties?.offset && properties?.arrow ? FloatingUI.arrow({ element: arrow, padding: 8 }) : undefined,
]
}).then(({ x, y, placement, middlewareData }) => {
Object.assign(floater.style, {
left: `${x}px`,
top: `${y}px`,
visibility: middlewareData.hide?.referenceHidden || middlewareData.hide?.escaped ? 'hidden' : 'visible',
});
const side = placement.split('-')[0] as FloatingUI.Side;
floater.setAttribute('data-side', side);
if(middlewareData.arrow)
{
const { x: arrowX, y: arrowY } = middlewareData.arrow;
const staticSide = {
top: 'bottom',
right: 'left',
bottom: 'top',
left: 'right',
}[side]!;
const rotation = {
top: "0",
bottom: "180",
left: "270",
right: "90"
}[side]!;
Object.assign(arrow.style, {
left: arrowX != null ? `${arrowX}px` : '',
top: arrowY != null ? `${arrowY}px` : '',
right: '',
bottom: '',
[staticSide]: `-7px`,
transform: `rotate(${rotation}deg)`,
});
}
});
}
let _stop: () => void | undefined, empty = true;
function show()
{
if(state !== 'shown' && state !== 'showing' && state !== 'pinned' && (!properties?.events?.onshow || properties?.events?.onshow(state) !== false))
{
if(typeof properties?.content === 'function')
properties.content = properties.content();
if(properties?.content && empty)
{
content.replaceChildren(...properties.content.map(e => typeof e === 'function' ? e() : e).filter(e => !!e));
empty = false;
}
clearTimeout(timeout);
state = 'showing';
timeout = setTimeout(() => {
if(state !== 'shown')
{
teleport!.appendChild(floater);
floater.setAttribute('data-state', 'open');
floater.classList.toggle('hidden', false);
update();
_stop && _stop();
_stop = FloatingUI.autoUpdate(container, floater, update, {
animationFrame: true,
layoutShift: false,
elementResize: false,
ancestorScroll: false,
ancestorResize: false,
});
}
state = 'shown';
}, properties?.delay ?? 0);
}
}
function hide()
{
if(state !== 'hiding' && state !== 'pinned' && (!properties?.events?.onhide || properties?.events?.onhide(state) !== false))
{
clearTimeout(timeout);
state = 'hiding';
timeout = setTimeout(() => {
floater.remove();
_stop && _stop();
floater.setAttribute('data-state', 'closed');
floater.classList.toggle('hidden', true);
state = 'hidden';
}, properties?.delay ?? 0);
}
}
function start()
{
state = 'hidden';
floater.toggleAttribute('data-pinned', false);
update();
_stop && _stop();
_stop = FloatingUI.autoUpdate(container, floater, update, {
animationFrame: true,
layoutShift: false,
elementResize: false,
ancestorScroll: false,
ancestorResize: false,
});
}
function stop()
{
state = 'pinned';
floater.toggleAttribute('data-pinned', true);
_stop && _stop();
clearTimeout(timeout);
}
function link(element: RedrawableHTML) {
(properties?.events?.show ?? ['mouseenter', 'mousemove', 'focus']).forEach((e: keyof HTMLElementEventMap) => element.addEventListener(e, show));
(properties?.events?.hide ?? ['mouseleave', 'blur']).forEach((e: keyof HTMLElementEventMap) => element.addEventListener(e, hide));
}
link(container);
link(floater);
const result = { container, content: floater, stop, start, show: () => {
if(typeof properties?.content === 'function')
properties.content = properties.content();
if(properties?.content && empty)
{
content.replaceChildren(...properties.content.map(e => typeof e === 'function' ? e() : e).filter(e => !!e));
empty = false;
}
if(state !== 'shown')
{
teleport!.appendChild(floater);
floater.setAttribute('data-state', 'open');
floater.classList.toggle('hidden', false);
update();
}
state = 'shown';
}, hide: () => {
floater.remove();
_stop && _stop();
floater.setAttribute('data-state', 'closed');
floater.classList.toggle('hidden', true);
floater.toggleAttribute('data-pinned', false);
state = 'hidden';
} };
cache.push({ ...result, persistant: properties?.persistant ?? false });
return result;
}
export function followermenu(target: FloatingUI.ReferenceElement, content: NodeChildren, properties?: FollowerProperties)
{
const rect = properties?.viewport?.getBoundingClientRect() ?? 'viewport';
const arrow = svg('svg', { class: 'absolute fill-light-35 dark:fill-dark-35', attributes: { width: "12", height: "8", viewBox: "0 0 20 10" } }, [svg('polygon', { attributes: { points: "0,0 20,0 10,10" } })]);
const container = dom('div', { class: ['fixed bg-light-20 dark:bg-dark-20 border border-light-35 dark:border-dark-35 z-50', properties?.class], style: properties?.style }, content);
function update()
{
FloatingUI.computePosition(target, container, {
placement: properties?.placement,
strategy: 'fixed',
middleware: [
properties?.offset ? FloatingUI.offset(properties?.offset) : undefined,
FloatingUI.hide({ rootBoundary: rect }),
FloatingUI.shift({ rootBoundary: rect }),
FloatingUI.flip({ rootBoundary: rect }),
properties?.cover && properties?.cover !== 'none' && FloatingUI.size({ rootBoundary: rect, apply: ({ availableWidth, availableHeight }) => {
if(properties?.cover === 'width' || properties?.cover === 'all')
container.style.width = `${availableWidth}px`;
if(properties?.cover === 'height' || properties?.cover === 'all')
container.style.height = `${availableHeight}px`;
} }),
properties?.offset && properties?.arrow ? FloatingUI.arrow({ element: arrow, padding: 8 }) : undefined,
]
}).then(({ x, y, placement, middlewareData }) => {
Object.assign(container.style, {
left: `${x}px`,
top: `${y}px`,
visibility: middlewareData.hide?.referenceHidden ? 'hidden' : 'visible',
});
const side = placement.split('-')[0] as FloatingUI.Side;
container.setAttribute('data-side', side);
if(middlewareData.arrow)
{
const { x: arrowX, y: arrowY } = middlewareData.arrow;
const staticSide = {
top: 'bottom',
right: 'left',
bottom: 'top',
left: 'right',
}[side]!;
const rotation = {
top: "0",
bottom: "180",
left: "270",
right: "90"
}[side]!;
Object.assign(arrow.style, {
left: arrowX != null ? `${arrowX}px` : '',
top: arrowY != null ? `${arrowY}px` : '',
right: '',
bottom: '',
[staticSide]: `-8px`,
transform: `rotate(${rotation}deg)`,
});
}
});
}
update();
properties?.priority || document.addEventListener('mousedown', close);
container.addEventListener('mousedown', cancelPropagation);
const stop = FloatingUI.autoUpdate(target, container, update, {
animationFrame: true,
layoutShift: false,
elementResize: false,
ancestorScroll: false,
ancestorResize: false,
});
teleport!.appendChild(container);
function close()
{
properties?.priority || document.removeEventListener('mousedown', close);
container.removeEventListener('mousedown', cancelPropagation);
container.remove();
stop();
properties?.blur && properties.blur();
}
return { close, container, content };
}
export function contextmenu(x: number, y: number, content: NodeChildren, properties?: FollowerProperties)
{
return followermenu({
getBoundingClientRect() {
return {
x: x,
y: y,
top: y,
left: x,
bottom: y,
right: x,
width: 0,
height: 0,
};
},
}, content, properties);
}
export function tooltip(container: RedrawableHTML, txt: string | Text, placement: FloatingUI.Placement, delay?: number): RedrawableHTML
{
return popper(container, {
arrow: true,
offset: 8,
delay: delay,
content: () => [ typeof txt === 'string' ? text(txt) : txt ],
placement: placement,
class: "border border-light-30 dark:border-dark-30 px-2 py-1 bg-light-10 dark:bg-dark-10 text-light-70 dark:text-dark-70 z-50 max-w-96"
}).container;
}
export function fullblocker(content: NodeChildren, properties?: ModalProperties)
{
if(!content)
return { close: () => {} };
const close = () => (!properties?.onClose || properties.onClose() !== false) && _modal.remove();
const _modalBlocker = dom('div', { class: [' absolute top-0 left-0 bottom-0 right-0 z-0', { 'bg-light-0 dark:bg-dark-0 opacity-70': properties?.priority ?? false }, properties?.class?.blocker], listeners: { click: properties?.closeWhenOutside ? (close) : undefined } });
const _modal = dom('div', { class: ['fixed flex justify-center items-center top-0 left-0 bottom-0 right-0 inset-0 z-40', properties?.class?.blocker] }, [ _modalBlocker, ...content]);
teleport.appendChild(_modal);
return { close };
}
export function modal(content: NodeChildren, properties?: ModalProperties & { class?: { container?: Class } })
{
return fullblocker([ dom('div', { class: ['max-h-[85vh] max-w-[450px] bg-light-10 dark:bg-dark-10 border border-light-30 dark:border-dark-30 p-6 text-light-100 dark:text-dark-100 z-10 relative', properties?.class?.container] }, content) ], properties);
}
export function confirm(title: string): Promise<boolean>
{
return new Promise(res => {
const mod = modal([ dom('div', { class: 'flex flex-col justify-start gap-4' }, [ dom('div', { class: 'text-xl' }, [ text(title) ]), dom('div', { class: 'flex flex-row gap-2' }, [ button(text("Non"), () => (mod.close(), res(false)), 'h-[35px] px-[15px]'), button(text("Oui"), () => (mod.close(), res(true)), 'h-[35px] px-[15px] !border-light-red dark:!border-dark-red text-light:red dark:text-dark-red') ]) ]) ], {
priority: true,
closeWhenOutside: false,
});
})
}