refresh, added emote support

This commit is contained in:
liyunze 2026-07-01 16:55:55 +10:00
parent 168ef05777
commit e6c58a1ec7
20 changed files with 1016 additions and 233 deletions

28
Code.cs
View file

@ -464,7 +464,7 @@ public class TaskOperations
} }
int newIndex = taskData[key].Tasks.Count - 1; int newIndex = taskData[key].Tasks.Count - 1;
broadcast(new { mode = "add", task = taskName, completed = completed, focused = focused }, null); broadcast(new { mode = "refresh", task = taskName, completed = completed, focused = focused }, null);
return new Response<(int, string)>(true, (newIndex, taskName), null); return new Response<(int, string)>(true, (newIndex, taskName), null);
} }
@ -481,7 +481,7 @@ public class TaskOperations
string oldName = userTasks[index].Name; string oldName = userTasks[index].Name;
userTasks[index].Name = newTask; userTasks[index].Name = newTask;
SaveIntoTasks(userTasks); SaveIntoTasks(userTasks);
broadcast(new { mode = "edit", index = index, task = newTask }, null); broadcast(new { mode = "refresh", index = index, task = newTask }, null);
return new Response<(string, string)>(true, (oldName, newTask), null); return new Response<(string, string)>(true, (oldName, newTask), null);
} }
@ -502,7 +502,7 @@ public class TaskOperations
UnfocusAll(tasks); UnfocusAll(tasks);
tasks[n].Focused = true; tasks[n].Focused = true;
SaveIntoTasks(tasks); SaveIntoTasks(tasks);
broadcast(new { mode = "focus", index = n }, null); broadcast(new { mode = "refresh", index = n }, null);
return new Response<(int, string)>(true, (n, tasks[n].Name), null); return new Response<(int, string)>(true, (n, tasks[n].Name), null);
} }
else if (indexByName > -1) else if (indexByName > -1)
@ -513,7 +513,7 @@ public class TaskOperations
UnfocusAll(tasks); UnfocusAll(tasks);
tasks[n].Focused = true; tasks[n].Focused = true;
SaveIntoTasks(tasks); SaveIntoTasks(tasks);
broadcast(new { mode = "focus", index = n }, null); broadcast(new { mode = "refresh", index = n }, null);
return new Response<(int, string)>(true, (n, tasks[n].Name), null); return new Response<(int, string)>(true, (n, tasks[n].Name), null);
} }
else else
@ -521,7 +521,7 @@ public class TaskOperations
var response = AddTask(rawInput, false, true); var response = AddTask(rawInput, false, true);
if (response.Success) if (response.Success)
{ {
broadcast(new { mode = "focus", index = response.Data.Item1 }, null); broadcast(new { mode = "refresh", index = response.Data.Item1 }, null);
return new Response<(int, string)>(true, response.Data, null); return new Response<(int, string)>(true, response.Data, null);
} }
@ -846,7 +846,7 @@ public class CPHInline
string completedTaskName = userTasks[focusedTaskIndex].Name; string completedTaskName = userTasks[focusedTaskIndex].Name;
userTasks[focusedTaskIndex].Completed = true; userTasks[focusedTaskIndex].Completed = true;
userTasks[focusedTaskIndex].Focused = false; userTasks[focusedTaskIndex].Focused = false;
Broadcast(new { mode = "done", index = focusedTaskIndex }, null); Broadcast(new { mode = "refresh", index = focusedTaskIndex }, null);
operations.SaveIntoTasks(userTasks); operations.SaveIntoTasks(userTasks);
SaveTasks(); SaveTasks();
Respond(BotResponses.NextSuccess(completedTaskName, focusResponse.Data.Item1 + 1, focusResponse.Data.Item2)); Respond(BotResponses.NextSuccess(completedTaskName, focusResponse.Data.Item1 + 1, focusResponse.Data.Item2));
@ -997,7 +997,7 @@ public class CPHInline
foreach (int i in taskIndices.OrderByDescending(n => n)) foreach (int i in taskIndices.OrderByDescending(n => n))
{ {
userTasks.RemoveAt(i); userTasks.RemoveAt(i);
Broadcast(new { mode = "remove", index = i }, null); Broadcast(new { mode = "refresh", index = i }, null);
} }
operations.SaveIntoTasks(userTasks); operations.SaveIntoTasks(userTasks);
@ -1024,7 +1024,7 @@ public class CPHInline
operations.RemoveUser(key); operations.RemoveUser(key);
SaveTasks(); SaveTasks();
Respond(BotResponses.AdminDeleteSuccess); Respond(BotResponses.AdminDeleteSuccess);
Broadcast(new { mode = "admindelete", id = key }, null); Broadcast(new { mode = "refresh", id = key }, null);
return true; return true;
} }
@ -1081,7 +1081,7 @@ public class CPHInline
{ {
userTasks[i].Completed = true; userTasks[i].Completed = true;
userTasks[i].Focused = false; userTasks[i].Focused = false;
Broadcast(new { mode = "done", index = i }, null); Broadcast(new { mode = "refresh", index = i }, null);
} }
IncrementDoneCount(taskIndices.Count); IncrementDoneCount(taskIndices.Count);
@ -1096,7 +1096,7 @@ public class CPHInline
operations.Unfocus(); operations.Unfocus();
SaveTasks(); SaveTasks();
Respond(BotResponses.Unfocused); Respond(BotResponses.Unfocused);
Broadcast(new { mode = "unfocus" }, null); Broadcast(new { mode = "refresh" }, null);
return true; return true;
} }
@ -1144,7 +1144,7 @@ public class CPHInline
{ {
userTasks[i].Completed = false; userTasks[i].Completed = false;
userTasks[i].Focused = false; userTasks[i].Focused = false;
Broadcast(new { mode = "undone", index = i }, null); Broadcast(new { mode = "refresh", index = i }, null);
} }
operations.SaveIntoTasks(userTasks); operations.SaveIntoTasks(userTasks);
@ -1169,7 +1169,7 @@ public class CPHInline
operations.ClearUserCompletedTasks(key); operations.ClearUserCompletedTasks(key);
operations.Cleanup(false); operations.Cleanup(false);
SaveTasks(); SaveTasks();
Broadcast(new { mode = "clearmydone" }, null); Broadcast(new { mode = "refresh" }, null);
Respond(BotResponses.ClearMyDone); Respond(BotResponses.ClearMyDone);
return true; return true;
} }
@ -1179,7 +1179,7 @@ public class CPHInline
operations.ClearCompletedTasks(); operations.ClearCompletedTasks();
operations.Cleanup(false); operations.Cleanup(false);
SaveTasks(); SaveTasks();
Broadcast(new { mode = "cleardone" }, null); Broadcast(new { mode = "refresh" }, null);
Respond(BotResponses.ClearDone); Respond(BotResponses.ClearDone);
return true; return true;
} }
@ -1189,7 +1189,7 @@ public class CPHInline
operations.FilterToStreamers(GetStreamerUsernames()); operations.FilterToStreamers(GetStreamerUsernames());
operations.Cleanup(false); operations.Cleanup(false);
SaveTasks(); SaveTasks();
Broadcast(new { mode = "clearns" }, null); Broadcast(new { mode = "refresh" }, null);
Respond(BotResponses.ClearNotStreamer); Respond(BotResponses.ClearNotStreamer);
return true; return true;
} }

