mirror of
https://github.com/bulwarkmail/webmail.git
synced 2026-10-31 08:13:50 +00:00
149 lines
6.3 KiB
TypeScript
149 lines
6.3 KiB
TypeScript
import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState, type RefObject } from "react";
|
|
import { useSettingsStore } from "@/stores/settings-store";
|
|
import { displayNow } from "@/lib/timezone";
|
|
import { FULL_DAY_HOURS, resolveDisplayHours, type DisplayHours } from "@/lib/calendar-display-range";
|
|
|
|
/** A time of day to bring into view, e.g. a new event's start; a new nonce asks again. */
|
|
export interface RevealTime {
|
|
minutes: number;
|
|
nonce: number;
|
|
}
|
|
|
|
interface UseDisplayHoursOptions {
|
|
scrollRef: RefObject<HTMLElement | null>;
|
|
/** The smallest height of an hour row; rows grow to fill a taller pane. */
|
|
minHourHeight: number;
|
|
/** The sticky header above the hour rows, whose height the rows don't get. */
|
|
headerRef?: RefObject<HTMLElement | null>;
|
|
revealAt?: RevealTime | null;
|
|
}
|
|
|
|
/** Room above an hour line for the label centred on it, in pixels. */
|
|
export const HOUR_LABEL_ROOM = 8;
|
|
|
|
/**
|
|
* The height of an hour row: `min`, or more when the hours would leave the
|
|
* pane's bottom empty (a short range on a tall tablet). A pane only a little
|
|
* short of the hours gets slightly lower rows rather than a few pixels of
|
|
* scrolling. Whole pixels, so the hour lines stay sharp.
|
|
*/
|
|
export function fillHourHeight(min: number, available: number, hourCount: number): number {
|
|
if (hourCount <= 0 || available <= 0) return min;
|
|
const fit = Math.floor(available / hourCount);
|
|
return fit >= Math.ceil(min * 0.9) ? fit : min;
|
|
}
|
|
|
|
/**
|
|
* The hours a time grid draws (#1164): the configured range, or the whole
|
|
* day while the user has asked to see it. Owns the grid's vertical scroll
|
|
* position too - it opens an hour before now, and keeps the same hours in
|
|
* place when the range or the row height changes underneath it. Rows fill
|
|
* the pane when the hours are fewer than it has room for.
|
|
*/
|
|
export function useDisplayHours({ scrollRef, minHourHeight, headerRef, revealAt }: UseDisplayHoursOptions) {
|
|
const limitHours = useSettingsStore((s) => s.calendarLimitHours);
|
|
const startHour = useSettingsStore((s) => s.calendarDayStartHour);
|
|
const endHour = useSettingsStore((s) => s.calendarDayEndHour);
|
|
const configured = useMemo(
|
|
() => resolveDisplayHours(limitHours, startHour, endHour),
|
|
[limitHours, startHour, endHour],
|
|
);
|
|
const [showAllHours, setShowAllHours] = useState(false);
|
|
const hours: DisplayHours = showAllHours ? FULL_DAY_HOURS : configured;
|
|
const hourCount = (hours.endMinutes - hours.startMinutes) / 60;
|
|
|
|
// The room below the header, measured whenever the pane or the header
|
|
// (the all-day row grows with its events) changes size.
|
|
const [available, setAvailable] = useState(0);
|
|
useLayoutEffect(() => {
|
|
const root = scrollRef.current;
|
|
if (!root) return;
|
|
const measure = () => setAvailable(Math.max(0, root.clientHeight - (headerRef?.current?.offsetHeight ?? 0)));
|
|
measure();
|
|
if (typeof ResizeObserver === "undefined") return;
|
|
const observer = new ResizeObserver(measure);
|
|
observer.observe(root);
|
|
if (headerRef?.current) observer.observe(headerRef.current);
|
|
return () => observer.disconnect();
|
|
}, [scrollRef, headerRef]);
|
|
const hourHeight = fillHourHeight(minHourHeight, available, hourCount);
|
|
|
|
// Opens an hour before now, once. Rows of any other height than the
|
|
// minimum fit the pane without scrolling, so the minimum is the height
|
|
// that matters here.
|
|
const initialHoursRef = useRef(hours);
|
|
useEffect(() => {
|
|
const root = scrollRef.current;
|
|
if (!root) return;
|
|
const { startMinutes, endMinutes } = initialHoursRef.current;
|
|
const now = displayNow();
|
|
const nowMinutes = now.getHours() * 60 + now.getMinutes();
|
|
// Outside the visible hours (an evening look at a working-hours grid)
|
|
// the day's first hour is more use than its last.
|
|
if (nowMinutes < startMinutes || nowMinutes >= endMinutes) {
|
|
root.scrollTop = 0;
|
|
return;
|
|
}
|
|
// A little above that hour's line: its label sits centred on the line,
|
|
// and its top half would be under the sticky header.
|
|
root.scrollTop = Math.max(0, ((now.getHours() - 1) * 60 - startMinutes) / 60 * minHourHeight - HOUR_LABEL_ROOM);
|
|
}, [scrollRef, minHourHeight]);
|
|
|
|
const revealRef = useRef<number | null>(null);
|
|
const renderedRef = useRef({ startMinutes: hours.startMinutes, hourHeight });
|
|
useLayoutEffect(() => {
|
|
const root = scrollRef.current;
|
|
const prev = renderedRef.current;
|
|
renderedRef.current = { startMinutes: hours.startMinutes, hourHeight };
|
|
const reveal = revealRef.current;
|
|
revealRef.current = null;
|
|
if (!root) return;
|
|
if (reveal !== null) {
|
|
root.scrollTop = Math.max(0, ((reveal - hours.startMinutes) / 60 - 0.5) * hourHeight);
|
|
} else if (prev.startMinutes !== hours.startMinutes || prev.hourHeight !== hourHeight) {
|
|
// The time at the top of the pane stays there.
|
|
const topMinutes = prev.startMinutes + (root.scrollTop / prev.hourHeight) * 60;
|
|
root.scrollTop = Math.max(0, ((topMinutes - hours.startMinutes) / 60) * hourHeight);
|
|
}
|
|
}, [scrollRef, hourHeight, hours.startMinutes]);
|
|
|
|
const toggleAllHours = useCallback(() => setShowAllHours((v) => !v), []);
|
|
/** Shows the whole day and scrolls to `minutes` from midnight. */
|
|
const revealMinutes = useCallback((minutes: number) => {
|
|
revealRef.current = minutes;
|
|
setShowAllHours(true);
|
|
}, []);
|
|
|
|
// A saved event lands where the user can see it: outside the shown hours
|
|
// the whole day opens, else the grid scrolls to it when it is out of view.
|
|
const revealNonce = revealAt?.nonce;
|
|
useEffect(() => {
|
|
if (!revealAt) return;
|
|
const { minutes } = revealAt;
|
|
if (minutes < hours.startMinutes || minutes >= hours.endMinutes) {
|
|
revealMinutes(minutes);
|
|
return;
|
|
}
|
|
const root = scrollRef.current;
|
|
if (!root) return;
|
|
const top = ((minutes - hours.startMinutes) / 60) * hourHeight;
|
|
if (top < root.scrollTop || top > root.scrollTop + root.clientHeight - 2 * hourHeight) {
|
|
root.scrollTop = Math.max(0, top - hourHeight / 2);
|
|
}
|
|
// Only a new request moves the grid, not a change of range under it.
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
}, [revealNonce]);
|
|
|
|
return {
|
|
hours,
|
|
/** The drawn height of an hour row, in pixels. */
|
|
hourHeight,
|
|
/** Whether a range is configured at all, i.e. the toggle has anything to do. */
|
|
canToggle: configured.restricted,
|
|
configured,
|
|
showAllHours,
|
|
toggleAllHours,
|
|
revealMinutes,
|
|
};
|
|
}
|