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 <noreply@anthropic.com>
This commit is contained in:
DemonGatanjieu
2026-11-05 15:36:52 +08:00
co-authored by Claude Opus 5.5
parent de48bff5b0
commit 029dce4d4d
9 changed files with 112 additions and 42 deletions
+2 -2
View File
@@ -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.
+1 -1
View File
@@ -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.
+3 -3
View File
@@ -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 · 存档跑出来全是画布上的内容
+1 -1
View File
@@ -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` 换掉。
+61 -4
View File
@@ -1,16 +1,20 @@
<!doctype html>
<meta charset="utf-8">
<title>Saves bar regression</title>
<title>Saves bar and page regression</title>
<link rel="stylesheet" href="../web/styles/28-workflows.css">
<script type="importmap">{"imports":{"/scripts/app.js":"data:text/javascript,export const app = {};","/scripts/api.js":"data:text/javascript,export const api = new EventTarget();"}}</script>
<pre id="results">Running…</pre>
<div class="graph-canvas-panel"></div>
<div id="page"></div>
<script type="module">
import { app } from '/scripts/app.js';
import { api } from '/scripts/api.js';
import { MAX_RUNS } from '../web/modules/snapshot_plan.js';
import { refreshGroup } from '../web/modules/snapshot_store.js';
import { savesToggle } from '../web/modules/ui_saves_list.js';
import { watchSaves } from '../web/modules/ui_snapshot_bar.js';
// The real bar on a page. The browser sends no click to an element that left the page during the press,
// The real bar and Saves page. The browser sends no click to an element that left the page during the press,
// so what must hold is that nothing here takes a control off it. Only ComfyUI and the saves routes are fake.
const save = id => ({ id, at: 0, count: 1, changes: [], pictures: [], running: 0 });
const group = { key: 'k', title: '', path: '', saves: [save('a'), save('b')] };
@@ -19,7 +23,7 @@ window.fetch = async (url, options) => {
const { id, count } = JSON.parse(options.body);
group.saves.find(item => item.id === id).count = count;
}
return new Response(JSON.stringify({ group, workflow: {} }));
return new Response(JSON.stringify({ group, groups: [{ key: 'k', title: 'canvas', path: '', count: group.saves.length, updated: 0, cover: null }], workflow: {} }));
};
app.rootGraph = { extra: { anomalous_saves: 'k' }, serialize: () => ({}) };
app.graph = app.rootGraph;
@@ -73,7 +77,7 @@ try {
group.saves.shift();
await refreshGroup('k');
const left = all('.anomalous-snapshot-mark');
check(left.map(chip => chip.dataset.save).join() === 'b,c' && all('.anomalous-snapshot-count')[0].value === '2', 'a part left by a removed save does not go on as the next one');
check(left.map(chip => chip.dataset.key).join() === 'b,c' && all('.anomalous-snapshot-count')[0].value === '2', 'a part left by a removed save does not go on as the next one');
one('.anomalous-snapshot-close').click();
const handle = one('.anomalous-snapshot-unfold');
@@ -81,6 +85,59 @@ try {
check(handle.isConnected, 'the folded handle stays on the page too');
handle.click();
check(one('.anomalous-snapshot-add') && one('.anomalous-snapshot-status') && !one('.anomalous-snapshot-unfold'), 'unfolding brings the row back');
const picture = filename => ({ filename, subfolder: '', type: 'output' });
group.saves[0].pictures = [picture('one.png')];
const owner = { recipeView: one('#page'), close() {}, show() {}, refreshRecipes() {} };
savesToggle(owner);
owner.savesOpen.add('k');
owner.savesToggle.click();
await answered();
const pageControls = all('.anomalous-saves-part button, .anomalous-saves-part input');
const pageStayed = () => pageControls.every(node => node.isConnected);
check(all('.anomalous-saves-save').length === 2 && one('.anomalous-saves-picture'), 'the page shows the saves and their pictures');
const pageBox = one('.anomalous-saves-tools .anomalous-snapshot-count');
await typed(pageBox, '4');
check(group.saves[0].count === 4 && pageStayed(), 'on the page too, the answer to a count keeps every control there');
check(one('.anomalous-saves-head .anomalous-snapshot-run').textContent.includes('5'), 'the part says the new total');
pageBox.focus();
pageBox.value = '8';
await refreshGroup('k');
check(document.activeElement === pageBox && pageBox.value === '8' && pageStayed(), 'a run ending leaves the part alone, a box being typed in too');
pageBox.blur();
const more = one('.anomalous-saves-tools .anomalous-wf-icon-btn');
more.click();
const [button, menu] = [one('.anomalous-saves-tools .anomalous-wf-icon-btn').getBoundingClientRect(), one('.anomalous-popup-menu').getBoundingClientRect()];
check(Math.abs(menu.right - button.right) < 2, 'a redrawn card\'s menu opens by its button');
more.click();
check(!one('.anomalous-popup-menu'), 'a second press closes it');
const tiles = all('.anomalous-saves-picture');
await refreshGroup('k');
check(tiles.every(node => node.isConnected), 'pictures that did not change stay on the page');
group.saves[0].pictures.push(picture('two.png'));
await refreshGroup('k');
const thumbs = all('.anomalous-saves-picture img');
check(thumbs.length === 2 && thumbs[0].src.includes('two.png'), 'a new picture goes first in its line');
group.saves[0].count = MAX_RUNS + 1;
await refreshGroup('k');
one('.anomalous-saves-head .anomalous-snapshot-run').click();
await answered();
check(one('.anomalous-saves-status').textContent, 'a refused run says so on the part it has after a redraw');
group.saves[0].count = 1;
one('.anomalous-saves-fold').click();
check(!one('.anomalous-saves-save'), 'a part folds');
one('.anomalous-saves-fold').click();
await answered();
check(all('.anomalous-saves-save').length === 2 && all('.anomalous-saves-picture').length === 2, 'and opens with its cards and pictures');
group.saves.shift();
await refreshGroup('k');
check(all('.anomalous-saves-save').map(card => card.dataset.key).join() === 'c' && one('.anomalous-saves-number').textContent === '1', 'a card left by a removed save does not go on as the next one');
results.textContent += failures ? `${failures} FAILED` : 'ALL PASSED';
} catch (error) { results.textContent += `FAIL ${error.stack}`; }
</script>
+30
View File
@@ -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();
}
+9 -6
View File
@@ -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);
}
+3 -1
View File
@@ -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);
+2 -24
View File
@@ -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;
}