From ffe0cb4f404d96fd30fadc0beb537310b4488e1b Mon Sep 17 00:00:00 2001 From: DemonGatanjieu <207458273+DemonGatanjieu@users.noreply.github.com> Date: Sat, 10 Oct 2026 19:36:54 +0800 Subject: [PATCH] Compare: say which side is which A and B each get their own colour, shown on the side cards, the column heads and every text line. Prompts list the tags only A has and only B has (each once) instead of removed/added; long texts take one line per side with the differing part marked; values wrap whole instead of ending in an ellipsis. Co-Authored-By: Claude Opus 5.5 --- ARCHITECTURE.md | 4 +- CHANGELOG.md | 2 +- docs/architecture/material-library.md | 5 +- tests/param_compare.mjs | 6 +-- web/modules/locales.js | 2 + web/modules/param_compare.js | 12 ++--- web/modules/ui_param_compare.js | 51 +++++++++++++------ web/styles.css | 2 +- web/styles/27-param-compare.css | 70 +++++++++++++++++++++------ 9 files changed, 108 insertions(+), 46 deletions(-) diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 216eefd..0fb96b5 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -392,8 +392,8 @@ covered by the `styles.css` manifest. - `ui_gallery_card.js` builds one output image's gallery card (viewer, workbench, cover pick, drag, star, compare, delete); `ui_param_compare.js` compares parameters: a card's ⇄ picks an image, a second image's ⇄ or the canvas completes the pair (the image workbench's ⇄ takes the canvas), and its window says - whether both are the same node structure and lists each value that differs (prompts by tags - taken out and put in, seeds apart), writing ticked image values onto the canvas as one Ctrl+Z + whether both are the same node structure and lists each value that differs (prompts by the tags + only one side has, seeds apart), writing ticked image values onto the canvas as one Ctrl+Z step with Undo; `param_compare.js` reads and compares the two sides without DOM. `ui_keep_menu.js` is the star's keep menu (also the workbench's Keep): the whole workflow, the combo or the prompts, each saying where it goes, opening what was kept already or removing it (to the Recycle Bin through the recipe, diff --git a/CHANGELOG.md b/CHANGELOG.md index 790fb29..6889d4c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -21,7 +21,7 @@ - A scanned model's card shows its Civitai version ("v2.0", "Illustrious", "fp8"…) as a small label above the name, so versions of one model that share a file name can be told apart. ### ⇄ Compare parameters (参数对比) -- Two pictures from the same workflow often differ in only a few values. Press ⇄ on one picture in the Gallery, then on another (or **Compare with the canvas**); the picture details have the same ⇄ for the canvas. The window says whether both use the same nodes and lists only what differs, node by node: prompts by the tags taken out and put in (marked positive or negative), seeds folded away, nodes only one side has listed apart. Two workflows numbered differently still compare the nodes each has one of, such as their only KSampler. +- Two pictures from the same workflow often differ in only a few values. Press ⇄ on one picture in the Gallery, then on another (or **Compare with the canvas**); the picture details have the same ⇄ for the canvas. The window says whether both use the same nodes and lists only what differs, node by node, A and B each in its own colour: prompts by the tags only A or only B has (marked positive or negative), long names one line per side with the differing part marked, seeds folded away, nodes only one side has listed apart. Two workflows numbered differently still compare the nodes each has one of, such as their only KSampler. - Against the canvas, tick the values to take from the picture and put them on the canvas in one go (seeds stay); one Ctrl+Z or the toast's Undo takes them back. ### ⚙️ Settings in tabs (设置分组) diff --git a/docs/architecture/material-library.md b/docs/architecture/material-library.md index fae9293..30cab5e 100644 --- a/docs/architecture/material-library.md +++ b/docs/architecture/material-library.md @@ -129,8 +129,9 @@ the Recycle Bin through its own delete route (`delete_recipe`, `delete_notebook` wiring and prompt-box role from `prompt_boxes.js`) and compares them (`compareParams`): nodes pair by id and type, then leftover nodes by type when each side has exactly one of it; values equal by their text form are counted, not listed; seeds (`isVolatileWidget`) are flagged. -`textDifference` shows a long text as tags removed and added, or as the changed span with -40 characters around it. `ui_param_compare.js` is the window: a side is an output image (its +`textDifference` shows a long text as the tags only one side has (each once), or as the +differing span with 40 characters around it; the window gives each side its own line and colour, +since neither side is older than the other. `ui_param_compare.js` is the window: a side is an output image (its workflow from `inspect_image_material`, laid on a detached graph) or the live canvas. With the canvas on one side, ticked image values are written through `applyNodeMaterialValues` (seeds, missing choices and vanished nodes skipped and counted), all as one Ctrl+Z step, with Undo in diff --git a/tests/param_compare.mjs b/tests/param_compare.mjs index 00c059a..5d46c4d 100644 --- a/tests/param_compare.mjs +++ b/tests/param_compare.mjs @@ -55,9 +55,9 @@ assert.deepEqual(other.nodes.map(n => [n.aId, n.bId, n.id, n.rows[0].aText, n.ro assert.deepEqual([other.onlyA.map(n => n.id), other.onlyB.map(n => n.id), other.compared], [['6', '7'], ['71', '72'], 1]); assert.equal(compareParams(readParams(graph([node(1, 'A', {})])), readParams(graph([node(2, 'B', {})]))).compared, 0); -// Prompts as tags: what was taken out and put in. -assert.deepEqual(textDifference('1girl, red dress, smile', '1girl, smile, white dress, hat'), - { tags: true, removed: ['red dress'], added: ['white dress', 'hat'] }); +// Prompts as tags: the tags only one side has, each once. +assert.deepEqual(textDifference('1girl, red dress, smile, red dress', '1girl, smile, white dress, hat'), + { tags: true, onlyA: ['red dress'], onlyB: ['white dress', 'hat'] }); // Other text: the changed part with a little around it. const long = `${'a'.repeat(50)} cat ${'b'.repeat(50)}`; assert.deepEqual(textDifference(long, long.replace('cat', 'dog')), { diff --git a/web/modules/locales.js b/web/modules/locales.js index a39c322..6f245aa 100644 --- a/web/modules/locales.js +++ b/web/modules/locales.js @@ -592,6 +592,7 @@ export const i18n = { compareState: "状态", compareParameter: "参数", compareMoreTags: "还有 {count} 个", + compareTagsOnly: "独有:", compareModeOn: "启用", compareModeMuted: "已静音", compareModeBypassed: "已绕过", @@ -2879,6 +2880,7 @@ export const i18n = { compareState: "State", compareParameter: "Parameter", compareMoreTags: "{count} more", + compareTagsOnly: "only here:", compareModeOn: "On", compareModeMuted: "Muted", compareModeBypassed: "Bypassed", diff --git a/web/modules/param_compare.js b/web/modules/param_compare.js index a1ec509..92407ff 100644 --- a/web/modules/param_compare.js +++ b/web/modules/param_compare.js @@ -112,9 +112,9 @@ export function compareParams(a, b) { const tagsOf = text => String(text).split(/[,,\n]/).map(tag => tag.trim()).filter(Boolean); /** - * How two texts differ, for showing: prompts written as tags by the tags taken out and put in - * ({ tags: true, removed, added }), anything else by the part that changed with a little of - * what surrounds it ({ tags: false, before, aPart, bPart, after }, cut ends marked with …). + * How two texts differ, for showing: prompts written as tags by the tags only one side has + * ({ tags: true, onlyA, onlyB }, each tag once), anything else by the part that differs with a + * little of what surrounds it ({ tags: false, before, aPart, bPart, after }, cut ends marked …). */ export function textDifference(aText, bText) { const aTags = tagsOf(aText); @@ -122,9 +122,9 @@ export function textDifference(aText, bText) { if (aTags.length >= MIN_TAGS && bTags.length >= MIN_TAGS) { const inA = new Set(aTags); const inB = new Set(bTags); - const removed = aTags.filter(tag => !inB.has(tag)); - const added = bTags.filter(tag => !inA.has(tag)); - if (removed.length || added.length) return { tags: true, removed, added }; + const onlyA = [...inA].filter(tag => !inB.has(tag)); + const onlyB = [...inB].filter(tag => !inA.has(tag)); + if (onlyA.length || onlyB.length) return { tags: true, onlyA, onlyB }; } const a = String(aText); const b = String(bText); diff --git a/web/modules/ui_param_compare.js b/web/modules/ui_param_compare.js index 64f0073..f0d4d00 100644 --- a/web/modules/ui_param_compare.js +++ b/web/modules/ui_param_compare.js @@ -15,7 +15,7 @@ import { showWorkbenchToast } from './ui_prompt_toast.js'; import { compareParams, isLongText, readParams, shownValue, textDifference } from './param_compare.js'; const MODE_KEYS = { 0: 'compareModeOn', 2: 'compareModeMuted', 4: 'compareModeBypassed' }; -const SHOWN_TAGS = 12; // a prompt swapped whole lists every tag: the rest wait behind a button +const SHOWN_TAGS = 10; // per side: a prompt swapped whole lists every tag, the rest wait behind a button const ROLE_KEYS = { positive: 'recipePromptRolePositive', negative: 'recipePromptRoleNegative', both: 'recipePromptRoleBoth' }; let openWindow = null; @@ -62,31 +62,45 @@ function sideCard(side, letter) { img.alt = ''; card.append(img); } - const name = el('div', 'anomalous-compare-side-name', `${letter} · ${sideName(side)}`); + const name = el('div', 'anomalous-compare-side-name', sideName(side)); name.title = sideName(side); - card.append(name); + card.append(letterBadge(letter), name); return card; } -/** A long text's difference: tags taken out and put in, or the changed part in its place. */ +/** A or B in its own colour, the same in the side cards, the column heads and the text lines. */ +const letterBadge = letter => el('span', `anomalous-compare-letter is-${letter.toLowerCase()}`, letter); + +/** One side's line of a long text: its letter, then its tags or its text with the differing part marked. */ +function textLine(letter, parts) { + const line = el('div', `anomalous-compare-text-line is-${letter.toLowerCase()}`); + line.append(letterBadge(letter), ...parts); + return line; +} + +/** A long text's difference, one line per side: the tags only that side has, or its differing part in place. */ function textCell(row) { const cell = el('div', 'anomalous-compare-text'); const diff = textDifference(row.aText, row.bText); if (diff.tags) { - const removed = diff.removed.map(tag => el('span', 'anomalous-compare-tag is-removed', `− ${tag}`)); - const added = diff.added.map(tag => el('span', 'anomalous-compare-tag is-added', `+ ${tag}`)); - // Both kinds show at first: half each, or more of one when the other has fewer. - const firstRemoved = Math.max(SHOWN_TAGS / 2, SHOWN_TAGS - added.length); - const shown = [...removed.slice(0, firstRemoved), ...added.slice(0, SHOWN_TAGS - Math.min(removed.length, firstRemoved))]; - const rest = [...removed.slice(firstRemoved), ...added.slice(SHOWN_TAGS - Math.min(removed.length, firstRemoved))]; - cell.append(...shown); - if (rest.length) { - const more = button('anomalous-compare-more', t('compareMoreTags', { count: rest.length }), () => more.replaceWith(...rest)); - cell.append(more); + for (const [letter, tags] of [['A', diff.onlyA], ['B', diff.onlyB]]) { + if (!tags.length) continue; + const chips = tags.map(tag => el('span', 'anomalous-compare-tag', tag)); + const parts = [el('span', 'anomalous-compare-line-label', t('compareTagsOnly')), ...chips.slice(0, SHOWN_TAGS)]; + if (chips.length > SHOWN_TAGS) { + const more = button('anomalous-compare-more', t('compareMoreTags', { count: chips.length - SHOWN_TAGS }), () => more.replaceWith(...chips.slice(SHOWN_TAGS))); + parts.push(more); + } + cell.append(textLine(letter, parts)); } return cell; } - cell.append(document.createTextNode(diff.before), el('span', 'is-out', diff.aPart), el('span', 'is-in', diff.bPart), document.createTextNode(diff.after)); + for (const [letter, part] of [['A', diff.aPart], ['B', diff.bPart]]) { + const text = el('span', 'anomalous-compare-text-body'); + // A side with nothing where the other has more shows no mark: the other's line marks it. + text.append(document.createTextNode(diff.before), ...(part ? [el('mark', '', part)] : []), document.createTextNode(diff.after)); + cell.append(textLine(letter, [text])); + } return cell; } @@ -114,7 +128,12 @@ function rowLine(row, tick) { function columnHeads() { const line = el('div', 'anomalous-compare-row is-head'); - line.append(el('span', '', t('compareParameter')), el('span', '', 'A'), el('span', '', 'B')); + const head = (letter) => { + const cell = el('span'); + cell.append(letterBadge(letter)); + return cell; + }; + line.append(el('span', '', t('compareParameter')), head('A'), head('B')); return line; } diff --git a/web/styles.css b/web/styles.css index 82d26fa..76d63c8 100644 --- a/web/styles.css +++ b/web/styles.css @@ -25,4 +25,4 @@ @import url("./styles/24-backup.css?v=20261007-theme-2"); @import url("./styles/25-model-import.css?v=20261008-import"); @import url("./styles/26-model-tidy.css?v=20261008-tidy"); -@import url("./styles/27-param-compare.css?v=20261010-compare-1"); +@import url("./styles/27-param-compare.css?v=20261010-compare-2"); diff --git a/web/styles/27-param-compare.css b/web/styles/27-param-compare.css index 932ea31..64a4f4a 100644 --- a/web/styles/27-param-compare.css +++ b/web/styles/27-param-compare.css @@ -99,6 +99,8 @@ } .anomalous-compare-dialog { + --amb-compare-a: var(--amb-link); + --amb-compare-b: var(--amb-warn); display: flex; flex-direction: column; gap: 12px; @@ -280,36 +282,74 @@ .anomalous-compare-role.is-positive { color: var(--amb-ok-text); } .anomalous-compare-role.is-negative { color: var(--amb-danger-text); } +/* Values wrap whole: a model's name often differs only at its end. */ .anomalous-compare-value { min-width: 0; - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; + color: var(--amb-text-main); + overflow-wrap: anywhere; } -.anomalous-compare-value.is-a { color: var(--amb-text-muted); } -.anomalous-compare-value.is-b { color: var(--amb-text-main); font-weight: 600; } +.anomalous-compare-letter { + display: inline-flex; + flex: 0 0 auto; + align-items: center; + justify-content: center; + width: 18px; + height: 18px; + border-radius: 5px; + font-size: 11px; + font-weight: 700; + line-height: 1; +} + +.anomalous-compare-letter.is-a { + background: color-mix(in srgb, var(--amb-compare-a) 22%, transparent); + color: var(--amb-compare-a); +} + +.anomalous-compare-letter.is-b { + background: color-mix(in srgb, var(--amb-compare-b) 22%, transparent); + color: var(--amb-compare-b); +} .anomalous-compare-text { grid-column: 2 / -1; + display: flex; + flex-direction: column; + gap: 6px; + min-width: 0; +} + +.anomalous-compare-text-line { display: flex; flex-wrap: wrap; - gap: 4px; + align-items: baseline; + gap: 4px 6px; line-height: 1.6; overflow-wrap: anywhere; } -.anomalous-compare-text .is-out, -.anomalous-compare-tag.is-removed { - color: var(--amb-danger-text); - background: color-mix(in srgb, var(--amb-danger) 14%, transparent); - text-decoration: line-through; +.anomalous-compare-text-body { min-width: 0; flex: 1 1 0; } + +.anomalous-compare-line-label { + color: var(--amb-text-muted); + font-size: 11.5px; } -.anomalous-compare-text .is-in, -.anomalous-compare-tag.is-added { - color: var(--amb-ok-text); - background: color-mix(in srgb, var(--amb-ok) 14%, transparent); +.anomalous-compare-text-line mark { + border-radius: 3px; + padding: 0 2px; + color: inherit; +} + +.anomalous-compare-text-line.is-a mark, +.anomalous-compare-text-line.is-a .anomalous-compare-tag { + background: color-mix(in srgb, var(--amb-compare-a) 20%, transparent); +} + +.anomalous-compare-text-line.is-b mark, +.anomalous-compare-text-line.is-b .anomalous-compare-tag { + background: color-mix(in srgb, var(--amb-compare-b) 20%, transparent); } .anomalous-compare-more {