This commit is contained in:
Michael Zhang 2023-08-24 11:33:14 -04:00
parent 59a7849173
commit f375c25a48
7 changed files with 366 additions and 229 deletions

View file

102
lib/ScrollContainer.tsx Normal file
View file

@ -0,0 +1,102 @@
import {
ForwardedRef,
forwardRef,
useCallback,
useEffect,
useState,
} from "react";
import styles from "./ScrollContainer.module.scss";
import { subMonths } from "date-fns";
interface ScrollContainerProps<T> {
direction: "horizontal" | "vertical";
/** A list of things managed */
list: T[];
generatePreviousObject: () => void;
generateNextObject: () => void;
}
/**
* Generic infinite scrolling container
*/
function ScrollContainer<T>(
{ direction, list }: ScrollContainerProps<T>,
ref: ForwardedRef<HTMLDivElement>
) {
const [resetScrollToEl, setResetScrollToEl] = useState<
[HTMLElement, number] | null
>(null);
// Viewport state
const [viewportSize, setViewportSize] = useState<[number, number]>([0, 0]);
const [viewportWidth, viewportHeight] = viewportSize;
const viewportMetric =
direction === "horizontal" ? viewportWidth : viewportHeight;
// Scroll element state
const [scrollyEl, setScrollyEl] = useState<HTMLDivElement | null>(null);
const scrollyRef = useCallback((node: HTMLDivElement) => {
if (node) {
setScrollyEl(node);
setViewportSize([node.clientWidth, node.clientHeight]);
}
}, []);
// Add resize observer on the viewport
useEffect(() => {
const observer = new ResizeObserver((entries) => {
if (entries && scrollyEl) {
setViewportSize([scrollyEl.clientWidth, scrollyEl.clientHeight]);
}
});
if (scrollyEl) observer.observe(scrollyEl);
return () => {
if (scrollyEl) observer.unobserve(scrollyEl);
};
}, [scrollyEl]);
// Add intersection observer to tell when we scrolled too far
useEffect(() => {
if (!scrollyEl) return;
const children: HTMLElement[] = [...scrollyEl.children];
const firstChild = children[0];
const lastChild = children[children.length - 1];
const observer = new IntersectionObserver((entries) => {
if (viewportMetric === 0) return;
let newList = [...list];
for (const entry of entries) {
if (!entry.isIntersecting) continue;
if (entry.target === firstChild) {
console.log("intersected");
const firstChildDate = new Date(firstChild.dataset.isodate!);
const prevMonth = subMonths(firstChildDate, 1);
newList = [prevMonth, ...monthsShown];
newList = newMonthsShown.slice(0, NUM_MONTHS_SHOWN);
setResetScrollToEl([
firstChild,
scrollyEl.scrollTop - firstChild.offsetTop,
]);
} else if (entry.target === lastChild) {
const lastChildDate = new Date(lastChild.dataset.isodate!);
const nextMonth = addMonths(lastChildDate, 1);
newMonthsShown = [...monthsShown, nextMonth];
newMonthsShown = newMonthsShown.slice(-NUM_MONTHS_SHOWN);
}
}
setMonthsShown(newMonthsShown);
});
});
return <div className={styles.scrollyEl} ref={scrollyRef}></div>;
}
export default forwardRef(ScrollContainer);

View file

@ -22,31 +22,3 @@
gap: 12px; gap: 12px;
} }
} }
.daysOfWeek {
display: grid;
grid-template-columns: repeat(7, 1fr);
div {
flex-grow: 1;
text-align: right;
font-weight: 100;
border-bottom: 1px solid gray;
padding: 4px;
}
}
.scrollyPart {
flex-grow: 1;
overflow-y: scroll; /* Add the ability to scroll the y axis */
/* Hide the scrollbar for Internet Explorer, Edge and Firefox */
-ms-overflow-style: none; /* Internet Explorer and Edge */
scrollbar-width: none; /* Firefox */
/* Hide the scrollbar for Chrome, Safari and Opera */
&::-webkit-scrollbar {
display: none;
}
}

View file

