From 24ad1a8f5bc62f2fd03106d113b7d70533bc84a3 Mon Sep 17 00:00:00 2001 From: Ohkubo KOHEI Date: Sat, 4 Apr 2026 01:14:56 +0000 Subject: [PATCH] infinite scroll --- README.md | 2 + manifest.json | 2 +- styles.css | 57 +++++++ ui/calendar-renderer.ts | 318 +++++++++++++++++++++++++++----------- ui/daily-calendar-view.ts | 4 +- ui/preview.html | 1 + 6 files changed, 291 insertions(+), 93 deletions(-) diff --git a/README.md b/README.md index ecd9b9d..0e9c5c7 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/manifest.json b/manifest.json index 5c791a0..4be77e8 100644 --- a/manifest.json +++ b/manifest.json @@ -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", diff --git a/styles.css b/styles.css index 92ace7d..7181f18 100644 --- a/styles.css +++ b/styles.css @@ -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 { diff --git a/ui/calendar-renderer.ts b/ui/calendar-renderer.ts index c0de29a..667b624 100644 --- a/ui/calendar-renderer.ts +++ b/ui/calendar-renderer.ts @@ -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 { 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 { + 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 { + 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 { + 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); diff --git a/ui/daily-calendar-view.ts b/ui/daily-calendar-view.ts index f5a2e89..ff88f58 100644 --- a/ui/daily-calendar-view.ts +++ b/ui/daily-calendar-view.ts @@ -34,9 +34,11 @@ export class DailyCalendarView extends ItemView { override async onOpen(): Promise { 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())); diff --git a/ui/preview.html b/ui/preview.html index f53e723..73861b8 100644 --- a/ui/preview.html +++ b/ui/preview.html @@ -25,6 +25,7 @@ } #calendar-root { width: 320px; + height: 80vh; }