Compare commits

...
6 Commits
Author SHA1 Message Date
梦圣智杰 fbddc63fde chore: 发布 v2.2.2 / Release v2.2.2
- 快捷添加的反馈收干净:翻译过程只写状态栏,结果只走右下角;被拒绝时右上角额外贴出模型原样返回的内容。
- 修好删行尾标签会拽掉换行、只改颜色会写入权重 (标签:1) 两个会丢东西的问题。
- 删除动画改成从右往左擦,换行块的 × 也走同一套动画和让位。
- 拖动跨行的落点修正:拖到行尾/下一行开头各归各位,行首上移、行尾下移不再差一行。
- 系统开了「减少动态效果」时,首次进入自动关掉拖动预览与增删动画,只做一次。
- 翻译成功的右下角提示改成中文「已翻译「xxx」」。
2026-11-08 00:31:21 +08:00
梦圣智杰 2be6bd39ff - 技术债_结构拆分
- 新增模块依赖体检测试,防止拆模块时漏 import / 漏传参
2026-11-08 00:24:46 +08:00
梦圣智杰 b6faf77477 - 翻译成功的右下角提示改成中文「已翻译「xxx」」
- 系统开了「减少动态效果」时,首次进入自动关掉拖动预览和增删动画(只做一次,之后手动打开就按你的来)
2026-11-07 09:11:40 +08:00
梦圣智杰 8d77815a02 fix: 换行处理与拖动落点的修复 / Fix line-break handling and drag drop targets
- 快捷添加的反馈只走右下角:翻译过程写状态栏,成功紫色、失败红色;结果被拒绝时右上角额外弹一张卡,带上模型原样返回的内容。
- 编辑标签只改颜色时,不会再把权重写成 (标签:1);只有真的动过权重数字才写回。
- 删除行尾标签时保留换行,下一行不再被拽上来;删除独占一行的标签也不会空出一行。
- 删除动画最后一拍改为从右往左擦;点换行块上的 × 删除换行也走同一套动画和让位。
- 移动标签时的换行方向修正:拖到行尾就落在行尾,拖到下一行开头就落在下一行开头;行首标签拖到上一行末尾会正确上移一行,行尾标签拖到下一行开头会正确下移一行。
- 明细表里的换行行去掉没用的拖动手柄。
2026-11-07 08:42:09 +08:00
梦圣智杰 2066b93b40 chore: 发布 v2.2.1 / Release v2.2.1 2026-11-06 01:35:17 +08:00
梦圣智杰 1e0cf30c7c fix: 双击标签打不开编辑弹窗 / Fix double-click not opening the editor
- README 修了几处排版:demo 图前面误入的零宽度空格和 Tab(会让那行变成代码块、图片不显示)、一处粗体末尾多出的空格(星号会直接显示出来),以及「git安转」的错字。
2026-11-06 01:32:42 +08:00
24 changed files with 2177 additions and 1543 deletions
+1
View File
@@ -31,3 +31,4 @@ data/packs/community_*.json
__pycache__/
*.pyc
.gitattributes
bug.md
+3
View File
@@ -39,3 +39,6 @@ tag.sqlite
/output/
*.zip
*.log
#userinfo
bug.md
+41
View File
@@ -1,5 +1,46 @@
# 更新记录
## v2.2.2(2026-11-08)
### 快捷添加的反馈收干净了
- 快捷添加不再一直挂着一张右上角「处理中」卡:翻译过程只写节点状态栏,结果只走右下角——加成功是紫色提示,出错是红色提示。
- 翻译结果被异常拒绝时(比如一句话被展开成好几个标签),右上角会额外弹一张卡,把模型原样返回的内容贴出来,方便判断是翻译跑偏了还是规则太严。
### 两个改了会丢东西的小问题
- 编辑标签时只改颜色,不会再把权重写成 (标签:1);只有真的动过权重那个数字才写回。
- 删掉行尾的标签时换行会保留,下一行不会被拽上来;标签独占一行被删掉后也不会空出一行。
### 删除动画改从右往左擦掉
- 删标签的动画最后一下改成从右往左擦(以前是从左往右)。
- 点换行块上的 × 删掉换行,也走同一套动画和让位,不再是一下子消失。
### 拖标签跨行时落点终于对了
- 拖到一行的末尾就落在行尾,拖到下一行的开头就落在下一行开头,不再总是差一行。
- 行首的标签拖到上一行的末尾会正确上移一行;行尾的标签拖到下一行开头会正确下移一行。
- 明细表里的换行行去掉了那个没用的拖动手柄——它本来就拖不动。
### 系统开了「减少动态效果」就自动少动一点
- 系统里开了「减少动态效果」的,第一次进来会把拖动预览和标签增删动画都关掉,并在右上角说明一句、告诉你去哪重新打开。只做这一次:之后你手动打开就按你的意思来,不会再被覆盖。
- 右上角弹窗和右下角提示条不受影响——它们本来就只是操作回执,关掉等于连结果都看不见了;入场动画在这种系统设置下已经自动压成瞬时。
### 翻译成功的提示改成中文
- 翻译成功后右下角那条从 `Translated "xxx"` 改成「已翻译「xxx」」,和其他提示统一。
## v2.2.1(2026-11-06)
### 修复:双击标签打不开编辑弹窗
- 2.2.0 里在节点英文标签区双击一张标签打不开编辑弹窗了,第二下只会被当成又一次单击,弹不出权重 / 颜色 / 编辑 / 词条信息那个窗口。
- 原因:单击会立刻重渲染整个标签区,原来的卡片元素被换成新的,浏览器就不再把第二下算成「同一个东西被点两次」,双击事件根本不会触发。现在改由插件自己按「两次点击同一张卡、间隔够短」来判断双击,不再依赖元素身份。
- 多选不受影响:已经在一组多选里的标签,双击仍保持整组选中,不会被打回单选。
- 顺手修了 README 几处排版:demo 图前面误入的零宽空格和制表符(会让那行变成代码块、图片显示不出来)、一处粗体末尾多出的空格,以及「git安转」错字。
## v2.2.0(2026-11-05)
### 新增右下角提示条
+5 -3
View File
@@ -1,4 +1,4 @@
# ComfyUI 双语提示词管理器 v2.2.0
# ComfyUI 双语提示词管理器 v2.2.2
> ComfyUI 双语提示词节点:把英文提示词拆成可点击的标签,边写边看中文解释、调顺序、试效果。
@@ -188,13 +188,13 @@ cd ComfyUI/custom_nodes/
git clone --depth 1 https://github.com/mengshengzhijie/ComfyUI-Bilingual-Prompt-Inspector.git
```
**如果网络受限也可以使用gitee或者使用神奇的 “-c” **
**如果网络受限也可以使用 gitee,或者用上神奇的 `-c`**:
```bash
git clone https://gitee.com/MengShengZhiJie/ComfyUI-Bilingual-Prompt-Inspector.git
```
不过这里如果有动手能力的话推荐使用git安转,因为现在就我和我的朋友在使用,有时候修复bug和制作新功能我是总结下来然后一步一步的修复。直到把这次收录的bug和建议给完成之后才bump版本。
不过这里如果有动手能力的话推荐使用 git 安装,因为现在就我和我的朋友在使用,有时候修复bug和制作新功能我是总结下来然后一步一步的修复。直到把这次收录的bug和建议给完成之后才bump版本。
重启 ComfyUI,在节点菜单的 **text/prompt** 分类里找到 **提示词翻译与管理(英文输出)**(英文界面下显示为 Prompt Translator & Manager),也可以直接搜索「提示词」。
@@ -255,6 +255,8 @@ git clone https://gitee.com/MengShengZhiJie/ComfyUI-Bilingual-Prompt-Inspector.g
折叠上方还有一项「点击节点时跟随切换」:点画布上的节点时,侧边栏「标签管理」是否跟着切过去。
系统开了「减少动态效果」的话,第一次进入会把上面这几个纯装饰性的动画(两个拖动预览、标签增删动画)自动关掉,并在右上角说明一句。只做这一次,之后你手动打开就按你的来;右上角弹窗和右下角提示条不受影响——它们只是操作回执,关掉等于连结果都看不见。
---
## 常见问题
+20
View File
@@ -232,3 +232,23 @@ ComfyUI 原生 text 控件(工作流真值)
- 前端依赖 ComfyUI DOM Widget/PrimeVue 文本控件结构,升级 ComfyUI 后应重点回归空节点、旧工作流、文本编辑和标签联动。
- `panel_sync` 只解决同一页面多节点同步;跨标签页、跨浏览器和跨设备不是当前承诺。
- 任何解析增强都要同时测试末尾逗号、权重、重复标签、自然语言多逗号和夹心结构。
## 9. 已知工程债
- **单文件过大(已拆六块 + 显式 store + render 函数化)**:`js/bilingual_prompt.js` 从 4546 行降到 3218 行。
- `js/bpi_store.js`(129 行)—— 节点状态与偏好:`createStore({ node, textWidget })` 返回 `{ node, textWidget, state, hooks, storedMode, storedFillHeight }`。每个面板(每个节点)一份,多节点互不共享。主文件在 `renderNow` 之后调 `bindStoreHooks(store, {...})` 把 `render / updateText / setStatus / applyTokenWeight` 等填进 `hooks`;三个高度常量也在这里导出,供主文件与尺寸模块共用。
- 功能模块统一收 `{ store }`(个别还需要一两个额外依赖),内部 `const { state, textWidget } = store` 取稳定引用,回调一律 `store.hooks.xxx()`(**不能提前解构**,bind 发生在后面)。
- 已拆出的功能模块:
| 文件 | 行数 | 职责 | 额外依赖 |
|---|---|---|---|
| `bpi_line_breaks.js` | 123 | 换行块数据层(gap 读写、插入/删除/移动)| — |
| `bpi_editor_modal.js` | 365 | 标签编辑弹窗(权重与颜色 / 编辑原文 / 词条信息)| — |
| `bpi_chip_drag.js` | 412 | 拖拽与实时预览(含明细表行)| `englishTokenView`、`clickGuard` |
| `bpi_hidden.js` | 224 | 隐藏标签、「唯一」开关、输出管道(effective_text)| `buildTokenCard`(惰性)|
| `bpi_shortcut.js` | 74 | 撤销/重做、顺序微调 | `setHiddenTags`、`setUniqueKeys` |
| `bpi_node_size.js` | 208 | 节点尺寸与高度(自动高度、手动拉伸、适应高度,共 12 个函数)| `refs`(6 个 DOM)、`persistPreferences` |
- 这样以后拆新块时参数表不再随功能膨胀,一个 `store` 就够;要新增能力就往 `hooks` 里加一项。
- 剩下的候选:翻译批处理、卡片渲染、明细表、字典搜索(与 `render` 咬得紧,风险最高)。`render` 已把明细表与问题面板抽成同文件内的具名函数(306 → 57 行),不再作为候选;继续拆的前提是先把这些 DOM 收进 `store.refs`。实测主文件 0 个疑似死代码、连续 6 行重复只有 2 处,行数主要来自 172 次 `element()` 与 76 处 `addEventListener` —— 再搬模块的收益低于风险。
- **UI 层没有自动化测试**:`tests/` 覆盖的是 `parser`(100+ 断言)和几个纯逻辑模块。拆模块后有 `tests/test_module_deps.mjs` 的静态体检(漏 import、漏传参、import→export 链接),能抓到浏览器模块链接失败这类 `node --check` 查不出的问题;DOM 初始化顺序、拖拽落点与节点高度仍只能手测。
- **拖拽落点的历史坑**:`targetIndex` 表达不了「换行之前 / 之后」,必须配合 `beforeBreak` / `afterBreak` 两个标记;动这块之前先读 `js/parser.js` 里 `movePromptToken` 的注释,以及仓库根目录 `bug.md` 的复盘记录。
- **动画与无障碍**:系统开了 `prefers-reduced-motion` 时,首次进入会把拖动预览与增删动画关掉(标记 `bpi.reducedMotion.v1`,只做一次);弹窗与提示条的入场动画由各自的 `@media` 规则压成瞬时,不靠开关。
+227 -1512
View File
File diff suppressed because it is too large Load Diff
+412
View File
@@ -0,0 +1,412 @@
// 标签卡片 / 明细表行的拖拽:落点计算 + 实时预览(浮层跟手、其余项让位)。
// 这里只算出「插到第几个 token」,文本改动一律交给 parser 的 movePromptToken。
import { movePromptToken } from "./parser.js";
import { dragPreviewNodeEnabled } from "./bpi_drag_preview.js";
export function createChipDrag({ store, englishTokenView, clickGuard }) {
// 依赖统一从 store 取:state / textWidget 是稳定引用,回调走 store.hooks
const { state } = store;
const hooks = store.hooks;
const bindDragTrackers = (onMove, finish) => {
document.addEventListener("pointermove", onMove, true);
document.addEventListener("pointerup", finish, true);
document.addEventListener("pointercancel", finish, true);
};
const unbindDragTrackers = (onMove, finish) => {
document.removeEventListener("pointermove", onMove, true);
document.removeEventListener("pointerup", finish, true);
document.removeEventListener("pointercancel", finish, true);
};
// 拖动实时预览(设置里两个开关,默认关):把被拖的那一项复制成浮层跟着光标走,
// 原位留一个等大的空位,其余标签用 FLIP 动画平移让位——手感接近手机桌面拖图标。
// 全程只动 DOM,文本不动;松手才由 moveTokenTo 统一改文本,只留一条撤销记录。
//
// 落点 targetIndex 是 state.tokens 的下标(movePromptToken 的「先摘出来再插进去」语义),
// 实际要插到哪个 DOM 元素前面由 data-token-id 反查;那一项被筛掉了就往后找最近的可见项。
// 注意:一旦动了 DOM,它的顺序就不再等于 state.tokens,落点下标必须一直按拖动前
// 那份快照算(这两个拖拽的实现都已经在 pointerdown 时把候选列表存好了)。
// options.raise:默认抬起(浮层跟光标走,原位留空位);明细表传 false —— 不要抬起效果,
// 行自己留在原位,只在换位时跟着大家一起滑过去(像列表重排,不像手机挪图标)。
// FLIP_MS:让位动画时长,同时充当落点更新的节流窗口(动画播完才判下一次)。
// 拖着划过一长串标签时,落点会在相邻两个槽位之间反复抖动;窗口太短就会
// 不断打断重启动画,看上去就是疯狂左右闪。
const FLIP_MS = 160;
// 落点滞回:指针从上一次生效的位置挪出这么多像素才重新判定
const MOVE_EPSILON = 10;
const createDragPreview = (dragged, candidates, enabled, options = {}) => {
const homeParent = dragged.parentNode;
const homeNext = dragged.nextSibling;
const tracked = candidates.slice();
if (!tracked.includes(dragged)) tracked.push(dragged);
const byTokenId = new Map();
for (const item of candidates) {
const id = Number(item.dataset?.tokenId);
if (Number.isFinite(id)) byTokenId.set(id, item);
}
const candidateSet = new Set(candidates);
// 下一个「同类」兄弟:中间的分隔符、换行块、落点提示线一律跳过
const nextCandidate = (from) => {
let node = from?.nextElementSibling ?? null;
while (node && !candidateSet.has(node)) node = node.nextElementSibling;
return node;
};
// FLIP:改 DOM 前先量一遍「看起来的位置」,改完把每个动过的元素用 transform
// 拉回旧位,下一帧把 transform 清成空,浏览器自己去补这段位移 —— 平滑滑过去。
// 量「改完之后的位置」之前必须先清掉上一次动画留下的 transform —— 否则量到的是
// 动画中途的坐标,算出来的补偿位移是错的,下一次判定又会往回翻,来回振荡 = 疯狂闪。
let animating = false;
// 让位动画还没落地时攒着的落点,落地那一刻接着挪:最后一次移动不会被丢掉
let wanted = null;
let unlockTimer = null;
// 指针位置:每次 track 更新,run 真正执行时才拿它算落点
let pendingX = 0;
let pendingY = 0;
let appliedX = null;
let appliedY = null;
// 这次落点是不是「上一行末尾(换行块前面)」:拖拽层算好传进来,预览层不自己猜
let pendingBreak = false;
// 调试用(localStorage bpi.debug=1):落点每次变化打一行,方便看它到底算到哪张卡
let debugLastKey = "";
const flip = (mutate) => {
// 被拖的那张(透明空位)不参与 FLIP:它要「啪」地挪到落点,
// 只有旁边的元素需要平滑让开。要是连空位一起做过渡,眼睛就会看到
// 原位还留着一块、落点又多出一块,像是「原位凭空挤出一个空间」。
const before = tracked
.filter((el) => el !== dragged)
.map((el) => [el, el.getBoundingClientRect()]);
mutate();
const shifted = [];
for (const [el, first] of before) {
if (!el.isConnected) continue;
el.style.transition = "none";
el.style.transform = "";
const last = el.getBoundingClientRect();
const dx = first.left - last.left;
const dy = first.top - last.top;
if (!dx && !dy) {
// 它压根没动:临时关掉的 transition 要还回去,否则后面就不再有动画了
el.style.transition = "";
continue;
}
el.style.transform = `translate(${dx}px,${dy}px)`;
shifted.push(el);
}
if (!shifted.length) return;
void dragged.offsetWidth;
for (const el of shifted) el.style.transition = `transform ${FLIP_MS}ms cubic-bezier(.2,.8,.2,1)`;
animating = true;
requestAnimationFrame(() => {
for (const el of shifted) el.style.transform = "";
// 关键:等让位动画真正播完再解锁。早于一帧解除的话,动画还在半路,
// 下一次 pointermove 量到的是中间坐标 → 落点算错 → 又挪回去 → 来回闪
if (unlockTimer) clearTimeout(unlockTimer);
unlockTimer = setTimeout(() => {
unlockTimer = null;
animating = false;
// 动画期间攒下的新落点,这一刻再挪
run();
}, FLIP_MS);
});
};
// 让位:把攒着的落点真正落到 DOM 上,其余标签用 FLIP 滑过去
const run = (force = false) => {
if (wanted === null || !dragged.isConnected) return;
// 让位动画还在半路就只攒着:这时候 getBoundingClientRect / elementFromPoint
// 拿到的都是「半路坐标」,拿它判定会立刻往回翻,来回振荡 = 疯狂闪。
// force 是松手那一下的强制收口,此时必须把最后一次落点落实。
if (animating && !force) return;
// 指针停在两个槽位的分界线上时,一两像素的抖动也会让落点反复横跳,
// 所以要求它从上一次生效的位置挪出 MOVE_EPSILON 才重新判定(滞回)。
if (!force && appliedX !== null
&& Math.hypot(pendingX - appliedX, pendingY - appliedY) < MOVE_EPSILON) return;
appliedX = pendingX;
appliedY = pendingY;
// 落点在这里才算:此刻让位动画已播完,量到的是稳定的最终排版位置,
// 不会像「边挪边量」那样读到半路坐标、判定完立刻翻回去
const targetIndex = typeof wanted === "function" ? wanted() : wanted;
wanted = null;
let ref = null;
for (let i = targetIndex; i < state.tokens.length && !ref; i += 1) {
const found = byTokenId.get(state.tokens[i].id);
if (found && found !== dragged) ref = found;
}
if (window.localStorage.getItem("bpi.debug") === "1") {
const key = `${targetIndex}|${ref?.textContent ?? "null"}`;
if (key !== debugLastKey) {
debugLastKey = key;
console.log("[bpi] run", {
targetIndex,
ref: ref?.textContent?.slice(0, 24) ?? null,
dragged: dragged.textContent?.slice(0, 24),
});
}
}
// 落点其实是「上一行末尾」时,浮层要停到那一行的 ↵ 前面:
// 每行是独立的 .bpi-token-line,↵ 挂在上一行末尾,不是下一张卡的兄弟
if (ref && pendingBreak) {
const prevLine = ref.parentElement?.previousElementSibling;
const prevLast = prevLine?.lastElementChild;
if (prevLast?.classList?.contains("bpi-break-chip")) ref = prevLast;
}
const tail = candidates[candidates.length - 1];
const parent = ref ? ref.parentNode : tail?.parentNode;
if (!parent?.isConnected) return;
// 幂等检查:已经在目标位置就别再挪。这条同时也是防抖的关键,
// 少了它相邻两个槽位会来回跳。
// 注意别再补「落点等于原位就 return」那种判断——DOM 可能已经被前面几次
// 移动挪走了,这时恰恰需要真的移回原位,预览才会挤开。
if (dragged.parentNode === parent
&& (dragged.nextElementSibling === ref || nextCandidate(dragged) === ref)) return;
flip(() => parent.insertBefore(dragged, ref));
};
let clone = null;
let grabX = 0;
let grabY = 0;
let baseLeft = 0;
let baseTop = 0;
// 抬起:复制一份当浮层跟着光标走,原元素留在原位当空位(透明但仍参与命中测试)。
// options.raise===false(明细表):根本不复制 —— 行自己在原地,换位置的时候跟着
// 大家一起滑过去就行,不需要「拿起来」这回事。
const lift = (pointerX, pointerY) => {
if (!enabled || options.raise === false || clone) return;
const rect = dragged.getBoundingClientRect();
if (!rect.width || !rect.height) return;
grabX = pointerX - rect.left;
grabY = pointerY - rect.top;
baseLeft = rect.left;
baseTop = rect.top;
clone = dragged.cloneNode(true);
clone.classList.add("bpi-drag-float");
clone.removeAttribute("id");
// 拖起来的这份要看着就跟原物一模一样:原元素身上的「拖拽中」半透明状态(.bpi-dragging)
// 不能带到浮层上,否则整块发灰,像隔着一层毛玻璃
clone.classList.remove("bpi-dragging");
clone.style.position = "fixed";
clone.style.left = `${rect.left}px`;
clone.style.top = `${rect.top}px`;
// 必须按 border-box 算:行/卡片自带 padding 和边框,rect 是含这些的总尺寸,
// 用默认的 content-box 会把浮层撑大一圈,看着像被放大了
clone.style.boxSizing = "border-box";
clone.style.width = `${rect.width}px`;
clone.style.height = `${rect.height}px`;
clone.style.margin = "0";
clone.style.zIndex = "10050";
clone.style.pointerEvents = "none";
document.body.appendChild(clone);
dragged.classList.add("bpi-drag-source");
follow(pointerX, pointerY);
};
// 节点里:浮层跟着光标二维移动(抬起感)。不放大的,跟原物一样大,
// 抬起感全靠 .bpi-drag-float 的投影;明细表传了 raise:false,这一步直接空转
const follow = (pointerX, pointerY) => {
if (!clone) return;
clone.style.transform = `translate3d(${pointerX - grabX - baseLeft}px,${pointerY - grabY - baseTop}px,0)`;
};
// 只清 FLIP 留在被跟踪元素上的内联样式(transition / transform)
const clearInline = () => {
for (const el of tracked) {
el.style.transition = "";
el.style.transform = "";
}
};
return {
lift,
// 跟手 + 让位:让位动画播到一半时只攒着落点,等落地那刻再判。
// 这样最后一次移动不会被丢,也不会因为读到半路坐标而来回翻
track(pointerX, pointerY, resolveIndex, beforeBreak = false) {
if (!enabled) return;
follow(pointerX, pointerY);
pendingX = pointerX;
pendingY = pointerY;
pendingBreak = Boolean(beforeBreak);
wanted = resolveIndex;
run();
},
// 松手前收口:把攒着的那一步**强制**补上(不受让位动画节流限制),
// 让「眼前的顺序」正好等于「马上要写进文本的顺序」,再清掉动画残留
// (内联 transition / transform)。少了这一步,松手后排队中的重渲染
// 会把「拖动前的旧顺序」画出来一帧 —— 那正是闪回起点
settle() {
run(true);
clearInline();
},
// 浮层落地:对准传进来的落点(松手前量好的最终位置)飞过去,落地那一刻才消失。
// 期间真实那一张一直是透明的空位,所以中间不会看到两张。
// 落点量不到就直接删掉浮层 —— 无论如何不留残影
land() {
const dying = clone;
clone = null;
clearInline();
// 排队中的重渲染先撤掉:它会马上重建 DOM,把落点上那一张提前显示出来(重影),
// 等浮层落地再补刷一次
const pending = Boolean(state.renderTimer);
if (pending) {
clearTimeout(state.renderTimer);
state.renderTimer = null;
}
const reveal = () => {
dragged.classList.remove("bpi-drag-source");
if (pending) hooks.render();
};
if (!dying) {
reveal();
return;
}
// 上面 clearInline() 已经把让位动画的 transform 清掉了,
// 所以此刻量到的就是真正的排版位置 —— 浮层该去的落点
const rect = dragged.isConnected ? dragged.getBoundingClientRect() : null;
if (rect && (rect.width || rect.height)) {
dying.style.transition = "transform .18s cubic-bezier(.2,.8,.2,1)";
dying.style.transform = `translate3d(${rect.left - baseLeft}px,${rect.top - baseTop}px,0) scale(1)`;
window.setTimeout(() => {
dying.remove();
reveal();
}, 200);
return;
}
dying.remove();
reveal();
},
// 放回原位:移动没生效时(原地放下 / 目标被筛掉了)把预览挪出来的错位收回去
restore() {
if (!dragged.isConnected || !homeParent?.isConnected) return;
// 原来的下一个节点可能已经被重渲染换掉了,那就直接放到父节点末尾
const before = homeNext && homeNext.parentNode === homeParent ? homeNext : null;
if (dragged.parentNode === homeParent && dragged.nextSibling === before) return;
homeParent.insertBefore(dragged, before);
},
};
};
// Drag-to-reorder on the English token chips: pointerdown arms a possible
// drag, moving past a small threshold activates it (so plain clicks and
// double-clicks keep working), and releasing applies a structured move
// through the shared undo stack.
// view / selector 让英文标签区与中文对照区可以共用这套拖拽逻辑:
// 两处 chip 一一对应同一批 token,拖动任何一个都是重排同一份文本。
// selector 必须指到整张卡片(.bpi-token-card)而不是里面的英文行:卡片上
// 半是英文、下半是中文小块,只认英文行的话,指针落在中文小块上就判定不到
// 目标,插入提示不出现、落点还会被几何兜底算到列表末尾去。
const attachChipDrag = (chip, token, view = englishTokenView, selector = ".bpi-token-card") => {
chip.addEventListener("pointerdown", (event) => {
if (event.button !== 0 || !hooks.canReorderTokens()) return;
const startX = event.clientX;
const startY = event.clientY;
// 实时预览打开后卡片会被挪走,DOM 顺序就不等于 token 顺序了;
// 落点下标一律按拖动前这份顺序算(关掉预览时两者本来就是一样的)
const homeList = [...view.querySelectorAll(selector)];
const previewEnabled = dragPreviewNodeEnabled();
const preview = createDragPreview(chip, homeList, previewEnabled);
let active = false;
let targetIndex = null;
// 落点是不是「上一行末尾」(换行块前面):被拖的标签本身就在行首时
// token 顺序不会变,得靠这个标记把前面的换行挪到它后面
let beforeBreak = false;
let hoverChip = null;
const clearHover = () => {
if (hoverChip) hoverChip.classList.remove("bpi-drop-before", "bpi-drop-after");
hoverChip = null;
};
const onMove = (moveEvent) => {
if (!chip.isConnected) {
finish();
return;
}
if (!active) {
// 阈值调高一点:手抖 / 点着不动不会误触发拖动(顺带也让「让位」
// 不会在起手那一瞬间来回跳)
if (Math.abs(moveEvent.clientX - startX) < 7 && Math.abs(moveEvent.clientY - startY) < 7) return;
active = true;
clickGuard.suppressed = true;
chip.classList.add("bpi-dragging");
preview.lift(moveEvent.clientX, moveEvent.clientY);
}
moveEvent.preventDefault();
clearHover();
targetIndex = null;
const hit = document.elementFromPoint(moveEvent.clientX, moveEvent.clientY);
const hitChip = hit?.closest?.(selector);
// 拖拽元素现在可能是整张卡片:命中它内部的 chip 也算「拖到自己身上」,跳过
const onSelf = Boolean(hitChip) && (hitChip === chip || chip.contains(hitChip));
// 界面上真正排在后面的那张(跳过被拖的这张——它就是要插进来的那个)
const live = [...view.querySelectorAll(selector)];
const nextVisible = (from) => {
const start = live.indexOf(from);
for (let i = start + 1; i < live.length; i += 1) {
if (live[i] !== chip) return live[i];
}
return null;
};
// 落点一律换算成「插到哪张卡前面」,再变回 state.tokens 的下标;
// 这样即使预览已经把卡片挪过的位置串了,结果也跟 movePromptToken 一致
const resolve = (anchor) => {
const index = anchor ? homeList.indexOf(anchor) : -1;
return index >= 0 ? index : state.tokens.length;
};
// 这张卡是不是「某行第一个、上一行以换行块结尾、而且指针确实在上一行的高度里」。
// 最后那条不能少:光看结构的话,在同一行里往左拖一点、落点正好是行首那张卡,
// 就会被当成要跨行,浮层一下子飞到上一行的回车前面去
const landsBeforeBreak = (card, clientY) => {
if (!card) return false;
const prevLine = card.parentElement?.previousElementSibling;
if (!prevLine?.lastElementChild?.classList?.contains("bpi-break-chip")) return false;
// 用上一行容器的矩形来判:卡片自己带着 FLIP 的 transform,量出来不准
const box = prevLine.getBoundingClientRect();
return clientY >= box.top && clientY <= box.bottom;
};
if (hitChip && !onSelf) {
const rect = hitChip.getBoundingClientRect();
const before = moveEvent.clientX < rect.left + rect.width / 2;
const anchorCard = before ? hitChip : nextVisible(hitChip);
targetIndex = resolve(anchorCard);
beforeBreak = !before && landsBeforeBreak(anchorCard, moveEvent.clientY);
hoverChip = hitChip;
hoverChip.classList.add(before ? "bpi-drop-before" : "bpi-drop-after");
} else {
// 空白处、或光标停在自己浮起来的那份身上:按当前画出来的顺序做几何兜底。
// 少了后半句,卡片一旦挪到指针下面就再也算不出新落点,拖着就不让位了
let lastCard = null;
for (const other of live) {
if (other === chip) continue;
const rect = other.getBoundingClientRect();
if (rect.bottom <= moveEvent.clientY) lastCard = other;
else if (moveEvent.clientY >= rect.top && moveEvent.clientX >= rect.right) lastCard = other;
}
const anchor = lastCard ? nextVisible(lastCard) : live.find((item) => item !== chip) || null;
targetIndex = resolve(anchor);
beforeBreak = landsBeforeBreak(anchor, moveEvent.clientY);
}
preview.track(moveEvent.clientX, moveEvent.clientY, targetIndex, beforeBreak);
};
const finish = () => {
unbindDragTrackers(onMove, finish);
chip.classList.remove("bpi-dragging");
clearHover();
if (active) {
setTimeout(() => { clickGuard.suppressed = false; }, 0);
// 先收口:把攒着的那一步落到 DOM 上,让「眼前的顺序」正好等于
// 「马上要写进文本的顺序」,再清掉让位动画残留
preview.settle();
// 落点正好是自己后面那一格、而自己后面就是换行:这是「下移一行」,
// token 顺序没变,得让 parser 把换行挪到前面
const afterBreak = !beforeBreak && targetIndex === token.id + 1
&& token.id + 1 < state.tokens.length
&& hooks.gapHasBreak(token.id);
const committed = targetIndex !== null
? hooks.moveTokenTo(token, targetIndex, { beforeBreak, afterBreak })
: false;
// 没搬动(原地放下 / 目标被筛掉了):把预览挪出来的错位收回去
if (!committed) preview.restore();
// 有浮层时 land() 会等它落地再刷 DOM(提前刷会把落点上那一张显示出来,看着是重影)
preview.land();
if (committed) requestAnimationFrame(() => view.focus({ preventScroll: true }));
}
};
bindDragTrackers(onMove, finish);
});
};
return { attachChipDrag, createDragPreview, bindDragTrackers, unbindDragTrackers };
}
+1 -1
View File
@@ -1,4 +1,4 @@
// 删除标签时的消失动画:X 从四角向心长出 → 颜色从中心铺满整块 → 上下压两条灰边 → 整块从左往右擦掉。
// 删除标签时的消失动画:X 从四角向心长出 → 颜色从中心铺满整块 → 上下压两条灰边 → 整块从右往左擦掉。
// 关掉就是原来的样子:卡片直接消失。这个开关同时管添加动画(见 bpi_insert_anim.js),
// 设置里显示为「标签增删动画(节点标签区)」,默认关。
const KEY = "bpi.deleteAnim.node.v1";
+365
View File
@@ -0,0 +1,365 @@
// 标签编辑弹窗:权重与颜色 / 编辑原文 / 词条信息三页。
// 只负责 DOM 与交互,改文本、存词条都通过调用方传进来的钩子做。
import { buildTagFields, button, element, saveTag, setPlaceholder, setText, setTitle, t } from "./bpi_shared.js";
import { replacePromptSpan, UNKNOWN_CHINESE } from "./parser.js";
import { showToast } from "./bpi_toast.js";
export function createWeightEditor({ store }) {
// 依赖统一从 store 取:state / textWidget 是稳定引用,回调走 store.hooks
const { state, textWidget } = store;
const hooks = store.hooks;
const openWeightEditor = (token) => {
if (!token) return;
// 自然语言片段(以及 BREAK、<lora:…> 这类特殊语法)没有独立权重、也没有词条,
// 所以这两个页签按 token 形态收起来,但弹窗照开:配色和整段编辑对它们一样有用
const isPlainTag = token.syntax === "tag" && token.segmentKind !== "natural";
const canWeight = isPlainTag && ["tags", "mixed"].includes(state.modeInfo.mode);
const shade = element("div", "bpi-modal-shade");
const modal = element("div", "bpi-modal");
// 这句直接写中文:带变量 + 固定术语,走翻译层反而匹配不上,界面必须中文
// 自然语言片段 / BREAK 这类不叫「标签」,标题换个说法免得看着别扭
modal.appendChild(element("h3", "", isPlainTag ? `编辑标签:${token.term}` : `编辑片段:${token.term}`));
// 多选着进来、或者双击的不是普通标签(自然语言片段)→ 直接停在编辑页:
// 权重只对单个标签有意义,而自然语言片段本来就是整段改的
const startOnRaw = state.selected.size > 1 || !isPlainTag;
// Tab 切换:权重与颜色(一页管两样)/ 编辑(改这行原文)/ 词条信息(改的是整条词条)
// 自然语言片段没有权重也没有词条,顺序改成 编辑 → 颜色
const tabs = element("div", "bpi-editor-tabs");
// 没有权重可改时这页只剩配色,标签也就只写「颜色」
const tabWeight = element("button", `bpi-editor-tab${startOnRaw ? "" : " bpi-editor-tab-active"}`, canWeight ? t("Weight / color") : t("Colors"));
const tabRaw = element("button", `bpi-editor-tab${startOnRaw ? " bpi-editor-tab-active" : ""}`, t("Edit"));
const tabEdit = element("button", "bpi-editor-tab", t("Entry info"));
tabWeight.type = tabRaw.type = tabEdit.type = "button";
if (isPlainTag) tabs.append(tabWeight, tabRaw, tabEdit);
else tabs.append(tabRaw, tabWeight);
modal.appendChild(tabs);
const weightPanel = element("div", `bpi-editor-panel${startOnRaw ? " bpi-hidden" : ""}`);
// 权重那三块(说明 / 输入框 / 预设)单独装一个盒子,不能改权重时整盒收起,
// 下半部分的配色照旧留着 —— 自然语言片段也要能上色
const weightBlock = element("div");
// 这句直接写中文:固定术语,走翻译层反而匹配不上
weightBlock.appendChild(element("div", "bpi-config-note", "Anima 格式:(标签:权重)"));
const form = element("div", "bpi-form");
const input = element("input", "");
input.type = "number";
input.min = "0";
input.max = "3";
input.step = "0.05";
input.value = token.weight === null ? "1" : String(token.weight);
// 打开弹窗时这个数字是多少:只有它变了才写回权重,
// 否则「只想换个颜色」会被顺手落成 (标签:1)
const initialWeight = token.weight === null ? 1 : token.weight;
form.append(element("label", "", "Weight"), input);
weightBlock.appendChild(form);
const presets = element("div", "bpi-weight-presets");
for (const value of [0.7, 0.8, 0.9, 1, 1.1, 1.2, 1.3, 1.5]) {
presets.appendChild(button(String(value), () => {
input.value = String(value);
input.focus();
}, "bpi-mini"));
}
weightBlock.appendChild(presets);
weightPanel.appendChild(weightBlock);
// 同一页下半部分是配色:预设色块 + 自定义输入框;只在 default 模式下才看得见效果
// 说明跟上方的「Anima 格式」一样放在控件上面
const colorDivider = element("div", "bpi-editor-divider");
weightPanel.appendChild(colorDivider);
weightBlock.hidden = !canWeight;
colorDivider.hidden = !canWeight;
// 双击设的自定义色优先级最低:只有「默认」模式才轮到它显示,其它模式会盖住
weightPanel.appendChild(element("div", "bpi-color-note", t("This color has the lowest priority: it only shows in Default mode; other color modes override it.")));
const presetsRow = element("div", "bpi-color-presets");
let pickedColor = state.customColors.get(token.key) || "";
const customInput = element("input", "");
customInput.value = pickedColor;
// 统一选中态:值相同才高亮,「无」用空串,所以点它能直接清掉自定义色
const markPicked = (value, keepInput) => {
pickedColor = value;
if (!keepInput) customInput.value = value;
presetsRow.querySelectorAll(".bpi-color-swatch").forEach((s) => {
s.classList.toggle("bpi-color-swatch-active", (s.dataset.color || "") === value);
});
};
// 「无」= 不着色、跟随主题,放在预设色块最前面,一键回到默认
const noneSwatch = element("span", "bpi-color-swatch bpi-color-swatch-none", t("None"));
noneSwatch.dataset.color = "";
setTitle(noneSwatch, t("None"));
noneSwatch.addEventListener("click", () => markPicked(""));
presetsRow.appendChild(noneSwatch);
const presetsPool = (state.tokenColors.presets && state.tokenColors.presets.length) ? state.tokenColors.presets : ["#6b9b78"];
for (const hex of presetsPool) {
const swatch = element("span", "bpi-color-swatch");
swatch.dataset.color = hex;
if (hex === pickedColor) swatch.classList.add("bpi-color-swatch-active");
swatch.style.background = hex;
swatch.title = hex;
swatch.addEventListener("click", () => markPicked(hex));
presetsRow.appendChild(swatch);
}
if (!pickedColor) noneSwatch.classList.add("bpi-color-swatch-active");
weightPanel.appendChild(presetsRow);
const customRow = element("div", "bpi-color-custom");
customRow.appendChild(element("span", "", t("Custom")));
setPlaceholder(customInput, "#5b8a72 or rgb(91,138,114)");
customInput.addEventListener("input", () => {
// keepInput:正在打字就不要回写输入框,否则光标会被拉到末尾
markPicked(customInput.value.trim(), true);
});
customInput.addEventListener("keydown", (event) => {
if (event.key === "Enter") { event.preventDefault(); apply(); }
});
customRow.appendChild(customInput);
weightPanel.appendChild(customRow);
// 编辑 Tab:把选中那段原文原样搬进输入框,怎么改都行,应用时整段写回提示词
const rawPanel = element("div", `bpi-editor-panel${startOnRaw ? "" : " bpi-hidden"}`);
const rawSpan = hooks.spanForEdit(token);
const originalSlice = String(textWidget.value ?? "").slice(rawSpan.start, rawSpan.end).trim();
// 这三句直接写中文:本页专属说明,走翻译层反而匹配不上,界面必须中文
rawPanel.appendChild(element("div", "bpi-config-note", rawSpan.tokens.length > 1
? `已选中 ${rawSpan.tokens.length} 个标签,下面是它们在原文里的整段(含中间的标签)`
: isPlainTag ? "下面是这个标签在原文里的那段文字" : "下面是这段文字在原文里的整段"));
const rawEditor = element("textarea", "bpi-raw-editor");
rawEditor.value = originalSlice;
rawPanel.appendChild(rawEditor);
rawPanel.appendChild(element("div", "bpi-config-note", "怎么改都行:逗号会被重新切成标签,清空等于删除这一段;Ctrl+Enter 应用"));
// 编辑 Tab:整套词条字段(含分类下拉),跟「新增/编辑个人标签」弹窗同一份构件。
// 命中词库就整条带过来改;没命中就按这条标签卡起个头,存进去即新建。
const editPanel = element("div", "bpi-editor-panel bpi-hidden");
// 两句说明收进同一个盒子:应用时若发现还是「未收录」,整盒换成红色警告,
// 并先把盒子按当前高度钉住,换文案就不会把下面的表单顶上去
const editNotes = element("div", "bpi-editor-notes");
// 这两句是原文(英文的走翻译层,中文那句本页专属、不进译表),来回切换时都按原文重新渲染
const noteSavedSource = "Edits go to the personal dictionary and override same-name entries.";
const noteReadonlySource = "英文标签在这里只读;要改提示词里的文字,请用「编辑」页。";
const noteSaved = element("div", "bpi-config-note", noteSavedSource);
const noteReadonly = element("div", "bpi-config-note", noteReadonlySource);
editNotes.append(noteSaved, noteReadonly);
editPanel.appendChild(editNotes);
const editForm = element("div", "bpi-form");
const editFields = buildTagFields(editForm, state.index.get(token.key) ?? {
english: token.term,
// 未收录的中文名只是占位,这里预填成空:想存就得自己写一个,
// 下面的「还是未收录就拦掉」只是兜底(防手动敲回这三个字)
chinese: token.chinese === UNKNOWN_CHINESE ? "" : token.chinese ?? "",
models: ["general", "anima"],
source: "user",
verified: true,
// 英文只读:改它只会另存一条词条,不会动提示词;改文字请去「编辑」页
}, { withTarget: false, readonlyEnglish: true });
editPanel.appendChild(editForm);
// 保护性占位:未收录标签的中文名只是提示文案,原样存进个人词库会得到
// 一条中文名是「未收录」的残词条,标签从此显示成已收录却看不出是什么。
// 所以「词条信息」页点应用时先比对中文名,还是占位就不保存、只把说明换成红色警告。
let warnTimer = 0;
const restoreEditNotes = () => {
clearTimeout(warnTimer);
editNotes.style.minHeight = "";
setText(noteSaved, noteSavedSource);
setText(noteReadonly, noteReadonlySource);
noteSaved.classList.remove("bpi-config-note-warn");
noteReadonly.classList.remove("bpi-config-note-warn");
};
const warnUnknownChinese = () => {
// 先按现在的高度钉住:警告比原文短也不会缩,下面的表单一动不动
const height = editNotes.getBoundingClientRect().height;
if (height > 0) editNotes.style.minHeight = `${height}px`;
setText(noteSaved, "Nothing was saved: the Chinese name is still the unknown placeholder.");
setText(noteReadonly, "Enter a real Chinese name, then apply again.");
noteSaved.classList.add("bpi-config-note-warn");
noteReadonly.classList.add("bpi-config-note-warn");
clearTimeout(warnTimer);
warnTimer = setTimeout(restoreEditNotes, 2000);
// 光标直接落到中文名那一格,省得再去找
editFields.focus();
};
modal.append(weightPanel, rawPanel, editPanel);
const close = () => {
clearTimeout(warnTimer);
editFields.destroy();
shade.remove();
};
// 配色跟权重在同一页,所以 Apply 一次存两样;改动与否各自判断,
// 只想改颜色的人不会被「权重没有变化」挡住
const saveColor = () => {
const value = String(customInput.value || "").trim();
const previous = state.customColors.get(token.key) || "";
// 任何模式下都允许设置(切回 default 就显示);显示与否由 tokenCardColor 按模式决定
if (!value) state.customColors.delete(token.key);
else {
const okHex = /^#([0-9a-fA-F]{3}|[0-9a-fA-F]{6})$/.test(value);
const okRgb = /^rgb\(\s*\d+\s*,\s*\d+\s*,\s*\d+\s*\)$/.test(value);
if (!okHex && !okRgb) {
hooks.setStatus("Invalid color; use #hex or rgb(r,g,b)", "error");
customInput.focus();
return null;
}
state.customColors.set(token.key, value);
}
return value !== previous;
};
const apply = () => {
// 不能改权重时这一页只剩配色,直接存色走人
if (!canWeight) {
const colorOnly = saveColor();
if (colorOnly === null) return;
if (colorOnly) hooks.render();
close();
return;
}
const raw = String(input.value ?? "").trim();
const numeric = Number(raw);
if (!raw || !Number.isFinite(numeric) || numeric < 0 || numeric > 3) {
hooks.setStatus("Weight must be between 0 and 3", "error");
input.focus();
return;
}
const colorChanged = saveColor();
if (colorChanged === null) return;
// 打开时是 1(原标签没写权重)就不算改过:光改颜色的不应该被写成 (标签:1)
const weightDirty = Math.abs(numeric - initialWeight) > 1e-9;
if (weightDirty) {
// 改文本会顺带重绘,配色不用再刷一次
if (hooks.applyTokenWeight(token, numeric)) close();
return;
}
if (colorChanged) hooks.render();
close();
};
// 编辑页:整段写回当前节点的提示词原文,跟手改原文一样进撤销栈
const applyRawEdit = () => {
const before = String(textWidget.value ?? "");
const result = replacePromptSpan(before, rawSpan.start, rawSpan.end, rawEditor.value);
if (!result.changed) {
hooks.setStatus("No content change", "ok");
close();
return;
}
const multi = rawSpan.tokens.length > 1;
const label = multi ? `Edit ${rawSpan.tokens.length} tags` : `Edit “${rawSpan.tokens[0].term}”`;
state.undoStack.push({
before,
after: result.text,
beforeStart: rawSpan.start,
beforeEnd: rawSpan.end,
afterCursor: result.cursor,
label,
});
if (state.undoStack.length > 50) state.undoStack.shift();
state.redoStack = [];
state.pinned = null;
hooks.updateText(result.text, result.cursor);
close();
// 状态栏这两句分开写:套一层「“…”」再塞带引号的 label 会打乱变量折叠
const rewroteMsg = multi
? `rewrote ${rawSpan.tokens.length} tags`
: `rewrote “${rawSpan.tokens[0].term}”`;
if (!showToast(rewroteMsg, { type: "edit" })) hooks.setStatus(rewroteMsg, "ok");
};
// 词条页:只改词条,不动当前这行提示词文本,所以不进撤销栈
const applyEdit = async () => {
const payload = editFields.collect();
// 中文名还是「未收录」占位:存下去只会得到一条残词条,直接拦掉不保存
if (String(payload.chinese ?? "").trim() === UNKNOWN_CHINESE) {
warnUnknownChinese();
return;
}
try {
await saveTag(payload);
await hooks.refreshDictionary();
close();
hooks.setStatus(t("Tag saved"), "ok");
} catch (saveError) {
hooks.setStatus(saveError.message, "error");
}
};
const actions = element("div", "bpi-modal-actions");
// 「清除权重」只对权重页有意义,切到别的页就收起来;先存配色再清,别丢改动
let clearWeightButton = null;
if (canWeight && token.weight !== null) {
clearWeightButton = button("Clear weight", () => {
if (saveColor() === null) return;
if (hooks.applyTokenWeight(token, null)) close();
}, "bpi-danger");
clearWeightButton.hidden = startOnRaw;
actions.appendChild(clearWeightButton);
}
actions.append(button("Cancel", close));
// 先声明再让 applyButton 引用它:let 有 TDZ,声明放在闭包之后会有隐患
let activeTab = startOnRaw ? "raw" : "weight";
const applyButton = button(t("Apply"), () => {
if (activeTab === "edit") applyEdit();
else if (activeTab === "raw") applyRawEdit();
else apply();
}, "bpi-primary");
actions.append(applyButton);
modal.appendChild(actions);
shade.appendChild(modal);
document.body.appendChild(shade);
shade.addEventListener("mousedown", (event) => { if (event.target === shade) close(); });
modal.addEventListener("mousedown", (event) => event.stopPropagation());
input.addEventListener("keydown", (event) => {
if (event.key === "Enter") {
event.preventDefault();
apply();
} else if (event.key === "Escape") {
event.preventDefault();
close();
}
});
// 编辑框要能打换行,所以 Enter 不提交,只有 Ctrl/Cmd+Enter 才应用
rawEditor.addEventListener("keydown", (event) => {
if ((event.ctrlKey || event.metaKey) && event.key === "Enter") {
event.preventDefault();
applyRawEdit();
} else if (event.key === "Escape") {
event.preventDefault();
close();
}
});
// Tab 切换:共用底部那个 Apply 按钮,按 activeTab 分派到三个动作
const showPanel = (tab) => {
tabWeight.classList.toggle("bpi-editor-tab-active", tab === "weight");
tabRaw.classList.toggle("bpi-editor-tab-active", tab === "raw");
tabEdit.classList.toggle("bpi-editor-tab-active", tab === "edit");
weightPanel.classList.toggle("bpi-hidden", tab !== "weight");
rawPanel.classList.toggle("bpi-hidden", tab !== "raw");
editPanel.classList.toggle("bpi-hidden", tab !== "edit");
// 离开词条页就把红色警告收回去,别等 2 秒后回来还挂着
if (tab !== "edit") restoreEditNotes();
if (clearWeightButton) clearWeightButton.hidden = tab !== "weight";
};
tabWeight.addEventListener("click", () => {
activeTab = "weight";
showPanel("weight");
// 不能改权重时这一页只有配色,光标落到颜色输入框
setTimeout(() => {
if (canWeight) { input.focus(); input.select(); }
else { customInput.focus(); customInput.select(); }
}, 0);
});
tabRaw.addEventListener("click", () => {
activeTab = "raw";
showPanel("raw");
setTimeout(() => { rawEditor.focus(); rawEditor.select(); }, 0);
});
tabEdit.addEventListener("click", () => {
activeTab = "edit";
showPanel("edit");
setTimeout(() => editFields.focus(), 0);
});
if (startOnRaw) setTimeout(() => { rawEditor.focus(); rawEditor.select(); }, 0);
else if (canWeight) setTimeout(() => { input.focus(); input.select(); }, 0);
else setTimeout(() => { customInput.focus(); customInput.select(); }, 0);
};
return openWeightEditor;
}
+224
View File
@@ -0,0 +1,224 @@
// 隐藏标签与「唯一」开关,以及它们怎么影响真正的输出。
//
// 隐藏的标签仍然留在 text 里(界面上划线显示,照常拖动/改色/改权重),
// 真正送去出图的是两个隐藏 widget:effective_text(剔除隐藏后的文本)
// 和 use_effective(上游是否采纳)。旧工作流没有这两个 widget 时直通 text。
import { duplicateGroups, normalizeKey, parsePrompt, removePromptToken, restorePromptToken } from "./parser.js";
import { showToast } from "./bpi_toast.js";
export function createHiddenTags({ store, buildTokenCard }) {
const { state, textWidget, node } = store;
const hooks = store.hooks;
// ---- Hidden tags ---------------------------------------------------------
// A hidden tag STAYS in the text (the views draw it with a strikethrough and
// it can still be dragged / recolored / re-weighted); it is only stripped
// from the actual output, via the effective_text/use_effective widgets that
// syncEffectiveOutput keeps up to date. The list lives in
// properties.bpiHiddenTags so it survives workflow save/load, and every undo
// entry snapshots the list so undo rolls mark and text back together.
// Entries from older saves (whose tags were physically removed from the text
// back then) are re-inserted at their old anchor on restore.
const getHiddenTags = () => {
if (!node.properties || typeof node.properties !== "object") node.properties = {};
if (!Array.isArray(node.properties.bpiHiddenTags)) node.properties.bpiHiddenTags = [];
return node.properties.bpiHiddenTags;
};
const setHiddenTags = (nextList) => {
if (!node.properties || typeof node.properties !== "object") node.properties = {};
node.properties.bpiHiddenTags = Array.isArray(nextList) ? nextList : [];
};
const canHideTokens = () => ["tags", "mixed"].includes(state.modeInfo.mode);
// 隐藏条目 ↔ 当前 token 的匹配:优先按 key(normalizeKey(term),改权重不影响),
// 旧存档的条目没有 key,退回按 raw 匹配。
const hiddenEntryMatches = (item, token) =>
(item.key && token.key === item.key) || (!item.key && token.raw === item.raw);
const hiddenIndexOf = (token) => getHiddenTags().findIndex((item) => hiddenEntryMatches(item, token));
const isTokenHidden = (token) => hiddenIndexOf(token) >= 0;
// ---- Duplicate tags ------------------------------------------------------
// 重复标签走两条互不干扰的线路:
// 1. 「唯一」开关(问题行里):按 key 记进 node.properties.bpiUniqueKeys,
// 打开后第 2 次及以后出现的那几次不进实际输出,界面照常显示(划线)。
// 和隐藏标记一样按节点存,跟着工作流一起保存/读取。
// 2. 英文标签区下面的「重复」栏:单击依次跳到每一次出现(标红 + 联动选中),
// 已经定位过再双击就删掉当前这一处。
const getUniqueKeys = () => {
if (!node.properties || typeof node.properties !== "object") node.properties = {};
if (!Array.isArray(node.properties.bpiUniqueKeys)) node.properties.bpiUniqueKeys = [];
return node.properties.bpiUniqueKeys;
};
const setUniqueKeys = (nextList) => {
if (!node.properties || typeof node.properties !== "object") node.properties = {};
node.properties.bpiUniqueKeys = Array.isArray(nextList) ? nextList : [];
};
const isUniqueKey = (key) => Boolean(key) && getUniqueKeys().includes(key);
// 同一个标签的第 2 次及以后出现,被「唯一」开关排除在输出之外
const isTokenSuppressed = (token) => {
const rank = state.duplicateRanks.get(token.id);
return Boolean(rank && rank.rank > 0 && isUniqueKey(token.key));
};
// 重复栏当前标红的那一处所在的 token(跨 render 只记 key + 第几次,够稳)
const dupFocusToken = () => {
if (!state.dupFocus) return null;
const group = state.duplicateGroups.find((item) => item.key === state.dupFocus.key);
return group?.tokens[state.dupFocus.index] ?? null;
};
// ---- 输出管道 ------------------------------------------------------------
// 隐藏的标签留在 text 里(界面上划线显示,照常拖动/改色/改权重),真正输出
// 走两个隐藏 widget:effective_text = 剔除隐藏标签后的文本,use_effective = true。
// 旧工作流没有它们,Python 默认直通 text,行为不变。
const effectiveWidget = node.widgets?.find((widget) => widget.name === "effective_text") || null;
const useEffectiveWidget = node.widgets?.find((widget) => widget.name === "use_effective") || null;
const syncEffectiveOutput = () => {
if (!effectiveWidget) return;
const hidden = getHiddenTags();
let out = String(textWidget.value ?? "");
if (hidden.length) {
// 每次删完重新解析,偏移才不会错;隐藏条目通常很少,代价可忽略
for (;;) {
const tokens = parsePrompt(out, state.index, state.machine, { mode: state.modePreference });
const target = tokens.find((token) => hidden.some((item) => hiddenEntryMatches(item, token)));
if (!target) break;
const step = removePromptToken(out, target);
if (!step.changed) break;
out = step.text;
}
}
// 「唯一」再去重:同一个 key 只留第一次出现的那一次
const unique = getUniqueKeys();
if (unique.length) {
for (;;) {
const tokens = parsePrompt(out, state.index, state.machine, { mode: state.modePreference });
const target = duplicateGroups(tokens)
.filter((group) => unique.includes(group.key))
.flatMap((group) => group.tokens.slice(1))[0];
if (!target) break;
const step = removePromptToken(out, target);
if (!step.changed) break;
out = step.text;
}
}
effectiveWidget.value = out;
if (useEffectiveWidget) useEffectiveWidget.value = true;
};
const pushHistoryWithHidden = (entry) => {
state.undoStack.push(entry);
if (state.undoStack.length > 50) state.undoStack.shift();
state.redoStack = [];
};
// 隐藏只是"打标记":标签留在文本里,界面上划线显示,照常拖动/改色/改权重,
// 只是 syncEffectiveOutput 会把它从实际输出里剔除。所以隐藏/恢复的 undo 条目
// before === after(文本没变),撤销只回滚隐藏列表。
const hideToken = (token) => {
if (!token || !canHideTokens()) return false;
if (isTokenHidden(token)) return false;
const hidden = getHiddenTags();
const before = String(textWidget.value ?? "");
const entry = {
before,
after: before,
beforeStart: token.start,
beforeEnd: token.end,
afterCursor: token.start,
label: `隐藏「${token.term}」`,
hiddenBefore: hidden.slice(),
hiddenAfter: hidden.concat([{
raw: token.raw,
chinese: token.chinese ?? "",
after: null,
key: token.key,
}]),
};
pushHistoryWithHidden(entry);
setHiddenTags(entry.hiddenAfter);
state.pinned = null;
hooks.updateText(before, token.start);
// 有了右下角提示条就不用再写「点眼睛斜线图标取消隐藏」这种操作说明了
const hiddenMsg = `已隐藏「${token.term}」`;
if (!showToast(hiddenMsg, { type: "edit" })) hooks.setStatus(hiddenMsg, "ok");
return true;
};
// 取消隐藏 = 把条目从隐藏列表里摘掉。两种形态都兼容:
// 标签还在文本里(新模型)→ 只摘标记;旧版工作流当时把标签从文本删了 → 按旧锚点插回去。
const restoreHiddenTag = (index) => {
const hidden = getHiddenTags();
const item = hidden[index];
if (!item) return false;
const before = String(textWidget.value ?? "");
const inText = state.tokens.some((token) => hiddenEntryMatches(item, token));
let after = before;
let cursor = before.length;
if (!inText) {
const result = restorePromptToken(before, state.tokens, item.raw, item.after);
if (!result.changed) return false;
after = result.text;
cursor = result.cursor;
}
const entry = {
before,
after,
beforeStart: 0,
beforeEnd: before.length,
afterCursor: cursor,
label: `恢复「${item.raw}」`,
hiddenBefore: hidden.slice(),
hiddenAfter: hidden.filter((_, i) => i !== index),
};
pushHistoryWithHidden(entry);
setHiddenTags(entry.hiddenAfter);
hooks.updateText(after, cursor);
const restoreMsg = inText ? `已取消隐藏「${item.raw}」` : `已把「${item.raw}」恢复到原位置`;
if (!showToast(restoreMsg, { type: "edit" })) hooks.setStatus(restoreMsg, "ok");
return true;
};
const unhideToken = (token) => {
const index = hiddenIndexOf(token);
return index >= 0 ? restoreHiddenTag(index) : false;
};
const restoreAllHiddenTags = () => {
const hidden = getHiddenTags();
if (!hidden.length) return false;
const before = String(textWidget.value ?? "");
let text = before;
let tokens = state.tokens;
for (const item of hidden) {
if (tokens.some((token) => hiddenEntryMatches(item, token))) continue; // 还在文本里,只摘标记
const step = restorePromptToken(text, tokens, item.raw, item.after);
if (!step.changed) continue;
text = step.text;
tokens = parsePrompt(text, state.index, state.machine, { mode: state.modePreference });
}
const entry = {
before,
after: text,
beforeStart: 0,
beforeEnd: before.length,
afterCursor: text.length,
label: `恢复全部隐藏标签(${hidden.length})`,
hiddenBefore: hidden.slice(),
hiddenAfter: [],
};
pushHistoryWithHidden(entry);
setHiddenTags([]);
state.pinned = null;
hooks.updateText(text, text.length);
const restoreAllMsg = `已恢复 ${hidden.length} 个隐藏标签`;
if (!showToast(restoreAllMsg, { type: "edit" })) hooks.setStatus(restoreAllMsg, "ok");
return true;
};
return {
canHideTokens, dupFocusToken, effectiveWidget, getHiddenTags, getUniqueKeys, hiddenEntryMatches, hiddenIndexOf, hideToken, isTokenHidden, isTokenSuppressed, isUniqueKey, pushHistoryWithHidden, restoreAllHiddenTags, restoreHiddenTag, setHiddenTags, setUniqueKeys, syncEffectiveOutput, unhideToken, useEffectiveWidget,
};
}
+1 -1
View File
@@ -1,4 +1,4 @@
// 添加标签时的出现动画:新卡片从左往右展开(删除那套手势反过来用),
// 添加标签时的出现动画:新卡片从左往右展开,
// 其余卡片同时滑开让位。开关跟删除共用一个(设置里那个「标签增删动画」),
// 所以这个文件只管播,不管开关。
// 时长比删除短一些,但留足 0.42 秒:太快会看着像「啪」一下跳出来的。
+123
View File
@@ -0,0 +1,123 @@
// 换行块的数据层:原文里的换行既不是 token、也进不了撤销栈,所以用「第 n 个标签与
// 第 n+1 个标签之间的原文片段」(gap)来定位换行。这里只读写文本与撤销栈,不碰 DOM;
// 界面上的 ↵ 块由渲染层按 gapHasBreak 决定要不要画。
export function createLineBreaks({ store }) {
// 依赖统一从 store 取:state / textWidget 是稳定引用,回调走 store.hooks(运行时再取,
// 因为主文件是在这些函数定义完之后才 bind 进来的)
const { state, textWidget } = store;
const hooks = store.hooks;
// ---- Line breaks --------------------------------------------------------
// 换行在原文里就是真正的 \n:parser 只把它当分隔符丢掉,所以它既不是 token
// 也进不了撤销栈。这里给两个可视化区补一层「换行块」,让它和标签一样看得见、
// 拖得动、删得掉。换行唯一能待的位置是第 n 个标签与第 n+1 个标签之间的原文
// 片段(下面叫 gap),所以换行块一律用 gap 下标定位,parser 与数据格式不动。
const breakGapCount = () => Math.max(0, state.tokens.length - 1);
const gapSource = (gapIndex) => {
const source = String(textWidget.value ?? "");
const left = state.tokens[gapIndex];
const right = state.tokens[gapIndex + 1];
if (!left || !right) return "";
return source.slice(left.end, right.start);
};
const gapHasBreak = (gapIndex) => /[\r\n]/.test(gapSource(gapIndex));
const currentBreakGaps = () => {
const gaps = new Set();
for (let index = 0; index < breakGapCount(); index += 1) if (gapHasBreak(index)) gaps.add(index);
return gaps;
};
// 只改写 gap:标签原文(权重、括号、自然语言整段)一个字符都不动。
// 注意 parser 把分隔符后面的空格算进下一个 token(token.start 在空格前),
// 所以 gap 里本来就只有 "," / "\n" 这类分隔符——去掉换行时补一个逗号就够,
// 再补空格会出现 "a, b" 这种双空格。
const rebuildTextWithBreaks = (breakGaps) => {
const source = String(textWidget.value ?? "");
if (state.tokens.length < 2) return source;
const first = state.tokens[0];
const last = state.tokens[state.tokens.length - 1];
let out = source.slice(0, first.start);
for (const [index, token] of state.tokens.entries()) {
out += source.slice(token.start, token.end);
const next = state.tokens[index + 1];
if (!next) break;
const gap = source.slice(token.end, next.start);
if (!/[\r\n]/.test(gap)) {
out += breakGaps.has(index) ? "\n" : gap;
continue;
}
if (breakGaps.has(index)) {
out += gap;
continue;
}
const flat = gap.replace(/[\r\n]+/g, "").trim();
out += flat || ",";
}
return out + source.slice(last.end);
};
const replacePromptText = (nextText, label) => {
const before = String(textWidget.value ?? "");
if (nextText === before) return false;
state.undoStack.push({
before,
after: nextText,
beforeStart: before.length,
beforeEnd: null,
afterCursor: nextText.length,
label,
});
if (state.undoStack.length > 50) state.undoStack.shift();
state.redoStack = [];
hooks.updateText(nextText);
return true;
};
const applyBreakGaps = (breakGaps, label) => replacePromptText(rebuildTextWithBreaks(breakGaps), label);
const removeBreakAt = (gapIndex) => {
const gaps = currentBreakGaps();
if (!gaps.delete(gapIndex)) return false;
return applyBreakGaps(gaps, "删除换行");
};
const moveBreakTo = (fromGap, toGap) => {
const gaps = currentBreakGaps();
if (!gaps.has(fromGap)) return false;
const target = Math.max(0, Math.min(breakGapCount() - 1, toGap));
if (target === fromGap) return false;
gaps.delete(fromGap);
gaps.add(target);
return applyBreakGaps(gaps, "移动换行");
};
const insertBreakFromToolbar = () => {
if (state.englishEditing) {
hooks.setEnglishEditing(false, true);
hooks.render(); // setEnglishEditing 只排了个异步渲染,这里同步跑一遍拿最新的 token 偏移
}
if (!state.tokens.length) {
hooks.setStatus("Enter tags first, then insert a line break", "");
return;
}
const pinnedIndex = state.tokens.findIndex((item) => item.id === state.pinned);
if (pinnedIndex >= 0 && pinnedIndex < breakGapCount()) {
const gaps = currentBreakGaps();
gaps.add(pinnedIndex);
if (applyBreakGaps(gaps, "插入换行")) hooks.setStatus("Inserted a line break", "ok");
return;
}
// 没选中标签,或选中的是最后一个标签(它后面没有 gap):换行加在整段末尾。
const source = String(textWidget.value ?? "");
if (replacePromptText(`${source.replace(/[ \t]+$/, "")}\n`, "插入换行")) {
hooks.setStatus("Inserted a line break", "ok");
}
};
return {
breakGapCount, gapSource, gapHasBreak, currentBreakGaps, rebuildTextWithBreaks,
replacePromptText, applyBreakGaps, removeBreakAt, moveBreakTo, insertBreakFromToolbar,
};
}
+208
View File
@@ -0,0 +1,208 @@
// 节点尺寸与高度管理:从主文件 createPanel 闭包里拆出来的 12 个函数。
// 每个节点一份(createPanel 里调用),state / node 从 store 取,6 个 DOM 从 refs 取;
// render 必须走 store.hooks.render(),不能提前解构 —— hooks 是在 createPanel 后半段才 bind 的。
import { app } from "../../scripts/app.js";
import { inspectorNodeTargetHeight } from "./dictionary_tools.js";
import { setTitle } from "./bpi_shared.js";
import { COLLAPSED_NODE_MIN_HEIGHT, FILL_HEIGHT_MIN_GROWTH } from "./bpi_store.js";
export function createNodeSize({ store, refs, persistPreferences }) {
const { state, node } = store;
const { panel, englishSection, englishEditor, englishTokenView, fillSwitch, fillSwitchRow } = refs;
const autoFitGreenArea = (target, minimum) => {
if (!target || target.classList.contains("bpi-hidden")) return;
// 适应高度模式下高度交给 flex;这里再写死 height 会把刚放开的上限又按回去
if (state.fillHeight) {
target.style.height = "";
target.style.overflowY = "auto";
return;
}
const maximum = 360;
target.style.height = "auto";
const height = Math.max(minimum, Math.min(maximum, Math.ceil(target.scrollHeight + 2)));
target.style.height = `${height}px`;
target.style.overflowY = target.scrollHeight > height + 1 ? "auto" : "hidden";
};
const hasNodeSize = () => Number.isFinite(Number(node.size?.[0])) && Number.isFinite(Number(node.size?.[1]));
const resizeCornerHit = (elementValue, event) => {
const bounds = elementValue.getBoundingClientRect();
return bounds.right - event.clientX <= 24 && bounds.bottom - event.clientY <= 24;
};
const resolveDeepestWidget = (widget) => {
if (typeof widget?.resolveDeepest !== "function") return widget ?? null;
try {
return widget.resolveDeepest()?.widget ?? widget;
} catch {
return widget;
}
};
const applyEnglishEditorHeight = (height) => {
const value = Math.max(84, Math.min(420, Math.round(Number(height) || 0)));
englishEditor.style.height = `${value}px`;
return value;
};
const startManualResize = (kind, target, event) => {
if (event.button !== 0 || !resizeCornerHit(target, event)) return;
state.manualResize = {
kind,
target,
startHeight: target.offsetHeight,
startNodeHeight: Number(node.size?.[1]) || 0,
};
};
const finishManualResize = () => {
const resize = state.manualResize;
if (!resize) return;
state.manualResize = null;
requestAnimationFrame(() => {
if (!resize.target?.isConnected) return;
if (resize.kind === "english") {
const height = applyEnglishEditorHeight(resize.target.offsetHeight);
const delta = height - resize.startHeight;
state.preferences = { ...state.preferences, englishInputHeight: height };
persistPreferences();
if (hasNodeSize() && Math.abs(delta) > 0.5) {
node.setSize?.([node.size[0], Math.max(COLLAPSED_NODE_MIN_HEIGHT, resize.startNodeHeight + delta)]);
}
}
node.graph?.change?.();
node.graph?.setDirtyCanvas?.(true, true);
app.graph?.setDirtyCanvas?.(true, true);
});
};
// 这两行是原块里的副作用,必须留在工厂内、且排在 finishManualResize 定义之后
state.windowPointerUp = finishManualResize;
window.addEventListener("pointerup", state.windowPointerUp, true);
const measureCollapsedWidgetHeight = () => {
const styles = getComputedStyle(panel);
const pixels = (name) => Number.parseFloat(styles.getPropertyValue(name)) || 0;
// 底部快捷输入区是后加的,这里用选择器取高度,避免引用尚未初始化的常量
const quickHeight = panel.querySelector(".bpi-quick-section")?.offsetHeight ?? 0;
const height = englishSection.offsetHeight
+ quickHeight
+ pixels("padding-top")
+ pixels("padding-bottom")
+ pixels("border-top-width")
+ pixels("border-bottom-width")
+ pixels("row-gap");
return Math.max(280, Math.min(620, Math.ceil(height + 1)));
};
const requestNodeResize = () => {
if (state.resizeFrame !== null) cancelAnimationFrame(state.resizeFrame);
state.resizeFrame = requestAnimationFrame(() => {
state.resizeFrame = null;
const width = Math.max(node.size?.[0] ?? 0, 590);
// 适应高度模式:高度归用户,只同步宽度。这里绝不能按内容重算——
// 英文区一变高就会推高自动高度、再推高节点、英文区又更高,越滚越大。
if (state.fillHeight) {
if (Math.abs((node.size?.[0] ?? 0) - width) > 0.5) {
node.setSize?.([width, Number(node.size?.[1]) || COLLAPSED_NODE_MIN_HEIGHT]);
node.graph?.change?.();
}
node.graph?.setDirtyCanvas?.(true, true);
app.graph?.setDirtyCanvas?.(true, true);
return;
}
state.collapsedWidgetHeight = measureCollapsedWidgetHeight();
panel.style.setProperty("--bpi-collapsed-height", `${state.collapsedWidgetHeight}px`);
// 稳定基线 = 标题栏 + 原生 text 控件的真实内容高度。
// 绝不能读 inspectorWidget().y(= bilingual_inspector.y)——那是 ComfyUI arrange
// 从 text.computedHeight 派生的,而 text.computedHeight 在「setSize 变高 → 文本框
// 可用空间变大 → scrollHeight 变大 → computedHeight 再涨」的反馈里只增不减
// (实测每写一次值节点 +10px、永不回缩)。这里临时把 text 控件设成 auto 量
// scrollHeight,得到与 setSize 无关的真实内容高,再恢复,从源头断开反馈环。
const textWidget = node.widgets?.find((widget) => widget.name === "text");
let baseHeight = state.nodeBaseHeight ?? 96;
if (textWidget?.element) {
const element = textWidget.element;
const previousHeight = element.style.height;
element.style.height = "auto";
const natural = element.scrollHeight;
element.style.height = previousHeight;
const top = Number.isFinite(textWidget.y) && textWidget.y >= 0 ? textWidget.y : 26;
const margin = Math.max(0, Number(textWidget.margin) || 0);
if (Number.isFinite(natural) && natural > 0) baseHeight = top + natural + margin * 2;
state.nodeBaseHeight = baseHeight;
}
const targetHeight = inspectorNodeTargetHeight({
baseHeight,
targetWidgetHeight: state.collapsedWidgetHeight,
fallbackBaseHeight: state.nodeBaseHeight ?? 96,
minimumHeight: COLLAPSED_NODE_MIN_HEIGHT,
});
// 高度只补不缩:用户把节点拉大之后,任何一次 render 都不该把它拽回自动高度,
// 否则「拉大 → 改一个字 → 被打回原形」,拉大等于白拉。宽度仍按最小可用宽度收敛。
const currentHeight = Number(node.size?.[1]) || 0;
const nextHeight = Math.max(targetHeight, currentHeight);
const sizeChanged = Math.abs((node.size?.[0] ?? 0) - width) > 0.5
|| Math.abs(currentHeight - nextHeight) > 0.5;
if (sizeChanged) {
node.setSize?.([width, nextHeight]);
node.graph?.change?.();
}
node.graph?.setDirtyCanvas?.(true, true);
app.graph?.setDirtyCanvas?.(true, true);
});
};
// 量出英文标签区「没被 360 压住时」的真实高度:临时解除上限读 scrollHeight 再恢复
const measureNaturalEnglishHeight = () => {
const previousHeight = englishTokenView.style.height;
const previousMax = englishTokenView.style.maxHeight;
englishTokenView.style.height = "auto";
englishTokenView.style.maxHeight = "none";
const natural = englishTokenView.scrollHeight;
englishTokenView.style.height = previousHeight;
englishTokenView.style.maxHeight = previousMax;
return natural;
};
// 打开开关时把节点撑高一次,撑的量 = 被压掉的内容高度,但至少给一个看得出变化的量
const growNodeForFill = () => {
if (!hasNodeSize()) return;
const natural = measureNaturalEnglishHeight();
const extra = Math.max(FILL_HEIGHT_MIN_GROWTH, Math.ceil(natural - englishTokenView.offsetHeight));
const height = Math.min((Number(node.size?.[1]) || COLLAPSED_NODE_MIN_HEIGHT) + extra, 2400);
node.setSize?.([Math.max(node.size?.[0] ?? 0, 590), height]);
node.graph?.change?.();
};
const setFillHeight = (next) => {
const fill = Boolean(next);
if (fill === state.fillHeight) return;
state.fillHeight = fill;
if (!node.properties || typeof node.properties !== "object") node.properties = {};
node.properties.bpiFillHeight = fill;
panel.classList.toggle("bpi-fill", fill);
fillSwitch.checked = fill;
setTitle(fillSwitchRow, fill
? "Back to the automatic height; the prompt area stops following the node"
: "Let the prompt area grow with the node instead of being capped");
if (fill) {
// 先放开限制再撑高,撑完重排一次让英文区吃到新空间
growNodeForFill();
requestNodeResize();
} else {
state.collapsedWidgetHeight = measureCollapsedWidgetHeight();
panel.style.setProperty("--bpi-collapsed-height", `${state.collapsedWidgetHeight}px`);
if (hasNodeSize()) {
const baseHeight = state.nodeBaseHeight ?? 96;
node.setSize?.([Math.max(node.size?.[0] ?? 0, 590), Math.max(inspectorNodeTargetHeight({
baseHeight,
targetWidgetHeight: state.collapsedWidgetHeight,
fallbackBaseHeight: state.nodeBaseHeight ?? 96,
minimumHeight: COLLAPSED_NODE_MIN_HEIGHT,
}), COLLAPSED_NODE_MIN_HEIGHT)]);
node.graph?.change?.();
}
}
store.hooks.render();
};
return {
applyEnglishEditorHeight, autoFitGreenArea, finishManualResize, growNodeForFill,
hasNodeSize, measureCollapsedWidgetHeight, measureNaturalEnglishHeight, requestNodeResize,
resizeCornerHit, resolveDeepestWidget, setFillHeight, startManualResize,
};
}
+1 -1
View File
@@ -71,7 +71,7 @@ const CSS = `
.bpi-notif-content{display:flex;flex-direction:column;gap:10px;position:relative;z-index:0}
.bpi-notif-head{padding-right:24px}
.bpi-notif-title{font-size:15px;font-weight:600;color:var(--bpi-text,#e6edf7);letter-spacing:.3px;line-height:1.35;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
.bpi-notif-extra{font-size:13px;color:var(--bpi-text-muted,#aab5c5);line-height:1.65;letter-spacing:.2px;opacity:0;transition:opacity .4s ease .1s;padding-bottom:6px;word-break:break-word}
.bpi-notif-extra{font-size:13px;color:var(--bpi-text-muted,#aab5c5);line-height:1.65;letter-spacing:.2px;opacity:0;transition:opacity .4s ease .1s;padding-bottom:6px;word-break:break-word;white-space:pre-wrap}
.bpi-notif.bpi-expand .bpi-notif-extra{opacity:1}
.bpi-notif-logo{position:absolute;left:22px;top:50%;transform:translateY(-50%);line-height:0;color:#fff;pointer-events:none;user-select:none;filter:drop-shadow(var(--bpi-notif-logo-shadow,0 2px 14px rgba(0,0,0,.55)))}
.bpi-notif-logo svg{display:block;height:var(--bpi-notif-logo-size,36px);width:auto}
+10 -10
View File
@@ -1295,25 +1295,25 @@ function injectBpiStyles() {
.bpi-mirror-delete{position:absolute;top:2px;right:3px;cursor:pointer;padding:1px 3px;border-radius:999px;background:var(--bpi-chip-hide-bg);border:1px solid var(--bpi-border-2);color:var(--bpi-text-muted);font-size:11px;line-height:1;z-index:2}
.bpi-mirror-delete:hover{color:var(--bpi-status-error);border-color:var(--bpi-status-error)}
/* 删除标签的消失动画(设置里开关,默认关):X 从四角向心长出 → 颜色从中心铺满
→ 上下压两条灰边 → 整块从左往右擦掉。只加在正在删的那张卡上,别的卡不受影响 */
.bpi-token-card.bpi-deleting{--bpi-del-color:#a78bfa;--bpi-del-bar-color:#5a5a5a;overflow:hidden;pointer-events:none;clip-path:inset(0 0 0 0 round 5px);transition:clip-path .35s cubic-bezier(.4,0,.2,1)}
.bpi-token-card.bpi-deleting.bpi-del-wipe{clip-path:inset(0 0 0 100% round 5px)}
→ 上下压两条灰边 → 整块从右往左擦掉。只加在正在删的那张卡上,别的卡不受影响 */
:is(.bpi-token-card,.bpi-break-chip).bpi-deleting{--bpi-del-color:#a78bfa;--bpi-del-bar-color:#5a5a5a;overflow:hidden;pointer-events:none;clip-path:inset(0 0 0 0 round 5px);transition:clip-path .35s cubic-bezier(.4,0,.2,1)}
:is(.bpi-token-card,.bpi-break-chip).bpi-deleting.bpi-del-wipe{clip-path:inset(0 100% 0 0 round 5px)}
.bpi-del-stage{position:absolute;inset:0;pointer-events:none}
.bpi-del-half{position:absolute;width:var(--bpi-del-len,60px);height:2px;background-color:var(--bpi-del-color);transition:transform .16s cubic-bezier(.4,0,.2,1),height .4s cubic-bezier(.4,0,.2,1)}
.bpi-del-half-a1{left:0;top:0;transform-origin:0 0;transform:rotate(var(--bpi-del-angle,30deg)) scaleX(0)}
.bpi-del-half-a2{right:0;bottom:0;transform-origin:100% 100%;transform:rotate(var(--bpi-del-angle,30deg)) scaleX(0)}
.bpi-del-half-b1{right:0;top:0;transform-origin:100% 0;transform:rotate(calc(-1 * var(--bpi-del-angle,30deg))) scaleX(0)}
.bpi-del-half-b2{left:0;bottom:0;transform-origin:0 100%;transform:rotate(calc(-1 * var(--bpi-del-angle,30deg))) scaleX(0)}
.bpi-token-card.bpi-del-grow .bpi-del-half-a1{transform:rotate(var(--bpi-del-angle,30deg)) scaleX(1)}
.bpi-token-card.bpi-del-grow .bpi-del-half-a2{transform:rotate(var(--bpi-del-angle,30deg)) scaleX(1)}
.bpi-token-card.bpi-del-grow .bpi-del-half-b1{transform:rotate(calc(-1 * var(--bpi-del-angle,30deg))) scaleX(1)}
.bpi-token-card.bpi-del-grow .bpi-del-half-b2{transform:rotate(calc(-1 * var(--bpi-del-angle,30deg))) scaleX(1)}
.bpi-token-card.bpi-del-spread .bpi-del-half{height:var(--bpi-del-fill,60px)}
:is(.bpi-token-card,.bpi-break-chip).bpi-del-grow .bpi-del-half-a1{transform:rotate(var(--bpi-del-angle,30deg)) scaleX(1)}
:is(.bpi-token-card,.bpi-break-chip).bpi-del-grow .bpi-del-half-a2{transform:rotate(var(--bpi-del-angle,30deg)) scaleX(1)}
:is(.bpi-token-card,.bpi-break-chip).bpi-del-grow .bpi-del-half-b1{transform:rotate(calc(-1 * var(--bpi-del-angle,30deg))) scaleX(1)}
:is(.bpi-token-card,.bpi-break-chip).bpi-del-grow .bpi-del-half-b2{transform:rotate(calc(-1 * var(--bpi-del-angle,30deg))) scaleX(1)}
:is(.bpi-token-card,.bpi-break-chip).bpi-del-spread .bpi-del-half{height:var(--bpi-del-fill,60px)}
.bpi-del-cover{position:absolute;inset:0;background-color:var(--bpi-del-color);clip-path:inset(50% 50% 50% 50%);transition:clip-path .4s cubic-bezier(.4,0,.2,1);z-index:3}
.bpi-token-card.bpi-del-spread .bpi-del-cover{clip-path:inset(0 0 0 0)}
:is(.bpi-token-card,.bpi-break-chip).bpi-del-spread .bpi-del-cover{clip-path:inset(0 0 0 0)}
.bpi-del-bar{position:absolute;left:0;right:0;height:0;background-color:var(--bpi-del-bar-color);transition:height .1s ease;z-index:5}
.bpi-del-bar-top{top:0}.bpi-del-bar-bottom{bottom:0}
.bpi-token-card.bpi-del-wipe .bpi-del-bar{height:var(--bpi-del-bar-h,10px)}
:is(.bpi-token-card,.bpi-break-chip).bpi-del-wipe .bpi-del-bar{height:var(--bpi-del-bar-h,10px)}
/* 添加标签的出场动画:新卡片从左往右展开,旁边的卡片由 FLIP 滑开让位。
带宽高一起淡入,纯靠 clip-path 硬揭开会显得突然 */
@keyframes bpi-card-insert{from{clip-path:inset(0 100% 0 0 round 5px);opacity:.45}to{clip-path:inset(0 0 0 0 round 5px);opacity:1}}
+74
View File
@@ -0,0 +1,74 @@
// 撤销/重做与顺序微调:Shift+Alt+Z / Shift+Alt+Y、Alt+↑ / Alt+↓。
//
// 撤销栈的条目由各个操作自己 push(文本前后、光标、隐藏/唯一键的快照),
// 这里只负责「把最后一条回滚/重放」,以及把快捷键分派过来。
import { showToast } from "./bpi_toast.js";
export function createShortcuts({ store, setHiddenTags, setUniqueKeys }) {
const { state, textWidget } = store;
const hooks = store.hooks;
// Alt+ArrowUp / Alt+ArrowDown nudge the pinned token one slot. Returns
// true when the key was consumed.
const handleTokenReorderShortcut = (event) => {
if (!event.altKey || event.ctrlKey || event.metaKey || event.shiftKey) return false;
if (event.key !== "ArrowUp" && event.key !== "ArrowDown") return false;
const token = state.tokens.find((item) => item.id === state.pinned);
if (!token) return false;
const index = state.tokens.indexOf(token);
if (index < 0) return false;
event.preventDefault();
event.stopPropagation();
const upward = event.key === "ArrowUp";
const moved = hooks.moveTokenTo(token, upward ? index - 1 : index + 2);
if (!moved) hooks.setStatus(upward ? `“${token.term}” is already at the front` : `“${token.term}” is already at the back`, "");
return true;
};
const undoStructuredEdit = () => {
const entry = state.undoStack.at(-1);
if (!entry || String(textWidget.value ?? "") !== entry.after) return false;
state.undoStack.pop();
state.redoStack.push(entry);
state.pinned = null;
if (typeof entry.beforeCategoryView === "boolean") state.categoryView = entry.beforeCategoryView;
if (Array.isArray(entry.hiddenBefore)) setHiddenTags(entry.hiddenBefore);
if (Array.isArray(entry.uniqueBefore)) setUniqueKeys(entry.uniqueBefore);
hooks.updateText(entry.before, entry.beforeStart);
const undoneMsg = `已撤销:${entry.label}`;
if (!showToast(undoneMsg, { type: "history" })) hooks.setStatus(undoneMsg, "ok");
return true;
};
const redoStructuredEdit = () => {
const entry = state.redoStack.at(-1);
if (!entry || String(textWidget.value ?? "") !== entry.before) return false;
state.redoStack.pop();
state.undoStack.push(entry);
state.pinned = null;
if (typeof entry.afterCategoryView === "boolean") state.categoryView = entry.afterCategoryView;
if (Array.isArray(entry.hiddenAfter)) setHiddenTags(entry.hiddenAfter);
if (Array.isArray(entry.uniqueAfter)) setUniqueKeys(entry.uniqueAfter);
hooks.updateText(entry.after, entry.afterCursor);
const redoneMsg = `已重做:${entry.label}`;
if (!showToast(redoneMsg, { type: "history" })) hooks.setStatus(redoneMsg, "ok");
return true;
};
// 撤销/重做用 Shift+Alt+Z / Shift+Alt+Y:
// Ctrl+Z 在 ComfyUI 里被画布自己的撤销吃掉(焦点不在文本框时甚至根本到不了这里),
// 与其抢一个抢不过的键,不如整个让给它,换一个没人占的组合。
const handleHistoryShortcut = (event) => {
if (!event.altKey || !event.shiftKey || event.ctrlKey || event.metaKey) return;
const key = event.key.toLowerCase();
const handled = key === "y" ? redoStructuredEdit() : key === "z" ? undoStructuredEdit() : false;
if (handled) {
event.preventDefault();
event.stopPropagation();
}
};
return {
handleTokenReorderShortcut, undoStructuredEdit, redoStructuredEdit, handleHistoryShortcut,
};
}
+129
View File
@@ -0,0 +1,129 @@
// 节点状态与偏好:每个面板(每个节点)一份,由 createStore 建。
//
// 为什么要这一个文件:主文件里所有逻辑都在 createPanel 的闭包里,拆功能模块时
// 得把 state、render、updateText… 一长串闭包变量当参数传进去。把这些放进 store,
// 新模块只要拿一个 store 就能干活,参数表不再随功能膨胀。
//
// hooks 里的函数由主文件在各处定义完以后通过 bindStoreHooks 填进来,
// 所以模块调用时必须写 `store.hooks.render()`,不能提前解构(那时还是空的)。
import { loadPreferences } from "./bpi_shared.js";
import { panelSyncHub } from "./panel_sync.js";
// 「适应高度」关掉时面板的高度上限,state 初始化要用,主文件也读它
export const COLLAPSED_WIDGET_FALLBACK_HEIGHT = 390;
// 节点尺寸的下限与「适应高度」打开时至少撑高的量:节点尺寸模块与主文件都要用,
// 放在这里导出,避免两个模块互相 import 造成循环依赖
export const FILL_HEIGHT_MIN_GROWTH = 200;
export const COLLAPSED_NODE_MIN_HEIGHT = 360;
export function createStore({ node, textWidget }) {
// 模式跟着节点存:工作流保存后重开仍是上次选的那一档,没存过就回到自动检测
const storedMode = () => {
const value = node.properties?.bpiMode;
return ["tags", "natural"].includes(value) ? value : "auto";
};
// 「适应高度」也跟着节点存:工作流重开后仍是上次那个状态
const storedFillHeight = () => node.properties?.bpiFillHeight === true;
const state = {
data: { tags: [], builtin: [], user: [], packs: [] },
index: new Map(),
tokens: [],
machine: panelSyncHub.machineTranslations,
translating: new Set(),
pinned: null,
// Ctrl+点出来的多选,存 token.id;pinned 那一个恒在其中
selected: new Set(),
lastText: null,
renderTimer: null,
modePreference: storedMode(),
fillHeight: storedFillHeight(),
modeInfo: { mode: "tags", reason: "Empty input", confidence: "high" },
issues: [],
localOverrides: new Map(),
editing: null,
preferences: loadPreferences(),
tableFilter: "all",
// 明细表查找词:只筛视图,不进任何持久化数据
tableQuery: "",
filterButtons: new Map(),
// 表格里勾选待入库的机器翻译词条,只存 token.key
batchSelected: new Set(),
// 重复分组(render 里重算)与每个 token 在组里的次序:token.id → { rank, total }
duplicateGroups: [],
duplicateRanks: new Map(),
// 重复栏当前定位到的那一处:{ key, index };-1 表示还没定位过
dupFocus: null,
dupFocusId: null,
searchMatches: [],
searchIndex: -1,
largeCache: new Map(),
largeMisses: new Set(),
largePending: new Set(),
largeLookupTimer: null,
largeLookupGeneration: 0,
largeSearchMatches: [],
largeSearchQuery: "",
largeSearchLoading: false,
largeSearchTimer: null,
largeSearchGeneration: 0,
largeSearchOffset: 0,
largeSearchHasMore: false,
largeSearchExpandedTerms: [],
searchVisibleLimit: 40,
lastRenderedSearchQuery: "",
categoryView: false,
// 「按 Anima 顺序排序」写入后留一份快照 { before, after },按钮据此变成「取消排序」;
// 用户又手动改了文本就作废(见 render),避免回退把他后来的改动一起吞掉
animaSortSnapshot: null,
// 节点标签卡颜色模式:default(不着色)| status(按收录:未收录淡橙、机器翻译淡紫、已收录不变)
// | random(从 token_colors.json 摇色)| by_pack(按标签所在词库取 pack_colors)
colorMode: localStorage.getItem("bpi.colorMode") || "default",
// 从后端读到的 { presets, random_pool, pack_colors }
tokenColors: { presets: [], random_pool: [], pack_colors: {} },
// 每个卡的双击自定义色,key = token.key;切到非默认模式时被模式覆盖
customColors: new Map(),
// 随机模式下每个 token.key 摇到的颜色
randomColors: new Map(),
englishEditing: !String(textWidget.value ?? "").trim(),
englishEditingExplicit: false,
englishEditorDirty: false,
englishClearState: "idle",
clearedEnglishEntry: null,
assistantBusy: false,
undoStack: [],
redoStack: [],
boundTextarea: null,
textareaListeners: null,
nodeBaseHeight: null,
collapsedWidgetHeight: COLLAPSED_WIDGET_FALLBACK_HEIGHT,
resizeFrame: null,
manualResize: null,
windowPointerUp: null,
// 上游输入:是否正在等待用户确认,以及上次渲染时的连线状态
upstreamWaiting: false,
upstreamConnectedCache: null,
syncSource: Symbol(`bpi-node-${node.id ?? "unknown"}`),
};
// 常用回调的占位:值由主文件 bindStoreHooks 填,调用方一律走 store.hooks.xxx()
const hooks = {
render: () => {},
updateText: () => {},
setStatus: () => {},
applyTokenWeight: () => false,
spanForEdit: () => null,
refreshDictionary: async () => {},
setEnglishEditing: () => {},
moveTokenTo: () => false,
canReorderTokens: () => false,
gapHasBreak: () => false,
};
return { node, textWidget, state, hooks, storedMode, storedFillHeight };
}
export function bindStoreHooks(store, hooks) {
Object.assign(store.hooks, hooks);
}
+2
View File
@@ -50,6 +50,7 @@ export const TOAST_ACCENTS = {
history: "#94a3b8",
translate: "#34d399",
duplicate: "#fbbf24",
error: "#e06c75",
};
// 图标是塞进 24×24 viewBox 的内联 SVG 片段,颜色靠 currentColor 吃 --bpi-toast-accent
@@ -64,6 +65,7 @@ const ICONS = {
remove: '<path d="M4 7h16M9 7V4h6v3"/><path d="M6 7l1 13h10l1-13"/>',
move: '<path d="M4 12h16"/><path d="M8 8l-4 4 4 4M16 8l4 4-4 4"/>',
history: '<path d="M3.5 12a8.5 8.5 0 1 1 2.6 6.1"/><path d="M3 5v5h5"/><path d="M12 8v4l3 2"/>',
error: '<circle cx="12" cy="12" r="9"/><path d="M15 9l-6 6M9 9l6 6"/>',
};
const CLOSE_SVG =
+27 -2
View File
@@ -3,7 +3,7 @@ import { normalizeKey, validateTranslationResult } from "./parser.js";
import { mergeImportAsAliases, previewImport, toggleFavorite } from "./dictionary_tools.js";
import { panelSyncHub } from "./panel_sync.js";
import { installBpiWheelGuard } from "./wheel_guard.js";
import { notifyEnabled, setNotifyEnabled } from "./bpi_notify.js";
import { notify, notifyEnabled, setNotifyEnabled } from "./bpi_notify.js";
import { setToastEnabled, toastEnabled } from "./bpi_toast.js";
import {
dragPreviewNodeEnabled,
@@ -63,6 +63,8 @@ const SECTIONS = [
// 「跟随选中节点」的开关状态:默认关,关着就是原来只能在下拉里选节点的行为
const FOLLOW_SELECTION_KEY = "bpi.followSelection.v1";
// 系统「减少动态效果」已经处理过一次的标记:只做一次,之后用户手动打开就不再覆盖
const REDUCED_MOTION_KEY = "bpi.reducedMotion.v1";
const manager = {
data: { tags: [], builtin: [], user: [], packs: [] },
@@ -780,7 +782,7 @@ async function buildAppearanceForm(host, loading) {
setText(status, deleteAnimSwitch.checked ? "节点标签区增删动画已开" : "节点标签区增删动画已关");
status.dataset.kind = "ok";
});
const deleteAnimNote = element("div", "bpi-fold-note", "开:删标签时那张卡先被一个大 X 划掉,接着整块从左往右擦走,然后再消失(约 0.9 秒,这期间那张卡点不动);添加标签时新卡片从左往右展开,旁边的标签滑开让位。关掉就是原来那样:点一下直接没、加一个直接出现。");
const deleteAnimNote = element("div", "bpi-fold-note", "开:删标签时那张卡先被一个大 X 划掉,接着整块从右往左擦走,然后再消失(约 0.9 秒,这期间那张卡点不动);添加标签时新卡片从左往右展开,旁边的标签滑开让位。关掉就是原来那样:点一下直接没、加一个直接出现。");
const foldNote = element("div", "bpi-fold-note", "Operation results show as a popup in the top-right corner instead of the status line.");
const toastNote = element("div", "bpi-fold-note", "Per-operation feedback (translation, tag edits, duplicate toggle) shows as a small bar stacked in the bottom-right corner. Turn it off to keep everything in the status line and save a bit of CPU.");
// 总开关最后绑定:这时下面几个子开关都已经建好。
@@ -1795,6 +1797,29 @@ function destroyTab() {
refs.tagManagerHost?.replaceChildren?.();
}
// 系统开了「减少动态效果」:第一次进 ComfyUI 时把这几套「纯装饰」动画关掉,只做一次。
// 右上角弹窗和右下角提示条不动 —— 它们的入场在 CSS 的 reduced-motion 媒体查询里已经
// 压成瞬时了,等于没有动画;关掉开关反而会连操作回执一起消失。
function applyReducedMotionDefaults() {
if (typeof window.matchMedia !== "function") return;
if (!window.matchMedia("(prefers-reduced-motion: reduce)").matches) return;
try {
if (window.localStorage.getItem(REDUCED_MOTION_KEY) === "1") return;
window.localStorage.setItem(REDUCED_MOTION_KEY, "1");
} catch (error) {
return;
}
const dragNode = dragPreviewNodeEnabled();
const dragTable = dragPreviewTableEnabled();
const deleteAnim = deleteAnimNodeEnabled();
setDragPreviewNodeEnabled(false);
setDragPreviewTableEnabled(false);
setDeleteAnimNodeEnabled(false);
if ((dragNode || dragTable || deleteAnim) && document.body) {
notify("检测到系统开启了「减少动态效果」,拖动预览和增删动画已经关掉;需要的话可以在「设置 → 通用」里重新打开。", "notice", { hold: 9000 });
}
}
applyReducedMotionDefaults();
injectBpiStyles();
installBpiWheelGuard();
// 启动时把后端保存的外观 / 语言设置拉下来;语言还会顺带整树重刷一次已渲染的面板
+68 -11
View File
@@ -114,17 +114,23 @@ export function removePromptToken(text, token) {
return { text: value, cursor: start, changed: false };
}
if (end < value.length && (value[end] === "," || isLineBreak(value[end]))) {
const separator = value[end];
if (end < value.length && value[end] === ",") {
// 逗号是标签之间的分隔,跟着标签一起删;逗号后面紧跟的换行也算同一处分隔
// (前面已经有一个换行在分段了,再留一个就多出空行)
end += 1;
if (separator === "\r" && value[end] === "\n") end += 1;
while (end < value.length && /[ \t]/.test(value[end])) end += 1;
if (separator === "," && isLineBreak(value[end])) {
const firstBreak = value[end];
if (isLineBreak(value[end])) {
const separator = value[end];
end += 1;
if (firstBreak === "\r" && value[end] === "\n") end += 1;
if (separator === "\r" && value[end] === "\n") end += 1;
while (end < value.length && /[ \t]/.test(value[end])) end += 1;
}
} else if (end < value.length && isLineBreak(value[end])) {
// 标签正好在行尾、后面直接是换行:换行是分段标记,不能跟着删掉,
// 否则下一行会被拽上来、两段并成一段。只把标签前面那个逗号清掉
let probe = start - 1;
while (probe >= 0 && /[ \t]/.test(value[probe])) probe -= 1;
if (probe >= 0 && value[probe] === ",") start = probe;
} else {
let probe = start - 1;
while (probe >= 0 && /[ \t]/.test(value[probe])) probe -= 1;
@@ -141,6 +147,10 @@ export function removePromptToken(text, token) {
let left = value.slice(0, start);
let right = value.slice(end);
// 标签自己独占一行时前后各有一个换行,留两个就会空出一行;合成一个
if (/[\r\n]$/.test(left) && /^[\r\n]/.test(right)) {
right = right.replace(/^(\r\n|\n|\r)/, "");
}
if (!left.trim()) {
right = right.replace(/^[\s,]+/, "");
left = "";
@@ -240,7 +250,7 @@ export function replacePromptSpan(text, start, end, replacement) {
* present at the drop boundary. Duplicate tags are moved by occurrence via
* their start/end offsets, matching removePromptToken.
*/
export function movePromptToken(text, tokens, token, targetIndex) {
export function movePromptToken(text, tokens, token, targetIndex, options = {}) {
const value = String(text ?? "");
const list = Array.isArray(tokens) ? tokens : [];
const fromIndex = list.findIndex((item) =>
@@ -254,6 +264,45 @@ export function movePromptToken(text, tokens, token, targetIndex) {
if (!Number.isFinite(target)) target = fromIndex;
target = Math.max(0, Math.min(count, target));
const newIndex = target > fromIndex ? target - 1 : target;
// afterBreak:落点是「自己后面那一格」(把行尾标签拖到下一行开头)。
// token 顺序没有变化,要把自己后面的换行挪到前面 —— 效果是下移一行。
if (options.afterBreak && fromIndex < count - 1) {
const tokenEnd = list[fromIndex].end;
const nextStart = list[fromIndex + 1].start;
const brk = value.slice(tokenEnd, nextStart).match(/[\r\n]+/);
const head = value.slice(0, list[fromIndex].start).replace(/[\s,]+$/, "");
if (brk && head) {
const core = value.slice(list[fromIndex].start, tokenEnd).trim();
const rest = value.slice(nextStart).replace(/^[\s,]+/, "");
const nextText = `${head}${brk[0]}${core}, ${rest}`;
return {
text: nextText,
cursor: head.length + brk[0].length + core.length,
changed: nextText !== value,
index: fromIndex,
};
}
}
// beforeBreak:落点其实是「上一行末尾」(换行块前面)。被拖的标签自己就站在
// 某个行首时,token 顺序并没有变化,但要把挡在它前面的那个换行挪到它后面 ——
// 效果就是它上移一行。没有这一条,这种拖动会被当成「没变化」直接吞掉。
if (options.beforeBreak && fromIndex > 0) {
const prevEnd = list[fromIndex - 1].end;
const tokenStart = list[fromIndex].start;
const brk = value.slice(prevEnd, tokenStart).match(/[\r\n]+/);
if (brk) {
const head = value.slice(0, prevEnd).replace(/[\s,]+$/, "");
const core = value.slice(tokenStart, list[fromIndex].end).trim();
const rest = value.slice(list[fromIndex].end).replace(/^[\s,]+/, "");
const nextText = `${head}, ${core}${brk[0]}${rest}`;
return {
text: nextText,
cursor: head.length + 2 + core.length,
changed: nextText !== value,
index: fromIndex,
};
}
}
if (newIndex === fromIndex) {
return { text: value, cursor: token.start, changed: false, index: fromIndex };
}
@@ -315,10 +364,18 @@ export function movePromptToken(text, tokens, token, targetIndex) {
for (let p = 0; p < survivorCount; p += 1) {
const idx = order[p];
if (p === newIndex) {
const edge = p === 0 ? prefix : normGap(inEdge(p));
out += edge + movedCore;
cursor = out.length;
out += ", " + coreOf(idx) + trailOf(idx);
const gap = p === 0 ? prefix : inEdge(p);
if (p > 0 && options.beforeBreak && hasBreak(gap)) {
// 这一格的分隔符里带换行:换行是「行尾」,要留在被移过来的标签后面。
// 放在它前面的话,拖到行尾(换行块前面)的标签会直接掉到下一行去
out += ", " + movedCore + (gap.match(/[\r\n]+/) ?? ["\n"])[0];
cursor = out.length;
out += coreOf(idx) + trailOf(idx);
} else {
out += (p === 0 ? prefix : normGap(gap)) + movedCore;
cursor = out.length;
out += ", " + coreOf(idx) + trailOf(idx);
}
} else {
const lead = keepsLead(p) ? leadOf(idx) : "";
out += inEdge(p) + lead + coreOf(idx) + trailOf(idx);
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "comfyui-bilingual-prompt-inspector",
"version": "2.2.0",
"version": "2.2.2",
"description": "ComfyUI bilingual prompt inspection, translation, Anima sorting, and on-demand Danbooru dictionary extension.",
"private": true,
"type": "module",
+1 -1
View File
@@ -1,6 +1,6 @@
[project]
name = "prompt-translator-manager"
version = "2.2.0"
version = "2.2.2"
# 与 Comfy Registry 页面上显示的描述保持一致: publish 会用这里的值覆盖网页上的手改
description = "ComfyUI prompt node with translation and management: bilingual tag view, drag-to-reorder, temporary hiding, multiple translation services and a sidebar dictionary manager.\nComfyUI 提示节点,支持翻译和管理:双语标签视图、拖拽排序、临时隐藏、多翻译服务和侧边栏词典管理器。"
license = { file = "LICENSE" }
+206
View File
@@ -0,0 +1,206 @@
// 模块依赖体检:拆文件之后最容易犯的错是「引用了没 import / 没传进来 / 目标模块没导出的名字」。
// 这类错误 node --check 查不出来(语法合法),浏览器要到模块链接或运行时才炸 —— 这里做四重静态比对:
//
// 1) 反向:模块里用了「别的模块 export 出来的名字」,却没 import
// 2) 正向:模块里用了「主文件闭包里定义的名字」,却没通过参数传进来
// 3) 主文件用了「模块内部定义、却没导出」的名字
// 4) 链接:import 的名字在目标模块的 export 里找不到,或相对路径的文件不存在
//
// 字符串与注释先剥掉,避免把文案里的英文单词当成标识符。
import assert from "node:assert/strict";
import fs from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";
const JS_DIR = new URL("../js/", import.meta.url);
const MAIN = new URL("bilingual_prompt.js", JS_DIR);
const files = fs.readdirSync(JS_DIR).filter((name) => name.endsWith(".js"));
function stripLiterals(src) {
return src
.replace(/\/\*[\s\S]*?\*\//g, "")
.replace(/\/\/[^\n]*/g, "")
.replace(/`[^`]*`/g, "``")
.replace(/"(?:[^"\\]|\\.)*"/g, '""')
.replace(/'(?:[^'\\]|\\.)*'/g, "''");
}
// 全仓导出名:模块用了却没 import 的,一定是漏了
const exported = new Set();
const sources = new Map();
for (const file of files) {
const raw = fs.readFileSync(new URL(file, JS_DIR), "utf8");
sources.set(file, raw);
for (const match of raw.matchAll(/export\s+(?:async\s+)?function\s+([A-Za-z_$][\w$]*)/g)) exported.add(match[1]);
for (const match of raw.matchAll(/export\s+(?:const|let)\s+([A-Za-z_$][\w$]*)/g)) exported.add(match[1]);
for (const match of raw.matchAll(/export\s*\{([^}]*)\}/g)) {
for (const name of match[1].split(",")) {
const clean = name.trim().split(/\s+as\s+/).pop();
if (clean) exported.add(clean);
}
}
}
// 主文件闭包里的名字:模块用到了就必须出现在参数表里
const mainCode = stripLiterals(fs.readFileSync(MAIN, "utf8"));
const closureNames = new Set();
for (const match of mainCode.matchAll(/^ (?:const|let|function)\s+([A-Za-z_$][\w$]*)/gm)) closureNames.add(match[1]);
for (const match of mainCode.matchAll(/^ const \{([^}]*)\}\s*=/gm)) {
for (const name of match[1].split(",")) {
const clean = name.trim();
if (clean) closureNames.add(clean);
}
}
for (const match of mainCode.matchAll(/function createPanel\(([^)]*)\)/g)) {
for (const name of match[1].split(",")) {
const clean = name.trim();
if (clean) closureNames.add(clean);
}
}
const problems = [];
let checked = 0;
for (const file of files) {
const raw = sources.get(file);
if (!/export function create[A-Za-z]+\(/.test(raw)) continue;
checked += 1;
const imports = new Set();
for (const match of raw.matchAll(/import\s*\{([^}]*)\}/g)) {
for (const name of match[1].split(",")) {
const clean = name.trim();
if (clean) imports.add(clean);
}
}
const code = stripLiterals(raw);
const declared = new Set();
for (const match of code.matchAll(/export function create[A-Za-z]+\(\{([^}]*)\}\)/gs)) {
for (const name of match[1].split(",")) {
const clean = name.trim();
if (clean) declared.add(clean);
}
}
for (const match of code.matchAll(/\b(?:const|let|function|var)\s+([A-Za-z_$][\w$]*)/g)) declared.add(match[1]);
for (const match of code.matchAll(/\b(?:const|let)\s*\{([^}]*)\}\s*=/g)) {
for (const name of match[1].split(",")) {
const clean = name.trim().replace(/^\.\.\./, "").split(":").pop().trim();
if (clean) declared.add(clean);
}
}
for (const match of code.matchAll(/\(([^()]*)\)\s*=>/g)) {
for (const name of match[1].split(",")) {
const clean = name.trim().replace(/^\.\.\./, "").split("=")[0].trim();
if (clean) declared.add(clean);
}
}
for (const match of code.matchAll(/^\s*([A-Za-z_$][\w$]*)\s*\([^)]*\)\s*\{/gm)) declared.add(match[1]);
// 对象字面量的键(duplicateGroups: [] / showToast: () => ...)不是外部引用
for (const match of code.matchAll(/(?:^|[{,])\s*([A-Za-z_$][\w$]*)\s*:/gm)) declared.add(match[1]);
const used = new Set();
for (const match of code.matchAll(/(?<![.\w$])([A-Za-z_$][\w$]*)/g)) used.add(match[1]);
const missingImport = [...used].filter(
(name) => !imports.has(name) && !declared.has(name) && exported.has(name),
);
if (missingImport.length) {
problems.push(`${file} 用了别处导出的名字却没 import:${missingImport.sort().join(", ")}`);
}
const missingParam = [...used].filter(
(name) => closureNames.has(name) && !declared.has(name) && !imports.has(name),
);
if (missingParam.length) {
problems.push(`${file} 用了主文件里的名字却没传进来:${missingParam.sort().join(", ")}`);
}
}
assert.deepEqual(problems, [], problems.join("\n"));
assert.ok(checked >= 3, `只检查到 ${checked} 个工厂模块,匹配规则可能有问题`);
// 3) 反向:主文件用了「某个模块内部定义、但没导出」的名字 —— 拆模块时最容易漏的就是这种
const moduleLocals = new Map();
for (const [file, raw] of sources) {
if (file === "bilingual_prompt.js") continue;
const code = stripLiterals(raw);
const locals = new Set();
for (const match of code.matchAll(/\b(?:const|let|function|var)\s+([A-Za-z_$][\w$]*)/g)) locals.add(match[1]);
moduleLocals.set(file, locals);
}
const mainImports = new Set();
for (const match of mainCode.matchAll(/import\s*\{([^}]*)\}/g)) {
for (const name of match[1].split(",")) {
const clean = name.trim();
if (clean) mainImports.add(clean);
}
}
const mainUsed = new Set();
for (const match of mainCode.matchAll(/(?<![.\w$])([A-Za-z_$][\w$]*)/g)) mainUsed.add(match[1]);
const missingFromMain = new Set();
for (const [file, locals] of moduleLocals) {
for (const name of locals) {
if (exported.has(name) || closureNames.has(name) || mainImports.has(name)) continue;
if (mainUsed.has(name)) missingFromMain.add(`${name}(${file} 里定义但没导出)`);
}
}
if (missingFromMain.size) {
problems.push(`主文件用了模块内部的名字:${[...missingFromMain].sort().join(", ")}`);
}
// 4) 链接体检:import 的名字必须真的由目标模块导出,相对路径的文件必须存在。
// 浏览器在 ESM 链接阶段就会因此让整块扩展加载失败,node --check 与上面三项都查不出来。
function stripComments(src) {
return src
.replace(/\/\*[\s\S]*?\*\//g, "")
.replace(/\/\/[^\n]*/g, "");
}
const exportsByFile = new Map();
for (const [file, raw] of sources) {
const code = stripComments(raw);
const names = new Set();
for (const match of code.matchAll(/export\s+(?:async\s+)?function\s+([A-Za-z_$][\w$]*)/g)) names.add(match[1]);
for (const match of code.matchAll(/export\s+(?:const|let)\s+([A-Za-z_$][\w$]*)/g)) names.add(match[1]);
for (const match of code.matchAll(/export\s*\{([^}]*)\}/gs)) {
for (const item of match[1].split(",")) {
const name = item.trim().split(/\s+as\s+/).pop().trim();
if (name) names.add(name);
}
}
exportsByFile.set(file, names);
}
const jsDir = fileURLToPath(JS_DIR);
const linkProblems = [];
for (const [file, raw] of sources) {
const code = stripComments(raw);
const imports = [];
for (const match of code.matchAll(/import\s*\{([^}]*)\}\s*from\s*["']([^"']+)["']/gs)) {
for (const item of match[1].split(",")) {
const name = item.trim().split(/\s+as\s+/)[0].trim();
if (name) imports.push({ name, spec: match[2] });
}
}
for (const match of code.matchAll(/import\s*["']([^"']+)["']/g)) {
imports.push({ name: null, spec: match[1] });
}
for (const { name, spec } of imports) {
const abs = path.resolve(path.dirname(path.join(jsDir, file)), spec);
const rel = path.relative(jsDir, abs);
if (rel.startsWith("..") || path.isAbsolute(rel)) continue; // ../../scripts/app.js 由宿主提供
const target = path.basename(abs);
if (!sources.has(target)) {
linkProblems.push(`${file}: import 的文件不存在 ${spec}`);
} else if (name && !exportsByFile.get(target).has(name)) {
linkProblems.push(`${file}: ${spec} 没有导出 ${name}`);
}
}
}
assert.deepEqual(linkProblems, [], linkProblems.join("\n"));
console.log(`module deps tests: OK (${checked} files, ${sources.size} modules linked)`);
+27
View File
@@ -7,6 +7,7 @@ import {
detectInputMode,
extractInstructionBody,
looksSuspiciousNatural,
movePromptToken,
parsePrompt,
removePromptToken,
replacePromptSpan,
@@ -220,4 +221,30 @@ assert.equal(parsePrompt("masterpiece", unverified)[0].confidence, "medium");
const machineToken = parsePrompt("cinematic", dictionary, new Map([["cinematic", { text: "电影感", source: "prompt-assistant" }]]))[0];
assert.equal(machineToken.confidence, "low");
// ── 换行相关的删 / 移(2026-11-07 修的一批问题,这里把行为钉死)─────────────
const wrapText = "a, b\nc, d";
const wrapTokens = parsePrompt(wrapText, dictionary);
const term = (name) => wrapTokens.find((item) => item.term === name);
// 删行尾标签:换行留着,下一行不被拽上来
assert.equal(removePromptToken(wrapText, term("b")).text, "a\nc, d");
// 删独占一行的标签:前后换行合成一个,不留空行
assert.equal(removePromptToken("a\nb\nc", parsePrompt("a\nb\nc", dictionary)[1]).text, "a\nc");
// 删末行标签:结尾不留空行
assert.equal(removePromptToken("a\nb", parsePrompt("a\nb", dictionary)[1]).text, "a");
// 拖到行尾(换行块前面):落在第一行末尾,换行跟着到标签后面
assert.equal(movePromptToken(wrapText, wrapTokens, term("d"), 2, { beforeBreak: true }).text, "a, b, d\nc");
// 拖到下一行开头:落在换行后面
assert.equal(movePromptToken(wrapText, wrapTokens, term("d"), 2).text, "a, b\nd, c");
// 行首标签上移到上一行末尾
assert.equal(movePromptToken(wrapText, wrapTokens, term("c"), 2, { beforeBreak: true }).text, "a, b, c\nd");
// 行尾标签下移到下一行开头
assert.equal(movePromptToken(wrapText, wrapTokens, term("b"), 2, { afterBreak: true }).text, "a\nb, c, d");
// 落点就是自己后面但不给标记:视为没变化
assert.equal(movePromptToken(wrapText, wrapTokens, term("b"), 2).changed, false);
// 移动不应改变标签数量
assert.equal(parsePrompt(movePromptToken(wrapText, wrapTokens, term("d"), 2, { beforeBreak: true }).text, dictionary).length, wrapTokens.length);
console.log("parser tests: OK");