mirror of
https://github.com/by-sonic/tglock.git
synced 2026-11-05 15:21:55 +03:00
fix(ui): status refresh no longer recreates the power button (#71)
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 <Thartewerner536e@engineer.com> Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
+69
-19
@@ -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 {
|
function shell(content: string, pageClass = ""): string {
|
||||||
return `
|
return `
|
||||||
<main class="app-shell ${pageClass}">
|
<main class="app-shell ${pageClass}">
|
||||||
@@ -125,7 +166,7 @@ function shell(content: string, pageClass = ""): string {
|
|||||||
|
|
||||||
function renderHome(): void {
|
function renderHome(): void {
|
||||||
const connection = connectionState();
|
const connection = connectionState();
|
||||||
root.innerHTML = shell(`
|
patchRoot(shell(`
|
||||||
<section class="home">
|
<section class="home">
|
||||||
<header class="brand">
|
<header class="brand">
|
||||||
<div class="wordmark"><span>TG</span>Lock</div>
|
<div class="wordmark"><span>TG</span>Lock</div>
|
||||||
@@ -191,14 +232,7 @@ function renderHome(): void {
|
|||||||
</button>
|
</button>
|
||||||
</nav>
|
</nav>
|
||||||
</section>
|
</section>
|
||||||
`, "home-page");
|
`, "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"));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function pageHeader(title: string, subtitle: string): string {
|
function pageHeader(title: string, subtitle: string): string {
|
||||||
@@ -214,7 +248,7 @@ function pageHeader(title: string, subtitle: string): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function renderSettings(): void {
|
function renderSettings(): void {
|
||||||
root.innerHTML = shell(`
|
patchRoot(shell(`
|
||||||
<section class="subpage">
|
<section class="subpage">
|
||||||
${pageHeader("Настройки", "Подключение и локальная сеть")}
|
${pageHeader("Настройки", "Подключение и локальная сеть")}
|
||||||
|
|
||||||
@@ -275,15 +309,12 @@ function renderSettings(): void {
|
|||||||
</button>
|
</button>
|
||||||
</form>
|
</form>
|
||||||
</section>
|
</section>
|
||||||
`, "subpage-shell");
|
`, "subpage-shell"));
|
||||||
|
|
||||||
document.querySelector("#back")?.addEventListener("click", () => navigate("home"));
|
|
||||||
document.querySelector("#settings-form")?.addEventListener("submit", saveSettings);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderDiagnostics(): void {
|
function renderDiagnostics(): void {
|
||||||
const recentLogs = status.logs.slice(-4).reverse();
|
const recentLogs = status.logs.slice(-4).reverse();
|
||||||
root.innerHTML = shell(`
|
patchRoot(shell(`
|
||||||
<section class="subpage">
|
<section class="subpage">
|
||||||
${pageHeader("Диагностика", "Состояние маршрута в реальном времени")}
|
${pageHeader("Диагностика", "Состояние маршрута в реальном времени")}
|
||||||
|
|
||||||
@@ -372,12 +403,31 @@ function renderDiagnostics(): void {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
`, "subpage-shell");
|
`, "subpage-shell"));
|
||||||
|
|
||||||
document.querySelector("#copy-diagnostics")?.addEventListener("click", copyDiagnostics);
|
|
||||||
document.querySelector("#back")?.addEventListener("click", () => navigate("home"));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Обработчики висят на #app, а не на кнопках: patchRoot сохраняет кнопки,
|
||||||
|
// но новые (например, «Открыть Telegram» после включения) создаёт сам.
|
||||||
|
const clickActions: Record<string, () => 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<HTMLButtonElement>("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 {
|
function render(): void {
|
||||||
if (view === "settings") renderSettings();
|
if (view === "settings") renderSettings();
|
||||||
else if (view === "diagnostics") renderDiagnostics();
|
else if (view === "diagnostics") renderDiagnostics();
|
||||||
|
|||||||
Reference in New Issue
Block a user