View file

@ -3,7 +3,7 @@
const configs = (function () { const configs = (function () {
const streamerBotSettings = { const streamerBotSettings = {
host: "127.0.0.1", host: "127.0.0.1",
port: 8080, port: 6968,
endpoint: "/", endpoint: "/",
}; };
@ -17,18 +17,23 @@ const configs = (function () {
"!clearmydone", "!clearmydone",
]; ];
const twitchSettings = { const userColorSettings = {
autoUserColor: true autoUserColor: true,
} };
const kickSettings = { const emoteSettings = {
autouserColor: true enabled: true,
} channelName: "rythondev",
channelId: "248474026",
providers: ["7tv", "bttv", "ffz"],
size: "1x",
cacheHours: 24,
};
return { return {
streamerBotSettings, streamerBotSettings,
commands, commands,
twitchSettings, userColorSettings,
kickSettings emoteSettings,
}; };
})(); })();

View file

@ -5,6 +5,7 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<script src="./lib/streamerbot-client.js"></script> <script src="./lib/streamerbot-client.js"></script>
<script defer src="./config.js"></script> <script defer src="./config.js"></script>
<script defer src="./scripts/emotes.js"></script>
<script defer src="./scripts/tasklist-view.js"></script> <script defer src="./scripts/tasklist-view.js"></script>
<script defer src="./scripts/streamerbot.js"></script> <script defer src="./scripts/streamerbot.js"></script>
<script defer src="./scripts/header-animation.js"></script> <script defer src="./scripts/header-animation.js"></script>

