diff --git a/Code.cs b/Code.cs
index 08dbf28..ef9bbd2 100644
--- a/Code.cs
+++ b/Code.cs
@@ -464,7 +464,7 @@ public class TaskOperations
}
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);
}
@@ -481,7 +481,7 @@ public class TaskOperations
string oldName = userTasks[index].Name;
userTasks[index].Name = newTask;
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);
}
@@ -502,7 +502,7 @@ public class TaskOperations
UnfocusAll(tasks);
tasks[n].Focused = true;
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);
}
else if (indexByName > -1)
@@ -513,7 +513,7 @@ public class TaskOperations
UnfocusAll(tasks);
tasks[n].Focused = true;
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);
}
else
@@ -521,7 +521,7 @@ public class TaskOperations
var response = AddTask(rawInput, false, true);
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);
}
@@ -846,7 +846,7 @@ public class CPHInline
string completedTaskName = userTasks[focusedTaskIndex].Name;
userTasks[focusedTaskIndex].Completed = true;
userTasks[focusedTaskIndex].Focused = false;
- Broadcast(new { mode = "done", index = focusedTaskIndex }, null);
+ Broadcast(new { mode = "refresh", index = focusedTaskIndex }, null);
operations.SaveIntoTasks(userTasks);
SaveTasks();
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))
{
userTasks.RemoveAt(i);
- Broadcast(new { mode = "remove", index = i }, null);
+ Broadcast(new { mode = "refresh", index = i }, null);
}
operations.SaveIntoTasks(userTasks);
@@ -1024,7 +1024,7 @@ public class CPHInline
operations.RemoveUser(key);
SaveTasks();
Respond(BotResponses.AdminDeleteSuccess);
- Broadcast(new { mode = "admindelete", id = key }, null);
+ Broadcast(new { mode = "refresh", id = key }, null);
return true;
}
@@ -1081,7 +1081,7 @@ public class CPHInline
{
userTasks[i].Completed = true;
userTasks[i].Focused = false;
- Broadcast(new { mode = "done", index = i }, null);
+ Broadcast(new { mode = "refresh", index = i }, null);
}
IncrementDoneCount(taskIndices.Count);
@@ -1096,7 +1096,7 @@ public class CPHInline
operations.Unfocus();
SaveTasks();
Respond(BotResponses.Unfocused);
- Broadcast(new { mode = "unfocus" }, null);
+ Broadcast(new { mode = "refresh" }, null);
return true;
}
@@ -1144,7 +1144,7 @@ public class CPHInline
{
userTasks[i].Completed = false;
userTasks[i].Focused = false;
- Broadcast(new { mode = "undone", index = i }, null);
+ Broadcast(new { mode = "refresh", index = i }, null);
}
operations.SaveIntoTasks(userTasks);
@@ -1169,7 +1169,7 @@ public class CPHInline
operations.ClearUserCompletedTasks(key);
operations.Cleanup(false);
SaveTasks();
- Broadcast(new { mode = "clearmydone" }, null);
+ Broadcast(new { mode = "refresh" }, null);
Respond(BotResponses.ClearMyDone);
return true;
}
@@ -1179,7 +1179,7 @@ public class CPHInline
operations.ClearCompletedTasks();
operations.Cleanup(false);
SaveTasks();
- Broadcast(new { mode = "cleardone" }, null);
+ Broadcast(new { mode = "refresh" }, null);
Respond(BotResponses.ClearDone);
return true;
}
@@ -1189,7 +1189,7 @@ public class CPHInline
operations.FilterToStreamers(GetStreamerUsernames());
operations.Cleanup(false);
SaveTasks();
- Broadcast(new { mode = "clearns" }, null);
+ Broadcast(new { mode = "refresh" }, null);
Respond(BotResponses.ClearNotStreamer);
return true;
}
diff --git a/widgets/horizontal/config.js b/widgets/horizontal/config.js
index d8d4fb0..be20050 100644
--- a/widgets/horizontal/config.js
+++ b/widgets/horizontal/config.js
@@ -3,7 +3,7 @@
const configs = (function () {
const streamerBotSettings = {
host: "127.0.0.1",
- port: 8080,
+ port: 6968,
endpoint: "/",
};
@@ -17,18 +17,23 @@ const configs = (function () {
"!clearmydone",
];
- const twitchSettings = {
- autoUserColor: true
- }
+ const userColorSettings = {
+ autoUserColor: true,
+ };
- const kickSettings = {
- autouserColor: true
- }
+ const emoteSettings = {
+ enabled: true,
+ channelName: "rythondev",
+ channelId: "248474026",
+ providers: ["7tv", "bttv", "ffz"],
+ size: "1x",
+ cacheHours: 24,
+ };
return {
streamerBotSettings,
commands,
- twitchSettings,
- kickSettings
+ userColorSettings,
+ emoteSettings,
};
})();
diff --git a/widgets/horizontal/index.html b/widgets/horizontal/index.html
index 43b57f5..6fc7ee0 100644
--- a/widgets/horizontal/index.html
+++ b/widgets/horizontal/index.html
@@ -5,6 +5,7 @@
+
diff --git a/widgets/horizontal/scripts/emotes.js b/widgets/horizontal/scripts/emotes.js
new file mode 100644
index 0000000..881f87e
--- /dev/null
+++ b/widgets/horizontal/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 `
`;
+ });
+ }
+
+ 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/horizontal/scripts/streamerbot.js b/widgets/horizontal/scripts/streamerbot.js
index 00a73b6..f838b36 100644
--- a/widgets/horizontal/scripts/streamerbot.js
+++ b/widgets/horizontal/scripts/streamerbot.js
@@ -21,8 +21,9 @@ function getAllLocalstorage() {
}
client.on("General.Custom", (data) => onCustom(data));
-if (configs.twitchSettings.autoUserColor) {
- client.on("Twitch.ChatMessage", (data) => onTwitchFirstWord(data));
+if (configs.userColorSettings.autoUserColor) {
+ client.on("Twitch.ChatMessage", (data) => onChatMessage(data));
+ client.on("Kick.ChatMessage", (data) => onChatMessage(data));
}
let taskList;
@@ -57,12 +58,10 @@ function relativeLuminance({ r, g, 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
-
-
- if (userColor != undefined || userColor != 'undefined') {
+ if (userColor != undefined || userColor != "undefined") {
// update localstorage
// get id: platform-userID
let userId = data.data.user.id;
@@ -71,7 +70,7 @@ async function onTwitchFirstWord(data) {
localStorage.setItem(`${key}-color`, userColor);
userColors[`${key}-color`] = userColor;
}
-
+
return;
}
@@ -135,6 +134,22 @@ async function refresh() {
// LOAD TASK LIST
async function onConnect() {
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();
}
@@ -162,59 +177,9 @@ function onCustom(payload) {
const username = data.username;
switch (body.mode) {
- case "add":
- // 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":
+ case "refresh":
refresh();
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:
break;
}
diff --git a/widgets/horizontal/scripts/tasklist-view.js b/widgets/horizontal/scripts/tasklist-view.js
index 283a9a0..62b5911 100644
--- a/widgets/horizontal/scripts/tasklist-view.js
+++ b/widgets/horizontal/scripts/tasklist-view.js
@@ -302,6 +302,13 @@ class TaskList {
// ── 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 =
@@ -314,7 +321,8 @@ class TaskList {
numberSpan.textContent = `${index + 1}.`;
const textSpan = document.createElement("span");
- textSpan.textContent = task.text;
+ textSpan.className = "task-text";
+ textSpan.innerHTML = this.#parseTaskText(task.text);
div.replaceChildren(numberSpan, textSpan);
return div;
@@ -366,7 +374,8 @@ class TaskList {
}
if (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}.`;
diff --git a/widgets/horizontal/styles/style.css b/widgets/horizontal/styles/style.css
index 650f1f3..a77a541 100644
--- a/widgets/horizontal/styles/style.css
+++ b/widgets/horizontal/styles/style.css
@@ -141,3 +141,14 @@ body {
min-width: 16px;
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;
+}
diff --git a/widgets/streamer-only/config.js b/widgets/streamer-only/config.js
index 72d9fb6..cf97326 100644
--- a/widgets/streamer-only/config.js
+++ b/widgets/streamer-only/config.js
@@ -3,7 +3,7 @@
const configs = (function () {
const streamerBotSettings = {
host: "127.0.0.1",
- port: 8080,
+ port: 6968,
endpoint: "/",
};
@@ -19,12 +19,17 @@ const configs = (function () {
"!clearmydone",
];
- const twitchSettings = {
+ const userColorSettings = {
autoUserColor: true,
};
- const kickSettings = {
- autouserColor: true,
+ const emoteSettings = {
+ enabled: true,
+ channelName: "rythondev",
+ channelId: "248474026",
+ providers: ["7tv", "bttv", "ffz"],
+ size: "1x",
+ cacheHours: 24,
};
return {
@@ -33,5 +38,6 @@ const configs = (function () {
commands,
twitchSettings,
kickSettings,
+ emoteSettings,
};
})();
diff --git a/widgets/streamer-only/index.html b/widgets/streamer-only/index.html
index 43b57f5..6fc7ee0 100644
--- a/widgets/streamer-only/index.html
+++ b/widgets/streamer-only/index.html
@@ -5,6 +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 `
`;
+ });
+ }
+
+ 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/streamer-only/scripts/streamerbot.js b/widgets/streamer-only/scripts/streamerbot.js
index 36bf696..9dfa7bd 100644
--- a/widgets/streamer-only/scripts/streamerbot.js
+++ b/widgets/streamer-only/scripts/streamerbot.js
@@ -21,8 +21,9 @@ function getAllLocalstorage() {
}
client.on("General.Custom", (data) => onCustom(data));
-if (configs.twitchSettings.autoUserColor) {
- client.on("Twitch.ChatMessage", (data) => onTwitchFirstWord(data));
+if (configs.userColorSettings.autoUserColor) {
+ client.on("Twitch.ChatMessage", (data) => onChatMessage(data));
+ client.on("Kick.ChatMessage", (data) => onChatMessage(data));
}
let taskList;
@@ -57,7 +58,7 @@ function relativeLuminance({ r, g, 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
if (userColor != undefined || userColor != "undefined") {
@@ -134,6 +135,22 @@ async function refresh() {
// LOAD TASK LIST
async function onConnect() {
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();
}
@@ -161,63 +178,9 @@ function onCustom(payload) {
const username = data.username;
switch (body.mode) {
- case "add":
- // 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":
+ case "refresh":
refresh();
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:
break;
}
diff --git a/widgets/streamer-only/scripts/tasklist-view.js b/widgets/streamer-only/scripts/tasklist-view.js
index 455f49b..88af472 100644
--- a/widgets/streamer-only/scripts/tasklist-view.js
+++ b/widgets/streamer-only/scripts/tasklist-view.js
@@ -279,6 +279,13 @@ class TaskList {
// ── 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 =
@@ -291,7 +298,8 @@ class TaskList {
numberSpan.textContent = `${index + 1}.`;
const textSpan = document.createElement("span");
- textSpan.textContent = task.text;
+ textSpan.className = "task-text";
+ textSpan.innerHTML = this.#parseTaskText(task.text);
div.replaceChildren(numberSpan, textSpan);
return div;
@@ -343,7 +351,8 @@ class TaskList {
}
if (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}.`;
diff --git a/widgets/streamer-only/styles/style.css b/widgets/streamer-only/styles/style.css
index 69bfdcf..ee2c2d7 100644
--- a/widgets/streamer-only/styles/style.css
+++ b/widgets/streamer-only/styles/style.css
@@ -19,18 +19,18 @@
--focused-padding: 2px 4px; /* Spacing inside focused task highlight */
/* -- Font Sizes -- */
- --title-font-size: 1.5rem; /* Size of the panel title */
- --section-title-font-size: 1.25rem; /* Size of section headings */
- --header-font-size: 14px; /* Size of the header text */
- --header-subtitle-font-size: 13px; /* Size of the header subtitle */
- --task-font-size: 1.2rem; /* Size of task text */
+ --title-font-size: 1rem; /* Size of the panel title */
+ --section-title-font-size: 1rem; /* Size of section headings */
+ --header-font-size: 1rem; /* Size of the header text */
+ --header-subtitle-font-size: 1rem; /* Size of the header subtitle */
+ --task-font-size: 1rem; /* Size of task text */
/* -- Borders & Dividers -- */
--header-border-color: #e0dbd5; /* Line below the header */
--section-border-color: white; /* Line between sections */
/* -- 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 */
--task-gap: 6px; /* Gap between task items */
--task-padding: 3px 0; /* Spacing around each task */
@@ -104,7 +104,7 @@ body {
}
.section-title.has-user-color {
- color: var(--user-color); /*auto user color*/
+ color: var(--user-color); /*auto user color*/
}
.task.done,
@@ -129,3 +129,14 @@ body {
min-width: 16px;
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;
+}
diff --git a/widgets/vertical/README.md b/widgets/vertical/README.md
index f49b08a..aec396b 100644
--- a/widgets/vertical/README.md
+++ b/widgets/vertical/README.md
@@ -1,8 +1,8 @@
# 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.
@@ -13,14 +13,6 @@ Websocket server running is required for browser source to work.
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
-### 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
Note: `styles/structure.css` is meant to be the bare structure to allow the task widget to be functional.
diff --git a/widgets/vertical/config.js b/widgets/vertical/config.js
index d8d4fb0..be20050 100644
--- a/widgets/vertical/config.js
+++ b/widgets/vertical/config.js
@@ -3,7 +3,7 @@
const configs = (function () {
const streamerBotSettings = {
host: "127.0.0.1",
- port: 8080,
+ port: 6968,
endpoint: "/",
};
@@ -17,18 +17,23 @@ const configs = (function () {
"!clearmydone",
];
- const twitchSettings = {
- autoUserColor: true
- }
+ const userColorSettings = {
+ autoUserColor: true,
+ };
- const kickSettings = {
- autouserColor: true
- }
+ const emoteSettings = {
+ enabled: true,
+ channelName: "rythondev",
+ channelId: "248474026",
+ providers: ["7tv", "bttv", "ffz"],
+ size: "1x",
+ cacheHours: 24,
+ };
return {
streamerBotSettings,
commands,
- twitchSettings,
- kickSettings
+ userColorSettings,
+ emoteSettings,
};
})();
diff --git a/widgets/vertical/index.html b/widgets/vertical/index.html
index 43b57f5..6fc7ee0 100644
--- a/widgets/vertical/index.html
+++ b/widgets/vertical/index.html
@@ -5,6 +5,7 @@
+
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 `
`;
+ });
+ }
+
+ 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/streamerbot.js b/widgets/vertical/scripts/streamerbot.js
index 00a73b6..f838b36 100644
--- a/widgets/vertical/scripts/streamerbot.js
+++ b/widgets/vertical/scripts/streamerbot.js
@@ -21,8 +21,9 @@ function getAllLocalstorage() {
}
client.on("General.Custom", (data) => onCustom(data));
-if (configs.twitchSettings.autoUserColor) {
- client.on("Twitch.ChatMessage", (data) => onTwitchFirstWord(data));
+if (configs.userColorSettings.autoUserColor) {
+ client.on("Twitch.ChatMessage", (data) => onChatMessage(data));
+ client.on("Kick.ChatMessage", (data) => onChatMessage(data));
}
let taskList;
@@ -57,12 +58,10 @@ function relativeLuminance({ r, g, 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
-
-
- if (userColor != undefined || userColor != 'undefined') {
+ if (userColor != undefined || userColor != "undefined") {
// update localstorage
// get id: platform-userID
let userId = data.data.user.id;
@@ -71,7 +70,7 @@ async function onTwitchFirstWord(data) {
localStorage.setItem(`${key}-color`, userColor);
userColors[`${key}-color`] = userColor;
}
-
+
return;
}
@@ -135,6 +134,22 @@ async function refresh() {
// LOAD TASK LIST
async function onConnect() {
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();
}
@@ -162,59 +177,9 @@ function onCustom(payload) {
const username = data.username;
switch (body.mode) {
- case "add":
- // 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":
+ case "refresh":
refresh();
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:
break;
}
diff --git a/widgets/vertical/scripts/tasklist-view.js b/widgets/vertical/scripts/tasklist-view.js
index 969713d..ed5be9a 100644
--- a/widgets/vertical/scripts/tasklist-view.js
+++ b/widgets/vertical/scripts/tasklist-view.js
@@ -302,6 +302,13 @@ class TaskList {
// ── 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 =
@@ -314,7 +321,8 @@ class TaskList {
numberSpan.textContent = `${index + 1}.`;
const textSpan = document.createElement("span");
- textSpan.textContent = task.text;
+ textSpan.className = "task-text";
+ textSpan.innerHTML = this.#parseTaskText(task.text);
div.replaceChildren(numberSpan, textSpan);
return div;
@@ -366,7 +374,8 @@ class TaskList {
}
if (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}.`;
diff --git a/widgets/vertical/styles/style.css b/widgets/vertical/styles/style.css
index 69bfdcf..7a09d04 100644
--- a/widgets/vertical/styles/style.css
+++ b/widgets/vertical/styles/style.css
@@ -129,3 +129,14 @@ body {
min-width: 16px;
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;
+}