diff --git a/widgets/horizontal/index.html b/widgets/horizontal/index.html index 318dfcf..c0416ef 100644 --- a/widgets/horizontal/index.html +++ b/widgets/horizontal/index.html @@ -5,8 +5,8 @@ - - + + diff --git a/widgets/shared/scripts/emotes.js b/widgets/horizontal/scripts/emotes.js similarity index 100% rename from widgets/shared/scripts/emotes.js rename to widgets/horizontal/scripts/emotes.js diff --git a/widgets/shared/scripts/tasklist-view.js b/widgets/horizontal/scripts/tasklist-view.js similarity index 100% rename from widgets/shared/scripts/tasklist-view.js rename to widgets/horizontal/scripts/tasklist-view.js diff --git a/widgets/streamer-only/index.html b/widgets/streamer-only/index.html index 033836f..c0416ef 100644 --- a/widgets/streamer-only/index.html +++ b/widgets/streamer-only/index.html @@ -5,7 +5,7 @@ - + diff --git a/widgets/streamer-only/scripts/emotes.js b/widgets/streamer-only/scripts/emotes.js new file mode 100644 index 0000000..881f87e --- /dev/null +++ b/widgets/streamer-only/scripts/emotes.js @@ -0,0 +1,273 @@ +"use strict"; + +class EmoteManager { + #emotes = new Map(); + #channelName = ""; + #channelId = ""; + #providers = ["7tv", "bttv", "ffz"]; + #size = "1x"; + #loaded = false; + #cacheTTL = 24 * 60 * 60 * 1000; + #CACHE_KEY = "emote-cache-v1"; + #pattern = null; + #patternSize = 0; + + constructor(config = {}) { + this.#channelName = config.channelName || ""; + this.#channelId = config.channelId || ""; + if (config.providers) this.#providers = config.providers; + if (config.size) this.#size = config.size; + } + + get loaded() { + return this.#loaded; + } + + get emoteCount() { + return this.#emotes.size; + } + + get providers() { + return [...this.#providers]; + } + + getEmote(name) { + return this.#emotes.get(name) || null; + } + + #buildPattern() { + if (this.#emotes.size === 0) { + this.#pattern = null; + this.#patternSize = 0; + return; + } + + if (this.#pattern && this.#emotes.size === this.#patternSize) return; + + const sorted = [...this.#emotes.keys()].sort( + (a, b) => b.length - a.length, + ); + const escaped = sorted.map((n) => + n.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"), + ); + this.#pattern = new RegExp( + `(? { + const emote = this.#emotes.get(match); + if (!emote) return match; + console.log(emote); + return `${match}`; + }); + } + + async init() { + const cached = this.#loadCache(); + if (cached) { + this.#emotes = new Map(Object.entries(cached.emotes)); + this.#loaded = true; + this.#patternSize = 0; + return; + } + + await this.#fetchAll(); + this.#loaded = true; + this.#patternSize = 0; + this.#saveCache(); + } + + async forceRefresh() { + this.#emotes.clear(); + this.#patternSize = 0; + await this.#fetchAll(); + this.#loaded = true; + this.#patternSize = 0; + this.#saveCache(); + } + + async #fetchAll() { + const fetchers = []; + if (this.#providers.includes("7tv")) fetchers.push(this.#fetch7TV()); + if (this.#providers.includes("bttv")) fetchers.push(this.#fetchBTTV()); + if (this.#providers.includes("ffz")) fetchers.push(this.#fetchFFZ()); + await Promise.allSettled(fetchers); + } + + async #fetch7TV() { + try { + const globalResp = await fetch( + "https://7tv.io/v3/emote-sets/global", + ); + if (!globalResp.ok) return; + const globalData = await globalResp.json(); + if (globalData.emotes) { + for (const emote of globalData.emotes) { + this.#add7TVEmote(emote); + } + } + + if (this.#channelId) { + const userResp = await fetch( + `https://7tv.io/v3/users/twitch/${this.#channelId}`, + ); + if (!userResp.ok) return; + const userData = await userResp.json(); + if (userData.emote_set?.emotes) { + for (const emote of userData.emote_set.emotes) { + this.#add7TVEmote(emote); + } + } + } + } catch (e) { + console.warn("7TV emote fetch failed:", e.message); + } + } + + #add7TVEmote(emote) { + const entry = emote.data || emote; + const id = entry.id || emote.id; + const name = entry.name || emote.name; + if (!id || !name) return; + const nameLower = name; + const host = entry.host.url; + this.#emotes.set(nameLower, { + url: `https:${host}/${this.#size}.webp`, + provider: "7tv", + animated: entry.animated || false, + }); + } + + async #fetchBTTV() { + try { + const globalResp = await fetch( + "https://api.betterttv.net/3/cached/emotes/global", + ); + if (!globalResp.ok) return; + const globalData = await globalResp.json(); + if (Array.isArray(globalData)) { + for (const emote of globalData) { + this.#emotes.set(emote.code, { + url: `https://cdn.betterttv.net/emote/${emote.id}/${this.#size}`, + provider: "bttv", + animated: emote.imageType === "gif", + }); + } + } + + if (this.#channelId) { + const userResp = await fetch( + `https://api.betterttv.net/3/cached/users/twitch/${this.#channelId}`, + ); + if (!userResp.ok) return; + const userData = await userResp.json(); + const allEmotes = [ + ...(userData.channelEmotes || []), + ...(userData.sharedEmotes || []), + ]; + for (const emote of allEmotes) { + this.#emotes.set(emote.code, { + url: `https://cdn.betterttv.net/emote/${emote.id}/${this.#size}`, + provider: "bttv", + animated: emote.imageType === "gif", + }); + } + } + } catch (e) { + console.warn("BTTV emote fetch failed:", e.message); + } + } + + async #fetchFFZ() { + try { + const globalResp = await fetch( + "https://api.frankerfacez.com/v1/set/global", + ); + if (!globalResp.ok) return; + const globalData = await globalResp.json(); + if (globalData.sets) { + for (const setId in globalData.sets) { + const set = globalData.sets[setId]; + for (const emote of set.emoticons || []) { + if (emote.modifier) continue; + this.#emotes.set(emote.name, { + url: emote.urls[this.#toFFZSize(this.#size)], + provider: "ffz", + animated: false, + }); + } + } + } + + const ffzId = this.#channelName || this.#channelId; + if (ffzId) { + const roomUrl = this.#channelId + ? `https://api.frankerfacez.com/v1/room/id/${this.#channelId}` + : `https://api.frankerfacez.com/v1/room/${this.#channelName}`; + const roomResp = await fetch(roomUrl); + if (!roomResp.ok) return; + const roomData = await roomResp.json(); + if (roomData.sets) { + for (const setId in roomData.sets) { + const set = roomData.sets[setId]; + for (const emote of set.emoticons || []) { + if (emote.modifier) continue; + this.#emotes.set(emote.name, { + url: emote.urls[this.#toFFZSize(this.#size)], + provider: "ffz", + animated: false, + }); + } + } + } + } + } catch (e) { + console.warn("FFZ emote fetch failed:", e.message); + } + } + + #toFFZSize(size) { + switch (size) { + case "3x": + case "4x": + return "4"; + case "2x": + return "2"; + default: + return "1"; + } + } + + #saveCache() { + try { + const cache = { + timestamp: Date.now(), + channelId: this.#channelId, + emotes: Object.fromEntries(this.#emotes), + }; + localStorage.setItem(this.#CACHE_KEY, JSON.stringify(cache)); + } catch (e) {} + } + + #loadCache() { + try { + const raw = localStorage.getItem(this.#CACHE_KEY); + if (!raw) return null; + const cache = JSON.parse(raw); + if (cache.channelId !== this.#channelId) return null; + if (Date.now() - cache.timestamp > this.#cacheTTL) return null; + return cache; + } catch (e) { + return null; + } + } +} diff --git a/widgets/vertical/index.html b/widgets/vertical/index.html index 318dfcf..c0416ef 100644 --- a/widgets/vertical/index.html +++ b/widgets/vertical/index.html @@ -5,8 +5,8 @@ - - + + diff --git a/widgets/vertical/scripts/emotes.js b/widgets/vertical/scripts/emotes.js new file mode 100644 index 0000000..881f87e --- /dev/null +++ b/widgets/vertical/scripts/emotes.js @@ -0,0 +1,273 @@ +"use strict"; + +class EmoteManager { + #emotes = new Map(); + #channelName = ""; + #channelId = ""; + #providers = ["7tv", "bttv", "ffz"]; + #size = "1x"; + #loaded = false; + #cacheTTL = 24 * 60 * 60 * 1000; + #CACHE_KEY = "emote-cache-v1"; + #pattern = null; + #patternSize = 0; + + constructor(config = {}) { + this.#channelName = config.channelName || ""; + this.#channelId = config.channelId || ""; + if (config.providers) this.#providers = config.providers; + if (config.size) this.#size = config.size; + } + + get loaded() { + return this.#loaded; + } + + get emoteCount() { + return this.#emotes.size; + } + + get providers() { + return [...this.#providers]; + } + + getEmote(name) { + return this.#emotes.get(name) || null; + } + + #buildPattern() { + if (this.#emotes.size === 0) { + this.#pattern = null; + this.#patternSize = 0; + return; + } + + if (this.#pattern && this.#emotes.size === this.#patternSize) return; + + const sorted = [...this.#emotes.keys()].sort( + (a, b) => b.length - a.length, + ); + const escaped = sorted.map((n) => + n.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"), + ); + this.#pattern = new RegExp( + `(? { + const emote = this.#emotes.get(match); + if (!emote) return match; + console.log(emote); + return `${match}`; + }); + } + + async init() { + const cached = this.#loadCache(); + if (cached) { + this.#emotes = new Map(Object.entries(cached.emotes)); + this.#loaded = true; + this.#patternSize = 0; + return; + } + + await this.#fetchAll(); + this.#loaded = true; + this.#patternSize = 0; + this.#saveCache(); + } + + async forceRefresh() { + this.#emotes.clear(); + this.#patternSize = 0; + await this.#fetchAll(); + this.#loaded = true; + this.#patternSize = 0; + this.#saveCache(); + } + + async #fetchAll() { + const fetchers = []; + if (this.#providers.includes("7tv")) fetchers.push(this.#fetch7TV()); + if (this.#providers.includes("bttv")) fetchers.push(this.#fetchBTTV()); + if (this.#providers.includes("ffz")) fetchers.push(this.#fetchFFZ()); + await Promise.allSettled(fetchers); + } + + async #fetch7TV() { + try { + const globalResp = await fetch( + "https://7tv.io/v3/emote-sets/global", + ); + if (!globalResp.ok) return; + const globalData = await globalResp.json(); + if (globalData.emotes) { + for (const emote of globalData.emotes) { + this.#add7TVEmote(emote); + } + } + + if (this.#channelId) { + const userResp = await fetch( + `https://7tv.io/v3/users/twitch/${this.#channelId}`, + ); + if (!userResp.ok) return; + const userData = await userResp.json(); + if (userData.emote_set?.emotes) { + for (const emote of userData.emote_set.emotes) { + this.#add7TVEmote(emote); + } + } + } + } catch (e) { + console.warn("7TV emote fetch failed:", e.message); + } + } + + #add7TVEmote(emote) { + const entry = emote.data || emote; + const id = entry.id || emote.id; + const name = entry.name || emote.name; + if (!id || !name) return; + const nameLower = name; + const host = entry.host.url; + this.#emotes.set(nameLower, { + url: `https:${host}/${this.#size}.webp`, + provider: "7tv", + animated: entry.animated || false, + }); + } + + async #fetchBTTV() { + try { + const globalResp = await fetch( + "https://api.betterttv.net/3/cached/emotes/global", + ); + if (!globalResp.ok) return; + const globalData = await globalResp.json(); + if (Array.isArray(globalData)) { + for (const emote of globalData) { + this.#emotes.set(emote.code, { + url: `https://cdn.betterttv.net/emote/${emote.id}/${this.#size}`, + provider: "bttv", + animated: emote.imageType === "gif", + }); + } + } + + if (this.#channelId) { + const userResp = await fetch( + `https://api.betterttv.net/3/cached/users/twitch/${this.#channelId}`, + ); + if (!userResp.ok) return; + const userData = await userResp.json(); + const allEmotes = [ + ...(userData.channelEmotes || []), + ...(userData.sharedEmotes || []), + ]; + for (const emote of allEmotes) { + this.#emotes.set(emote.code, { + url: `https://cdn.betterttv.net/emote/${emote.id}/${this.#size}`, + provider: "bttv", + animated: emote.imageType === "gif", + }); + } + } + } catch (e) { + console.warn("BTTV emote fetch failed:", e.message); + } + } + + async #fetchFFZ() { + try { + const globalResp = await fetch( + "https://api.frankerfacez.com/v1/set/global", + ); + if (!globalResp.ok) return; + const globalData = await globalResp.json(); + if (globalData.sets) { + for (const setId in globalData.sets) { + const set = globalData.sets[setId]; + for (const emote of set.emoticons || []) { + if (emote.modifier) continue; + this.#emotes.set(emote.name, { + url: emote.urls[this.#toFFZSize(this.#size)], + provider: "ffz", + animated: false, + }); + } + } + } + + const ffzId = this.#channelName || this.#channelId; + if (ffzId) { + const roomUrl = this.#channelId + ? `https://api.frankerfacez.com/v1/room/id/${this.#channelId}` + : `https://api.frankerfacez.com/v1/room/${this.#channelName}`; + const roomResp = await fetch(roomUrl); + if (!roomResp.ok) return; + const roomData = await roomResp.json(); + if (roomData.sets) { + for (const setId in roomData.sets) { + const set = roomData.sets[setId]; + for (const emote of set.emoticons || []) { + if (emote.modifier) continue; + this.#emotes.set(emote.name, { + url: emote.urls[this.#toFFZSize(this.#size)], + provider: "ffz", + animated: false, + }); + } + } + } + } + } catch (e) { + console.warn("FFZ emote fetch failed:", e.message); + } + } + + #toFFZSize(size) { + switch (size) { + case "3x": + case "4x": + return "4"; + case "2x": + return "2"; + default: + return "1"; + } + } + + #saveCache() { + try { + const cache = { + timestamp: Date.now(), + channelId: this.#channelId, + emotes: Object.fromEntries(this.#emotes), + }; + localStorage.setItem(this.#CACHE_KEY, JSON.stringify(cache)); + } catch (e) {} + } + + #loadCache() { + try { + const raw = localStorage.getItem(this.#CACHE_KEY); + if (!raw) return null; + const cache = JSON.parse(raw); + if (cache.channelId !== this.#channelId) return null; + if (Date.now() - cache.timestamp > this.#cacheTTL) return null; + return cache; + } catch (e) { + return null; + } + } +} diff --git a/widgets/vertical/scripts/tasklist-view.js b/widgets/vertical/scripts/tasklist-view.js new file mode 100644 index 0000000..70ab5d5 --- /dev/null +++ b/widgets/vertical/scripts/tasklist-view.js @@ -0,0 +1,591 @@ +// 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"), + }; + } + + // ── 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; + } + + 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; + } + + // ── 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); + } + + if (this.#pendingRemovals === 0) { + this.#syncScroll(); + } + } + + // ── 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 ──────────────────────────────────── + + #parseTaskText(text) { + if (window.emoteManager && window.emoteManager.loaded) { + return window.emoteManager.parseText(text); + } + return text; + } + + #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"; + textSpan.innerHTML = this.#parseTaskText(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"); + textEl.innerHTML = this.#parseTaskText(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) { + clone = this.#els.content.cloneNode(true); + clone.id = ""; + clone.classList.add("scroll-clone"); + 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);