From 029dce4d4dab19ce166dcff66ec2d1f7aadfe389 Mon Sep 17 00:00:00 2001 From: DemonGatanjieu <207458273+DemonGatanjieu@users.noreply.github.com> Date: Thu, 5 Nov 2026 15:36:52 +0800 Subject: [PATCH] Fix the Saves page losing clicks the same way A part redrew on new elements each time a count was answered or a run ended, so a press on the page lost its click and a count being typed lost its focus. settle moves to ui_dom.js for both; a card is kept for its save, and a picture line stands while its pictures do. A press on a picture just as a new one reaches that line is still lost: the line shifts, and another picture is under the pointer by the mouseup. Tested with a real mouse on an isolated ComfyUI. tests/snapshot_bar.html now covers the page and fails 5 checks on the previous code. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 4 +- docs/architecture/frontend.md | 2 +- docs/architecture/lessons.md | 6 +-- docs/guides/testing.md | 2 +- tests/snapshot_bar.html | 65 ++++++++++++++++++++++++++++++-- web/modules/ui_dom.js | 30 +++++++++++++++ web/modules/ui_saves_list.js | 15 +++++--- web/modules/ui_saves_pictures.js | 4 +- web/modules/ui_snapshot_bar.js | 26 +------------ 9 files changed, 112 insertions(+), 42 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index def7b2b..26400fb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,8 +2,8 @@ ## v1.63.0 — unreleased -- 存档条上的按钮按一下就管用:刚在提示词框里打完字、刚改完次数,或者运行还在跑的时候,第一下不再被吃掉;运行途中正在输入的次数也不会被清掉。 -- The saves bar's controls answer the first click: right after typing in a prompt box or changing a count, or while runs go, the click is no longer lost, and a count being typed stays. +- 存档条和存档页上的按钮按一下就管用:刚在提示词框里打完字、刚改完次数,或者运行还在跑的时候,第一下不再被吃掉;运行途中正在输入的次数也不会被清掉。 +- The controls of the saves bar and the Saves page answer the first click: right after typing in a prompt box or changing a count, or while runs go, the click is no longer lost, and a count being typed stays. - **插入 LoRA 可以一次选多个**:选择窗口里点卡片加入,再点一下取消,卡片上的数字就是接上去的顺序,线自动连好,Ctrl+Z 一次全部退回。默认用 ComfyUI 自带的节点串成一串;装了 rgthree 或 Easy-Use 时可以改放进一个 Power Lora Loader 或 LoRA 堆叠节点,会记住上次的选择。 - 在采样器前面插入 LoRA 时,如果前面已经有一个 LoRA,新的 LoRA 也能接上 CLIP 线了。 - **Insert LoRA takes several at a time**: in the picker, click cards to add them and again to drop one; the number on a card is the order they go in. They are wired for you, and one Ctrl+Z takes them all out. They chain as ComfyUI's own nodes by default; with rgthree or Easy-Use installed they can go in one Power Lora Loader or LoRA stack, and the choice is remembered. diff --git a/docs/architecture/frontend.md b/docs/architecture/frontend.md index aec67a1..7ee775d 100644 --- a/docs/architecture/frontend.md +++ b/docs/architecture/frontend.md @@ -164,7 +164,7 @@ The main surfaces are: `node_parameter_sets.js` own the panel's one list of saved values for the node type; `ui_apply_receipt.js` is the receipt with Undo after a panel writes to a node. -`ui_dom.js` owns generic `text` and `jsonResponse` helpers. +`ui_dom.js` owns generic `text`, `jsonResponse` and `settle` helpers. `material_inspector.js` holds the image workbench's metadata helpers and node-parameter rendering; `png_metadata.js` reads a PNG's workflow in the browser. See `material-library.md` for the material files' API and persistence contract. diff --git a/docs/architecture/lessons.md b/docs/architecture/lessons.md index 65fcbdf..6abd60c 100644 --- a/docs/architecture/lessons.md +++ b/docs/architecture/lessons.md @@ -158,17 +158,17 @@ body:has(#anomalous-toolbox-modal[style*="display: flex"]) #anomalous-sidebar-to **以后**:会在加载时用到的值,放在循环外面的模块里。别只从 `main.js` 想加载顺序。要逐个模块验证,在浏览器里给每个模块开一个 iframe 单独 import。 -## 2026-11-05 · 存档条的按钮第一下没反应 +## 2026-11-05 · 存档条和存档页的按钮第一下没反应 **现象**:在提示词框打完字直接点条上的 +,什么都没存,再点一下才存上;先点一下画布空白处就没事。改完次数直接点运行也一样。运行途中,正在输入的次数会被清掉。 **根因**:`draw` 每次都把整行换成新元素,而按下和松开之间元素离开了页面,浏览器就不发 click。这段时间里能触发重画的有三样:ComfyUI 到 `window` 的 mouseup 才发现文本框的改动并发 `graphChanged`(不是失焦时的 `change`,打字时它也不看);保存次数的请求十几毫秒就回来;运行结束的 `status`。 -**做法**:`settle` 在已有元素上更新,一格只留给同一个存档,正在输入的框不动。 +**做法**:`ui_dom.js` 的 `settle` 在已有元素上更新,一格只留给同一个存档,正在输入的框不动。图片行自己填内容,图没变就原样留着。这个存档出了新图时按在它的图上,这一下还是没有:新图排第一,整行右移,松手时鼠标下已经是另一张图,留着元素也不会有 click。 **以后怎么做** - 用户可能正按着或正输入的控件,不要在异步事件里整块重建。 -- 处理函数搬到留下的元素上后,要读 `event.target`。第一版漏了这点,存下的次数全是 1,只查“点击到了”的测试还是全过:要查结果存对了。 +- 处理函数搬到留下的元素上后,要用事件上的元素,不能用闭包里新建的那个;存着元素的字段(`part.line`)也要重新取。第一版漏了这点,存下的次数全是 1,只查“点击到了”的测试还是全过:要查结果存对了。 - 这类问题要用真鼠标、按住约 90 毫秒来测,`element.click()` 测不出来。 ## 2026-11-04 · 存档跑出来全是画布上的内容 diff --git a/docs/guides/testing.md b/docs/guides/testing.md index 4c8c1f3..d7a5290 100644 --- a/docs/guides/testing.md +++ b/docs/guides/testing.md @@ -57,7 +57,7 @@ node tests/third_party_loras.mjs 同一静态服务器上的 `tests/ai_drafts.html` 验证展台和底部提问条的回答草稿同步、重画保留文字和选区、同时刷新不抢错焦点。它加载实际组件,仅替换网络和宿主边界;页面应显示 `ALL PASSED`,不包含在一键命令中。 -同一静态服务器上的 `tests/snapshot_bar.html` 验证存档条重画时控件都留在页面上(元素离开页面,按着的那一下就没有 click)、正在输入的次数不被清掉、一格不会串到别的存档。它加载实际的存档条,只替换 ComfyUI 和存档接口;页面应显示 `ALL PASSED`,不包含在上面的一键命令中。 +同一静态服务器上的 `tests/snapshot_bar.html` 验证存档条和存档页重画时控件都留在页面上(元素离开页面,按着的那一下就没有 click)、正在输入的次数不被清掉、一格不会串到别的存档、没变的图不重画。它加载实际的存档条和存档页,只替换 ComfyUI 和存档接口;页面应显示 `ALL PASSED`,不包含在上面的一键命令中。 - 前端规则测试放 `tests/<名字>.mjs`,用 `node:assert`,直接导入 `web/modules/` 里的真实模块,只替换浏览器和 ComfyUI 这些外部部分。 - 后端测试放 `tests/test_<名字>.py`,用 `unittest`。文件一律建在 `tempfile` 的临时文件夹里,网络请求用 `unittest.mock` 换掉。 diff --git a/tests/snapshot_bar.html b/tests/snapshot_bar.html index 8ace4a1..2072e95 100644 --- a/tests/snapshot_bar.html +++ b/tests/snapshot_bar.html @@ -1,16 +1,20 @@ -Saves bar regression +Saves bar and page regression +
Running…
+
diff --git a/web/modules/ui_dom.js b/web/modules/ui_dom.js index 25b18fa..babf83f 100644 --- a/web/modules/ui_dom.js +++ b/web/modules/ui_dom.js @@ -12,3 +12,33 @@ export async function jsonResponse(response, fallbackMessage) { if (!response.ok) throw new Error(payload.message || fallbackMessage); return payload; } + +/** + * `parent` comes to show `fresh` on the elements it has. The browser sends no click to an element that left + * the page during the press, and a redraw can come during one: ComfyUI's graphChanged at the mouseup, the + * answer to a request, a run ending. A box being typed in would lose its digits. An element with a `data-key` + * is kept only for the same key; with `data-fills` too it draws its own content and is left as it stands. + * Handlers move over to the kept element, so they use the event's element, not the one made with them. + */ +export function settle(parent, fresh) { + const there = [...parent.children]; + fresh.forEach((node, at) => { + const old = there[at]; + if (!old) return void parent.append(node); + if (old.tagName !== node.tagName || old.dataset.key !== node.dataset.key) return void old.replaceWith(node); + if ('fills' in node.dataset) return; + for (const name of old.getAttributeNames()) { + if (!node.hasAttribute(name)) old.removeAttribute(name); + } + for (const { name, value } of node.attributes) { + if (old.getAttribute(name) !== value) old.setAttribute(name, value); + } + old.onclick = node.onclick; + old.onchange = node.onchange; + if (node.tagName === 'INPUT') { + if (old !== document.activeElement) old.value = node.value; + } else if (!node.children.length && old.textContent !== node.textContent) old.textContent = node.textContent; + settle(old, [...node.children]); + }); + for (const extra of there.slice(fresh.length)) extra.remove(); +} diff --git a/web/modules/ui_saves_list.js b/web/modules/ui_saves_list.js index 52ae3ee..a1d21b5 100644 --- a/web/modules/ui_saves_list.js +++ b/web/modules/ui_saves_list.js @@ -14,6 +14,7 @@ import { changeKind, runTotal } from './snapshot_plan.js'; import { runSaves, seedMode, watchSeedMode } from './snapshot_run.js'; import { findGroup, listGroups, onSavesChange, readWorkflow, removeGroup, removeSave } from './snapshot_store.js'; import { anomalousConfirm } from './ui_dialog.js'; +import { settle } from './ui_dom.js'; import { openPopupMenu } from './ui_popup_menu.js'; import { showWorkbenchToast } from './ui_prompt_toast.js'; import { appendText, button } from './ui_recipe_detail_dom.js'; @@ -92,6 +93,7 @@ function saveMenu(owner, anchor, item, group, at) { function saveRow(owner, item, group, save, at) { const box = document.createElement('div'); box.className = 'anomalous-saves-save'; + box.dataset.key = save.id; const info = appendText(box, 'div', '', 'anomalous-saves-info'); const top = appendText(info, 'div', '', 'anomalous-saves-top'); appendText(top, 'span', String(at + 1), 'anomalous-saves-number'); @@ -109,7 +111,7 @@ function saveRow(owner, item, group, save, at) { button(tools, t('snapshotKeepCard'), 'anomalous-wf-filter').onclick = () => void keep(owner, group.key, save); const more = button(tools, '⋯', 'anomalous-wf-icon-btn'); more.title = t('notebookMore'); - more.onclick = () => saveMenu(owner, more, item, group, at); + more.onclick = event => saveMenu(owner, event.currentTarget, item, group, at); pictureLine(owner, box, save, placeOf(item, at)); return box; } @@ -145,7 +147,7 @@ function drawPart(owner, part, group) { head.className = 'anomalous-saves-head'; head.append(foldButton(owner, part, Boolean(group))); part.line = null; - if (!group) return void box.replaceChildren(head); + if (!group) return void settle(box, [head]); head.append(seedSwitch()); const go = button(head, t('snapshotRun', { count: runTotal(group.saves) }), 'anomalous-snapshot-run'); go.onclick = () => void run(owner, group, group.saves, seedMode()); @@ -154,13 +156,14 @@ function drawPart(owner, part, group) { if (!await anomalousConfirm(t('savesListRemoveConfirm', { name: item.title || t('recipeUntitled'), count: item.count }))) return; await removeGroup(item.key).catch(error => fail(owner, item.key, error)); }; - part.line = document.createElement('div'); - part.line.className = 'anomalous-saves-status'; - showLine(part); + const line = document.createElement('div'); + line.className = 'anomalous-saves-status'; const cards = document.createElement('div'); cards.className = 'anomalous-saves-cards'; cards.append(...group.saves.map((save, at) => saveRow(owner, item, group, save, at))); - box.replaceChildren(head, part.line, cards); + settle(box, [head, line, cards]); + part.line = box.querySelector('.anomalous-saves-status'); + showLine(part); fillLines(box); } diff --git a/web/modules/ui_saves_pictures.js b/web/modules/ui_saves_pictures.js index dafeac1..30b2711 100644 --- a/web/modules/ui_saves_pictures.js +++ b/web/modules/ui_saves_pictures.js @@ -94,11 +94,13 @@ function foldTile(line, next, count, total, open) { appendText(more, 'span', `+${count}`, 'anomalous-saves-folded'); } -/** `save`'s pictures in `box`, or what is said in their place. A line is filled by fillLines once it has its width. */ +/** `save`'s pictures in `box`, or what is said in their place. A line is filled by fillLines once it has its width, and stands while its key does. */ export function pictureLine(owner, box, save, where) { const all = newestFirst(save); if (!all.length) return void appendText(box, 'div', save.running ? t('savesRunsLeft', { count: save.running }) : t('snapshotNoPictures'), 'anomalous-saves-none'); const line = appendText(box, 'div', '', 'anomalous-saves-pictures'); + line.dataset.key = JSON.stringify([where.number, all.map(picture => [picture.filename, picture.subfolder, picture.type])]); + line.dataset.fills = ''; let room = 0; line.anomalousFill = () => { const fits = Math.floor((line.clientWidth + GAP) / TILE); diff --git a/web/modules/ui_snapshot_bar.js b/web/modules/ui_snapshot_bar.js index f35873a..363b283 100644 --- a/web/modules/ui_snapshot_bar.js +++ b/web/modules/ui_snapshot_bar.js @@ -19,6 +19,7 @@ import { translate as t } from './locales.js'; import { runCount, runTotal } from './snapshot_plan.js'; import { runSaves, seedMode, setSeedMode, watchRuns, watchSeedMode } from './snapshot_run.js'; import { addSave, findGroup, onSavesChange, readWorkflow, setCount } from './snapshot_store.js'; +import { settle } from './ui_dom.js'; import { showWorkbenchToast } from './ui_prompt_toast.js'; const EXTRA_KEY = 'anomalous_saves'; // in a workflow's extra: the group of saves it belongs to @@ -73,29 +74,6 @@ function button(className, label, run, title = '') { return node; } -/** - * `parent` comes to show `fresh` on the elements it has. The browser sends no click to an element that left - * the page during the press, and ComfyUI's graphChanged, an answer to a count or a run ending can all come - * during one; a box being typed in would lose its digits. A save's part is only kept for that save. - * Handlers move over to the kept element, so they read `event.target`, not the element made with them. - */ -function settle(parent, fresh) { - const there = [...parent.children]; - fresh.forEach((node, at) => { - const old = there[at]; - if (!old) return void parent.append(node); - if (old.tagName !== node.tagName || old.dataset.save !== node.dataset.save) return void old.replaceWith(node); - for (const { name, value } of node.attributes) old.setAttribute(name, value); - old.onclick = node.onclick; - old.onchange = node.onchange; - if (node.tagName === 'INPUT') { - if (old !== document.activeElement) old.value = node.value; - } else if (!node.children.length) old.textContent = node.textContent; - settle(old, [...node.children]); - }); - for (const extra of there.slice(fresh.length)) extra.remove(); -} - /** * Writes `key` on the open workflow without it counting as a change: the tab's undo states take it too, * so nothing asks to be saved and Ctrl+Z does not lose it. It reaches the file when the user next saves; @@ -284,7 +262,7 @@ export function seedSwitch() { function chip(save, at) { const box = el('span', `anomalous-snapshot-mark${save.id === current ? ' is-current' : ''}`); - box.dataset.save = save.id; + box.dataset.key = save.id; box.append(button('anomalous-snapshot-number', String(at + 1), () => void goBack(group.key, save), `${t('snapshotBack')}\n${saveWords(save, at)}`), el('span', 'anomalous-snapshot-times', '×'), runsBox(group.key, save)); return box; }