mirror of
https://github.com/bulwarkmail/webmail.git
synced 2026-11-04 16:14:07 +00:00
204 lines
9.4 KiB
TypeScript
204 lines
9.4 KiB
TypeScript
/**
|
|
* Window Controls Overlay: an installed app (and the desktop app's
|
|
* titleBarOverlay window) draws the page up to the top of the window and lays
|
|
* the native minimise, maximise and close buttons over it.
|
|
*
|
|
* There's no title bar row. The app's own columns run to the top of the window,
|
|
* the window's controls sit in the top row of whichever column is there, and that
|
|
* row leaves them room. Its empty parts are what the window is dragged by, and a
|
|
* double-click on them maximises, as on a title bar.
|
|
*
|
|
* Which row that is depends on the page and the width: the reader's toolbar, the
|
|
* list's header when there's no reader, a dialog that fills the window. So this
|
|
* looks: an app row that sits at the top of the window gets marked, and CSS in
|
|
* app/globals.css does the rest. A row that starts the window's top row is an h-14
|
|
* (or h-12, h-16) box all over the app; anything else says `data-window-top-row`.
|
|
*
|
|
* Asks navigator.windowControlsOverlay, never the media query:
|
|
* `(display-mode: window-controls-overlay)` is false in Electron with
|
|
* titleBarOverlay, while the API and env(titlebar-area-*) work there.
|
|
*/
|
|
|
|
export interface TitlebarArea {
|
|
x: number;
|
|
y: number;
|
|
width: number;
|
|
height: number;
|
|
}
|
|
|
|
interface OverlayLike extends EventTarget {
|
|
visible: boolean;
|
|
getTitlebarAreaRect?: () => { x: number; y: number; width: number; height: number };
|
|
}
|
|
|
|
export const TITLEBAR_RIGHT_VAR = '--titlebar-right-inset';
|
|
export const TITLEBAR_LEFT_VAR = '--titlebar-left-inset';
|
|
export const TITLEBAR_ROW_VAR = '--titlebar-row-height';
|
|
|
|
/** The window has its controls over the page. */
|
|
export const CONTROLS_ATTRIBUTE = 'data-window-controls';
|
|
/** They're at the left (macOS): the rail makes room at its top. */
|
|
export const CONTROLS_LEFT_ATTRIBUTE = 'data-window-controls-left';
|
|
/** This row is at the top of the window: its empty parts drag. */
|
|
export const ROW_ATTRIBUTE = 'data-window-row';
|
|
/** The window's controls are over this end of it. */
|
|
export const ROW_RIGHT_ATTRIBUTE = 'data-window-row-right';
|
|
export const ROW_LEFT_ATTRIBUTE = 'data-window-row-left';
|
|
/**
|
|
* The end of this row or pane head, where its own close usually goes, is at the
|
|
* window's controls: a close there would read as a second one for the window, so
|
|
* it goes to the other end (hooks/use-close-beside-controls.ts).
|
|
*/
|
|
export const CLOSE_MOVES_ATTRIBUTE = 'data-window-close-moves';
|
|
/** The head of a pane at the window's end, a row below the controls: its close can sit right under the window's. */
|
|
export const PANE_HEAD_ATTRIBUTE = 'data-window-pane-head';
|
|
|
|
function overlay(): OverlayLike | null {
|
|
if (typeof navigator === 'undefined') return null;
|
|
const value = (navigator as Navigator & { windowControlsOverlay?: OverlayLike }).windowControlsOverlay;
|
|
return value ?? null;
|
|
}
|
|
|
|
/** The strip the page may draw in next to the window's controls, or null when there is no overlay. */
|
|
export function readTitlebarArea(): TitlebarArea | null {
|
|
const current = overlay();
|
|
if (!current?.visible) return null;
|
|
const rect = current.getTitlebarAreaRect?.();
|
|
if (!rect || !(rect.height > 0)) return null;
|
|
return { x: rect.x, y: rect.y, width: rect.width, height: rect.height };
|
|
}
|
|
|
|
/** What the controls take of each side, from the strip they leave. */
|
|
export function controlsInsets(area: TitlebarArea, viewportWidth: number): { left: number; right: number } {
|
|
return { left: Math.max(0, area.x), right: Math.max(0, viewportWidth - area.x - area.width) };
|
|
}
|
|
|
|
/**
|
|
* Says where the controls are, for the CSS. With no area the inline values go, so
|
|
* what the stylesheet has from env() (nothing without an overlay) counts again.
|
|
*/
|
|
export function applyControlsArea(area: TitlebarArea | null, root: HTMLElement = document.documentElement): void {
|
|
if (!area) {
|
|
for (const name of [TITLEBAR_RIGHT_VAR, TITLEBAR_LEFT_VAR, TITLEBAR_ROW_VAR]) root.style.removeProperty(name);
|
|
root.removeAttribute(CONTROLS_ATTRIBUTE);
|
|
root.removeAttribute(CONTROLS_LEFT_ATTRIBUTE);
|
|
return;
|
|
}
|
|
const { left, right } = controlsInsets(area, root.ownerDocument.defaultView?.innerWidth ?? 0);
|
|
root.style.setProperty(TITLEBAR_RIGHT_VAR, `${right}px`);
|
|
root.style.setProperty(TITLEBAR_LEFT_VAR, `${left}px`);
|
|
root.style.setProperty(TITLEBAR_ROW_VAR, `${area.height}px`);
|
|
root.setAttribute(CONTROLS_ATTRIBUTE, '');
|
|
root.toggleAttribute(CONTROLS_LEFT_ATTRIBUTE, left > 0);
|
|
}
|
|
|
|
/**
|
|
* Calls `listener` now and whenever the overlay's size or visibility changes
|
|
* (`geometrychange`). Without the API it calls it once with null and listens
|
|
* to nothing, so a browser without it runs no extra code. Returns the
|
|
* unsubscribe.
|
|
*/
|
|
export function watchTitlebarArea(listener: (area: TitlebarArea | null) => void): () => void {
|
|
listener(readTitlebarArea());
|
|
const current = overlay();
|
|
if (!current) return () => {};
|
|
const onChange = () => listener(readTitlebarArea());
|
|
current.addEventListener('geometrychange', onChange);
|
|
return () => current.removeEventListener('geometrychange', onChange);
|
|
}
|
|
|
|
const ROW_CANDIDATES = '[data-window-top-row], [data-composer-header], header, div.h-14, div.min-h-14, div.h-12, div.h-16, div.min-h-16';
|
|
/** No toolbar is taller than this: past it, it's a tile or a pane that happens to sit at the top. */
|
|
const MAX_ROW_HEIGHT = 96;
|
|
|
|
/**
|
|
* Marks the rows at the top of the window, for what lies over them, and returns them.
|
|
* One pass, cheap: a dozen rects.
|
|
*/
|
|
export function markWindowRows(area: TitlebarArea, doc: Document = document): HTMLElement[] {
|
|
const viewportWidth = doc.defaultView?.innerWidth ?? 0;
|
|
const { left, right } = controlsInsets(area, viewportWidth);
|
|
// A row's own close is at its end: the right, or the left in a right-to-left page.
|
|
const rtl = doc.documentElement.dir === 'rtl';
|
|
const seen = new Set<HTMLElement>();
|
|
for (const el of Array.from(doc.querySelectorAll<HTMLElement>(ROW_CANDIDATES))) {
|
|
const rect = el.getBoundingClientRect();
|
|
const explicit = el.hasAttribute('data-window-top-row');
|
|
const shaped = rect.height > 0 && rect.width > 0 && (explicit || rect.height <= MAX_ROW_HEIGHT);
|
|
const atTop = shaped && rect.top <= 1;
|
|
// A row a line above pushed down (a note that the message moved, that the app is
|
|
// offline, an update) still starts beside the controls and needs the same room.
|
|
// It isn't what the window is dragged by, though: that's the top. A list's row
|
|
// scrolled up under its header isn't one of these.
|
|
const underControls = shaped && !atTop && rect.top < area.height && !inScrolledList(el);
|
|
// The outermost box of a row: an inner one sits inside its padding already.
|
|
const nested = (atTop || underControls) && [...seen].some((outer) => outer.contains(el));
|
|
if ((!atTop && !underControls) || nested) {
|
|
el.removeAttribute(ROW_ATTRIBUTE);
|
|
el.removeAttribute(ROW_RIGHT_ATTRIBUTE);
|
|
el.removeAttribute(ROW_LEFT_ATTRIBUTE);
|
|
el.removeAttribute(CLOSE_MOVES_ATTRIBUTE);
|
|
continue;
|
|
}
|
|
seen.add(el);
|
|
const onRight = right > 0 && rect.right > viewportWidth - right;
|
|
const onLeft = left > 0 && rect.left < left;
|
|
el.toggleAttribute(ROW_ATTRIBUTE, atTop);
|
|
el.toggleAttribute(ROW_RIGHT_ATTRIBUTE, onRight);
|
|
el.toggleAttribute(ROW_LEFT_ATTRIBUTE, onLeft);
|
|
el.toggleAttribute(CLOSE_MOVES_ATTRIBUTE, rtl ? onLeft : onRight);
|
|
}
|
|
for (const el of Array.from(doc.querySelectorAll<HTMLElement>(`[${PANE_HEAD_ATTRIBUTE}]`))) {
|
|
const rect = el.getBoundingClientRect();
|
|
const near = rect.height > 0 && rect.top < area.height * 2;
|
|
el.toggleAttribute(CLOSE_MOVES_ATTRIBUTE, near && (rtl ? left > 0 && rect.left < left : right > 0 && rect.right > viewportWidth - right));
|
|
}
|
|
return [...seen];
|
|
}
|
|
|
|
/** Inside something scrolled away from its top: a row passing under, not a toolbar. */
|
|
function inScrolledList(el: HTMLElement): boolean {
|
|
for (let at = el.parentElement; at && at !== el.ownerDocument.body; at = at.parentElement) {
|
|
if (at.scrollTop > 0) return true;
|
|
}
|
|
return false;
|
|
}
|
|
|
|
/**
|
|
* Keeps those marks right as the page changes, before the next paint. Mutation and
|
|
* resize callbacks both run ahead of painting, so a row never shows without its room.
|
|
* A pane can also change width with no change to the page, in a transition or by
|
|
* being dragged, so the rows themselves are watched too, and transitions' ends.
|
|
*/
|
|
export function watchWindowRows(area: TitlebarArea, doc: Document = document): () => void {
|
|
let queued = false;
|
|
let watched = new Set<HTMLElement>();
|
|
const resize = typeof ResizeObserver === 'undefined' ? null : new ResizeObserver(() => queue());
|
|
const run = () => {
|
|
queued = false;
|
|
const rows = new Set(markWindowRows(area, doc));
|
|
if (!resize) return;
|
|
for (const row of watched) if (!rows.has(row)) resize.unobserve(row);
|
|
for (const row of rows) if (!watched.has(row)) resize.observe(row);
|
|
watched = rows;
|
|
};
|
|
function queue() {
|
|
if (queued) return;
|
|
queued = true;
|
|
queueMicrotask(run);
|
|
}
|
|
run();
|
|
const mutations = new MutationObserver((records) => {
|
|
// Our own marks changing say nothing new.
|
|
if (records.some((record) => record.type !== 'attributes' || !record.attributeName?.startsWith('data-window-'))) queue();
|
|
});
|
|
mutations.observe(doc.body, { subtree: true, childList: true, attributes: true, attributeFilter: ['class', 'hidden', 'style'] });
|
|
resize?.observe(doc.documentElement);
|
|
doc.addEventListener('transitionend', queue, true);
|
|
return () => {
|
|
mutations.disconnect();
|
|
resize?.disconnect();
|
|
doc.removeEventListener('transitionend', queue, true);
|
|
};
|
|
}
|