From 7b48f0046b1a1b44f366be2deb9ed5f78f73ef21 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=9D=D0=B8=D0=BA=D0=B8=D1=82=D0=B0=20Sonic?= Date: Mon, 26 Oct 2026 19:24:09 +0000 Subject: [PATCH] fix(ui): status refresh no longer recreates the power button (#71) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit refreshStatus() runs every second and render() used to rewrite root.innerHTML, so #power became a new DOM node each tick. A tap whose press and release landed on different nodes produced no click in Chromium/WebView; on a slow WebView the "Выключить" tap was lost. render now patches the existing DOM in place (text and attributes only; a node is replaced only where the tag or id at that position differs), and click/submit handlers are delegated from #app instead of being reattached to fresh nodes on every render. Texts, classes, ids and accessible labels are unchanged. Co-authored-by: babin Co-authored-by: Claude Opus 5.5 --- ui/main.ts | 88 ++++++++++++++++++++++++++++++++++++++++++------------ 1 file changed, 69 insertions(+), 19 deletions(-) diff --git a/ui/main.ts b/ui/main.ts index dc58574..96dec70 100644 --- a/ui/main.ts +++ b/ui/main.ts @@ -113,6 +113,47 @@ function connectionState(): { title: string; subtitle: string; className: string }; } +// Статус приходит раз в секунду. Если каждый раз переписывать root.innerHTML, +// кнопки становятся новыми узлами, и тап, начатый на старой кнопке и +// законченный на новой, Chromium не превращает в click. Поэтому разметка +// накладывается на уже существующие узлы: меняются только текст и атрибуты, +// а элемент заменяется лишь там, где на его месте теперь другой тег или id. +function patchRoot(html: string): void { + const next = document.createElement("template"); + next.innerHTML = html; + patchChildren(root, next.content); +} + +function patchChildren(current: Node, next: Node): void { + const nextChildren = Array.from(next.childNodes); + nextChildren.forEach((nextChild, index) => { + const currentChild = current.childNodes[index]; + if (!currentChild) current.appendChild(nextChild); + else if (!sameSlot(currentChild, nextChild)) current.replaceChild(nextChild, currentChild); + else patchNode(currentChild, nextChild); + }); + while (current.childNodes.length > nextChildren.length) current.lastChild?.remove(); +} + +function sameSlot(current: Node, next: Node): boolean { + if (current.nodeType !== next.nodeType || current.nodeName !== next.nodeName) return false; + return !(current instanceof Element && next instanceof Element) || current.id === next.id; +} + +function patchNode(current: Node, next: Node): void { + if (!(current instanceof Element && next instanceof Element)) { + if (current.nodeValue !== next.nodeValue) current.nodeValue = next.nodeValue; + return; + } + for (const { name } of Array.from(current.attributes)) { + if (!next.hasAttribute(name)) current.removeAttribute(name); + } + for (const { name, value } of Array.from(next.attributes)) { + if (current.getAttribute(name) !== value) current.setAttribute(name, value); + } + patchChildren(current, next); +} + function shell(content: string, pageClass = ""): string { return `
@@ -125,7 +166,7 @@ function shell(content: string, pageClass = ""): string { function renderHome(): void { const connection = connectionState(); - root.innerHTML = shell(` + patchRoot(shell(`
TGLock
@@ -191,14 +232,7 @@ function renderHome(): void {
- `, "home-page"); - - document.querySelector("#power")?.addEventListener("click", toggleProtection); - document.querySelector("#open-telegram")?.addEventListener("click", openTelegram); - document.querySelector("#copy-link")?.addEventListener("click", copyTelegramLink); - document.querySelector("#copy-address")?.addEventListener("click", copyShareAddress); - document.querySelector("#settings-nav")?.addEventListener("click", () => navigate("settings")); - document.querySelector("#diagnostics-nav")?.addEventListener("click", () => navigate("diagnostics")); + `, "home-page")); } function pageHeader(title: string, subtitle: string): string { @@ -214,7 +248,7 @@ function pageHeader(title: string, subtitle: string): string { } function renderSettings(): void { - root.innerHTML = shell(` + patchRoot(shell(`
${pageHeader("Настройки", "Подключение и локальная сеть")} @@ -275,15 +309,12 @@ function renderSettings(): void {
- `, "subpage-shell"); - - document.querySelector("#back")?.addEventListener("click", () => navigate("home")); - document.querySelector("#settings-form")?.addEventListener("submit", saveSettings); + `, "subpage-shell")); } function renderDiagnostics(): void { const recentLogs = status.logs.slice(-4).reverse(); - root.innerHTML = shell(` + patchRoot(shell(`
${pageHeader("Диагностика", "Состояние маршрута в реальном времени")} @@ -372,12 +403,31 @@ function renderDiagnostics(): void {
- `, "subpage-shell"); - - document.querySelector("#copy-diagnostics")?.addEventListener("click", copyDiagnostics); - document.querySelector("#back")?.addEventListener("click", () => navigate("home")); + `, "subpage-shell")); } +// Обработчики висят на #app, а не на кнопках: patchRoot сохраняет кнопки, +// но новые (например, «Открыть Telegram» после включения) создаёт сам. +const clickActions: Record void> = { + power: toggleProtection, + "open-telegram": openTelegram, + "copy-link": copyTelegramLink, + "copy-address": copyShareAddress, + "settings-nav": () => navigate("settings"), + "diagnostics-nav": () => navigate("diagnostics"), + "copy-diagnostics": copyDiagnostics, + back: () => navigate("home"), +}; + +root.addEventListener("click", (event) => { + const button = (event.target as Element).closest("button[id]"); + if (button && !button.disabled) clickActions[button.id]?.(); +}); + +root.addEventListener("submit", (event) => { + if ((event.target as Element).id === "settings-form") void saveSettings(event); +}); + function render(): void { if (view === "settings") renderSettings(); else if (view === "diagnostics") renderDiagnostics();