View 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;
}
}
}

View file

@ -21,8 +21,9 @@ function getAllLocalstorage() {
} }
client.on("General.Custom", (data) => onCustom(data)); client.on("General.Custom", (data) => onCustom(data));
if (configs.twitchSettings.autoUserColor) { if (configs.userColorSettings.autoUserColor) {
client.on("Twitch.ChatMessage", (data) => onTwitchFirstWord(data)); client.on("Twitch.ChatMessage", (data) => onChatMessage(data));
client.on("Kick.ChatMessage", (data) => onChatMessage(data));
} }
let taskList; let taskList;
@ -57,12 +58,10 @@ function relativeLuminance({ r, g, b }) {
return 0.2126 * R + 0.7152 * G + 0.0722 * B; return 0.2126 * R + 0.7152 * G + 0.0722 * B;
} }
async function onTwitchFirstWord(data) { async function onChatMessage(data) {
let userColor = data.data.user.color; // hex colour #FF69B4 let userColor = data.data.user.color; // hex colour #FF69B4
if (userColor != undefined || userColor != "undefined") {
if (userColor != undefined || userColor != 'undefined') {
// update localstorage // update localstorage
// get id: platform-userID // get id: platform-userID
let userId = data.data.user.id; let userId = data.data.user.id;
@ -71,7 +70,7 @@ async function onTwitchFirstWord(data) {
localStorage.setItem(`${key}-color`, userColor); localStorage.setItem(`${key}-color`, userColor);
userColors[`${key}-color`] = userColor; userColors[`${key}-color`] = userColor;
} }
return; return;
} }
@ -135,6 +134,22 @@ async function refresh() {
// LOAD TASK LIST // LOAD TASK LIST
async function onConnect() { async function onConnect() {
taskList = new TaskList(".task-panel"); taskList = new TaskList(".task-panel");
if (configs.emoteSettings.enabled && configs.emoteSettings.channelId) {
window.emoteManager = new EmoteManager({
channelName: configs.emoteSettings.channelName,
channelId: configs.emoteSettings.channelId,
providers: configs.emoteSettings.providers,
size: configs.emoteSettings.size,
});
window.emoteManager.init().then(() => {
if (taskList) {
taskList.load(taskList.getData());
}
});
}
refresh(); refresh();
} }
@ -162,59 +177,9 @@ function onCustom(payload) {
const username = data.username; const username = data.username;
switch (body.mode) { switch (body.mode) {
case "add": case "refresh":
// body.task;
let taskPayload = {
text: body.task,
done: body.completed,
focused: body.focused,
};
let usernameColor =
userColors[id] ?? localStorage.getItem(`${id}-color`);
if (configs.twitchSettings.autoUserColor && usernameColor != undefined && usernameColor != null) {
taskPayload["color"] = usernameColor;
}
taskList.addTask(id, taskPayload, username);
break;
case "focus":
taskList.focusTask(id, body.index, usernameColor);
break;
case "edit":
taskList.editTask(id, body.index, body.task, usernameColor);
break;
case "remove":
taskList.removeTask(id, body.index, usernameColor);
break;
case "done":
taskList.doneTask(id, body.index, usernameColor);
break;
case "admindelete":
taskList.removeSection(body.id);
break;
case "clearns":
refresh(); refresh();
break; break;
case "cleardone":
taskList.cleardone();
break;
case "clearmydone":
taskList.clearmydone(id);
break;
case "clearall":
refresh();
break;
case "undone":
taskList.undoneTask(id, body.index);
break;
case "unfocus":
taskList.unfocusTask(id);
break;
case "admindelete":
taskList.removeSection(body.id);
break;
default: default:
break; break;
} }

View file

@ -302,6 +302,13 @@ class TaskList {
// ── dom helpers ──────────────────────────────────── // ── dom helpers ────────────────────────────────────
#parseTaskText(text) {
if (window.emoteManager && window.emoteManager.loaded) {
return window.emoteManager.parseText(text);
}
return text;
}
#createTaskEl(task, index) { #createTaskEl(task, index) {
const div = document.createElement("div"); const div = document.createElement("div");
div.className = div.className =
@ -314,7 +321,8 @@ class TaskList {
numberSpan.textContent = `${index + 1}.`; numberSpan.textContent = `${index + 1}.`;
const textSpan = document.createElement("span"); const textSpan = document.createElement("span");
textSpan.textContent = task.text; textSpan.className = "task-text";
textSpan.innerHTML = this.#parseTaskText(task.text);
div.replaceChildren(numberSpan, textSpan); div.replaceChildren(numberSpan, textSpan);
return div; return div;
@ -366,7 +374,8 @@ class TaskList {
} }
if (el.dataset.text !== task.text) { if (el.dataset.text !== task.text) {
el.dataset.text = task.text; el.dataset.text = task.text;
el.querySelector("span:last-child").textContent = 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}.`; el.querySelector(".task-number").textContent = `${i + 1}.`;

View file

@ -141,3 +141,14 @@ body {
min-width: 16px; min-width: 16px;
text-align: right; text-align: right;
} }
.emote-img {
height: 1.4em;
vertical-align: middle;
display: inline-block;
margin: -0.2em 0;
}
.task.done .emote-img {
opacity: 0.5;
}

View file

@ -3,7 +3,7 @@
const configs = (function () { const configs = (function () {
const streamerBotSettings = { const streamerBotSettings = {
host: "127.0.0.1", host: "127.0.0.1",
port: 8080, port: 6968,
endpoint: "/", endpoint: "/",
}; };
@ -19,12 +19,17 @@ const configs = (function () {
"!clearmydone", "!clearmydone",
]; ];
const twitchSettings = { const userColorSettings = {
autoUserColor: true, autoUserColor: true,
}; };
const kickSettings = { const emoteSettings = {
autouserColor: true, enabled: true,
channelName: "rythondev",
channelId: "248474026",
providers: ["7tv", "bttv", "ffz"],
size: "1x",
cacheHours: 24,
}; };
return { return {
@ -33,5 +38,6 @@ const configs = (function () {
commands, commands,
twitchSettings, twitchSettings,
kickSettings, kickSettings,
emoteSettings,
}; };
})(); })();

View file

@ -5,6 +5,7 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<script src="./lib/streamerbot-client.js"></script> <script src="./lib/streamerbot-client.js"></script>
<script defer src="./config.js"></script> <script defer src="./config.js"></script>
<script defer src="./scripts/emotes.js"></script>
<script defer src="./scripts/tasklist-view.js"></script> <script defer src="./scripts/tasklist-view.js"></script>
<script defer src="./scripts/streamerbot.js"></script> <script defer src="./scripts/streamerbot.js"></script>
<script defer src="./scripts/header-animation.js"></script> <script defer src="./scripts/header-animation.js"></script>

View 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;
}
}
}

View file

@ -21,8 +21,9 @@ function getAllLocalstorage() {
} }
client.on("General.Custom", (data) => onCustom(data)); client.on("General.Custom", (data) => onCustom(data));
if (configs.twitchSettings.autoUserColor) { if (configs.userColorSettings.autoUserColor) {
client.on("Twitch.ChatMessage", (data) => onTwitchFirstWord(data)); client.on("Twitch.ChatMessage", (data) => onChatMessage(data));
client.on("Kick.ChatMessage", (data) => onChatMessage(data));
} }
let taskList; let taskList;
@ -57,7 +58,7 @@ function relativeLuminance({ r, g, b }) {
return 0.2126 * R + 0.7152 * G + 0.0722 * B; return 0.2126 * R + 0.7152 * G + 0.0722 * B;
} }
async function onTwitchFirstWord(data) { async function onChatMessage(data) {
let userColor = data.data.user.color; // hex colour let userColor = data.data.user.color; // hex colour
if (userColor != undefined || userColor != "undefined") { if (userColor != undefined || userColor != "undefined") {
@ -134,6 +135,22 @@ async function refresh() {
// LOAD TASK LIST // LOAD TASK LIST
async function onConnect() { async function onConnect() {
taskList = new TaskList(".task-panel"); taskList = new TaskList(".task-panel");
if (configs.emoteSettings.enabled && configs.emoteSettings.channelId) {
window.emoteManager = new EmoteManager({
channelName: configs.emoteSettings.channelName,
channelId: configs.emoteSettings.channelId,
providers: configs.emoteSettings.providers,
size: configs.emoteSettings.size,
});
window.emoteManager.init().then(() => {
if (taskList) {
taskList.load(taskList.getData());
}
});
}
refresh(); refresh();
} }
@ -161,63 +178,9 @@ function onCustom(payload) {
const username = data.username; const username = data.username;
switch (body.mode) { switch (body.mode) {
case "add": case "refresh":
// body.task;
let taskPayload = {
text: body.task,
done: body.completed,
focused: body.focused,
};
let usernameColor =
userColors[id] ?? localStorage.getItem(`${id}-color`);
if (
configs.twitchSettings.autoUserColor &&
usernameColor != undefined &&
usernameColor != null
) {
taskPayload["color"] = usernameColor;
}
taskList.addTask(id, taskPayload, username);
break;
case "focus":
taskList.focusTask(id, body.index, usernameColor);
break;
case "edit":
taskList.editTask(id, body.index, body.task, usernameColor);
break;
case "remove":
taskList.removeTask(id, body.index, usernameColor);
break;
case "done":
taskList.doneTask(id, body.index, usernameColor);
break;
case "admindelete":
taskList.removeSection(body.id);
break;
case "clearns":
refresh(); refresh();
break; break;
case "cleardone":
taskList.cleardone();
break;
case "clearmydone":
taskList.clearmydone(id);
break;
case "clearall":
refresh();
break;
case "undone":
taskList.undoneTask(id, body.index);
break;
case "unfocus":
taskList.unfocusTask(id);
break;
case "admindelete":
taskList.removeSection(body.id);
break;
default: default:
break; break;
} }

View file

@ -279,6 +279,13 @@ class TaskList {
// ── dom helpers ──────────────────────────────────── // ── dom helpers ────────────────────────────────────
#parseTaskText(text) {
if (window.emoteManager && window.emoteManager.loaded) {
return window.emoteManager.parseText(text);
}
return text;
}
#createTaskEl(task, index) { #createTaskEl(task, index) {
const div = document.createElement("div"); const div = document.createElement("div");
div.className = div.className =
@ -291,7 +298,8 @@ class TaskList {
numberSpan.textContent = `${index + 1}.`; numberSpan.textContent = `${index + 1}.`;
const textSpan = document.createElement("span"); const textSpan = document.createElement("span");
textSpan.textContent = task.text; textSpan.className = "task-text";
textSpan.innerHTML = this.#parseTaskText(task.text);
div.replaceChildren(numberSpan, textSpan); div.replaceChildren(numberSpan, textSpan);
return div; return div;
@ -343,7 +351,8 @@ class TaskList {
} }
if (el.dataset.text !== task.text) { if (el.dataset.text !== task.text) {
el.dataset.text = task.text; el.dataset.text = task.text;
el.querySelector("span:last-child").textContent = 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}.`; el.querySelector(".task-number").textContent = `${i + 1}.`;

View file

@ -19,18 +19,18 @@
--focused-padding: 2px 4px; /* Spacing inside focused task highlight */ --focused-padding: 2px 4px; /* Spacing inside focused task highlight */
/* -- Font Sizes -- */ /* -- Font Sizes -- */
--title-font-size: 1.5rem; /* Size of the panel title */ --title-font-size: 1rem; /* Size of the panel title */
--section-title-font-size: 1.25rem; /* Size of section headings */ --section-title-font-size: 1rem; /* Size of section headings */
--header-font-size: 14px; /* Size of the header text */ --header-font-size: 1rem; /* Size of the header text */
--header-subtitle-font-size: 13px; /* Size of the header subtitle */ --header-subtitle-font-size: 1rem; /* Size of the header subtitle */
--task-font-size: 1.2rem; /* Size of task text */ --task-font-size: 1rem; /* Size of task text */
/* -- Borders & Dividers -- */ /* -- Borders & Dividers -- */
--header-border-color: #e0dbd5; /* Line below the header */ --header-border-color: #e0dbd5; /* Line below the header */
--section-border-color: white; /* Line between sections */ --section-border-color: white; /* Line between sections */
/* -- Spacing -- */ /* -- Spacing -- */
--header-padding: 16px 16px 12px; /* Spacing inside the header */ --header-padding: 10px 16px 5px; /* Spacing inside the header */
--section-padding: 10px 16px 6px; /* Spacing inside each section */ --section-padding: 10px 16px 6px; /* Spacing inside each section */
--task-gap: 6px; /* Gap between task items */ --task-gap: 6px; /* Gap between task items */
--task-padding: 3px 0; /* Spacing around each task */ --task-padding: 3px 0; /* Spacing around each task */
@ -104,7 +104,7 @@ body {
} }
.section-title.has-user-color { .section-title.has-user-color {
color: var(--user-color); /*auto user color*/ color: var(--user-color); /*auto user color*/
} }
.task.done, .task.done,
@ -129,3 +129,14 @@ body {
min-width: 16px; min-width: 16px;
text-align: right; text-align: right;
} }
.emote-img {
height: 1.4em;
vertical-align: middle;
display: inline-block;
margin: -0.2em 0;
}
.task.done .emote-img {
opacity: 0.5;
}

View file

@ -1,8 +1,8 @@
# Widget - Rython Task Bot v2 # Widget - Rython Task Bot v2
## Instructions Optional widget for [Rython Task Bot v2](https://github.com/liyunze-coding/rython-task-bot-v2)
### Websocket Server ## Instructions
Websocket server running is required for browser source to work. Websocket server running is required for browser source to work.
@ -13,14 +13,6 @@ Websocket server running is required for browser source to work.
3. If port number already taken, change port number 3. If port number already taken, change port number
4. Ensure that [config.js](./config.js)'s websocket settings are in-sync with Streamer.Bot's 4. Ensure that [config.js](./config.js)'s websocket settings are in-sync with Streamer.Bot's
### OBS
1. Add a new browser source in your scene
2. Tick on "Local"
3. Navigate to the index.html file
4. Optionally customise the width and height
5. Click on "Ok"
## Style Customisation ## Style Customisation
Note: `styles/structure.css` is meant to be the bare structure to allow the task widget to be functional. Note: `styles/structure.css` is meant to be the bare structure to allow the task widget to be functional.

View file

@ -3,7 +3,7 @@
const configs = (function () { const configs = (function () {
const streamerBotSettings = { const streamerBotSettings = {
host: "127.0.0.1", host: "127.0.0.1",
port: 8080, port: 6968,
endpoint: "/", endpoint: "/",
}; };
@ -17,18 +17,23 @@ const configs = (function () {
"!clearmydone", "!clearmydone",
]; ];
const twitchSettings = { const userColorSettings = {
autoUserColor: true autoUserColor: true,
} };
const kickSettings = { const emoteSettings = {
autouserColor: true enabled: true,
} channelName: "rythondev",
channelId: "248474026",
providers: ["7tv", "bttv", "ffz"],
size: "1x",
cacheHours: 24,
};
return { return {
streamerBotSettings, streamerBotSettings,
commands, commands,
twitchSettings, userColorSettings,
kickSettings emoteSettings,
}; };
})(); })();

View file

@ -5,6 +5,7 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<script src="./lib/streamerbot-client.js"></script> <script src="./lib/streamerbot-client.js"></script>
<script defer src="./config.js"></script> <script defer src="./config.js"></script>
<script defer src="./scripts/emotes.js"></script>
<script defer src="./scripts/tasklist-view.js"></script> <script defer src="./scripts/tasklist-view.js"></script>
<script defer src="./scripts/streamerbot.js"></script> <script defer src="./scripts/streamerbot.js"></script>
<script defer src="./scripts/header-animation.js"></script> <script defer src="./scripts/header-animation.js"></script>

View 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;
}
}
}

