infinite scroll

This commit is contained in:
Ohkubo KOHEI 2026-04-04 01:14:56 +00:00
parent 1902a8fc6f
commit 24ad1a8f5b
No known key found for this signature in database
6 changed files with 291 additions and 93 deletions

View file

@ -12,6 +12,8 @@ This plugin adds two commands to the command palette:
It is recommended to use with
[Note Toolbar](https://github.com/chrisgurney/obsidian-note-toolbar)
obsidian://note-toolbar?import=%3E%20%5B!note-toolbar%7Cborder-even-sticky-mrght%5D%20daily%0A%3E%20-%20%5B%3ALiCalendarMinus%3A%5D()%3Cdata%20data-ntb-command%3D%22daily-nav%3Aopen-yesterdays-note%22%2F%3E%20%3C!--%20yesterday%20--%3E%0A%3E%20-%20%5B%3ALiCalendarDays%3A%5D()%3Cdata%20data-ntb-command%3D%22daily-notes%22%2F%3E%20%3C!--%20today%20--%3E%0A%3E%20-%20%5B%3ALiCalendarPlus%3A%5D()%3Cdata%20data-ntb-command%3D%22daily-nav%3Aopen-tomorrows-note%22%2F%3E%20%3C!--%20tomorrow%20--%3E%0A
## How it works
This plugin depends on

View file

@ -1,7 +1,7 @@
{
"id": "daily-nav",
"name": "Daily Nav",
"version": "1.1.0",
"version": "1.1.1",
"minAppVersion": "1.12.0",
"description": "Open prev/next note based on explorer sorting order",
"author": "kuboon",

View file

@ -1,5 +1,15 @@
.daily-calendar {
padding: 8px;
display: flex;
flex-direction: column;
height: 100%;
overflow: hidden;
}
.daily-calendar-viewport {
flex: 1 1 0;
overflow-y: auto;
min-height: 0;
}
.daily-calendar-header {
@ -58,6 +68,7 @@
grid-template-columns: auto repeat(7, minmax(0, 1fr));
gap: 1px;
text-align: center;
position: relative;
}
.daily-calendar-week-header,
@ -85,6 +96,10 @@
font-weight: 600;
color: var(--text-muted);
padding: 4px 0;
position: sticky;
top: 0;
background: var(--background-primary, #1e1e1e);
z-index: 1;
}
.daily-calendar-day {
@ -97,6 +112,48 @@
flex-direction: column;
align-items: center;
min-height: 32px;
position: relative;
z-index: 1;
}
.daily-calendar-week {
z-index: 1;
}
/* Month overlay — absolutely positioned behind day cells */
.daily-calendar-month-overlay {
position: absolute;
display: flex;
align-items: center;
justify-content: center;
pointer-events: none;
z-index: 0;
overflow: hidden;
border-radius: 6px;
}
.daily-calendar-month-overlay-num {
font-size: 7em;
font-weight: 900;
line-height: 1;
}
/* Odd months: green bg, number "knocked out" in bg color */
.daily-calendar-month-overlay-odd {
background: rgba(80, 180, 100, 0.08);
}
.daily-calendar-month-overlay-odd .daily-calendar-month-overlay-num {
color: var(--background-primary, #1e1e1e);
}
/* Even months: transparent bg, green number */
.daily-calendar-month-overlay-even {
background: transparent;
}
.daily-calendar-month-overlay-even .daily-calendar-month-overlay-num {
color: rgba(80, 180, 100, 0.08);
}
.daily-calendar-day-num {

View file

@ -16,110 +16,258 @@ export interface CalendarDataSource {
onWeekClick(isoYear: number, isoWeek: number): void;
}
/** Pure-DOM calendar renderer. No Obsidian dependency. */
/**
* Pure-DOM calendar renderer with infinite vertical scroll.
* Renders 3x the viewport height of week rows; on scrollend,
* re-centers the grid around the new position.
*/
export class CalendarRenderer {
private displayDate: Date;
/** Monday of the center week */
private centerDate: Date;
private container: HTMLElement;
private dataSource: CalendarDataSource;
private viewport: HTMLElement | null = null;
private grid: HTMLElement | null = null;
private rangeEl: HTMLElement | null = null;
private rowHeight = 0;
private visibleWeeks = 0;
private rendering = false;
constructor(container: HTMLElement, dataSource: CalendarDataSource, initialDate?: Date) {
this.container = container;
this.dataSource = dataSource;
this.displayDate = initialDate ?? new Date();
}
setDisplayDate(date: Date): void {
this.displayDate = date;
// Snap to Monday of the week containing initialDate
this.centerDate = weekStart(initialDate ?? new Date());
}
/** Jump to today and re-render */
goToToday(): void {
this.displayDate = new Date();
this.centerDate = weekStart(new Date());
this.renderGrid();
}
shiftMonth(offset: number): void {
const d = new Date(this.displayDate);
d.setDate(1);
d.setMonth(d.getMonth() + offset);
this.displayDate = d;
/** Shift center by N weeks and re-render */
shiftWeeks(n: number): void {
this.centerDate = addDays(this.centerDate, n * 7);
this.renderGrid();
}
/** Initial full render — call once */
async render(): Promise<void> {
this.container.innerHTML = "";
const wrapper = el("div", "daily-calendar");
this.container.appendChild(wrapper);
// Nav
this.renderNav(wrapper);
const prevMonth = monthOffset(this.displayDate, -1);
const nextMonth = monthOffset(this.displayDate, 1);
// Range label
this.rangeEl = el("div", "daily-calendar-range");
wrapper.appendChild(this.rangeEl);
const range = el("div", "daily-calendar-range");
range.textContent = `${fmt(prevMonth)} ~ ${fmt(nextMonth)}`;
wrapper.appendChild(range);
// Scrollable viewport fills remaining height
this.viewport = el("div", "daily-calendar-viewport");
wrapper.appendChild(this.viewport);
const grid = el("div", "daily-calendar-grid");
wrapper.appendChild(grid);
// Grid inside viewport
this.grid = el("div", "daily-calendar-grid");
this.viewport.appendChild(this.grid);
// Header
grid.appendChild(elText("div", "W", "daily-calendar-weekday daily-calendar-week-header"));
// Sticky header row
this.grid.appendChild(elText("div", "W", "daily-calendar-weekday daily-calendar-week-header"));
for (const day of ["Mo", "Tu", "We", "Th", "Fr", "Sa", "Su"]) {
grid.appendChild(elText("div", day, "daily-calendar-weekday"));
this.grid.appendChild(elText("div", day, "daily-calendar-weekday"));
}
const startOfRange = startOfMonth(prevMonth);
const endOfRange = endOfMonth(nextMonth);
// First render to measure row height
await this.buildWeeks(20); // render 20 weeks to measure
await frame();
this.measureRow();
const firstDay = weekStart(startOfRange); // Monday
const lastDay = weekEnd(endOfRange); // Sunday
// Now re-render with correct 3x buffer
await this.renderGrid();
const dayInfoMap = await this.dataSource.loadRange(firstDay, lastDay);
const today = stripTime(new Date());
// Scroll events
this.viewport.addEventListener("scrollend", () => this.onScrollEnd());
}
const cursor = new Date(firstDay);
while (cursor <= lastDay) {
// Week number on Monday
if (isoWeekday(cursor) === 1) {
const [isoYear, isoWeek] = isoWeekOfDate(cursor);
const weekEl = elText("div", `W${isoWeek}`, "daily-calendar-week");
weekEl.setAttribute("aria-label", `${isoYear}-W${pad2(isoWeek)}`);
weekEl.addEventListener("click", () => this.dataSource.onWeekClick(isoYear, isoWeek));
grid.appendChild(weekEl);
}
// ── Internal ──
const date = new Date(cursor);
const key = dateKey(date);
const isToday = key === dateKey(today);
const info = dayInfoMap.get(key);
const hasNote = !!info;
private measureRow(): void {
if (!this.viewport || !this.grid) return;
// Find first week cell to measure row height
const weekCell = this.grid.querySelector(".daily-calendar-week") as HTMLElement | null;
if (!weekCell) return;
// Row height = height of 8-cell grid row (week + 7 days)
// Measure from one week cell to the next
const allWeekCells = this.grid.querySelectorAll(".daily-calendar-week");
if (allWeekCells.length >= 2) {
const first = (allWeekCells[0] as HTMLElement).getBoundingClientRect().top;
const second = (allWeekCells[1] as HTMLElement).getBoundingClientRect().top;
this.rowHeight = second - first;
} else {
// Fallback: use cell offsetHeight + gap
this.rowHeight = weekCell.offsetHeight + 1;
}
if (this.rowHeight <= 0) this.rowHeight = 34; // safety fallback
this.visibleWeeks = Math.max(4, Math.ceil(this.viewport.clientHeight / this.rowHeight));
}
const classes = ["daily-calendar-day"];
if (isToday) classes.push("daily-calendar-day-today");
if (hasNote) classes.push("daily-calendar-day-has-note");
if (info?.hasUnchecked) classes.push("daily-calendar-day-unchecked");
private async renderGrid(): Promise<void> {
if (!this.grid || !this.viewport) return;
if (this.rendering) return;
this.rendering = true;
const dayEl = el("div", classes.join(" "));
dayEl.appendChild(elText("div", String(date.getDate()), "daily-calendar-day-num"));
// Clear day cells but keep sticky header (first 8 children)
while (this.grid.children.length > 8) {
this.grid.removeChild(this.grid.lastChild!);
}
if (info?.heading) {
const label = elText("div", info.heading, "daily-calendar-day-label");
label.title = info.heading;
dayEl.appendChild(label);
}
const totalWeeks = this.visibleWeeks * 3;
const halfWeeks = Math.floor(totalWeeks / 2);
dayEl.addEventListener("click", () => this.dataSource.onDayClick(date));
dayEl.setAttribute("aria-label", key);
dayEl.setAttribute("tabindex", "0");
dayEl.addEventListener("keydown", (e: KeyboardEvent) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
this.dataSource.onDayClick(date);
await this.buildWeeks(totalWeeks);
await frame();
// Update range label (visible portion only)
const visibleHalf = Math.floor(this.visibleWeeks / 2);
const visibleFirst = addDays(this.centerDate, -visibleHalf * 7);
const visibleLast = addDays(this.centerDate, (this.visibleWeeks - visibleHalf) * 7 - 1);
if (this.rangeEl) {
this.rangeEl.textContent = `${fmt(visibleFirst)} ~ ${fmt(visibleLast)}`;
}
// Center scroll: the center week should be in the middle of viewport
const headerHeight = (this.grid.children[0] as HTMLElement).offsetHeight;
this.viewport.scrollTop = halfWeeks * this.rowHeight + headerHeight
- this.viewport.clientHeight / 2
+ this.rowHeight / 2;
this.rendering = false;
}
private async buildWeeks(totalWeeks: number): Promise<void> {
if (!this.grid) return;
const halfWeeks = Math.floor(totalWeeks / 2);
const firstMonday = addDays(this.centerDate, -halfWeeks * 7);
const lastSunday = addDays(firstMonday, totalWeeks * 7 - 1);
const dayInfoMap = await this.dataSource.loadRange(firstMonday, lastSunday);
const todayKey = dateKey(stripTime(new Date()));
// Track month boundaries: weekIndex where the 1st appears
const monthMarkers: { weekIndex: number; month: number; isOdd: boolean }[] = [];
const cursor = new Date(firstMonday);
for (let w = 0; w < totalWeeks; w++) {
// Week number cell
const [isoYear, isoWeek] = isoWeekOfDate(cursor);
const weekEl = elText("div", `W${isoWeek}`, "daily-calendar-week");
weekEl.setAttribute("aria-label", `${isoYear}-W${pad2(isoWeek)}`);
weekEl.addEventListener("click", () => this.dataSource.onWeekClick(isoYear, isoWeek));
this.grid.appendChild(weekEl);
// 7 day cells
for (let d = 0; d < 7; d++) {
const date = new Date(cursor);
const key = dateKey(date);
const isToday = key === todayKey;
const info = dayInfoMap.get(key);
const classes = ["daily-calendar-day"];
if (isToday) classes.push("daily-calendar-day-today");
if (info) classes.push("daily-calendar-day-has-note");
if (info?.hasUnchecked) classes.push("daily-calendar-day-unchecked");
const dayEl = el("div", classes.join(" "));
dayEl.appendChild(elText("div", String(date.getDate()), "daily-calendar-day-num"));
if (info?.heading) {
const label = elText("div", info.heading, "daily-calendar-day-label");
label.title = info.heading;
dayEl.appendChild(label);
}
});
grid.appendChild(dayEl);
cursor.setDate(cursor.getDate() + 1);
dayEl.addEventListener("click", () => this.dataSource.onDayClick(date));
dayEl.setAttribute("aria-label", key);
dayEl.setAttribute("tabindex", "0");
dayEl.addEventListener("keydown", (e: KeyboardEvent) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
this.dataSource.onDayClick(date);
}
});
this.grid.appendChild(dayEl);
if (date.getDate() === 1) {
monthMarkers.push({
weekIndex: w,
month: date.getMonth() + 1,
isOdd: date.getMonth() % 2 === 1,
});
}
cursor.setDate(cursor.getDate() + 1);
}
}
// Add month overlays — positioned absolutely within the grid
// Wait for layout so we can measure cell positions
await frame();
// Find the first day cell in each week row to get positions
const weekCells = this.grid.querySelectorAll(".daily-calendar-week");
const dayCells = this.grid.querySelectorAll(".daily-calendar-day");
const gridRect = this.grid.getBoundingClientRect();
for (const marker of monthMarkers) {
const weekCell = weekCells[marker.weekIndex] as HTMLElement | undefined;
if (!weekCell) continue;
// Left edge: first day cell of this week (right after week cell)
const firstDayIdx = marker.weekIndex * 7;
const firstDayCell = dayCells[firstDayIdx] as HTMLElement | undefined;
if (!firstDayCell) continue;
const top = weekCell.getBoundingClientRect().top - gridRect.top;
const left = firstDayCell.getBoundingClientRect().left - gridRect.left;
const right = gridRect.right - gridRect.left;
const height = this.rowHeight * 4;
const cls = marker.isOdd
? "daily-calendar-month-overlay daily-calendar-month-overlay-odd"
: "daily-calendar-month-overlay daily-calendar-month-overlay-even";
const overlay = el("div", cls);
overlay.style.top = `${top}px`;
overlay.style.left = `${left}px`;
overlay.style.width = `${right - left}px`;
overlay.style.height = `${height}px`;
const num = elText("span", String(marker.month), "daily-calendar-month-overlay-num");
overlay.appendChild(num);
this.grid.appendChild(overlay);
}
}
private onScrollEnd(): void {
if (!this.viewport || !this.grid || this.rendering) return;
if (this.rowHeight <= 0) return;
const totalWeeks = this.visibleWeeks * 3;
const halfWeeks = Math.floor(totalWeeks / 2);
const headerHeight = (this.grid.children[0] as HTMLElement).offsetHeight;
// Current center of viewport in scroll coordinates
const viewportCenter = this.viewport.scrollTop + this.viewport.clientHeight / 2;
// Expected center (the center week row)
const expectedCenter = halfWeeks * this.rowHeight + headerHeight + this.rowHeight / 2;
const weeksMoved = Math.round((viewportCenter - expectedCenter) / this.rowHeight);
if (weeksMoved === 0) return;
this.centerDate = addDays(this.centerDate, weeksMoved * 7);
this.renderGrid();
}
private renderNav(wrapper: HTMLElement): void {
@ -128,25 +276,29 @@ export class CalendarRenderer {
const prevBtn = elText("button", "\u2039", "daily-calendar-nav");
prevBtn.setAttribute("aria-label", "Previous month");
prevBtn.addEventListener("click", () => { this.shiftMonth(-1); this.render(); });
prevBtn.addEventListener("click", () => this.shiftWeeks(-4));
header.appendChild(prevBtn);
const titleGroup = el("div", "daily-calendar-title-group");
const todayBtn = elText("button", "Today", "daily-calendar-today");
todayBtn.setAttribute("aria-label", "Go to today");
todayBtn.addEventListener("click", () => { this.goToToday(); this.render(); });
todayBtn.addEventListener("click", () => this.goToToday());
titleGroup.appendChild(todayBtn);
header.appendChild(titleGroup);
const nextBtn = elText("button", "\u203A", "daily-calendar-nav");
nextBtn.setAttribute("aria-label", "Next month");
nextBtn.addEventListener("click", () => { this.shiftMonth(1); this.render(); });
nextBtn.addEventListener("click", () => this.shiftWeeks(4));
header.appendChild(nextBtn);
}
}
// ── Date helpers (no moment dependency) ──
function frame(): Promise<void> {
return new Promise((r) => requestAnimationFrame(() => r()));
}
function el(tag: string, cls: string): HTMLElement {
const e = document.createElement(tag);
e.className = cls;
@ -163,6 +315,12 @@ function stripTime(d: Date): Date {
return new Date(d.getFullYear(), d.getMonth(), d.getDate());
}
function addDays(d: Date, n: number): Date {
const r = new Date(d);
r.setDate(r.getDate() + n);
return r;
}
function dateKey(d: Date): string {
return `${d.getFullYear()}-${pad2(d.getMonth() + 1)}-${pad2(d.getDate())}`;
}
@ -175,35 +333,14 @@ function fmt(d: Date): string {
return `${d.getFullYear()}-${pad2(d.getMonth() + 1)}`;
}
function monthOffset(d: Date, offset: number): Date {
const r = new Date(d.getFullYear(), d.getMonth() + offset, 1);
return r;
}
function startOfMonth(d: Date): Date {
return new Date(d.getFullYear(), d.getMonth(), 1);
}
function endOfMonth(d: Date): Date {
return new Date(d.getFullYear(), d.getMonth() + 1, 0);
}
/** Monday of the week containing d (ISO: Mon=1) */
function weekStart(d: Date): Date {
const r = new Date(d);
const r = new Date(d.getFullYear(), d.getMonth(), d.getDate());
const day = isoWeekday(r);
r.setDate(r.getDate() - (day - 1));
return r;
}
/** Sunday of the week containing d */
function weekEnd(d: Date): Date {
const r = new Date(d);
const day = isoWeekday(r);
r.setDate(r.getDate() + (7 - day));
return r;
}
/** 1=Mon .. 7=Sun */
function isoWeekday(d: Date): number {
return d.getDay() === 0 ? 7 : d.getDay();
@ -212,7 +349,6 @@ function isoWeekday(d: Date): number {
/** Returns [isoYear, isoWeek] */
function isoWeekOfDate(d: Date): [number, number] {
const tmp = new Date(Date.UTC(d.getFullYear(), d.getMonth(), d.getDate()));
// Set to nearest Thursday: current date + 4 - current day number (Mon=1)
tmp.setUTCDate(tmp.getUTCDate() + 4 - (tmp.getUTCDay() || 7));
const yearStart = new Date(Date.UTC(tmp.getUTCFullYear(), 0, 1));
const weekNo = Math.ceil((((tmp.getTime() - yearStart.getTime()) / 86400000) + 1) / 7);

View file

@ -34,9 +34,11 @@ export class DailyCalendarView extends ItemView {
override async onOpen(): Promise<void> {
await Promise.resolve();
const container = this.containerEl.children[1] as HTMLElement;
container.style.height = "100%";
container.style.overflow = "hidden";
const dataSource = new ObsidianDataSource(this.app, () => m());
this.renderer = new CalendarRenderer(container, dataSource);
this.renderer.render();
await this.renderer.render();
this.registerEvent(this.app.vault.on("create", () => this.renderer.render()));
this.registerEvent(this.app.vault.on("delete", () => this.renderer.render()));
this.registerEvent(this.app.vault.on("rename", () => this.renderer.render()));

View file

@ -25,6 +25,7 @@
}
#calendar-root {
width: 320px;
height: 80vh;
}
</style>
<link rel="stylesheet" href="../styles.css">