diff --git a/main.js b/main.js index 70190ab..03957e1 100644 --- a/main.js +++ b/main.js @@ -45,6 +45,9 @@ class OrgTodoListView extends ItemView { this.filterText = ""; this.activeTags = new Set(); this.showDone = false; + this.ROW_H = 28; // fixed row height in px — MUST match CSS + this.BUFFER = 5; // extra rows above/below viewport, hides scroll seams + this.filtered = []; } getViewType() { @@ -210,9 +213,8 @@ class OrgTodoListView extends ItemView { } renderList() { - this.listEl.empty(); - - const filtered = this.tasks.filter((t) => { + // 1. Filter (cheap — pure in-memory). + this.filtered = this.tasks.filter((t) => { if (!this.showDone && t.done) return false; if (this.activeTags.size > 0) { for (const tag of this.activeTags) if (!t.tags.includes(tag)) return false; @@ -224,13 +226,45 @@ class OrgTodoListView extends ItemView { return true; }); - if (filtered.length === 0) { + this.listEl.empty(); + + if (this.filtered.length === 0) { this.listEl.createEl("div", { text: "No matching tasks.", cls: "org-todo-empty" }); return; } - for (const t of filtered) { - const row = this.listEl.createDiv({ cls: "org-todo-row" }); + // 2. A tall spacer establishes the full scrollable height... + this.spacerEl = this.listEl.createDiv({ cls: "org-todo-spacer" }); + this.spacerEl.style.height = this.filtered.length * this.ROW_H + "px"; + + // 3. ...and an absolutely-positioned layer holds the handful of visible rows. + this.rowsEl = this.spacerEl.createDiv({ cls: "org-todo-rows" }); + + // Repaint visible rows on scroll. Registered once per renderList; the old + // listEl (and its listener) is discarded by empty() above. + this.listEl.onscroll = () => this.renderVisible(); + + this.renderVisible(); + } + + // Paints only the rows currently in the viewport, recycling on scroll. + renderVisible() { + if (!this.filtered || this.filtered.length === 0) return; + + const scrollTop = this.listEl.scrollTop; + const viewportH = this.listEl.clientHeight; + + const first = Math.max(0, Math.floor(scrollTop / this.ROW_H) - this.BUFFER); + const visibleCount = Math.ceil(viewportH / this.ROW_H) + this.BUFFER * 2; + const last = Math.min(this.filtered.length, first + visibleCount); + + // Offset the rows layer so the painted slice sits at the right scroll position. + this.rowsEl.style.transform = `translateY(${first * this.ROW_H}px)`; + this.rowsEl.empty(); + + for (let i = first; i < last; i++) { + const t = this.filtered[i]; + const row = this.rowsEl.createDiv({ cls: "org-todo-row" }); const box = row.createEl("input", { type: "checkbox" }); box.checked = t.done; diff --git a/styles.css b/styles.css index 9583db7..7d2d098 100644 --- a/styles.css +++ b/styles.css @@ -31,13 +31,38 @@ background: var(--interactive-accent); color: var(--text-on-accent); } +.org-todo-list { + position: relative; + overflow-y: auto; + height: 100%; /* the scroll viewport — must have a bounded height */ +} +.org-todo-spacer { + position: relative; + width: 100%; +} +.org-todo-rows { + position: absolute; + top: 0; + left: 0; + right: 0; +} .org-todo-row { display: flex; - align-items: baseline; + align-items: center; /* center, not baseline — fixed height now */ gap: 6px; - padding: 3px 0; + height: 28px; /* MUST equal ROW_H */ + box-sizing: border-box; + padding: 0; border-bottom: 1px solid var(--background-modifier-border-hover); } +.org-todo-text { + cursor: pointer; + flex: 1; + white-space: nowrap; /* truncation: no wrap... */ + overflow: hidden; + text-overflow: ellipsis; /* ...show ellipsis instead */ + min-width: 0; /* lets flex item actually shrink to ellipsis */ +} .org-todo-text { cursor: pointer; flex: 1;