View file

@ -21,8 +21,9 @@ function getAllLocalstorage() {
} }
client.on("General.Custom", (data) => onCustom(data)); client.on("General.Custom", (data) => onCustom(data));
if (configs.twitchSettings.autoUserColor) { if (configs.userColorSettings.autoUserColor) {
client.on("Twitch.ChatMessage", (data) => onTwitchFirstWord(data)); client.on("Twitch.ChatMessage", (data) => onChatMessage(data));
client.on("Kick.ChatMessage", (data) => onChatMessage(data));
} }
let taskList; let taskList;
@ -57,12 +58,10 @@ function relativeLuminance({ r, g, b }) {
return 0.2126 * R + 0.7152 * G + 0.0722 * B; return 0.2126 * R + 0.7152 * G + 0.0722 * B;
} }
async function onTwitchFirstWord(data) { async function onChatMessage(data) {
let userColor = data.data.user.color; // hex colour #FF69B4 let userColor = data.data.user.color; // hex colour #FF69B4
if (userColor != undefined || userColor != "undefined") {
if (userColor != undefined || userColor != 'undefined') {
// update localstorage // update localstorage
// get id: platform-userID // get id: platform-userID
let userId = data.data.user.id; let userId = data.data.user.id;
@ -71,7 +70,7 @@ async function onTwitchFirstWord(data) {
localStorage.setItem(`${key}-color`, userColor); localStorage.setItem(`${key}-color`, userColor);
userColors[`${key}-color`] = userColor; userColors[`${key}-color`] = userColor;
} }
return; return;
} }
@ -135,6 +134,22 @@ async function refresh() {
// LOAD TASK LIST // LOAD TASK LIST
async function onConnect() { async function onConnect() {
taskList = new TaskList(".task-panel"); taskList = new TaskList(".task-panel");
if (configs.emoteSettings.enabled && configs.emoteSettings.channelId) {
window.emoteManager = new EmoteManager({
channelName: configs.emoteSettings.channelName,
channelId: configs.emoteSettings.channelId,
providers: configs.emoteSettings.providers,
size: configs.emoteSettings.size,
});
window.emoteManager.init().then(() => {
if (taskList) {
taskList.load(taskList.getData());
}
});
}
refresh(); refresh();
} }
@ -162,59 +177,9 @@ function onCustom(payload) {
const username = data.username; const username = data.username;
switch (body.mode) { switch (body.mode) {
case "add": case "refresh":
// body.task;
let taskPayload = {
text: body.task,
done: body.completed,
focused: body.focused,
};
let usernameColor =
userColors[id] ?? localStorage.getItem(`${id}-color`);
if (configs.twitchSettings.autoUserColor && usernameColor != undefined && usernameColor != null) {
taskPayload["color"] = usernameColor;
}
taskList.addTask(id, taskPayload, username);
break;
case "focus":
taskList.focusTask(id, body.index, usernameColor);
break;
case "edit":
taskList.editTask(id, body.index, body.task, usernameColor);
break;
case "remove":
taskList.removeTask(id, body.index, usernameColor);
break;
case "done":
taskList.doneTask(id, body.index, usernameColor);
break;
case "admindelete":
taskList.removeSection(body.id);
break;
case "clearns":
refresh(); refresh();
break; break;
case "cleardone":
taskList.cleardone();
break;
case "clearmydone":
taskList.clearmydone(id);
break;
case "clearall":
refresh();
break;
case "undone":
taskList.undoneTask(id, body.index);
break;
case "unfocus":
taskList.unfocusTask(id);
break;
case "admindelete":
taskList.removeSection(body.id);
break;
default: default:
break; break;
} }

View file

@ -302,6 +302,13 @@ class TaskList {
// ── dom helpers ──────────────────────────────────── // ── dom helpers ────────────────────────────────────
#parseTaskText(text) {
if (window.emoteManager && window.emoteManager.loaded) {
return window.emoteManager.parseText(text);
}
return text;
}
#createTaskEl(task, index) { #createTaskEl(task, index) {
const div = document.createElement("div"); const div = document.createElement("div");
div.className = div.className =
@ -314,7 +321,8 @@ class TaskList {
numberSpan.textContent = `${index + 1}.`; numberSpan.textContent = `${index + 1}.`;
const textSpan = document.createElement("span"); const textSpan = document.createElement("span");
textSpan.textContent = task.text; textSpan.className = "task-text";
textSpan.innerHTML = this.#parseTaskText(task.text);
div.replaceChildren(numberSpan, textSpan); div.replaceChildren(numberSpan, textSpan);
return div; return div;
@ -366,7 +374,8 @@ class TaskList {
} }
if (el.dataset.text !== task.text) { if (el.dataset.text !== task.text) {
el.dataset.text = task.text; el.dataset.text = task.text;
el.querySelector("span:last-child").textContent = 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}.`; el.querySelector(".task-number").textContent = `${i + 1}.`;

View file

@ -129,3 +129,14 @@ body {
min-width: 16px; min-width: 16px;
text-align: right; text-align: right;
} }
.emote-img {
height: 1.4em;
vertical-align: middle;
display: inline-block;
margin: -0.2em 0;
}
.task.done .emote-img {
opacity: 0.5;
}