@ -1,225 +1,75 @@
import { forwardRef, useCallback, useEffect, useRef, useState } from "react"; import { Dispatch, SetStateAction, createContext, useState } from "react";
import styles from "./Calendar.module.scss"; import styles from "./Calendar.module.scss";
import {
addDays,
addMonths,
differenceInWeeks,
isSameMonth,
startOfMonth,
subMonths,
} from "date-fns";
import Month from "./Month";
import { monthNameOf, weekBoundsOfMonth } from "lib/month";
import { useJournals } from "lib/queries";
import ToggleSwitch from "src/widgets/ToggleSwitch"; import ToggleSwitch from "src/widgets/ToggleSwitch";
import InputBox from "src/widgets/InputBox"; import InputBox from "src/widgets/InputBox";
import Button from "src/widgets/Button"; import Button from "src/widgets/Button";
import MonthCalendar from "./MonthCalendar";
const daysOfWeek = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"]; interface ICalendarContext {
const NUM_MONTHS_SHOWN = 5; onDateClick: (_: Date) => void;
setCurrentTitle: Dispatch<SetStateAction<JSX.Element>>;
}
export const CalendarContext = createContext<ICalendarContext>(null!);
interface CalendarProps { interface CalendarProps {
onDateClick?: (_: Date) => void; onDateClick: (_: Date) => void;
} }
export default function Calendar({ onDateClick }: CalendarProps) { export default function Calendar({ onDateClick }: CalendarProps) {
const now = new Date(); const [currentTitle, setCurrentTitle] = useState<JSX.Element>(<></>);
const [viewportHeight, setViewportHeight] = useState(0); const scrollToToday = () => {};
const [scrollyEl, setScrollyEl] = useState<HTMLDivElement | null>(null);
const [resetScrollToEl, setResetScrollToEl] = useState<
[HTMLElement, number] | null
>(null);
const range = [-2, -1, 0, 1, 2];
const [monthsShown, setMonthsShown] = useState(
range.map((x) => addMonths(startOfMonth(now), x))
);
const centerMonth = monthsShown[2];
const startDate = monthsShown[0];
const endDate = monthsShown[NUM_MONTHS_SHOWN - 1];
const [firstLoad, setFirstLoad] = useState(true);
useEffect(() => {
if (firstLoad && scrollyEl && viewportHeight > 0) {
// Identify the middle month
const el = scrollyEl.querySelector(
`[data-isodate="${centerMonth.toISOString()}"]`
);
if (el) {
console.log("first run, scrolling into view", el);
el.scrollIntoView();
setFirstLoad(false);
}
}
}, [firstLoad, scrollyEl, viewportHeight]);
const scrollyRef = useCallback((node: HTMLDivElement) => {
if (node) {
setScrollyEl(node);
setViewportHeight(node.clientHeight);
}
}, []);
// Add resize observer on the viewport
useEffect(() => {
const observer = new ResizeObserver((entries) => {
if (entries && scrollyEl) setViewportHeight(scrollyEl.clientHeight);
});
if (scrollyEl) observer.observe(scrollyEl);
return () => {
if (scrollyEl) observer.unobserve(scrollyEl);
};
}, [scrollyEl]);
// Add intersection observer to tell when we scrolled too far up or down
useEffect(() => {
if (!scrollyEl) return;
const children: HTMLElement[] = [...scrollyEl.children];
if (children.length !== NUM_MONTHS_SHOWN) throw new Error("wtf");
const firstChild = children[0];
const lastChild = children[NUM_MONTHS_SHOWN - 1];
const observer = new IntersectionObserver((entries) => {
let newMonthsShown = monthsShown;
let scrollUp = false;
for (const entry of entries) {
if (!entry.isIntersecting) continue;
if (entry.target === firstChild) {
console.log("intersected");
const firstChildDate = new Date(firstChild.dataset.isodate!);
const prevMonth = subMonths(firstChildDate, 1);
newMonthsShown = [prevMonth, ...monthsShown];
newMonthsShown = newMonthsShown.slice(0, NUM_MONTHS_SHOWN);
setResetScrollToEl([
firstChild,
scrollyEl.scrollTop - firstChild.offsetTop,
]);
} else if (entry.target === lastChild) {
const lastChildDate = new Date(lastChild.dataset.isodate!);
const nextMonth = addMonths(lastChildDate, 1);
newMonthsShown = [...monthsShown, nextMonth];
newMonthsShown = newMonthsShown.slice(-NUM_MONTHS_SHOWN);
}
}
setMonthsShown(newMonthsShown);
});
console.log("observing", firstChild, lastChild);
observer.observe(firstChild);
observer.observe(lastChild);
return () => {
observer.unobserve(firstChild);
observer.unobserve(lastChild);
};
}, [monthsShown, scrollyEl]);
useEffect(() => {
if (!resetScrollToEl) return;
if (!scrollyEl) return;
console.log("current scroll top", scrollyEl.scrollTop);
console.log("params", resetScrollToEl);
const [el, height] = resetScrollToEl;
scrollyEl.scrollTop = el.offsetTop + height;
console.log("new scroll top", scrollyEl.scrollTop, el.offsetTop + height);
setResetScrollToEl(null);
}, [resetScrollToEl, scrollyEl]);
console.log("SCROLLY REF IS", scrollyRef);
// The calendar should always show 6 rows
const dateCellHeight = viewportHeight / 6;
const monthName = monthNameOf(centerMonth);
// useEffect(() => {
// middleMonthEl?.scrollIntoView();
// }, [middleMonthRef]);
const [searchQuery, setSearchQuery] = useState(""); const [searchQuery, setSearchQuery] = useState("");
const [currentLayout, setCurrentLayout] = useState("Month"); const [currentMode, setCurrentMode] = useState("Month");
const [currentView, setCurrentView] = useState("Calendar"); const [currentView, setCurrentView] = useState("Calendar");
const contextValue = { onDateClick, setCurrentTitle };
return ( return (
<div className={styles.calendar}> <CalendarContext.Provider value={contextValue}>
<div className={styles.header}> <div className={styles.calendar}>
<div className={styles.title}> <div className={styles.header}>
<div> <div className={styles.title}>
<b>{monthName}</b> <div>{currentTitle}</div>
{centerMonth.getFullYear()}
</div>
<div> <div>
<InputBox <InputBox
value={searchQuery} value={searchQuery}
setValue={setSearchQuery} setValue={setSearchQuery}
placeholder="Search..." placeholder="Search..."
/> />
</div>
<div className={styles.controlButtons}>
<Button>Today</Button>
<ToggleSwitch
options={["Week", "Month", "Year"]}
value={currentLayout}
setValue={setCurrentLayout}
/>
<ToggleSwitch
options={["Calendar", "Graph"]}
value={currentView}
setValue={setCurrentView}
/>
</div>
</div>
<div className={styles.daysOfWeek}>
{daysOfWeek.map((day) => (
<div key={day} className={styles.dayOfWeek}>
{day}
</div> </div>
))}
<div className={styles.controlButtons}>
<Button>Today</Button>
<ToggleSwitch
options={["Week", "Month", "Year"]}
value={currentMode}
setValue={setCurrentMode}
/>
<ToggleSwitch
options={["Calendar", "Graph"]}
value={currentView}
setValue={setCurrentView}
/>
</div>
</div>
</div> </div>
</div>
<div className={styles.scrollyPart} ref={scrollyRef}> <ActualCalendar mode={currentMode} />
{monthsShown.map((month) => {
// How many rows will this month take up?
const [startWeek, endWeek] = weekBoundsOfMonth(month);
const numWeeks = differenceInWeeks(endWeek, startWeek);
const dateGridHeight = dateCellHeight * numWeeks;
const props = {
month,
dateGridHeight,
dateCellHeight,
isActive: isSameMonth(centerMonth, month),
};
const ref = undefined;
// (month === currentlyShownMonth && middleMonthRef) || undefined;
return (
<Month
key={month.toString()}
{...props}
ref={ref}
onDateClick={onDateClick}
/>
);
})}
</div> </div>
</div> </CalendarContext.Provider>
); );
} }
function ActualCalendar({ mode }) {
switch (mode) {
default:
return <MonthCalendar />;
}
}

