From fa1a563d742a9a65141ab9c3542056d426eea2b9 Mon Sep 17 00:00:00 2001
From: AliceJump <149395013+AliceJump@users.noreply.github.com>
Date: Tue, 29 Sep 2026 02:53:18 +0800
Subject: [PATCH 01/19] feat(boxes): add the box resource contract and
annotation visibility
Authoring and runtime box files stay separate, matching the two COCO libraries.
---
docs/box-resources.md | 175 +++++++++++
docs/ok-script-toolkit.example.json | 4 +
docs/project-config.md | 11 +-
jetbrains | 2 +-
media/annotationPanel/app.js | 96 ++++++
media/annotationPanel/index.html | 17 +-
media/annotationPanel/style.css | 53 ++++
package.json | 3 +-
python/probe_window_config.py | 17 ++
python/tests/test_probe_window_config.py | 17 +-
schemas/ok-script-toolkit.schema.json | 12 +
scripts/test_box_resource.js | 147 +++++++++
src/boxResourcePure.ts | 369 +++++++++++++++++++++++
src/localization.ts | 15 +
src/projectConfig.ts | 11 +
src/projectConfigPure.ts | 23 ++
src/screenshotCapture.ts | 7 +
17 files changed, 969 insertions(+), 10 deletions(-)
create mode 100644 docs/box-resources.md
create mode 100644 scripts/test_box_resource.js
create mode 100644 src/boxResourcePure.ts
diff --git a/docs/box-resources.md b/docs/box-resources.md
new file mode 100644
index 0000000..a80d22f
--- /dev/null
+++ b/docs/box-resources.md
@@ -0,0 +1,175 @@
+# 框资源设计
+
+框管理对标现有模板管理,分成两份资源。标注工作副本只给插件编辑;运行时副本才是游戏和代码补全读取的那份。图片、画布和显隐不另起一套。
+
+本文是实现依据。ok-neverness-to-everness(下称 ok-nte)里的真实调用是 `self.pos.screen.main_viewport.to_box()`,不是 `self.pos.main_viewport`,也没有 `screen_pos`。
+
+## 1. 现有能力
+
+工具箱有两条已经分开的模板线。
+
+| | 标注资源 | 运行时资源 |
+|---|---|---|
+| 文件 | `/coco_annotations.json`,默认目录 `ok_templates` | `config.py` 的 `template_matching.coco_feature_json`,缺省探测 `assets/coco_annotations.json` 与 `ok_tasks/assets/coco_annotations.json` |
+| 谁读写 | 标注管理(素材面板) | 模板画廊、补全、Hover;ok 框架做模板匹配 |
+| 如何过去 | 显式「导出到 assets」。编辑不会自动写运行时文件 | |
+
+标注编辑器(`AnnotationDialog` / `media/annotationPanel`)已有选择、拖动、八向缩放、增删、缩放、平移、撤销、复制粘贴和跨图导航。内存模型是像素 `xywh`。落盘是 COCO `bbox`。`NormalizedBox` 只服务坐标复制,不落盘,也不是 ok-script 的 `Box`。
+
+编辑器此前没有标注列表,也没有显隐。隐藏和删除是同一件事。
+
+模板画廊只读运行时 COCO。单击插入 `fL.<名称>`。区域预览是原图加像素 bbox 的动态裁剪,缓存键是图片内容哈希加 bbox。Quick Documentation 和 Hover 使用同一张裁剪图。JetBrains 查找项右侧是尺寸;VS Code 在补全项 resolve 时把图放进 documentation。
+
+本仓库没有业务上的 Point 资源,也没有位置表。
+
+## 2. ok-nte 的 pos / Box
+
+位置表在 `src/scene/`,手写 Python。
+
+- `self.pos` 是 `PositionMap`,在 `BaseNTETask` 里创建。
+- `self.pos.screen` 是矩形。目前有 `center`、`dialog_icon`、`main_viewport`,四个数是相对整屏的 `(left, top, right, bottom)`。
+- `self.pos.panels.*` 是点击点,两个数,用 `*self.pos.panels.esc.mail` 解包。两点调用 `to_box()` 会抛错。
+- `ScreenRatio.to_box()` 调用 `box_of_screen(..., hcenter=True)`,按当前采集帧换成像素 `ok.Box`。`hcenter` 发生在调用时,不能写进资源。
+- `Box.name` 由描述符写成 `ScreenPosition.<字段>`,只用于调试。查找区域不按这个名字匹配。
+- 约 73 处任务私有 `box_of_screen(...)` 明确不进位置表。框资源不收编它们。
+- 运行时不依赖 IDE 插件。
+
+## 3. 两份框资源
+
+| | 模板 | 框 |
+|---|---|---|
+| 标注资源 | `<模板目录>/coco_annotations.json` | `<模板目录>/boxes.json` |
+| 运行时资源 | `templates.cocoAnnotations` → `config.py` → 探测 | `boxes.runtime` → `config.py` 的 `boxes_json` → 探测 `src/scene/boxes.json` |
+| 编辑入口 | 标注管理 | 框资源管理 |
+| 浏览 / 补全 | 模板管理 | 框管理 |
+| 从标注到运行时 | 显式导出 | 显式发布 |
+
+游戏加载器不读 `ok-script-toolkit.json`。插件索引运行时文件时,约定优先于 `config.py`,与模板相同。ok-nte 两处都不写时,两边都用 `src/scene/boxes.json`。
+
+`panels` 前缀留给现有点击点。框路径不得占用。
+
+## 4. 数据模型
+
+标注资源带着原图,供编辑和裁剪:
+
+```json
+{
+ "version": 1,
+ "boxes": [
+ {
+ "path": "screen.main_viewport",
+ "image": "12.png",
+ "rect": [0.098400, 0.104200, 0.896100, 0.894400]
+ }
+ ]
+}
+```
+
+运行时资源只有几何。`to_box()` 不需要图片:
+
+```json
+{
+ "version": 1,
+ "boxes": [
+ {
+ "path": "screen.main_viewport",
+ "rect": [0.098400, 0.104200, 0.896100, 0.894400]
+ }
+ ]
+}
+```
+
+约定:
+
+- `path` 是 `self.pos.` 后面的属性路径。至少两段,每段是 Python 标识符。`screen.main_viewport` 对应 `self.pos.screen.main_viewport`。
+- `rect` 是 `[left, top, right, bottom]`,相对这张原图的整幅,范围 0–1。这就是 `ScreenRatio` 的四个数。
+- 序列化按 `path` 排序,矩形固定 6 位小数,两空格缩进,文件末尾换行。改一个框时 diff 停在那个对象。
+- `image` 是模板目录下的文件名,用现有文件名归一化规则对齐。不复制图片,不把裁剪 PNG 写进仓库。
+- 参照图必须是整屏截图。`assets/images` 里的打包裁切块不能当框的原图。
+- `to_box()` 的调试名由 path 推导:`screen` 下的叶子写成 `ScreenPosition.<叶子>`。它不是主键。
+
+发布把标注条目投影成 `{path, rect}`,去掉 `image`。发布是整份快照:标注里删掉的框,发布后从运行时消失;若会删掉运行时已有 path,先确认。编辑器保存只写标注资源。
+
+## 5. 图片与编辑器
+
+框引用标注管理正在用的同一批原图。框资源管理不导入图片、不截图入库、不打包导出。
+
+不新写画布。编辑器会话只认识图片和 `id + 标签 + 像素 xywh`。模板适配器的标签是分类名,保存仍写 COCO。框适配器的标签是 path,打开时把 `rect` 换成像素框,写回时:
+
+- 像素矩形没变的条目保留原来的浮点 `rect`。
+- 变过的条目才用 `left = x / 宽` 重新计算。
+
+这样打开后直接保存不会改文件。
+
+点不单独做编辑器。条目按 id 工作,以后的点走同一套会话和显隐。
+
+## 6. 显隐
+
+显隐是编辑器会话状态,按条目标记记在隐藏集合里。不进 COCO,不进两份框文件,不进撤销栈。
+
+标注列表始终列出全部条目。画布和命中测试只使用未隐藏的条目。
+
+| 操作 | 行为 |
+|---|---|
+| 显示全部 | 清空隐藏集合 |
+| 隐藏全部 | 隐藏当前图上每一条 |
+| 只显示当前 | 只留选中项 |
+| 勾选 | 逐条改隐藏集合 |
+
+每张图一份隐藏集合,留在本次编辑会话里。关掉对话框就丢。新图默认全部显示。模板、框、点共用这一个过滤。
+
+## 7. 预览、补全、生成
+
+框管理和 `self.pos.screen.` 的补全只索引运行时资源。插入文本是 `self.pos.screen.main_viewport.to_box()`。另给一个只复制属性路径的动作。
+
+预览图不进运行时文件。用 path 回查标注资源拿 `image`,再按原图和 `rect` 裁剪,复用模板的裁剪缓存。缓存键仍然是内容哈希加像素 bbox,框一改就自然失效。对不上原图时,文档只显示 path 和 rect。
+
+文档内容沿用模板:裁剪图、表达式、path、归一化 rect、原图相对路径。JetBrains 可以加一行短坐标 inlay;VS Code 不加,与「模板不做幽灵注释」一致。
+
+单个模板生成框:用该标注所在原图把像素 `xywh` 归一化,默认 path 建议 `screen.<分类名>`,已存在则要求换名。多个模板生成包围框只允许同一张原图:
+
+```text
+left = min(x) / 宽
+top = min(y) / 高
+right = max(x + w) / 宽
+bottom = max(y + h) / 高
+```
+
+生成结果是普通框,进入标注资源,发布后才进入运行时。
+
+## 8. 运行时加载
+
+加载放在业务项目的 `ScreenPosition`,不放进插件,也不改 `ok.Box`。
+
+`ScreenRatio` 只有 `__get__`。实例上的同名属性会盖住类描述符。JSON 里有的名字用 JSON;没有的名字仍走手写类属性。`to_box()` 继续调用 `box_of_screen(..., hcenter=True)`。
+
+现有三个矩形可以一次性导入标注资源并绑定整屏原图。发布前手写属性继续有效,插件的框管理和补全只认运行时 JSON。
+
+## 9. 模块
+
+| 位置 | 职责 |
+|---|---|
+| `src/boxResourcePure.ts` 与 `core/BoxResource.kt` | 路径、矩形、发布、显隐。两端语义一致 |
+| `core/BoxRuntimePath.kt` 与纯模块中的路径函数 | 运行时文件取值链,对标 `CocoFeaturePath` |
+| `schemas/ok-script-toolkit.schema.json` 的 `boxes.runtime` | 约定文件。没有个人偏好层 |
+| `python/probe_window_config.py` 的 `boxes_json` | 从 `config.py` 顶层读出运行时路径 |
+| 标注编辑器两侧 | 标注列表和显隐 |
+| 后续:框资源管理、框管理、补全、从模板生成 | 按第 10 节往下接,复用上面的契约 |
+
+## 10. 实施顺序
+
+1. 数据契约、路径解析、编辑器显隐。
+2. 框资源管理:按原图浏览,用现有编辑器改标注资源。
+3. 发布到运行时文件。
+4. 框管理卡片和文档预览。
+5. `self.pos` 的补全与 Hover。
+6. 单模板生成框、同图多模板包围框。
+7. ok-nte 的 `ScreenPosition` 加载器。三个矩形确认无误后,再手工去掉对应类属性。
+
+## 11. 风险
+
+- 编辑器改造不能改变模板标注的保存结果。显隐默认全显,撤销不含显隐。
+- 打开即保存不得改写未移动框的浮点。
+- 框若标在裁切块上,归一化结果不是屏幕比例。
+- JSON 与类属性同名时以 JSON 为准。导入并发布后,应在业务项目里删掉对应手写属性,避免两处各改各的。
+- 插件不得把 `hcenter` 算进 `rect`。
diff --git a/docs/ok-script-toolkit.example.json b/docs/ok-script-toolkit.example.json
index 9afed48..6e9bcb8 100644
--- a/docs/ok-script-toolkit.example.json
+++ b/docs/ok-script-toolkit.example.json
@@ -24,6 +24,10 @@
"cocoAnnotations": "assets/coco_annotations.json"
},
+ "boxes": {
+ "runtime": "src/scene/boxes.json"
+ },
+
"i18n": {
"enabled": true,
"langDirectory": "assets/lang",
diff --git a/docs/project-config.md b/docs/project-config.md
index 2ee358a..14a826c 100644
--- a/docs/project-config.md
+++ b/docs/project-config.md
@@ -168,12 +168,14 @@
| | `afterConfigImport` | **无此信息** | 按约定试 `src.patches.startup_patches:install_startup_patches` |
| `templates` | `directory` | 无(插件侧约定) | IDE 设置 → `ok_templates` |
| | `cocoAnnotations` | **6/6 有** | `config.py` 的 `template_matching.coco_feature_json` → 依次探测两个候选 |
+| `boxes` | `runtime` | 新字段,现有项目还没有 | `config.py` 顶层 `boxes_json` → `src/scene/boxes.json` |
| `i18n` | `enabled` / `langDirectory` / `poDirectory` / `poDomains` | 无 | IDE 设置 → 内置默认 |
| `characters` | `projectPath` / `masterFile` / `skillsDirectory` / `localeFile` / `avatarTemplateRegex` | 无 | IDE 设置 → 内置默认 |
| `effects` | `file` | 无 | IDE 设置 → `src/data/effects.py` |
**接线状态**:全部字段已接线(`templates.directory` / `templates.cocoAnnotations` /
-`labelEnum.*` / `i18n` / `characters` / `effects`)。
+`boxes.runtime` / `labelEnum.*` / `i18n` / `characters` / `effects`)。
+`boxes.runtime` 的后半段(读 `config.py`、定位文件)已接上;框管理界面仍按 `docs/box-resources.md` 继续。
**⚠️ 两个同名的 `coco_annotations.json` 不是一回事** —— 接错会静默指向错的文件:
@@ -181,10 +183,11 @@
|---|---|---|---|
| `assets/coco_annotations.json`(或 config.py 指的别处) | ok 框架加载的**运行时模板库** | `featureData` / `OkProjectDataService` 读,文件监听盯它 | `templates.cocoAnnotations` → config.py → 两个惯例位置 |
| `<模板目录>/coco_annotations.json` | 素材面板自己的**标注工作文件** | `templateAssetData` / `TemplateAssetDataService` 读写 | `templates.directory`(**不受** `cocoAnnotations` 影响) |
+| `src/scene/boxes.json`(或 `boxes_json` 指的别处) | 业务项目加载的**运行时框** | 路径已能解析;框管理 / 补全尚未读取。加载器在业务项目的 `ScreenPosition` | `boxes.runtime` → config.py → 探测位置 |
+| `<模板目录>/boxes.json` | 框资源管理的**标注工作文件** | 契约已定,面板尚未接上 | `templates.directory`(**不受** `boxes.runtime` 影响) |
-`cocoAnnotations` 是**唯一**一层"`config.py` 已声明的事实"真正落地的字段 ——
-其余字段 `config.py` 要么不声明,要么(`labelEnum.path`)插件至今没读。
-它也是唯一**没有 IDE 设置**的链(没有"个人偏好"层),所以不进溯源面板。
+`cocoAnnotations` 与 `boxes.runtime` 是"`config.py` 已声明的事实"落地的两条链,
+都**没有 IDE 设置**(没有"个人偏好"层)。`boxes_json` 在现有项目里还没有,缺席时探测 `src/scene/boxes.json`。
**按字段类型选归一化方式**(做错是**静默**的,所以这里写死):
diff --git a/jetbrains b/jetbrains
index cb16c63..eae80a0 160000
--- a/jetbrains
+++ b/jetbrains
@@ -1 +1 @@
-Subproject commit cb16c63efe10a11227463789c8beb91d216f9989
+Subproject commit eae80a05839bb4f3c1d139d17d5ef0859789a2e7
diff --git a/media/annotationPanel/app.js b/media/annotationPanel/app.js
index bb55803..5e9003e 100644
--- a/media/annotationPanel/app.js
+++ b/media/annotationPanel/app.js
@@ -22,6 +22,10 @@
document.getElementById('prevBtn').title = t('prevImage');
document.getElementById('nextBtn').title = t('nextImage');
document.getElementById('emptyMsg').textContent = t('noImageLoaded');
+ document.getElementById('annotationListTitle').textContent = t('annotationListTitle');
+ document.getElementById('showAllBtn').textContent = t('showAllAnnotations');
+ document.getElementById('hideAllBtn').textContent = t('hideAllAnnotations');
+ document.getElementById('onlyCurrentBtn').textContent = t('showOnlyCurrent');
// 归一化坐标(x,y,tox,toy)保留的小数位
const COORD_DECIMALS = 4;
@@ -36,6 +40,9 @@
// 标注
let annotations = [];
+ /** 按图片文件名记住隐藏的分类。不进保存消息,撤销也不恢复。 */
+ const hiddenByFile = new Map();
+ let listSignature = '';
let nextId = 1;
let selectedIdx = -1;
let hoveredIdx = -1;
@@ -132,6 +139,14 @@
if (drawBtn) { drawBtn.textContent = t('drawBbox'); drawBtn.title = t('drawBboxTooltip'); }
if (coordBtn) { coordBtn.textContent = t('copyCoords'); coordBtn.title = t('copyCoordsTooltip') + ' (' + keybindings.copyCoords + ')'; }
if (deleteBtn) { deleteBtn.textContent = t('deleteMode'); deleteBtn.title = t('deleteBboxTooltip'); }
+ const listTitle = document.getElementById('annotationListTitle');
+ const showAllBtn = document.getElementById('showAllBtn');
+ const hideAllBtn = document.getElementById('hideAllBtn');
+ const onlyCurrentBtn = document.getElementById('onlyCurrentBtn');
+ if (listTitle) listTitle.textContent = t('annotationListTitle');
+ if (showAllBtn) showAllBtn.textContent = t('showAllAnnotations');
+ if (hideAllBtn) hideAllBtn.textContent = t('hideAllAnnotations');
+ if (onlyCurrentBtn) onlyCurrentBtn.textContent = t('showOnlyCurrent');
updateUndoRedoButtons();
}
@@ -198,8 +213,18 @@
}
/* ---------- 查找标注 ---------- */
+ function hiddenSet() {
+ const key = imageData && imageData.filename;
+ if (!key) return new Set();
+ if (!hiddenByFile.has(key)) hiddenByFile.set(key, new Set());
+ return hiddenByFile.get(key);
+ }
+ function isShown(ann) {
+ return !hiddenSet().has(ann.category);
+ }
function findAnnAt(px, py) {
for (let i = annotations.length - 1; i >= 0; i--) {
+ if (!isShown(annotations[i])) continue;
const r = annWidgetRect(annotations[i]);
if (rectContains(r, px, py)) return i;
}
@@ -207,12 +232,71 @@
}
function findHandleAt(px, py) {
for (let i = annotations.length - 1; i >= 0; i--) {
+ if (!isShown(annotations[i])) continue;
const r = annWidgetRect(annotations[i]);
const h = detectHandle(px, py, r);
if (h) return { idx: i, handle: h };
}
return { idx: -1, handle: null };
}
+ function syncAnnotationList() {
+ const rows = document.getElementById('annotationRows');
+ const onlyCurrentBtn = document.getElementById('onlyCurrentBtn');
+ if (!rows) return;
+ const hidden = hiddenSet();
+ const sig = annotations.map((ann) => ann.id + '\t' + ann.category + '\t' + (hidden.has(ann.category) ? '0' : '1')).join('\n') + '#' + selectedIdx;
+ if (onlyCurrentBtn) onlyCurrentBtn.disabled = selectedIdx < 0;
+ if (sig === listSignature) return;
+ listSignature = sig;
+ while (rows.firstChild) rows.removeChild(rows.firstChild);
+ if (!annotations.length) {
+ const empty = document.createElement('div');
+ empty.className = 'panel-hint';
+ empty.textContent = t('annotationListEmpty');
+ rows.append(empty);
+ return;
+ }
+ annotations.forEach((ann, index) => {
+ const label = document.createElement('label');
+ label.className = 'annotation-row' + (index === selectedIdx ? ' is-selected' : '');
+ const input = document.createElement('input');
+ input.type = 'checkbox';
+ input.checked = !hidden.has(ann.category);
+ input.addEventListener('change', () => {
+ selectedIdx = index;
+ if (input.checked) hidden.delete(ann.category);
+ else hidden.add(ann.category);
+ listSignature = '';
+ paint();
+ });
+ const text = document.createElement('span');
+ text.textContent = ann.category;
+ label.append(input, text);
+ rows.append(label);
+ });
+ }
+ function showAllAnnotations() {
+ hiddenSet().clear();
+ listSignature = '';
+ paint();
+ }
+ function hideAllAnnotations() {
+ const hidden = hiddenSet();
+ hidden.clear();
+ annotations.forEach((ann) => hidden.add(ann.category));
+ listSignature = '';
+ paint();
+ }
+ function showOnlyCurrent() {
+ const hidden = hiddenSet();
+ const current = annotations[selectedIdx];
+ hidden.clear();
+ annotations.forEach((ann) => {
+ if (!current || ann.category !== current.category) hidden.add(ann.category);
+ });
+ listSignature = '';
+ paint();
+ }
/* ---------- 绘制 ---------- */
function paint() {
@@ -227,8 +311,11 @@
ctx.drawImage(img, offsetX, offsetY, img.width * scale, img.height * scale);
}
+ syncAnnotationList();
+
// 画标注
annotations.forEach((ann, i) => {
+ if (!isShown(ann)) return;
const isSel = i === selectedIdx;
const isHov = i === hoveredIdx;
const r = annWidgetRect(ann);
@@ -893,7 +980,12 @@
showBBoxDialog(ann.category, ann.x, ann.y, ann.w, ann.h, (cat, x, y, w, h) => {
if (cat) {
pushUndo();
+ const previous = ann.category;
ann.category = cat; ann.x = x; ann.y = y; ann.w = w; ann.h = h;
+ if (previous !== cat && hiddenSet().has(previous)) {
+ hiddenSet().delete(previous);
+ hiddenSet().add(cat);
+ }
saveAnnotations(); paint();
updateUndoRedoButtons();
}
@@ -1010,6 +1102,9 @@
document.getElementById('redoBtn').onclick = () => { redo(); updateUndoRedoButtons(); };
document.getElementById('prevBtn').onclick = () => navigate(-1);
document.getElementById('nextBtn').onclick = () => navigate(1);
+ document.getElementById('showAllBtn').onclick = () => showAllAnnotations();
+ document.getElementById('hideAllBtn').onclick = () => hideAllAnnotations();
+ document.getElementById('onlyCurrentBtn').onclick = () => showOnlyCurrent();
/* ---------- 接收消息 ---------- */
window.addEventListener('message', (e) => {
@@ -1030,6 +1125,7 @@
annotations = msg.annotations || [];
nextId = annotations.length ? Math.max(...annotations.map(a => a.id)) + 1 : 1;
selectedIdx = -1; hoveredIdx = -1;
+ listSignature = '';
undoStack = []; redoStack = [];
if (msg.imageBase64) {
diff --git a/media/annotationPanel/index.html b/media/annotationPanel/index.html
index 623da08..4da0a77 100644
--- a/media/annotationPanel/index.html
+++ b/media/annotationPanel/index.html
@@ -19,9 +19,20 @@
-
-
-
+
diff --git a/media/annotationPanel/style.css b/media/annotationPanel/style.css
index 19423e8..1b205b1 100644
--- a/media/annotationPanel/style.css
+++ b/media/annotationPanel/style.css
@@ -21,10 +21,63 @@ body {
.toolbar .spacer { flex: 1; }
.toolbar .info { color: var(--text-muted); font-size: var(--font-xs); white-space: nowrap; }
+.editor-body {
+ flex: 1;
+ display: flex;
+ min-height: 0;
+}
+
.canvas-wrap {
flex: 1;
position: relative;
overflow: hidden;
+ min-width: 0;
+}
+
+.annotation-list {
+ width: 220px;
+ flex-shrink: 0;
+ display: flex;
+ flex-direction: column;
+ gap: var(--space-sm);
+ padding: var(--space-sm);
+ border-left: var(--border-width) solid var(--border);
+ background: var(--bg-container);
+ min-height: 0;
+}
+
+.annotation-list-title {
+ font-size: var(--font-sm);
+ font-weight: var(--weight-medium);
+}
+
+.annotation-list-actions {
+ display: flex;
+ flex-direction: column;
+ gap: var(--space-xs);
+}
+
+.annotation-list-rows {
+ flex: 1;
+ overflow: auto;
+ display: flex;
+ flex-direction: column;
+ gap: var(--space-xs);
+}
+
+.annotation-row {
+ display: flex;
+ align-items: center;
+ gap: var(--space-sm);
+ min-height: var(--control-h-sm);
+ padding: 0 var(--space-xs);
+ background: var(--bg-row);
+ border-radius: var(--radius-sm);
+ font-size: var(--font-sm);
+}
+
+.annotation-row.is-selected {
+ font-weight: var(--weight-medium);
}
canvas {
diff --git a/package.json b/package.json
index 5291272..f9b65d6 100644
--- a/package.json
+++ b/package.json
@@ -335,7 +335,7 @@
"verify:version": "node scripts/release/verify-version.js",
"verify:l10n": "node scripts/release/verify-l10n.js",
"release": "sh scripts/release.sh",
- "test": "npm run verify:version && npm run verify:l10n && npm run compile && npm run test:webview-html && npm run test:task-launcher && npm run test:console-accountseg && npm run test:task-launcher-executor && npm run test:task-reconcile && npm run test:temp-screenshots && npm run test:annotation-coords && npm run test:annotation-swap && npm run test:asset-swap-picker && npm run test:thumb-content-hash && npm run test:list-editor && npm run test:project-config && npm run test:project-config-schema && npm run test:convention-sources && npm run test:label-enum-guard && npm run test:save-to-assets-flow && npm run test:save-to-assets && npm run test:asset-pack-worker && npm run test:coco-feature-path && npm run test:probe-schemas && npm run test:probe-gui-group-names && npm run test:probe-qt-layer && npm run test:probe-multi-account-path && npm run test:probe-window-config && npm run test:run-sandbox && npm run test:startup-patches && npm run test:executor-project-config && npm run test:executor-gconfig && npm run test:project-store && npm run test:project-runtime",
+ "test": "npm run verify:version && npm run verify:l10n && npm run compile && npm run test:webview-html && npm run test:task-launcher && npm run test:console-accountseg && npm run test:task-launcher-executor && npm run test:task-reconcile && npm run test:temp-screenshots && npm run test:annotation-coords && npm run test:annotation-swap && npm run test:asset-swap-picker && npm run test:thumb-content-hash && npm run test:list-editor && npm run test:project-config && npm run test:project-config-schema && npm run test:convention-sources && npm run test:label-enum-guard && npm run test:save-to-assets-flow && npm run test:save-to-assets && npm run test:asset-pack-worker && npm run test:coco-feature-path && npm run test:box-resource && npm run test:probe-schemas && npm run test:probe-gui-group-names && npm run test:probe-qt-layer && npm run test:probe-multi-account-path && npm run test:probe-window-config && npm run test:run-sandbox && npm run test:startup-patches && npm run test:executor-project-config && npm run test:executor-gconfig && npm run test:project-store && npm run test:project-runtime",
"test:webview-html": "node scripts/test_webview_html.js",
"test:task-launcher": "node scripts/test_task_launcher_subconfigs.js",
"test:console-accountseg": "node scripts/test_console_accountseg.js",
@@ -353,6 +353,7 @@
"test:label-enum-guard": "node scripts/test_label_enum_guard.js",
"test:save-to-assets-flow": "node scripts/test_save_to_assets_flow.js",
"test:coco-feature-path": "node scripts/test_coco_feature_path.js",
+ "test:box-resource": "node scripts/test_box_resource.js",
"test:save-to-assets": "node scripts/test_save_to_assets.js",
"test:asset-pack-worker": "node scripts/test_asset_pack_worker.js",
"test:probe-schemas": "python python/tests/test_probe_pure_group_labels.py",
diff --git a/python/probe_window_config.py b/python/probe_window_config.py
index 127ccee..46cfe77 100644
--- a/python/probe_window_config.py
+++ b/python/probe_window_config.py
@@ -34,6 +34,8 @@
# `os.path.join("assets", "coco_annotations.json")`。
TEMPLATE_MATCHING_SUB_KEYS = ("coco_feature_json",)
TEMPLATE_TAB_SUB_KEYS = ("label_enum_relative_path",)
+# config 顶层的运行时框文件。现有项目还没有这项,缺席时插件探测 src/scene/boxes.json。
+ROOT_BOXES_KEY = "boxes_json"
def _resolve_config_path_from_main(project_dir):
@@ -147,6 +149,19 @@ def _extract_template_matching_keys(config_path):
return _extract_sub_dict(_find_config_dict(tree), "template_matching", TEMPLATE_MATCHING_SUB_KEYS)
+def _extract_root_key(config_path, name):
+ """从顶层 config dict 取出一个键。框文件不放进 template_matching。"""
+ with open(config_path, encoding="utf-8") as f:
+ tree = ast.parse(f.read(), filename=config_path)
+ config_dict = _find_config_dict(tree)
+ if not config_dict:
+ return None
+ for key, value in zip(config_dict.keys, config_dict.values):
+ if isinstance(key, ast.Constant) and key.value == name:
+ return _extract_value(value)
+ return None
+
+
def _extract_template_tab_keys(config_path):
"""Read the enum module path used by the project's own template tab."""
with open(config_path, encoding="utf-8") as f:
@@ -241,6 +256,7 @@ def main():
window_config = {k: _clean(v) for k, v in window_config.items()}
coco_feature_json = _clean(template_matching.get("coco_feature_json"))
label_enum_relative_path = _clean(template_tab.get("label_enum_relative_path"))
+ boxes_json = _clean(_extract_root_key(config_path, ROOT_BOXES_KEY))
print(json.dumps({
"ok": True,
@@ -248,6 +264,7 @@ def main():
**window_config,
"coco_feature_json": coco_feature_json,
"label_enum_relative_path": label_enum_relative_path,
+ "boxes_json": boxes_json,
}, ensure_ascii=False))
sys.exit(0)
diff --git a/python/tests/test_probe_window_config.py b/python/tests/test_probe_window_config.py
index 3655f5b..e79aa8c 100644
--- a/python/tests/test_probe_window_config.py
+++ b/python/tests/test_probe_window_config.py
@@ -154,7 +154,22 @@ def probe(text, at="src/config.py"):
no_config = probe('config = {"windows": {}}\n', at="nothing/here.py")
check(no_config.get("ok") is False, "找不到 config.py 时 ok=false(插件据此走兜底,不报错)")
-# ── 4. 找不到 config.py 时的路径解析 ────────────────────────────────
+# ── 4. 运行时框文件是顶层 boxes_json,不进 template_matching ────────
+print("\nboxes_json")
+
+boxes = probe(
+ 'import os\n'
+ 'config = {\n'
+ ' "boxes_json": os.path.join("src", "scene", "boxes.json"),\n'
+ ' "template_matching": {"coco_feature_json": "assets/coco_annotations.json"},\n'
+ '}\n'
+)
+check(boxes.get("boxes_json") == "src/scene/boxes.json", "顶层 boxes_json 用 os.path.join 能静态求值")
+check(boxes.get("coco_feature_json") == "assets/coco_annotations.json", "读框路径时模板库路径仍在")
+absent_boxes = probe('config = {"template_matching": {"coco_feature_json": "assets/c.json"}}\n')
+check(absent_boxes.get("boxes_json") is None, "没写 boxes_json 时输出 null,插件退回 src/scene/boxes.json")
+
+# ── 5. 找不到 config.py 时的路径解析 ────────────────────────────────
print("\n找不到 config.py")
empty_dir = make_tmp_dir("ok-probe-empty")
diff --git a/schemas/ok-script-toolkit.schema.json b/schemas/ok-script-toolkit.schema.json
index 39a59f5..e193b3b 100644
--- a/schemas/ok-script-toolkit.schema.json
+++ b/schemas/ok-script-toolkit.schema.json
@@ -125,6 +125,18 @@
}
},
+ "boxes": {
+ "type": "object",
+ "additionalProperties": false,
+ "description": "框资源。标注工作文件固定是 `
/boxes.json`,不由本组控制。本组只声明游戏和补全读取的那份运行时位置表。见 docs/box-resources.md。",
+ "properties": {
+ "runtime": {
+ "type": "string",
+ "description": "运行时框文件,相对项目根。业务项目的 ScreenPosition 加载它,插件的框管理和补全也读它。\n\n⚠️ 别和 `<模板目录>/boxes.json` 搞混。那是框资源管理的标注工作文件,路径由 `templates.directory` 派生。\n\n没有对应的 IDE 设置。取值链:本字段 → config.py 顶层 `boxes_json` → `src/scene/boxes.json`。通常不必写。"
+ }
+ }
+ },
+
"effects": {
"type": "object",
"additionalProperties": false,
diff --git a/scripts/test_box_resource.js b/scripts/test_box_resource.js
new file mode 100644
index 0000000..af83967
--- /dev/null
+++ b/scripts/test_box_resource.js
@@ -0,0 +1,147 @@
+#!/usr/bin/env node
+/**
+ * 框资源纯契约测试(`src/boxResourcePure.ts`)。
+ *
+ * 钉住和模板两份 COCO 同一类的不变量:
+ * 1. 运行时路径是 约定 > config.py > src/scene/boxes.json,首选存在时不合并探测位置;
+ * 2. 序列化按 path 排序、矩形 6 位小数,改一个框不会重排其余对象;
+ * 3. 像素没变时写回保留原浮点;
+ * 4. 发布丢掉 image,显隐不进资源。
+ */
+const assert = require('assert');
+const path = require('path');
+
+const root = path.resolve(__dirname, '..');
+const pure = require(path.join(root, 'out', 'boxResourcePure'));
+const config = require(path.join(root, 'out', 'projectConfigPure'));
+
+const failures = [];
+function check(condition, message) {
+ if (condition) console.log(` ok ${message}`);
+ else {
+ console.log(` FAIL ${message}`);
+ failures.push(message);
+ }
+}
+
+const ROOT = path.join('X:', 'proj');
+const PROBE = path.join(ROOT, 'src', 'scene', 'boxes.json');
+
+const plan = (declared, fromConfigPy) => pure.resolveBoxRuntimePlan(ROOT, declared, fromConfigPy);
+
+check(plan().layer === 'probe' && plan().preferred === undefined, '都没声明时来源是探测');
+check(plan().probeCandidates.length === 1 && plan().probeCandidates[0] === PROBE, '探测位置是 src/scene/boxes.json');
+check(plan('custom/boxes.json').layer === 'convention', '项目约定 boxes.runtime 生效');
+check(plan(undefined, 'assets/boxes.json').layer === 'configPy', '只有 config.py 的 boxes_json 时来源是 configPy');
+check(plan('custom/boxes.json', 'assets/boxes.json').layer === 'convention', '约定压过 config.py');
+check(plan(' ', ' ').layer === 'probe', '空白声明等同于没写');
+
+const preferred = plan('custom/boxes.json');
+check(
+ pure.effectiveBoxRuntimeFile(preferred, () => true) === path.join(ROOT, 'custom', 'boxes.json'),
+ '首选存在时只用首选,不把探测位置并进来',
+);
+check(pure.effectiveBoxRuntimeFile(plan(), () => false) === undefined, '文件都不存在时读取结果为空');
+check(pure.runtimeWriteTarget(plan()) === PROBE, '未声明时发布目标就是探测位置');
+check(
+ pure.runtimeWriteTarget(preferred) === path.join(ROOT, 'custom', 'boxes.json'),
+ '已声明时发布目标是首选,即使文件还没创建',
+);
+
+check(pure.boxPathError('screen.main_viewport') === undefined, 'screen.main_viewport 合法');
+check(pure.boxPathError('main_viewport') === 'shallow', '少一层 group 不合法');
+check(pure.boxPathError('panels.esc.mail') === 'reserved', 'panels 留给点击点');
+check(pure.boxPathError('screen.bad-name') === 'segment', '非法标识符拒绝');
+check(pure.boxPathError('') === 'empty', '空路径拒绝');
+check(pure.imageFileName('ok_templates/12.png') === '12.png', 'image 只留文件名');
+
+const original = [0.0984, 0.1042, 0.8961, 0.8944];
+const pixel = pure.rectToPixel(original, 2560, 1440);
+const kept = pure.rectForSave(original, pixel, 2560, 1440);
+check(kept === original, '像素没变时保留原浮点数组,打开后保存不会重算');
+const moved = { ...pixel, x: pixel.x + 4 };
+const rewritten = pure.rectForSave(original, moved, 2560, 1440);
+check(rewritten !== original && rewritten[0] !== original[0], '像素变了才重新归一化');
+
+const union = pure.unionOnImage(
+ [{ x: 10, y: 20, w: 30, h: 40 }, { x: 50, y: 10, w: 20, h: 15 }],
+ 100,
+ 100,
+);
+check(
+ union[0] === 0.1 && union[1] === 0.1 && union[2] === 0.7 && union[3] === 0.6,
+ '同图包围框取 min left/top 与 max right/bottom',
+);
+check(pure.unionOnImage([], 100, 100) === undefined, '空列表没有包围框');
+
+const authoring = {
+ version: 1,
+ boxes: [
+ { path: 'screen.main_viewport', image: '12.png', rect: [0.0984, 0.1042, 0.8961, 0.8944] },
+ { path: 'screen.dialog_icon', image: '3.png', rect: [0.845, 0.047, 0.975, 0.074] },
+ ],
+};
+const text = pure.serializeAuthoring(authoring);
+const expected = [
+ '{',
+ ' "version": 1,',
+ ' "boxes": [',
+ ' {',
+ ' "path": "screen.dialog_icon",',
+ ' "image": "3.png",',
+ ' "rect": [0.845000, 0.047000, 0.975000, 0.074000]',
+ ' },',
+ ' {',
+ ' "path": "screen.main_viewport",',
+ ' "image": "12.png",',
+ ' "rect": [0.098400, 0.104200, 0.896100, 0.894400]',
+ ' }',
+ ' ]',
+ '}',
+ '',
+].join('\n');
+check(text === expected, '标注资源按 path 排序,矩形固定 6 位小数');
+
+const parsed = pure.parseAuthoring(text);
+check(parsed.errors.length === 0 && parsed.file.boxes.length === 2, '刚写出的文件能原样读回');
+check(parsed.file.boxes[0].path === 'screen.dialog_icon', '读回后仍按 path 排序');
+
+const runtimeText = pure.serializeRuntime(pure.publishBoxes(parsed.file));
+check(!runtimeText.includes('"image"'), '发布结果不含 image');
+check(runtimeText.includes('"path": "screen.dialog_icon"'), '发布保留 path 和 rect');
+
+const duplicate = pure.parseAuthoring(JSON.stringify({
+ version: 1,
+ boxes: [
+ { path: 'screen.a', image: '1.png', rect: [0, 0, 0.5, 0.5] },
+ { path: 'screen.a', image: '2.png', rect: [0, 0, 0.2, 0.2] },
+ ],
+}));
+check(duplicate.file.boxes.length === 1 && duplicate.file.boxes[0].image === '1.png', '重复 path 保留先出现的那条');
+check(duplicate.errors.some((item) => item.endsWith(':duplicate')), '重复 path 记一条错误');
+
+const bad = pure.parseRuntime('{');
+check(bad.errors[0] === 'json' && bad.file.boxes.length === 0, '坏 JSON 给出空运行时文件而不是抛异常');
+
+const hidden = pure.applyVisibility(['a', 'b'], new Set(), 'hideAll');
+check(hidden.has('a') && hidden.has('b'), '隐藏全部覆盖当前条目');
+const shown = pure.applyVisibility(['a', 'b'], hidden, 'showAll');
+check(shown.size === 0, '显示全部清空隐藏集合');
+const only = pure.applyVisibility(['a', 'b'], shown, 'only', 'b');
+check(only.has('a') && !only.has('b'), '只显示当前留下选中项');
+const toggled = pure.applyVisibility(['a', 'b'], only, 'toggle', 'a');
+check(!toggled.has('a') && !toggled.has('b'), '再勾一次把隐藏去掉');
+check(pure.isAnnotationVisible('b', toggled), '不在隐藏集合里的条目可见');
+
+const statuses = pure.publishStatus(parsed.file, pure.parseRuntime(runtimeText).file);
+check(statuses.every((item) => item.status === 'same'), '刚发布的运行时与标注几何一致');
+
+const declared = config.boxesRuntimeOf({ boxes: { runtime: 'src/scene/boxes.json' } });
+check(declared === 'src/scene/boxes.json', '约定文件的 boxes.runtime 归一化后可读');
+check(config.boxesRuntimeOf({ boxes: { runtime: 42 } }) === undefined, '类型不对当没写');
+
+if (failures.length) {
+ console.error(`\n${failures.length} failed`);
+ process.exit(1);
+}
+console.log('\nbox resource pure ok');
diff --git a/src/boxResourcePure.ts b/src/boxResourcePure.ts
new file mode 100644
index 0000000..1c5315c
--- /dev/null
+++ b/src/boxResourcePure.ts
@@ -0,0 +1,369 @@
+/**
+ * 框资源的纯数据契约。与 JetBrains 侧 `core/BoxResource.kt`、`core/BoxRuntimePath.kt` 一一对应。
+ *
+ * 两份文件,关系对标模板的两份 COCO:
+ *
+ * | 文件 | 是什么 | 谁读写 |
+ * |---|---|---|
+ * | `<模板目录>/boxes.json` | 框标注资源(带原图文件名) | 框资源管理 |
+ * | `src/scene/boxes.json`(或约定 / config.py 指定的别处) | 运行时位置表 | 框管理、补全;业务项目 `ScreenPosition` 加载 |
+ *
+ * 设计见 `docs/box-resources.md`。本模块不读盘、不 import `vscode`。
+ */
+import * as path from 'path';
+
+export const BOX_FILE_VERSION = 1;
+export const BOX_RECT_DECIMALS = 6;
+export const AUTHORING_FILE_NAME = 'boxes.json';
+
+/** 运行时文件的探测位置(相对项目根)。ok-nte 的位置表就在这里。 */
+export const PROBE_BOX_CANDIDATES = ['src/scene/boxes.json'];
+
+/** 留给现有点击点,框路径不能占用这个根。 */
+export const RESERVED_BOX_ROOTS = ['panels'];
+
+export type BoxRect = [number, number, number, number];
+
+export interface AuthoringBox {
+ path: string;
+ image: string;
+ rect: BoxRect;
+}
+
+export interface RuntimeBox {
+ path: string;
+ rect: BoxRect;
+}
+
+export interface AuthoringFile {
+ version: number;
+ boxes: AuthoringBox[];
+}
+
+export interface RuntimeFile {
+ version: number;
+ boxes: RuntimeBox[];
+}
+
+export interface PixelBox {
+ x: number;
+ y: number;
+ w: number;
+ h: number;
+}
+
+export interface BoxParseResult {
+ file: T;
+ errors: string[];
+}
+
+export type BoxRuntimeLayer = 'convention' | 'configPy' | 'probe';
+
+export interface BoxRuntimePlan {
+ preferred?: string;
+ probeCandidates: string[];
+ layer: BoxRuntimeLayer;
+}
+
+const SEGMENT = /^[A-Za-z_][A-Za-z0-9_]*$/;
+
+export function emptyAuthoringFile(): AuthoringFile {
+ return { version: BOX_FILE_VERSION, boxes: [] };
+}
+
+export function emptyRuntimeFile(): RuntimeFile {
+ return { version: BOX_FILE_VERSION, boxes: [] };
+}
+
+/** 标注资源路径。跟随模板目录,与 `<模板目录>/coco_annotations.json` 同一规则。 */
+export function authoringFile(rootDir: string, templatesDirectory: string): string {
+ return path.join(rootDir, templatesDirectory, AUTHORING_FILE_NAME);
+}
+
+function toAbsolute(rootDir: string, value: string): string {
+ return path.isAbsolute(value) ? value : path.join(rootDir, value);
+}
+
+function nonEmpty(value: string | undefined): string | undefined {
+ const trimmed = value?.trim();
+ return trimmed ? trimmed : undefined;
+}
+
+/**
+ * 运行时框文件的取值链,对标 `resolveCocoFeaturePlan`:
+ * 项目约定 `boxes.runtime` > `config.py` 的 `boxes_json` > `src/scene/boxes.json`。
+ */
+export function resolveBoxRuntimePlan(
+ rootDir: string,
+ declared?: string,
+ fromConfigPy?: string,
+): BoxRuntimePlan {
+ const probeCandidates = PROBE_BOX_CANDIDATES.map((rel) => path.join(rootDir, rel));
+ const declaredPath = nonEmpty(declared);
+ if (declaredPath) {
+ return { preferred: toAbsolute(rootDir, declaredPath), probeCandidates, layer: 'convention' };
+ }
+ const fromPy = nonEmpty(fromConfigPy);
+ if (fromPy) {
+ return { preferred: toAbsolute(rootDir, fromPy), probeCandidates, layer: 'configPy' };
+ }
+ return { probeCandidates, layer: 'probe' };
+}
+
+/** 首选存在时只用首选。都不存在时返回空,调用方改用 [runtimeWriteTarget] 创建。 */
+export function effectiveBoxRuntimeFile(plan: BoxRuntimePlan, exists: (file: string) => boolean): string | undefined {
+ if (plan.preferred && exists(plan.preferred)) return plan.preferred;
+ return plan.probeCandidates.find(exists);
+}
+
+/** 发布时要写入的那个文件:已声明的首选,否则探测位置。文件可以尚不存在。 */
+export function runtimeWriteTarget(plan: BoxRuntimePlan): string {
+ return plan.preferred ?? plan.probeCandidates[0];
+}
+
+export function boxRuntimeRelPaths(plan: BoxRuntimePlan, rootDir: string): string[] {
+ const all = plan.preferred ? [plan.preferred, ...plan.probeCandidates] : plan.probeCandidates;
+ return [...new Set(all)]
+ .map((abs) => path.relative(rootDir, abs).replace(/\\/g, '/'))
+ .filter((rel) => rel.length > 0 && !rel.startsWith('..') && !path.isAbsolute(rel));
+}
+
+/** 合法返回 `undefined`。否则返回稳定错误码,供界面翻译。 */
+export function boxPathError(value: string): 'empty' | 'segment' | 'shallow' | 'reserved' | undefined {
+ const pathValue = value.trim();
+ if (!pathValue) return 'empty';
+ const segments = pathValue.split('.');
+ if (segments.length < 2) return 'shallow';
+ if (segments.some((segment) => !SEGMENT.test(segment))) return 'segment';
+ if (RESERVED_BOX_ROOTS.includes(segments[0])) return 'reserved';
+ return undefined;
+}
+
+export function imageFileName(value: string): string {
+ const normalized = value.replace(/\\/g, '/').split('/').pop()?.trim() ?? '';
+ return normalized;
+}
+
+export function rectToPixel(rect: BoxRect, width: number, height: number): PixelBox | undefined {
+ if (width <= 0 || height <= 0) return undefined;
+ const [left, top, right, bottom] = rect;
+ const x = Math.round(left * width);
+ const y = Math.round(top * height);
+ const w = Math.round((right - left) * width);
+ const h = Math.round((bottom - top) * height);
+ if (w <= 0 || h <= 0) return undefined;
+ return { x, y, w, h };
+}
+
+export function pixelToRect(box: PixelBox, width: number, height: number): BoxRect | undefined {
+ if (width <= 0 || height <= 0 || box.w <= 0 || box.h <= 0) return undefined;
+ return [box.x / width, box.y / height, (box.x + box.w) / width, (box.y + box.h) / height];
+}
+
+export function samePixel(a: PixelBox, b: PixelBox): boolean {
+ return a.x === b.x && a.y === b.y && a.w === b.w && a.h === b.h;
+}
+
+/**
+ * 写回标注资源时用。像素框没变就保留原来的浮点,避免「打开后直接保存」把文件写脏。
+ * 变过才从像素重新归一化。
+ */
+export function rectForSave(
+ original: BoxRect | undefined,
+ pixel: PixelBox,
+ width: number,
+ height: number,
+): BoxRect | undefined {
+ if (original) {
+ const quantized = rectToPixel(original, width, height);
+ if (quantized && samePixel(quantized, pixel)) return original;
+ }
+ return pixelToRect(pixel, width, height);
+}
+
+/** 同一张原图上的像素框取最小包围矩形,再归一化。空列表返回 `undefined`。 */
+export function unionOnImage(boxes: readonly PixelBox[], width: number, height: number): BoxRect | undefined {
+ if (boxes.length === 0) return undefined;
+ let left = Infinity;
+ let top = Infinity;
+ let right = -Infinity;
+ let bottom = -Infinity;
+ for (const box of boxes) {
+ left = Math.min(left, box.x);
+ top = Math.min(top, box.y);
+ right = Math.max(right, box.x + box.w);
+ bottom = Math.max(bottom, box.y + box.h);
+ }
+ return pixelToRect({ x: left, y: top, w: right - left, h: bottom - top }, width, height);
+}
+
+export function publishBoxes(file: AuthoringFile): RuntimeFile {
+ return {
+ version: BOX_FILE_VERSION,
+ boxes: file.boxes.map((box) => ({ path: box.path, rect: [...box.rect] as BoxRect })),
+ };
+}
+
+export type PublishStatus = 'same' | 'unpublished' | 'runtimeOnly';
+
+export function publishStatus(authoring: AuthoringFile, runtime: RuntimeFile): Array<{ path: string; status: PublishStatus }> {
+ const runtimeByPath = new Map(runtime.boxes.map((box) => [box.path, box]));
+ const seen = new Set();
+ const result: Array<{ path: string; status: PublishStatus }> = [];
+ for (const box of authoring.boxes) {
+ seen.add(box.path);
+ const published = runtimeByPath.get(box.path);
+ const same = published !== undefined && sameRect(published.rect, box.rect);
+ result.push({ path: box.path, status: same ? 'same' : 'unpublished' });
+ }
+ for (const box of runtime.boxes) {
+ if (!seen.has(box.path)) result.push({ path: box.path, status: 'runtimeOnly' });
+ }
+ return result;
+}
+
+function sameRect(a: BoxRect, b: BoxRect): boolean {
+ return a.length === b.length && a.every((value, index) => value === b[index]);
+}
+
+export function applyVisibility(
+ ids: readonly string[],
+ hidden: ReadonlySet,
+ action: 'showAll' | 'hideAll' | 'only' | 'toggle',
+ target?: string,
+): Set {
+ if (action === 'showAll') return new Set();
+ if (action === 'hideAll') return new Set(ids);
+ if (action === 'only') {
+ const next = new Set(ids);
+ if (target !== undefined) next.delete(target);
+ return next;
+ }
+ const live = new Set(ids);
+ const next = new Set([...hidden].filter((id) => live.has(id)));
+ if (target === undefined || !live.has(target)) return next;
+ if (next.has(target)) next.delete(target);
+ else next.add(target);
+ return next;
+}
+
+export function isAnnotationVisible(id: string, hidden: ReadonlySet): boolean {
+ return !hidden.has(id);
+}
+
+export function parseAuthoring(text: string): BoxParseResult {
+ return parseBoxes(text, true);
+}
+
+export function parseRuntime(text: string): BoxParseResult {
+ const parsed = parseBoxes(text, false);
+ return {
+ file: { version: parsed.file.version, boxes: parsed.file.boxes.map(({ path: boxPath, rect }) => ({ path: boxPath, rect })) },
+ errors: parsed.errors,
+ };
+}
+
+export function serializeAuthoring(file: AuthoringFile): string {
+ const boxes = uniqueAuthoring(file.boxes);
+ const body = boxes.map((box) =>
+ [
+ ' {',
+ ` "path": ${JSON.stringify(box.path)},`,
+ ` "image": ${JSON.stringify(box.image)},`,
+ ` "rect": [${box.rect.map(formatRectNumber).join(', ')}]`,
+ ' }',
+ ].join('\n'),
+ ).join(',\n');
+ return `{\n "version": ${BOX_FILE_VERSION},\n "boxes": [${body ? `\n${body}\n ` : ''}]\n}\n`;
+}
+
+export function serializeRuntime(file: RuntimeFile): string {
+ const boxes = uniqueRuntime(file.boxes);
+ const body = boxes.map((box) =>
+ [
+ ' {',
+ ` "path": ${JSON.stringify(box.path)},`,
+ ` "rect": [${box.rect.map(formatRectNumber).join(', ')}]`,
+ ' }',
+ ].join('\n'),
+ ).join(',\n');
+ return `{\n "version": ${BOX_FILE_VERSION},\n "boxes": [${body ? `\n${body}\n ` : ''}]\n}\n`;
+}
+
+function parseBoxes(text: string, requireImage: boolean): BoxParseResult {
+ let raw: unknown;
+ try {
+ raw = JSON.parse(text);
+ } catch {
+ return { file: emptyAuthoringFile(), errors: ['json'] };
+ }
+ if (!raw || typeof raw !== 'object' || Array.isArray(raw)) {
+ return { file: emptyAuthoringFile(), errors: ['root'] };
+ }
+ const record = raw as { version?: unknown; boxes?: unknown };
+ const errors: string[] = [];
+ if (record.version !== BOX_FILE_VERSION) errors.push('version');
+ if (!Array.isArray(record.boxes)) {
+ errors.push('boxes');
+ return { file: emptyAuthoringFile(), errors };
+ }
+ const boxes: AuthoringBox[] = [];
+ const seen = new Set();
+ record.boxes.forEach((entry, index) => {
+ const parsed = parseBoxEntry(entry, requireImage);
+ if (!parsed.box) {
+ errors.push(`${index}:${parsed.error}`);
+ return;
+ }
+ if (seen.has(parsed.box.path)) {
+ errors.push(`${index}:duplicate`);
+ return;
+ }
+ seen.add(parsed.box.path);
+ boxes.push(parsed.box);
+ });
+ boxes.sort((a, b) => (a.path < b.path ? -1 : a.path > b.path ? 1 : 0));
+ return { file: { version: BOX_FILE_VERSION, boxes }, errors };
+}
+
+function parseBoxEntry(entry: unknown, requireImage: boolean): { box?: AuthoringBox; error?: string } {
+ if (!entry || typeof entry !== 'object' || Array.isArray(entry)) return { error: 'entry' };
+ const record = entry as { path?: unknown; image?: unknown; rect?: unknown };
+ if (typeof record.path !== 'string' || boxPathError(record.path)) return { error: 'path' };
+ const image = typeof record.image === 'string' ? imageFileName(record.image) : '';
+ if (requireImage && !image) return { error: 'image' };
+ const rect = parseRect(record.rect);
+ if (!rect) return { error: 'rect' };
+ return { box: { path: record.path.trim(), image, rect } };
+}
+
+function parseRect(value: unknown): BoxRect | undefined {
+ if (!Array.isArray(value) || value.length !== 4) return undefined;
+ if (!value.every((item) => typeof item === 'number' && Number.isFinite(item))) return undefined;
+ const rect = value as BoxRect;
+ const [left, top, right, bottom] = rect;
+ if (left < 0 || top < 0 || right > 1 || bottom > 1) return undefined;
+ if (left >= right || top >= bottom) return undefined;
+ return [left, top, right, bottom];
+}
+
+function uniqueAuthoring(boxes: readonly AuthoringBox[]): AuthoringBox[] {
+ const seen = new Set();
+ return [...boxes]
+ .filter((box) => {
+ if (seen.has(box.path)) return false;
+ seen.add(box.path);
+ return true;
+ })
+ .sort((a, b) => (a.path < b.path ? -1 : a.path > b.path ? 1 : 0));
+}
+
+function uniqueRuntime(boxes: readonly RuntimeBox[]): RuntimeBox[] {
+ return uniqueAuthoring(boxes.map((box) => ({ ...box, image: '' }))).map(({ path: boxPath, rect }) => ({ path: boxPath, rect }));
+}
+
+function formatRectNumber(value: number): string {
+ const rounded = Math.round(value * 1e6) / 1e6;
+ const normalized = Object.is(rounded, -0) ? 0 : rounded;
+ return normalized.toFixed(BOX_RECT_DECIMALS);
+}
diff --git a/src/localization.ts b/src/localization.ts
index ab45cad..a5c0868 100644
--- a/src/localization.ts
+++ b/src/localization.ts
@@ -382,6 +382,11 @@ const ZH_CN: WebviewStrings = {
skillTypeTalent: '天赋',
skillTypePotential: '潜能',
noWorkspaceFolder: '未打开工作区',
+ annotationListTitle: '标注',
+ showAllAnnotations: '显示全部',
+ hideAllAnnotations: '隐藏全部',
+ showOnlyCurrent: '只显示当前',
+ annotationListEmpty: '没有标注',
};
const EN: WebviewStrings = {
@@ -499,6 +504,11 @@ const EN: WebviewStrings = {
accountStoreSeeOutput: 'See the "ok-script Console" output channel for details.',
accountReadFailed: 'Store file could not be parsed (see executor output).',
overriddenTasksLabel: 'Overridden tasks',
+ annotationListTitle: 'Annotations',
+ showAllAnnotations: 'Show all',
+ hideAllAnnotations: 'Hide all',
+ showOnlyCurrent: 'Only current',
+ annotationListEmpty: 'No annotations',
};
const ZH_TW: WebviewStrings = {
@@ -586,6 +596,11 @@ const ZH_TW: WebviewStrings = {
assetSwapEmpty: '沒有其他圖片可以交換',
assetSwapBoxes: '{count} 個標註',
assetSwapNoBoxes: '無標註',
+ annotationListTitle: '標註',
+ showAllAnnotations: '顯示全部',
+ hideAllAnnotations: '隱藏全部',
+ showOnlyCurrent: '只顯示目前',
+ annotationListEmpty: '沒有標註',
refresh: '重新整理', open: '開啟', add: '新增', modify: '修改', delete: '刪除', cancel: '取消', save: '儲存', copy: '複製', close: '關閉', search: '搜尋', loading: '載入中…', none: '無', current: '目前', enabled: '開啟', disabled: '關閉', error: '錯誤', warning: '警告', info: '資訊',
charactersTitle: '角色技能管理', charactersTab: '角色與技能', effectsTab: '效果索引', localesTab: '名稱本地化', issuesTab: '資料診斷', searchCharacters: '搜尋角色、技能、效果、描述…', allStars: '全部星級', allElements: '全部元素', allProfessions: '全部職業', allSkillTypes: '全部技能類型', enhancementOnly: '僅顯示含強化組的角色', issueOnly: '僅顯示存在診斷的角色', characterCount: '{shown} / {total} 個角色', skillsEnhancements: '技能 / 強化', noCharacters: '沒有符合的角色', selectCharacter: '從左側選擇角色', openCharacterJson: '開啟角色 JSON', skillsAndEnhancements: '技能與強化效果', addSkill: '新增技能', modifySkill: '修改技能', addEnhancement: '新增強化組', modifyEnhancement: '修改強化組', baseEffects: '基礎效果', triggerEffects: '觸發依賴效果', outputEffects: '強化產出效果', visiblePulse: '可見脈衝', searchEffects: '搜尋效果 ID、描述、角色或技能…', allCategories: '全部分類', allEffects: '全部效果', usedOnly: '僅已引用', unusedOnly: '僅未引用', unknownOnly: '僅未知', addEffectCategory: '新增分類', addEffect: '新增效果', localizationMatrix: '角色名稱多語言矩陣', localizationHint: '空白儲存格表示缺失,可點擊角色開啟語言來源檔案。', missing: '缺失', searchIssues: '搜尋診斷資訊…', allSeverities: '全部級別', openSource: '開啟來源檔案', readingCharacterData: '正在讀取角色、技能、效果與多語言資料…', unknownCurrentEffect: '目前資料中的未知效果', inferredFromTriggerText: '由觸發文字推斷', clickCopySkillId: '點擊複製技能 ID', clickCopyEffectId: '點擊複製效果 ID', openCharacterLocaleFile: '開啟角色名稱語言檔案', loadFailed: '載入失敗', saveFailed: '儲存失敗', charactersMetric: '角色', skillsMetric: '技能', enhancementsMetric: '強化組', effectReferencesMetric: '效果引用', effectDefinitionsMetric: '效果定義', valueLabel: '值', taskTitle: 'ok-script 任務啟動', noTasks: '找不到任務。\n請先在設定中配置專案路徑。', parameters: '參數', launch: '啟動', stop: '停止', pause: '暫停', resume: '恢復', running: '執行中…', toolboxOpenCharacterManager: '開啟角色技能管理面板', templatesSearch: '搜尋模板名稱…', templatesTitle: '模板面板', noTemplates: '找不到任何模板。', noTemplatesWithHint: '找不到任何模板。\n請確認工作區存在 assets/coco_annotations.json\n(或 ok_tasks/assets/coco_annotations.json)。', thumbnailStats: '已載入 {loaded} 個縮圖', thumbnailStatsWithFailures: '已載入 {loaded} 個縮圖 / {failed} 個失敗', thumbnailLoadFailed: '縮圖載入失敗', templateSize: '尺寸:{width}×{height}', templateSource: '來源:{path}',
unknownStar: '星級未定', unknownElement: '元素未定', unknownProfession: '職業未定', unknownWeapon: '武器未定', skillsCount: '{count} 個技能', enhancementsCount: '{count} 個強化組', syncedSkillLocked: '同步技能 · ID/名稱/類型/元素/描述已鎖定', enhancedState: '強化態', multiplier: '倍率', stagger: '失衡', cooldown: '冷卻', spiritCost: '技力', noSkills: '此角色尚無技能資料', effectsCount: '{shown} / {total} 個效果', noEffects: '沒有符合的效果', undefinedEffect: '未定義效果', openDefinition: '開啟定義', moreUsages: '另有 {count} 處引用', characterIdColumn: '角色 / ID', issuesCount: '{shown} / {total} 條診斷', noIssuesMatch: '沒有符合的診斷', noIssues: '未發現資料問題', skillId: '技能 ID', skillName: '技能名稱', skillType: '技能類型', element: '元素', damageMultiplier: '傷害倍率', staggerValue: '失衡值', cooldownField: '冷卻', spiritCostField: '技力消耗', skillDescription: '技能描述', baseEffectsMulti: '基礎效果(可多選)', enhancementName: '強化組名稱', visibleMarker: '顯示標記', triggerText: '觸發條件文字', enhancementDescription: '強化效果說明', triggerEffectsMulti: '觸發依賴效果(可多選)', outputEffectsMulti: '強化產出效果(可多選)', effectCategory: '效果分類', effectId: '效果 ID(大寫底線)', effectDescription: '效果描述', categoryName: '分類名稱', effectMultiHint: '按住 Ctrl / Cmd 可多選;選項來自 effects.py,並依效果分類分組。', selectedTriggerEffects: '已選擇 {count} 個觸發依賴效果', noTriggerEffects: '未選擇觸發依賴效果', noSelectedEffects: '未選擇效果', unknownEffect: '未知效果', confirmDelete: '確定要刪除「{name}」嗎?\n儲存前會自動建立 .bak 備份。', copied: '已複製:{text}', collapseParameters: '收合參數', oneTimeTask: '一次性', triggerTask: '觸發任務', enableTrigger: '啟用', triggerDisabled: '未啟用', triggerArmed: '已啟用', triggerEnqueued: '已入列', triggerPolling: '輪詢中', taskQueued: '排隊中', taskRunning: '執行中', executorIdle: '執行器未啟動', executorConnecting: '執行器啟動中…', executorRunning: '執行器執行中 · {count} 個觸發任務已入列', executorPaused: '已暫停(輪詢與任務皆暫停)', startExecutor: '啟動執行器', stopExecutor: '關閉執行器', stopCurrent: '停止目前任務', configGroup: '設定分組', commonParameters: '通用參數', groupParameters: '分組參數', childTaskConfig: '子任務設定', otherParameters: '其他參數', launchSettings: '啟動設定', saved: '已自動儲存', reset: '重設', noConfigParameters: '目前沒有可設定參數(schema 尚未就緒或任務沒有 default_config)。', schemaFailed: '此任務 schema 收集失敗,無法自動產生表單:{error}', holdCtrlMulti: '按住 Ctrl 多選', currentValue: '{value}(目前)', selectedOptionsHint: '可選值:{values}', structuredJsonHint: 'JSON 陣列;支援條件物件與動作序列。', debugOverlay: '除錯浮層', debugOverlayHint: '任務啟動時自動沿用;任務執行中可即時開關。開啟後 draw_boxes 的識別框會繪製到遊戲視窗上(GDI 浮層)。', toolboxGameSection: '遊戲連線', toolboxConnectGame: '連線遊戲', toolboxDisconnect: '中斷', toolboxGameConnected: '已連線:{title}(PID {pid})', toolboxGameNotConnected: '未連線遊戲。連線後任務啟動將優先複用該視窗;遊戲未執行時會自動啟動。', availableOptions: '可用選項', clickOptionToAdd: '點擊選項新增。', selectedOptions: '已選清單', searchOptions: '搜尋選項…', moveUp: '上移', moveDown: '下移', removeItem: '移除', confirm: '確認', addValue: '新增值', taskPaused: '任務已暫停,點擊恢復繼續執行', taskResumed: '任務已恢復執行', stopping: '正在停止任務…', taskStopped: '任務已停止,詳見輸出面板', taskCompleted: '任務完成,詳見輸出面板', taskFailed: '任務異常結束,詳見輸出面板', templatesHint: '單擊=插入 · 雙擊=複製 · 將游標移到縮圖並點擊 👁 檢視原圖', templatesCount: '{shown}/{total} 個模板', noTemplateMatch: '沒有符合「{query}」的模板。', viewOriginal: '檢視原圖(標示位置)',
diff --git a/src/projectConfig.ts b/src/projectConfig.ts
index 5cd746d..a7c0985 100644
--- a/src/projectConfig.ts
+++ b/src/projectConfig.ts
@@ -30,6 +30,7 @@ import {
labelEnumPathInputError,
labelEnumPathResolved,
normalizeLabelEnumFile,
+ boxesRuntimeOf,
normalizeRelPath,
parseProjectConfig,
templatesDirectoryOf,
@@ -390,3 +391,13 @@ export function labelEnumClassName(filePath: string, projectDir?: string, scope?
export function templatesCocoAnnotationsSetting(projectDir?: string): string | undefined {
return normalizeRelPath(templatesOf(loadProjectConfig(projectDir)).cocoAnnotations);
}
+
+/**
+ * 运行时框文件的项目约定(`boxes.runtime`),已归一化。没声明返回 `undefined`。
+ *
+ * 它指向业务项目加载的那份位置表(`config.py` 的 `boxes_json`,缺省 `src/scene/boxes.json`),
+ * **不是**框资源管理自己的 `<模板目录>/boxes.json`。见 `docs/box-resources.md`。
+ */
+export function boxesRuntimeSetting(projectDir?: string): string | undefined {
+ return boxesRuntimeOf(loadProjectConfig(projectDir));
+}
diff --git a/src/projectConfigPure.ts b/src/projectConfigPure.ts
index 9e08f83..410a47e 100644
--- a/src/projectConfigPure.ts
+++ b/src/projectConfigPure.ts
@@ -25,6 +25,14 @@ export interface ProjectTemplates {
cocoAnnotations?: string;
}
+export interface ProjectBoxes {
+ /**
+ * 运行时框文件,相对项目根。
+ * 不是 `<模板目录>/boxes.json`(那份由 templates.directory 派生)。
+ */
+ runtime?: string;
+}
+
export interface ProjectConfig {
labelEnum?: ProjectLabelEnum;
executor?: {
@@ -34,6 +42,7 @@ export interface ProjectConfig {
};
};
templates?: ProjectTemplates;
+ boxes?: ProjectBoxes;
i18n?: ProjectI18n;
characters?: ProjectCharacters;
effects?: ProjectEffects;
@@ -151,6 +160,20 @@ export function templatesOf(config: ProjectConfig): ProjectTemplates {
return value && typeof value === 'object' && !Array.isArray(value) ? value : {};
}
+/** `boxes` 一组(保证是对象)。 */
+export function boxesOf(config: ProjectConfig): ProjectBoxes {
+ const value = config.boxes;
+ return value && typeof value === 'object' && !Array.isArray(value) ? value : {};
+}
+
+/**
+ * 运行时框文件的项目约定(`boxes.runtime`),已归一化。没声明或类型不对返回 `undefined`。
+ * 没有个人偏好层。链的后半段在 `boxResourcePure.resolveBoxRuntimePlan`。
+ */
+export function boxesRuntimeOf(config: ProjectConfig): string | undefined {
+ return normalizeRelPath(boxesOf(config).runtime);
+}
+
/** `i18n` 一组(保证是对象)。 */
export function i18nOf(config: ProjectConfig): ProjectI18n {
const value = config.i18n;
diff --git a/src/screenshotCapture.ts b/src/screenshotCapture.ts
index f0230af..065c4b6 100644
--- a/src/screenshotCapture.ts
+++ b/src/screenshotCapture.ts
@@ -18,6 +18,11 @@ export interface WindowConfig {
* 是两个不同的文件。
*/
cocoFeatureJson?: string;
+ /**
+ * `config.py` 顶层 `boxes_json` —— 运行时框文件。
+ * 和 `<模板目录>/boxes.json` 不是同一个文件。见 `docs/box-resources.md`。
+ */
+ boxesJson?: string;
/** Project template tab's enum module path, without a required .py suffix. */
labelEnumRelativePath?: string;
}
@@ -33,6 +38,7 @@ interface ProbeWindowConfigResult {
top_hwnd_class?: string;
coco_feature_json?: string | null;
label_enum_relative_path?: string | null;
+ boxes_json?: string | null;
}
/** 读取 okScriptToolkit 扩展配置中的项目路径和 Python 解释器 */
@@ -80,6 +86,7 @@ export async function probeWindowConfig(projectDir: string, pythonPath: string):
// 探针解不出来时给的是 `null`(AST 里掺了变量),这里归一成 `undefined`
cocoFeatureJson: parsed.coco_feature_json ?? undefined,
labelEnumRelativePath: parsed.label_enum_relative_path ?? undefined,
+ boxesJson: parsed.boxes_json ?? undefined,
};
}
return undefined;
From c82b6b0a9f7df43c6872cd17499c2bdd7f0bb64f Mon Sep 17 00:00:00 2001
From: AliceJump <149395013+AliceJump@users.noreply.github.com>
Date: Tue, 29 Sep 2026 03:04:52 +0800
Subject: [PATCH 02/19] fix(annotations): select a box from its name and nudge
it with arrows
The checkbox only toggles visibility. Point the JetBrains submodule at the matching editor fix.
---
jetbrains | 2 +-
media/annotationPanel/app.js | 52 ++++++++++++++++++++++++++-------
media/annotationPanel/style.css | 9 ++++++
3 files changed, 51 insertions(+), 12 deletions(-)
diff --git a/jetbrains b/jetbrains
index eae80a0..bd69fa6 160000
--- a/jetbrains
+++ b/jetbrains
@@ -1 +1 @@
-Subproject commit eae80a05839bb4f3c1d139d17d5ef0859789a2e7
+Subproject commit bd69fa6922af6a46d3d988534fee4b800191448b
diff --git a/media/annotationPanel/app.js b/media/annotationPanel/app.js
index 5e9003e..25ab7f4 100644
--- a/media/annotationPanel/app.js
+++ b/media/annotationPanel/app.js
@@ -257,24 +257,48 @@
return;
}
annotations.forEach((ann, index) => {
- const label = document.createElement('label');
- label.className = 'annotation-row' + (index === selectedIdx ? ' is-selected' : '');
+ const row = document.createElement('div');
+ row.className = 'annotation-row' + (index === selectedIdx ? ' is-selected' : '');
const input = document.createElement('input');
input.type = 'checkbox';
input.checked = !hidden.has(ann.category);
+ input.addEventListener('click', (event) => event.stopPropagation());
input.addEventListener('change', () => {
- selectedIdx = index;
if (input.checked) hidden.delete(ann.category);
else hidden.add(ann.category);
listSignature = '';
paint();
});
const text = document.createElement('span');
+ text.className = 'annotation-name';
text.textContent = ann.category;
- label.append(input, text);
- rows.append(label);
+ text.addEventListener('click', () => {
+ selectedIdx = index;
+ listSignature = '';
+ paint();
+ });
+ row.append(input, text);
+ rows.append(row);
});
}
+ function nudgeSelected(dx, dy) {
+ const ann = annotations[selectedIdx];
+ if (!ann || !isShown(ann)) return false;
+ let nx = ann.x + dx;
+ let ny = ann.y + dy;
+ if (img) {
+ nx = Math.max(0, Math.min(nx, img.width - ann.w));
+ ny = Math.max(0, Math.min(ny, img.height - ann.h));
+ }
+ if (nx === ann.x && ny === ann.y) return true;
+ pushUndo();
+ ann.x = nx;
+ ann.y = ny;
+ saveAnnotations();
+ paint();
+ updateUndoRedoButtons();
+ return true;
+ }
function showAllAnnotations() {
hiddenSet().clear();
listSignature = '';
@@ -1075,12 +1099,18 @@
} else if (matchKeybinding(e, keybindings.deleteSelected) && selectedIdx >= 0 && mode === 'none') {
// 删除选中
deleteSelected();
- } else if (matchKeybinding(e, keybindings.prevImage)) {
- // 上一张
- navigate(-1);
- } else if (matchKeybinding(e, keybindings.nextImage)) {
- // 下一张
- navigate(1);
+ } else if (e.key === 'ArrowLeft' || e.key === 'ArrowRight' || e.key === 'ArrowUp' || e.key === 'ArrowDown') {
+ if (selectedIdx >= 0 && mode === 'none' && !e.ctrlKey && !e.metaKey && !e.altKey) {
+ const step = e.shiftKey ? 10 : 1;
+ const dx = e.key === 'ArrowLeft' ? -step : e.key === 'ArrowRight' ? step : 0;
+ const dy = e.key === 'ArrowUp' ? -step : e.key === 'ArrowDown' ? step : 0;
+ if (nudgeSelected(dx, dy)) {
+ e.preventDefault();
+ return;
+ }
+ }
+ if (matchKeybinding(e, keybindings.prevImage)) navigate(-1);
+ else if (matchKeybinding(e, keybindings.nextImage)) navigate(1);
}
});
diff --git a/media/annotationPanel/style.css b/media/annotationPanel/style.css
index 1b205b1..4b3226b 100644
--- a/media/annotationPanel/style.css
+++ b/media/annotationPanel/style.css
@@ -80,6 +80,15 @@ body {
font-weight: var(--weight-medium);
}
+.annotation-name {
+ flex: 1;
+ min-width: 0;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+ cursor: pointer;
+}
+
canvas {
display: block;
width: 100%;
From c6df741e6ef29cbdba2540e7a7adb35a648fa78d Mon Sep 17 00:00:00 2001
From: AliceJump <149395013+AliceJump@users.noreply.github.com>
Date: Tue, 29 Sep 2026 03:34:13 +0800
Subject: [PATCH 03/19] feat(boxes): add box asset and gallery views
The same template images are edited into an authoring file, published to the runtime file, and offered as self.pos completion with a cropped preview.
---
jetbrains | 2 +-
media/annotationPanel/app.js | 66 ++++++++
media/annotationPanel/index.html | 16 ++
media/boxPanel/app.js | 36 +++++
media/boxPanel/index.html | 21 +++
media/boxPanel/style.css | 13 ++
package.json | 12 ++
package.nls.es.json | 4 +
package.nls.ja.json | 4 +
package.nls.json | 4 +
package.nls.ko.json | 4 +
package.nls.zh-cn.json | 4 +
package.nls.zh-tw.json | 4 +
src/annotationPanel.ts | 24 ++-
src/boxPanels.ts | 252 +++++++++++++++++++++++++++++++
src/boxResourceStore.ts | 139 +++++++++++++++++
src/cocoFeaturePath.ts | 9 +-
src/extension.ts | 11 +-
src/localization.ts | 27 ++++
src/providers.ts | 36 +++++
20 files changed, 683 insertions(+), 5 deletions(-)
create mode 100644 media/boxPanel/app.js
create mode 100644 media/boxPanel/index.html
create mode 100644 media/boxPanel/style.css
create mode 100644 src/boxPanels.ts
create mode 100644 src/boxResourceStore.ts
diff --git a/jetbrains b/jetbrains
index bd69fa6..852b329 160000
--- a/jetbrains
+++ b/jetbrains
@@ -1 +1 @@
-Subproject commit bd69fa6922af6a46d3d988534fee4b800191448b
+Subproject commit 852b3295d0c94cd934a7e825683162fc6125e692
diff --git a/media/annotationPanel/app.js b/media/annotationPanel/app.js
index 25ab7f4..60bfdd3 100644
--- a/media/annotationPanel/app.js
+++ b/media/annotationPanel/app.js
@@ -26,6 +26,16 @@
document.getElementById('showAllBtn').textContent = t('showAllAnnotations');
document.getElementById('hideAllBtn').textContent = t('hideAllAnnotations');
document.getElementById('onlyCurrentBtn').textContent = t('showOnlyCurrent');
+ const generateBoxBtn = document.getElementById('generateBoxBtn');
+ if (generateBoxBtn) generateBoxBtn.textContent = t('generateBox');
+ const generateTitle = document.getElementById('generateTitle');
+ if (generateTitle) generateTitle.textContent = t('generateBoxTitle');
+ const generatePathLabel = document.getElementById('generatePathLabel');
+ if (generatePathLabel) generatePathLabel.textContent = t('generatePath');
+ const generateCancel = document.getElementById('generateCancel');
+ if (generateCancel) generateCancel.textContent = t('cancel');
+ const generateOk = document.getElementById('generateOk');
+ if (generateOk) generateOk.textContent = t('ok');
// 归一化坐标(x,y,tox,toy)保留的小数位
const COORD_DECIMALS = 4;
@@ -33,6 +43,7 @@
// 状态
let imageData = null; // { imagePath, imageBase64, annotations, allCategories, filename }
+ let boxMode = false;
let img = null; // HTMLImageElement
let scale = 1.0;
let fitScale = 1.0;
@@ -147,6 +158,8 @@
if (showAllBtn) showAllBtn.textContent = t('showAllAnnotations');
if (hideAllBtn) hideAllBtn.textContent = t('hideAllAnnotations');
if (onlyCurrentBtn) onlyCurrentBtn.textContent = t('showOnlyCurrent');
+ const generateBoxBtn = document.getElementById('generateBoxBtn');
+ if (generateBoxBtn) generateBoxBtn.textContent = t('generateBox');
updateUndoRedoButtons();
}
@@ -299,6 +312,30 @@
updateUndoRedoButtons();
return true;
}
+ function openGenerateBox() {
+ const choices = document.getElementById('generateChoices');
+ const error = document.getElementById('generateError');
+ const pathInput = document.getElementById('generatePath');
+ if (!choices || !pathInput) return;
+ while (choices.firstChild) choices.removeChild(choices.firstChild);
+ if (error) error.textContent = '';
+ const selected = annotations[selectedIdx];
+ annotations.forEach((ann, index) => {
+ const label = document.createElement('label');
+ label.className = 'annotation-row';
+ const input = document.createElement('input');
+ input.type = 'checkbox';
+ input.checked = !selected || index === selectedIdx;
+ input.dataset.index = String(index);
+ const text = document.createElement('span');
+ text.textContent = ann.category;
+ label.append(input, text);
+ choices.append(label);
+ });
+ const seed = selected ? selected.category : (annotations[0] ? annotations[0].category : 'region');
+ pathInput.value = 'screen.' + String(seed).replace(/[^A-Za-z0-9_]/g, '_');
+ document.getElementById('generateModal').classList.add('visible');
+ }
function showAllAnnotations() {
hiddenSet().clear();
listSignature = '';
@@ -1132,6 +1169,26 @@
document.getElementById('redoBtn').onclick = () => { redo(); updateUndoRedoButtons(); };
document.getElementById('prevBtn').onclick = () => navigate(-1);
document.getElementById('nextBtn').onclick = () => navigate(1);
+ document.getElementById('generateBoxBtn').onclick = () => openGenerateBox();
+ document.getElementById('generateCancel').onclick = () => {
+ document.getElementById('generateModal').classList.remove('visible');
+ };
+ document.getElementById('generateOk').onclick = () => {
+ const chosen = [];
+ document.querySelectorAll('#generateChoices input').forEach((input) => {
+ if (input.checked) chosen.push(annotations[Number(input.dataset.index)]);
+ });
+ const error = document.getElementById('generateError');
+ if (!chosen.length) {
+ if (error) error.textContent = t('generateNeedSelection');
+ return;
+ }
+ vscode.postMessage({
+ type: 'generateBox',
+ path: document.getElementById('generatePath').value.trim(),
+ boxes: chosen.map((ann) => ({ x: ann.x, y: ann.y, w: ann.w, h: ann.h })),
+ });
+ };
document.getElementById('showAllBtn').onclick = () => showAllAnnotations();
document.getElementById('hideAllBtn').onclick = () => hideAllAnnotations();
document.getElementById('onlyCurrentBtn').onclick = () => showOnlyCurrent();
@@ -1147,9 +1204,18 @@
if (typeof msg.copyCoordsSpace === 'boolean') {
copyCoordsSpace = msg.copyCoordsSpace;
}
+ boxMode = msg.boxMode === true;
+ const generateBoxBtn = document.getElementById('generateBoxBtn');
+ if (generateBoxBtn) generateBoxBtn.style.display = boxMode ? 'none' : '';
updateButtonTexts();
return;
}
+ if (msg.type === 'generateBoxResult') {
+ const error = document.getElementById('generateError');
+ if (msg.ok) document.getElementById('generateModal').classList.remove('visible');
+ else if (error) error.textContent = msg.error || t('generateNeedSelection');
+ return;
+ }
if (msg.type === 'load') {
imageData = msg;
annotations = msg.annotations || [];
diff --git a/media/annotationPanel/index.html b/media/annotationPanel/index.html
index 4da0a77..3c32f20 100644
--- a/media/annotationPanel/index.html
+++ b/media/annotationPanel/index.html
@@ -12,6 +12,7 @@
+
@@ -58,6 +59,21 @@ Bounding Box
+
+
+
Create box
+
+
+
+
+
+
+
+
+
+
+
+
__I18N_JSON__
+
+
+
+
+
+
+ __I18N_JSON__
+ __MODE__
+
+
diff --git a/media/boxPanel/app.js b/media/boxPanel/app.js
new file mode 100644
index 0000000..8bd66c8
--- /dev/null
+++ b/media/boxPanel/app.js
@@ -0,0 +1,36 @@
+(function () {
+ const I18N = JSON.parse(document.getElementById('boxPanelI18n')?.textContent || '{}');
+ const t = (key) => I18N[key] || key;
+ const vscode = acquireVsCodeApi();
+ const mode = (document.getElementById('boxPanelMode')?.textContent || 'assets').trim();
+ document.getElementById('refreshBtn').textContent = t('refresh');
+ const publish = document.getElementById('publishBtn');
+ publish.textContent = t('publish');
+ publish.style.display = mode === 'assets' ? '' : 'none';
+ document.getElementById('hint').textContent = mode === 'assets' ? t('assetsHint') : t('galleryHint');
+ document.getElementById('refreshBtn').onclick = () => vscode.postMessage({ type: 'refresh' });
+ publish.onclick = () => vscode.postMessage({ type: 'publish' });
+
+ window.addEventListener('message', (event) => {
+ const msg = event.data;
+ if (msg.type !== 'rows') return;
+ const rows = document.getElementById('rows');
+ while (rows.firstChild) rows.removeChild(rows.firstChild);
+ (msg.rows || []).forEach((row) => {
+ const button = document.createElement('button');
+ button.type = 'button';
+ button.className = 'box-row';
+ button.textContent = row.label;
+ button.onclick = () => vscode.postMessage({ type: 'activate', id: row.id, clicks: 1 });
+ button.ondblclick = () => vscode.postMessage({ type: 'activate', id: row.id, clicks: 2 });
+ rows.append(button);
+ });
+ if (!(msg.rows || []).length) {
+ const empty = document.createElement('div');
+ empty.className = 'panel-hint';
+ empty.textContent = t(mode === 'assets' ? 'assetsEmpty' : 'galleryEmpty');
+ rows.append(empty);
+ }
+ });
+ vscode.postMessage({ type: 'ready' });
+})();
diff --git a/media/boxPanel/index.html b/media/boxPanel/index.html
new file mode 100644
index 0000000..cb0bd55
--- /dev/null
+++ b/media/boxPanel/index.html
@@ -0,0 +1,21 @@
+
+
+