mirror of
https://github.com/liyunze-coding/rython-task-bot-v2.git
synced 2026-09-22 07:24:27 +00:00
modified shared scripts
This commit is contained in:
parent
c57bfd75fb
commit
abb3f5f7a0
8 changed files with 1142 additions and 5 deletions
|
|
@ -5,8 +5,8 @@
|
|||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<script src="../shared/lib/streamerbot-client.js"></script>
|
||||
<script defer src="./config.js"></script>
|
||||
<script defer src="../shared/scripts/emotes.js"></script>
|
||||
<script defer src="../shared/scripts/tasklist-view.js"></script>
|
||||
<script defer src="./scripts/emotes.js"></script>
|
||||
<script defer src="./scripts/tasklist-view.js"></script>
|
||||
<script defer src="../shared/scripts/streamerbot.js"></script>
|
||||
<script defer src="./scripts/header-animation.js"></script>
|
||||
<link rel="stylesheet" href="./styles/style.css" />
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@
|
|||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<script src="../shared/lib/streamerbot-client.js"></script>
|
||||
<script defer src="./config.js"></script>
|
||||
<script defer src="../shared/scripts/emotes.js"></script>
|
||||
<script defer src="./scripts/emotes.js"></script>
|
||||
<script defer src="./scripts/tasklist-view.js"></script>
|
||||
<script defer src="../shared/scripts/streamerbot.js"></script>
|
||||
<script defer src="./scripts/header-animation.js"></script>
|
||||
|
|
|
|||
273
widgets/streamer-only/scripts/emotes.js
Normal file
273
widgets/streamer-only/scripts/emotes.js
Normal file
|
|
@ -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(
|
||||
`(?<![\\w])(?:${escaped.join("|")})(?![\\w])`,
|
||||
"g",
|
||||
);
|
||||
this.#patternSize = this.#emotes.size;
|
||||
}
|
||||
|
||||
parseText(text) {
|
||||
if (!text || !this.#loaded || this.#emotes.size === 0) return text;
|
||||
|
||||
this.#buildPattern();
|
||||
if (!this.#pattern) return text;
|
||||
|
||||
return text.replace(this.#pattern, (match) => {
|
||||
const emote = this.#emotes.get(match);
|
||||
if (!emote) return match;
|
||||
console.log(emote);
|
||||
return `<img src="${emote.url}" alt="${match}" title="${match} (${emote.provider})" class="emote-img" />`;
|
||||
});
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -5,8 +5,8 @@
|
|||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<script src="../shared/lib/streamerbot-client.js"></script>
|
||||
<script defer src="./config.js"></script>
|
||||
<script defer src="../shared/scripts/emotes.js"></script>
|
||||
<script defer src="../shared/scripts/tasklist-view.js"></script>
|
||||
<script defer src="./scripts/emotes.js"></script>
|
||||
<script defer src="./scripts/tasklist-view.js"></script>
|
||||
<script defer src="../shared/scripts/streamerbot.js"></script>
|
||||
<script defer src="./scripts/header-animation.js"></script>
|
||||
<link rel="stylesheet" href="./styles/style.css" />
|
||||
|
|
|
|||
273
widgets/vertical/scripts/emotes.js
Normal file
273
widgets/vertical/scripts/emotes.js
Normal file
|
|
@ -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(
|
||||
`(?<![\\w])(?:${escaped.join("|")})(?![\\w])`,
|
||||
"g",
|
||||
);
|
||||
this.#patternSize = this.#emotes.size;
|
||||
}
|
||||
|
||||
parseText(text) {
|
||||
if (!text || !this.#loaded || this.#emotes.size === 0) return text;
|
||||
|
||||
this.#buildPattern();
|
||||
if (!this.#pattern) return text;
|
||||
|
||||
return text.replace(this.#pattern, (match) => {
|
||||
const emote = this.#emotes.get(match);
|
||||
if (!emote) return match;
|
||||
console.log(emote);
|
||||
return `<img src="${emote.url}" alt="${match}" title="${match} (${emote.provider})" class="emote-img" />`;
|
||||
});
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
591
widgets/vertical/scripts/tasklist-view.js
Normal file
591
widgets/vertical/scripts/tasklist-view.js
Normal file
|
|
@ -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);
|
||||
Loading…
Reference in a new issue