From 47442d00f625aa209f6ca07fd35580371ef49b92 Mon Sep 17 00:00:00 2001 From: DemonGatanjieu <207458273+DemonGatanjieu@users.noreply.github.com> Date: Thu, 5 Nov 2026 21:21:41 +0800 Subject: [PATCH] Keep the Saves list still during a press A new picture puts itself first in its line, so a press on a picture ended on another one and was no click. While the main button is held on a button of the list, what comes waits until the press and its click are over. Tested with a real mouse on an isolated ComfyUI: a press held until a new picture came opened the pressed picture 4 times of 4 (0 before), and 16 of 16 long presses during runs answered (13 before). Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 4 ++-- docs/architecture/lessons.md | 2 +- docs/guides/testing.md | 2 +- tests/snapshot_bar.html | 32 ++++++++++++++++++++++++++++++++ web/modules/ui_saves_list.js | 23 +++++++++++++++++++++++ 5 files changed, 59 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index c5dd8ac..af390b7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,8 +4,8 @@ - AI 工作台的任务列表:一条线上的任务排在一起,后续任务缩进挂在最早的任务下面,一眼看出谁接着谁。每个任务行上多了 ⋯,改名、隐私模式、从列表移除不用先打开任务。移除中间的任务时,线不会断。 - The AI workbench's task list keeps a line of tasks together: follow-ups hang under the first task, so you see what goes on from what. Each task row has a ⋯ for rename, privacy mode and remove, without opening the task first. Removing a task in the middle does not break the line. -- 存档条和存档页上的按钮按一下就管用:刚在提示词框里打完字、刚改完次数,或者运行还在跑的时候,第一下不再被吃掉;运行途中正在输入的次数也不会被清掉。改完次数马上点运行,也一定按新的次数跑。次数没存上时会提示,框里退回实际的次数。 -- 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. Run right after changing a count goes by the new count. A count that could not be saved says so, and the box goes back to the real one. +- 存档条和存档页上的按钮按一下就管用:刚在提示词框里打完字、刚改完次数,或者运行还在跑的时候,第一下不再被吃掉;运行途中正在输入的次数也不会被清掉。改完次数马上点运行,也一定按新的次数跑。次数没存上时会提示,框里退回实际的次数。按住存档页的图时刚好出了新图,这一下也算数,新图等松手再排进来。 +- 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. Run right after changing a count goes by the new count. A count that could not be saved says so, and the box goes back to the real one. A press on a picture on the Saves page counts even if a new picture arrives during it; the new one joins after you let go. - **插入 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/lessons.md b/docs/architecture/lessons.md index 6abd60c..b025cb4 100644 --- a/docs/architecture/lessons.md +++ b/docs/architecture/lessons.md @@ -164,7 +164,7 @@ body:has(#anomalous-toolbox-modal[style*="display: flex"]) #anomalous-sidebar-to **根因**:`draw` 每次都把整行换成新元素,而按下和松开之间元素离开了页面,浏览器就不发 click。这段时间里能触发重画的有三样:ComfyUI 到 `window` 的 mouseup 才发现文本框的改动并发 `graphChanged`(不是失焦时的 `change`,打字时它也不看);保存次数的请求十几毫秒就回来;运行结束的 `status`。 -**做法**:`ui_dom.js` 的 `settle` 在已有元素上更新,一格只留给同一个存档,正在输入的框不动。图片行自己填内容,图没变就原样留着。这个存档出了新图时按在它的图上,这一下还是没有:新图排第一,整行右移,松手时鼠标下已经是另一张图,留着元素也不会有 click。 +**做法**:`ui_dom.js` 的 `settle` 在已有元素上更新,一格只留给同一个存档,正在输入的框不动。图片行自己填内容,图没变就原样留着。元素留着还不够:新图排第一,整行右移,松手时鼠标下已经是另一张图,也不会有 click。所以按在存档页的按钮上时列表不动,这期间来的内容等松手、click 发完再画(`holdStill`)。 **以后怎么做** - 用户可能正按着或正输入的控件,不要在异步事件里整块重建。 diff --git a/docs/guides/testing.md b/docs/guides/testing.md index 2840bf0..2d33efa 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 e61871c..070a798 100644 --- a/tests/snapshot_bar.html +++ b/tests/snapshot_bar.html @@ -147,6 +147,38 @@ try { 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'); + const press = (node, button = 0) => node.dispatchEvent(new PointerEvent('pointerdown', { bubbles: true, button })); + const firstThumb = () => one('.anomalous-saves-picture img').src; + const pressed = one('.anomalous-saves-picture'); + press(pressed.querySelector('img')); + group.saves[0].pictures.push(picture('three.png')); + await refreshGroup('k'); + check(pressed.isConnected && firstThumb().includes('two.png'), 'a press on a picture holds its line: a new one would put another under the pointer'); + group.saves[0].pictures.push(picture('four.png')); + await refreshGroup('k'); + window.dispatchEvent(new PointerEvent('pointerup')); + check(pressed.isConnected, 'its click still finds it as the press ends'); + await answered(); + check(firstThumb().includes('four.png'), 'what came meanwhile shows after the press, as it stands last'); + + press(one('.anomalous-saves-picture')); + group.saves[0].pictures.push(picture('five.png')); + await refreshGroup('k'); + window.dispatchEvent(new PointerEvent('pointercancel')); + await answered(); + check(firstThumb().includes('five.png'), 'a press the browser takes over, a drag, ends the hold too'); + + press(one('.anomalous-saves-picture'), 2); + group.saves[0].pictures.push(picture('six.png')); + await refreshGroup('k'); + check(firstThumb().includes('six.png'), 'a right press holds nothing: no click follows it, its pointerup may never come'); + press(one('.anomalous-saves-list')); + group.saves[0].pictures.push(picture('seven.png')); + await refreshGroup('k'); + check(firstThumb().includes('seven.png'), 'nor does a press beside the buttons, on the scrollbar say'); + group.saves[0].pictures.length = 2; + await refreshGroup('k'); + group.saves[0].count = MAX_RUNS + 1; await refreshGroup('k'); one('.anomalous-saves-head .anomalous-snapshot-run').click(); diff --git a/web/modules/ui_saves_list.js b/web/modules/ui_saves_list.js index a1d21b5..4a77db2 100644 --- a/web/modules/ui_saves_list.js +++ b/web/modules/ui_saves_list.js @@ -181,10 +181,31 @@ function sayCount(owner) { owner.savesTitle.textContent = t('savesListTitle', { canvases: parts.length, saves: parts.reduce((sum, part) => sum + part.item.count, 0) }); } +/** + * While the main button is held on a button of the list, nothing in it moves: a new picture would put + * another under the pointer and the press would be no click. What comes meanwhile waits in + * owner.savesHeld, a group's key to how it stands, and shows after the press. + */ +function holdStill(owner, event) { + if (event.button !== 0 || owner.savesHeld || !event.target.closest('button')) return; + owner.savesHeld = new Map(); + const ended = new AbortController(); + const release = () => { + ended.abort(); + setTimeout(() => { // the click comes right after pointerup + const held = owner.savesHeld; + owner.savesHeld = null; + for (const [key, group] of held) groupChanged(owner, key, group); + }); + }; + for (const type of ['pointerup', 'pointercancel']) window.addEventListener(type, release, { capture: true, signal: ended.signal }); +} + function setView(owner, on) { if (!owner.savesList) { owner.savesList = document.createElement('div'); owner.savesList.className = 'anomalous-wf-list anomalous-saves-list'; + owner.savesList.addEventListener('pointerdown', event => holdStill(owner, event)); owner.recipeView.append(owner.savesList); new ResizeObserver(() => fillLines(owner.savesList)).observe(owner.savesList); } @@ -231,6 +252,7 @@ export async function refreshSaves(owner) { /** A group changed: its part shows it; a new one or one gone changes the list. */ function groupChanged(owner, key, group) { if (!owner.savesListOn) return; + if (owner.savesHeld) return void owner.savesHeld.set(key, group); const part = owner.savesParts.get(key); if (!part || !group) return void refreshSaves(owner); part.item.count = group.saves.length; @@ -250,6 +272,7 @@ export function showSaves(key) { export function savesToggle(owner) { owner.savesOpen = new Set(); owner.savesParts = new Map(); + owner.savesHeld = null; owner.savesToggle = document.createElement('button'); owner.savesToggle.type = 'button'; owner.savesToggle.className = 'anomalous-wf-filter anomalous-wf-saves-toggle';