mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-10-28 06:05:24 +00:00
159 lines
4.4 KiB
TypeScript
159 lines
4.4 KiB
TypeScript
import { useEffect, useRef, type CSSProperties, type ReactNode } from "react";
|
|
|
|
interface Bounds {
|
|
x: number;
|
|
y: number;
|
|
width: number;
|
|
height: number;
|
|
}
|
|
|
|
interface EmbeddedViewApi {
|
|
open: (options: {
|
|
id: string;
|
|
url: string;
|
|
ignoreCert?: boolean;
|
|
bounds: Bounds;
|
|
visible: boolean;
|
|
}) => Promise<unknown>;
|
|
setBounds: (id: string, bounds: Bounds) => Promise<unknown>;
|
|
setVisible: (id: string, visible: boolean) => Promise<unknown>;
|
|
close: (id: string) => Promise<unknown>;
|
|
}
|
|
|
|
function embeddedViewApi(): EmbeddedViewApi | null {
|
|
if (typeof window === "undefined") return null;
|
|
const api = (
|
|
window as unknown as { electronAPI?: { embeddedView?: unknown } }
|
|
).electronAPI?.embeddedView;
|
|
return (api as EmbeddedViewApi | undefined) ?? null;
|
|
}
|
|
|
|
const OVERLAY = [
|
|
'[role="dialog"]',
|
|
'[role="alertdialog"]',
|
|
'[role="menu"]',
|
|
'[role="listbox"]',
|
|
"[data-radix-popper-content-wrapper]",
|
|
].join(",");
|
|
|
|
// A native view is drawn above the page, so anything the page opens over it
|
|
// has to hide it first.
|
|
function overlayOpen(host: HTMLElement): boolean {
|
|
return Array.from(document.querySelectorAll(OVERLAY)).some(
|
|
(node) => !host.contains(node),
|
|
);
|
|
}
|
|
|
|
export interface NativeViewProps {
|
|
url: string;
|
|
/** Accept an invalid TLS certificate from this URL's origin. */
|
|
ignoreCert?: boolean;
|
|
/** Hide the view, for example while the tab is not shown. */
|
|
hidden?: boolean;
|
|
/** Shown where the desktop app's native view is not available. */
|
|
fallback?: ReactNode;
|
|
className?: string;
|
|
style?: CSSProperties;
|
|
}
|
|
|
|
/**
|
|
* A page shown in a native view (desktop app only), for sites that do not
|
|
* work in a sandboxed iframe. It fills this element, hides itself when the
|
|
* element is not visible or a dialog or menu is open, and is closed on
|
|
* unmount. In a browser it renders `fallback`.
|
|
*/
|
|
export function NativeView({
|
|
url,
|
|
ignoreCert,
|
|
hidden,
|
|
fallback,
|
|
className,
|
|
style,
|
|
}: NativeViewProps) {
|
|
const host = useRef<HTMLDivElement>(null);
|
|
const api = embeddedViewApi();
|
|
const hiddenRef = useRef(!!hidden);
|
|
hiddenRef.current = !!hidden;
|
|
const sync = useRef<() => void>(() => {});
|
|
|
|
useEffect(() => {
|
|
const element = host.current;
|
|
const view = embeddedViewApi();
|
|
if (!element || !view) return;
|
|
const id = crypto.randomUUID();
|
|
let opened = false;
|
|
let lastBounds = "";
|
|
let lastVisible: boolean | null = null;
|
|
let frame = 0;
|
|
|
|
const update = () => {
|
|
frame = 0;
|
|
const rect = element.getBoundingClientRect();
|
|
const bounds = {
|
|
x: rect.left,
|
|
y: rect.top,
|
|
width: rect.width,
|
|
height: rect.height,
|
|
};
|
|
const visible =
|
|
!hiddenRef.current &&
|
|
rect.width > 0 &&
|
|
rect.height > 0 &&
|
|
element.offsetParent !== null &&
|
|
!overlayOpen(element);
|
|
if (!opened) {
|
|
opened = true;
|
|
lastBounds = JSON.stringify(bounds);
|
|
lastVisible = visible;
|
|
void view.open({ id, url, ignoreCert, bounds, visible });
|
|
return;
|
|
}
|
|
const key = JSON.stringify(bounds);
|
|
if (key !== lastBounds) {
|
|
lastBounds = key;
|
|
void view.setBounds(id, bounds);
|
|
}
|
|
if (visible !== lastVisible) {
|
|
lastVisible = visible;
|
|
void view.setVisible(id, visible);
|
|
}
|
|
};
|
|
const schedule = () => {
|
|
if (!frame) frame = requestAnimationFrame(update);
|
|
};
|
|
sync.current = schedule;
|
|
|
|
const resize = new ResizeObserver(schedule);
|
|
resize.observe(element);
|
|
const mutations = new MutationObserver(schedule);
|
|
mutations.observe(document.body, {
|
|
childList: true,
|
|
subtree: true,
|
|
attributes: true,
|
|
attributeFilter: ["class", "style", "hidden", "data-state"],
|
|
});
|
|
window.addEventListener("resize", schedule);
|
|
window.addEventListener("scroll", schedule, true);
|
|
const timer = window.setInterval(schedule, 500);
|
|
schedule();
|
|
|
|
return () => {
|
|
sync.current = () => {};
|
|
resize.disconnect();
|
|
mutations.disconnect();
|
|
window.removeEventListener("resize", schedule);
|
|
window.removeEventListener("scroll", schedule, true);
|
|
window.clearInterval(timer);
|
|
if (frame) cancelAnimationFrame(frame);
|
|
void view.close(id);
|
|
};
|
|
}, [url, ignoreCert]);
|
|
|
|
useEffect(() => {
|
|
sync.current();
|
|
}, [hidden]);
|
|
|
|
if (!api) return <>{fallback ?? null}</>;
|
|
return <div ref={host} className={className} style={style} />;
|
|
}
|