rython-task-bot-v2/widgets/vertical/scripts/tasklist-view.js
2026-08-16 22:50:12 +10:00

625 lines
14 KiB
JavaScript

// Task list view
class TaskList {
#data = [];
#firstRender = true;
#pendingRemovals = 0;
#scroll = {
offset: 0,
contentH: 0,
viewportH: 0,
speed: 50,
raf: null,
lastT: 0,
active: false,
wantStop: false,
};
#els = {};
#ANIM_DURATION = 400;
#ANIM_EASING = "cubic-bezier(0.22, 1, 0.36, 1)";
#parseHexColor(hex) {
if (typeof hex !== "string") return null;
const s = hex.trim();
const m = /^#?([0-9a-f]{3}|[0-9a-f]{6})$/i.exec(s);
if (!m) return null;
let h = m[1].toLowerCase();
if (h.length === 3) {
h = h
.split("")
.map((c) => c + c)
.join("");
}
const r = parseInt(h.slice(0, 2), 16);
const g = parseInt(h.slice(2, 4), 16);
const b = parseInt(h.slice(4, 6), 16);
return { r, g, b };
}
#relativeLuminance({ r, g, b }) {
const toLinear = (v) => {
const s = v / 255;
return s <= 0.04045
? s / 12.92
: Math.pow((s + 0.055) / 1.055, 2.4);
};
const R = toLinear(r);
const G = toLinear(g);
const B = toLinear(b);
return 0.2126 * R + 0.7152 * G + 0.0722 * B;
}
constructor(container) {
const el =
typeof container === "string"
? document.querySelector(container)
: container;
this.#els = {
track: el.querySelector(".scroll-track"),
content: el.querySelector(".scroll-content"),
viewport: el.querySelector(".scroll-viewport"),
count: el.querySelector("#task-count"),
};
}
// ── public API ─────────────────────────────────────
load(sections) {
this.#data = structuredClone(sections);
this.#render();
return this;
}
addSection(section) {
this.#data.push(structuredClone(section));
this.#render();
return this;
}
removeSection(id) {
const i = this.#data.findIndex((s) => s.id === id);
if (i === -1) return this;
this.#data.splice(i, 1);
this.#render();
return this;
}
updateSection(id, updater) {
const section = this.#data.find((s) => s.id === id);
if (!section) return this;
updater(section);
this.#render();
return this;
}
updateUserColor(sectionId, color) {
const section = this.#data.find((s) => s.id === sectionId);
if (!section) return this;
section.color = color;
document.querySelectorAll(".section").forEach((el) => {
if (el.dataset.key !== sectionId) return;
const title = el.querySelector(":scope > .section-title");
if (!title) return;
title.style.setProperty("--user-color", color);
title.classList.add("has-user-color");
});
return this;
}
addTask(sectionId, task, sectionTitle) {
if (!this.#data.find((s) => s.id === sectionId)) {
this.addSection({
id: sectionId,
title: sectionTitle || sectionId,
tasks: [],
});
}
return this.updateSection(sectionId, (s) => {
s.tasks.push(structuredClone(task));
});
}
removeTask(sectionId, taskIndex) {
this.updateSection(sectionId, (s) => {
s.tasks.splice(taskIndex, 1);
});
this.#data = this.#data.filter((s) => s.tasks.length > 0);
this.#render();
return this;
}
editTask(sectionId, taskIndex, newText, userColor) {
return this.updateSection(sectionId, (s) => {
if (s.tasks[taskIndex]) {
s.tasks[taskIndex].text = newText;
}
});
}
doneTask(sectionId, taskIndex) {
return this.updateSection(sectionId, (s) => {
if (s.tasks[taskIndex]) {
s.tasks[taskIndex].done = true;
s.tasks[taskIndex].focused = false;
}
});
}
undoneTask(sectionId, taskIndex) {
return this.updateSection(sectionId, (s) => {
if (s.tasks[taskIndex]) {
s.tasks[taskIndex].done = false;
}
});
}
focusTask(sectionId, taskIndex) {
// unfocus any previously focused task
this.unfocusTask(sectionId);
return this.updateSection(sectionId, (s) => {
if (s.tasks[taskIndex]) {
s.tasks[taskIndex].focused = true;
}
});
}
unfocusTask(sectionId) {
return this.updateSection(sectionId, (s) => {
for (let i = 0; i < s.tasks.length; i++) {
s.tasks[i].focused = false;
}
});
}
clearmydone(sectionId) {
this.updateSection(sectionId, (s) => {
s.tasks = s.tasks.filter((t) => !t.done);
});
this.#data = this.#data.filter((s) => s.tasks.length > 0);
this.#render();
return this;
}
cleardone() {
this.#data.forEach((s) => {
s.tasks = s.tasks.filter((t) => !t.done);
});
this.#data = this.#data.filter((s) => s.tasks.length > 0);
this.#render();
return this;
}
getData() {
return structuredClone(this.#data);
}
get sectionCount() {
return this.#data.length;
}
get taskCount() {
return this.#data.reduce((sum, s) => sum + s.tasks.length, 0);
}
get doneCount() {
return this.#data.reduce(
(sum, s) => sum + s.tasks.filter((t) => t.done).length,
0,
);
}
destroy() {
this.#forceStop();
this.#els.content.innerHTML = "";
this.#data = [];
this.#firstRender = true;
this.#updateTaskCount();
}
// ── render ─────────────────────────────────────────
#render() {
if (this.#firstRender) {
this.#data.forEach((s) =>
this.#els.content.appendChild(this.#createSectionEl(s)),
);
this.#firstRender = false;
} else {
this.#patchContainer(this.#els.content, this.#data, true);
}
this.#updateTaskCount();
if (this.#pendingRemovals === 0) {
this.#syncScroll();
}
}
#updateTaskCount() {
if (!this.#els.count) return;
this.#els.count.textContent = `${this.doneCount}/${this.taskCount}`;
}
// ── animations ─────────────────────────────────────
#animateIn(el) {
el.style.overflow = "hidden";
const h = el.scrollHeight;
el.style.height = "0px";
el.style.opacity = "0";
requestAnimationFrame(() => {
const anim = el.animate(
[
{
height: "0px",
opacity: 0,
transform: "translateX(-8px)",
},
{
height: h + "px",
opacity: 1,
transform: "translateX(0)",
},
],
{
duration: this.#ANIM_DURATION,
easing: this.#ANIM_EASING,
fill: "forwards",
},
);
anim.onfinish = () => {
el.style.height = "";
el.style.opacity = "";
el.style.overflow = "";
el.style.transform = "";
anim.cancel();
this.#syncScroll();
};
});
}
#animateRemove(el) {
this.#pendingRemovals++;
const h = el.scrollHeight;
const anim = el.animate(
[
{ height: h + "px", opacity: 1, transform: "translateX(0)" },
{ height: "0px", opacity: 0, transform: "translateX(-8px)" },
],
{
duration: this.#ANIM_DURATION,
easing: this.#ANIM_EASING,
fill: "forwards",
},
);
anim.onfinish = () => {
el.remove();
this.#pendingRemovals--;
if (this.#pendingRemovals === 0) {
this.#syncScroll();
}
};
}
#animateStrikethrough(el) {
el.animate(
[
{ opacity: 0.5, transform: "scale(0.98)" },
{ opacity: 1, transform: "scale(1)" },
],
{ duration: 250, easing: "ease-out" },
);
}
// ── dom helpers ────────────────────────────────────
#stripHtml(text) {
return String(text ?? "").replace(/<\/?[^>\n]+>/g, "");
}
#setTaskText(el, text) {
const safe = this.#stripHtml(text);
if (window.emoteManager && window.emoteManager.loaded) {
window.emoteManager.renderInto(el, safe);
} else {
el.textContent = safe;
}
}
#createTaskEl(task, index) {
const div = document.createElement("div");
div.className =
"task" +
(task.done ? " done" : "") +
(task.focused ? " focused" : "");
div.dataset.text = task.text;
const numberSpan = document.createElement("span");
numberSpan.className = "task-number";
numberSpan.textContent = `${index + 1}.`;
const textSpan = document.createElement("span");
textSpan.className = "task-text";
this.#setTaskText(textSpan, task.text);
div.replaceChildren(numberSpan, textSpan);
return div;
}
#createSectionEl(section) {
const div = document.createElement("div");
div.className = "section";
div.dataset.key = section.id;
if (section.title) {
const t = document.createElement("div");
t.className = "section-title";
t.textContent = section.title;
let color =
section.color ?? localStorage.getItem(`${section.id}-color`);
if (
configs.userColorSettings.autoUserColor &&
color != undefined &&
color != "undefined" &&
color != null
) {
t.style.setProperty("--user-color", color);
t.classList.add("has-user-color");
}
div.appendChild(t);
}
section.tasks.forEach((task, i) =>
div.appendChild(this.#createTaskEl(task, i)),
);
return div;
}
// ── patching ───────────────────────────────────────
#patchTasks(sectionEl, tasks, animate) {
const existing = [...sectionEl.querySelectorAll(":scope > .task")];
tasks.forEach((task, i) => {
if (i < existing.length) {
const el = existing[i];
const wantClass =
"task" +
(task.done ? " done" : "") +
(task.focused ? " focused" : "");
if (el.className !== wantClass) {
el.className = wantClass;
if (animate) this.#animateStrikethrough(el);
}
if (el.dataset.text !== task.text) {
el.dataset.text = task.text;
const textEl =
el.querySelector(".task-text") ||
el.querySelector("span:last-child");
this.#setTaskText(textEl, task.text);
}
el.querySelector(".task-number").textContent = `${i + 1}.`;
// Update color if changed
if (task.color != undefined && task.color != "undefined") {
el.style.setProperty("--user-color", task.color);
el.classList.add("has-user-color");
} else {
el.style.removeProperty("--user-color");
el.classList.remove("has-user-color");
}
} else {
const newEl = this.#createTaskEl(task, i);
sectionEl.appendChild(newEl);
if (animate) this.#animateIn(newEl);
}
});
for (let i = existing.length - 1; i >= tasks.length; i--) {
if (animate) {
this.#animateRemove(existing[i]);
} else {
existing[i].remove();
}
}
}
#patchContainer(container, sections, animate) {
const oldMap = new Map();
container.querySelectorAll(":scope > .section").forEach((el) => {
oldMap.set(el.dataset.key, el);
});
let cursor = container.firstElementChild;
sections.forEach((section) => {
const key = section.id;
const existing = oldMap.get(key);
if (existing) {
this.#patchTasks(existing, section.tasks, animate);
if (existing !== cursor) {
container.insertBefore(existing, cursor);
} else {
cursor = cursor.nextElementSibling;
}
oldMap.delete(key);
} else {
const newEl = this.#createSectionEl(section);
container.insertBefore(newEl, cursor);
if (animate) this.#animateIn(newEl);
}
});
oldMap.forEach((el) => {
if (animate) {
this.#animateRemove(el);
} else {
el.remove();
}
});
}
// ── scroll ─────────────────────────────────────────
#tick = (now) => {
const s = this.#scroll;
const dt = (now - s.lastT) / 1000;
s.lastT = now;
if (dt < 0.2) {
s.contentH = this.#els.content.scrollHeight;
s.offset += s.speed * dt;
if (s.contentH > 0 && s.offset >= s.contentH) {
s.offset -= s.contentH;
if (s.wantStop) {
s.wantStop = false;
this.#forceStop();
return;
}
}
this.#els.track.style.transform = `translateY(${-s.offset}px)`;
}
s.raf = requestAnimationFrame(this.#tick);
};
#startScroll() {
const s = this.#scroll;
s.wantStop = false;
if (s.active) return;
s.active = true;
s.lastT = performance.now();
s.raf = requestAnimationFrame(this.#tick);
}
#requestStop() {
if (!this.#scroll.active) return;
this.#scroll.wantStop = true;
}
#forceStop() {
const s = this.#scroll;
s.active = false;
s.wantStop = false;
cancelAnimationFrame(s.raf);
s.raf = null;
s.offset = 0;
this.#els.track.style.transform = "";
const clone = this.#els.track.querySelector(".scroll-clone");
if (clone) clone.remove();
}
#syncScroll() {
const s = this.#scroll;
s.contentH = this.#els.content.scrollHeight;
s.viewportH = this.#els.viewport.clientHeight;
const needs = s.contentH > s.viewportH;
let clone = this.#els.track.querySelector(".scroll-clone");
if (needs) {
if (!clone) {
// Build the loop copy from data. Deep-cloning the live content also
// copies temporary height/opacity styles from enter animations,
// leaving the copy collapsed and causing a visible jump at the seam.
clone = this.#els.content.cloneNode(false);
clone.id = "";
clone.classList.add("scroll-clone");
this.#patchContainer(clone, this.#data, false);
this.#els.track.appendChild(clone);
} else {
this.#patchContainer(clone, this.#data, false);
}
if (s.offset >= s.contentH) {
s.offset %= s.contentH;
}
this.#startScroll();
} else {
if (s.active) {
if (clone) this.#patchContainer(clone, this.#data, false);
this.#requestStop();
} else {
if (clone) clone.remove();
}
}
}
}
// ── usage ──────────────────────────────────────────────
// const list2 = new TaskList(".task-panel");
// list2.load([
// {
// title: "someone",
// tasks: [
// { text: "dinner" },
// { text: "watch bad bunny halftime show" },
// { text: "tie tf down", done: true },
// { text: "boy dinner", done: true },
// { text: "3 hour SOOP" },
// ],
// },
// {
// title: "Cape_Codder",
// tasks: [
// { text: "Part 2", done: true },
// { text: "Ch 1-2", done: true },
// ],
// },
// {
// title: "sunflawer",
// tasks: [
// { text: "fix walls from home office" },
// { text: "sand walls zzzsdkjhfsk", done: true },
// { text: "meal prep" },
// { text: "buy wrist brace maybe" },
// ],
// },
// {
// title: "extra_user1",
// tasks: [
// { text: "go grocery shopping" },
// { text: "clean the kitchen", done: true },
// { text: "respond to emails" },
// ],
// },
// ]);
// demo
// setTimeout(() => {
// list2.addTask("someone2", { text: "new task just dropped" });
// }, 1000);
// setTimeout(() => {
// list2.;
// }, 3000);
// setTimeout(() => {
// list.toggleTask("sunflawer", 0);
// }, 10000);
// setTimeout(() => {
// list.removeSection("Cape_Codder");
// }, 12000);
// setTimeout(() => {
// list.removeTask("extra_user1", 1);
// }, 14000);