View file

@ -0,0 +1,27 @@
.daysOfWeek {
display: grid;
grid-template-columns: repeat(7, 1fr);
div {
flex-grow: 1;
text-align: right;
font-weight: 100;
border-bottom: 1px solid gray;
padding: 4px;
}
}
.scrollyPart {
flex-grow: 1;
overflow-y: scroll; /* Add the ability to scroll the y axis */
/* Hide the scrollbar for Internet Explorer, Edge and Firefox */
-ms-overflow-style: none; /* Internet Explorer and Edge */
scrollbar-width: none; /* Firefox */
/* Hide the scrollbar for Chrome, Safari and Opera */
&::-webkit-scrollbar {
display: none;
}
}

View file

@ -0,0 +1,178 @@
import { useCallback, useContext, useEffect, useState } from "react";
import styles from "./MonthCalendar.module.scss";
import {
addMonths,
differenceInWeeks,
isSameMonth,
startOfMonth,
subMonths,
} from "date-fns";
import { monthNameOf, weekBoundsOfMonth } from "lib/month";
import Month from "./Month";
import { CalendarContext } from "./Calendar";
const daysOfWeek = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"];
const NUM_MONTHS_SHOWN = 5;
export default function MonthCalendar() {
const { onDateClick, setCurrentTitle } = useContext(CalendarContext);
const now = new Date();
const [resetScrollToEl, setResetScrollToEl] = useState<
[HTMLElement, number] | null
>(null);
const [viewportHeight, setViewportHeight] = useState(0);
const [scrollyEl, setScrollyEl] = useState<HTMLDivElement | null>(null);
const scrollyRef = useCallback((node: HTMLDivElement) => {
if (node) {
setScrollyEl(node);
setViewportHeight(node.clientHeight);
}
}, []);
const range = [-2, -1, 0, 1, 2];
const [monthsShown, setMonthsShown] = useState(
range.map((x) => addMonths(startOfMonth(now), x))
);
const centerMonth = monthsShown[2];
useEffect(() => {
setCurrentTitle(
<>
<b>{monthNameOf(centerMonth)}</b>
{centerMonth.getFullYear()}
</>
);
}, [centerMonth]);
// Scroll to today on first load
const [firstLoad, setFirstLoad] = useState(true);
useEffect(() => {
if (firstLoad && scrollyEl && viewportHeight > 0) {
// Identify the middle month
const el = scrollyEl.querySelector(
`[data-isodate="${centerMonth.toISOString()}"]`
);
if (el) {
console.log("first run, scrolling into view", el);
el.scrollIntoView();
setFirstLoad(false);
}
}
}, [firstLoad, scrollyEl, viewportHeight]);
// Add resize observer on the viewport
useEffect(() => {
const observer = new ResizeObserver((entries) => {
if (entries && scrollyEl) setViewportHeight(scrollyEl.clientHeight);
});
if (scrollyEl) observer.observe(scrollyEl);
return () => {
if (scrollyEl) observer.unobserve(scrollyEl);
};
}, [scrollyEl]);
// The calendar should always show 6 rows
const dateCellHeight = viewportHeight / 6;
// Add intersection observer to tell when we scrolled too far up or down
useEffect(() => {
if (!scrollyEl) return;
const children: HTMLElement[] = [...scrollyEl.children];
if (children.length !== NUM_MONTHS_SHOWN) throw new Error("wtf");
const firstChild = children[0];
const lastChild = children[NUM_MONTHS_SHOWN - 1];
const observer = new IntersectionObserver((entries) => {
if (viewportHeight === 0) return;
let newMonthsShown = monthsShown;
for (const entry of entries) {
if (!entry.isIntersecting) continue;
if (entry.target === firstChild) {
console.log("intersected");
const firstChildDate = new Date(firstChild.dataset.isodate!);
const prevMonth = subMonths(firstChildDate, 1);
newMonthsShown = [prevMonth, ...monthsShown];
newMonthsShown = newMonthsShown.slice(0, NUM_MONTHS_SHOWN);
setResetScrollToEl([
firstChild,
scrollyEl.scrollTop - firstChild.offsetTop,
]);
} else if (entry.target === lastChild) {
const lastChildDate = new Date(lastChild.dataset.isodate!);
const nextMonth = addMonths(lastChildDate, 1);
newMonthsShown = [...monthsShown, nextMonth];
newMonthsShown = newMonthsShown.slice(-NUM_MONTHS_SHOWN);
}
}
setMonthsShown(newMonthsShown);
});
console.log("observing", firstChild, lastChild);
observer.observe(firstChild);
observer.observe(lastChild);
return () => {
observer.unobserve(firstChild);
observer.unobserve(lastChild);
};
}, [monthsShown, scrollyEl]);
useEffect(() => {
if (!resetScrollToEl) return;
if (!scrollyEl) return;
console.log("current scroll top", scrollyEl.scrollTop);
console.log("params", resetScrollToEl);
const [el, height] = resetScrollToEl;
scrollyEl.scrollTop = el.offsetTop + height;
console.log("new scroll top", scrollyEl.scrollTop, el.offsetTop + height);
setResetScrollToEl(null);
}, [resetScrollToEl, scrollyEl]);
return (
<>
<div className={styles.daysOfWeek}>
{daysOfWeek.map((day) => (
<div key={day} className={styles.dayOfWeek}>
{day}
</div>
))}
</div>
<div className={styles.scrollyPart} ref={scrollyRef}>
{monthsShown.map((month) => {
// How many rows will this month take up?
const [startWeek, endWeek] = weekBoundsOfMonth(month);
const numWeeks = differenceInWeeks(endWeek, startWeek);
const dateGridHeight = dateCellHeight * numWeeks;
const props = {
month,
dateGridHeight,
dateCellHeight,
isActive: isSameMonth(centerMonth, month),
};
const ref = undefined;
// (month === currentlyShownMonth && middleMonthRef) || undefined;
return (
<Month
key={month.toString()}
{...props}
ref={ref}
onDateClick={onDateClick}
/>
);
})}
</div>
</>
);
}

View file

@ -0,0 +1,8 @@
import { useContext } from "react";
import { CalendarContext } from "./Calendar";
export default function WeekCalendar() {
const { onDateClick, setCurrentTitle } = useContext(CalendarContext);
return <></>;
}