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:
co-authored by
Claude Opus 5.5
parent
de48bff5b0
commit
029dce4d4d
+2
-2
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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 · 存档跑出来全是画布上的内容
|
||||
|
||||
@@ -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
@@ -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>
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user