diff --git a/docs/box-resources.md b/docs/box-resources.md
new file mode 100644
index 0000000..6161688
--- /dev/null
+++ b/docs/box-resources.md
@@ -0,0 +1,175 @@
+# 框资源设计
+
+框管理对标现有模板管理,分成两份资源。标注工作副本只给插件编辑。运行时副本是发布后的位置表:插件的框管理和 `self.pos` 补全读它。游戏进程要等业务项目的 `ScreenPosition` 加载这份文件之后才会用到它;ok 框架现在的模板匹配仍然只读 `template_matching.coco_feature_json`。图片、画布和显隐不另起一套。
+
+本文是实现依据。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..a739d58 160000
--- a/jetbrains
+++ b/jetbrains
@@ -1 +1 @@
-Subproject commit cb16c63efe10a11227463789c8beb91d216f9989
+Subproject commit a739d58e1da7b84c15b1a7e0f01af2dba1d12fa0
diff --git a/l10n/bundle.l10n.es.json b/l10n/bundle.l10n.es.json
index 741a4a3..6625dd2 100644
--- a/l10n/bundle.l10n.es.json
+++ b/l10n/bundle.l10n.es.json
@@ -159,6 +159,7 @@
"Enter game window title pattern (regex), or leave empty for all windows": "Introduce el patrón de título de la ventana (regex), o déjalo vacío para todas las ventanas",
"Failed to add to template assets: {error}": "No se pudo añadir a recursos de plantillas: {error}",
"Failed to delete: {name}": "No se pudo eliminar: {name}",
+ "Deleted the record for {name}, but the temporary file is still at {path}.": "Se eliminó el registro de {name}, pero el archivo temporal sigue en {path}.",
"Failed to save screenshot.": "No se pudo guardar la captura.",
"Import images to {dir}": "Importar imágenes a {dir}",
"Imported {count} image(s)": "Se importaron {count} imagen(es)",
@@ -216,5 +217,8 @@
"Failed to swap annotations.": "No se pudieron intercambiar las anotaciones.",
"Annotations changed while confirming. Retry the swap.": "Las anotaciones cambiaron durante la confirmación. Vuelve a intentar el intercambio.",
"Cannot read image size, so annotations cannot be swapped.": "No se pudo leer el tamaño de la imagen, así que no se pueden intercambiar las anotaciones.",
- "Neither image has annotations, nothing to swap.": "Ninguna de las dos imágenes tiene anotaciones, no hay nada que intercambiar."
+ "Neither image has annotations, nothing to swap.": "Ninguna de las dos imágenes tiene anotaciones, no hay nada que intercambiar.",
+ "Could not save the box resource.": "No se pudo guardar el recurso de cuadros.",
+ "Could not save annotations.": "No se pudieron guardar las anotaciones.",
+ "Publish": "Publicar"
}
diff --git a/l10n/bundle.l10n.ja.json b/l10n/bundle.l10n.ja.json
index 638a2b4..b72566f 100644
--- a/l10n/bundle.l10n.ja.json
+++ b/l10n/bundle.l10n.ja.json
@@ -159,6 +159,7 @@
"Enter game window title pattern (regex), or leave empty for all windows": "ゲームウィンドウのタイトル正規表現を入力(空欄ですべてのウィンドウ)",
"Failed to add to template assets: {error}": "テンプレートアセットへの追加に失敗: {error}",
"Failed to delete: {name}": "削除に失敗: {name}",
+ "Deleted the record for {name}, but the temporary file is still at {path}.": "{name} の記録は削除しましたが、一時ファイルが {path} に残っています。",
"Failed to save screenshot.": "スクリーンショットの保存に失敗しました。",
"Import images to {dir}": "{dir} に画像を読み込む",
"Imported {count} image(s)": "{count} 件の画像を読み込みました",
@@ -216,5 +217,8 @@
"Failed to swap annotations.": "アノテーションの交換に失敗しました。",
"Annotations changed while confirming. Retry the swap.": "確認中にアノテーションが変更されました。交換をやり直してください。",
"Cannot read image size, so annotations cannot be swapped.": "画像サイズを読み取れないため、アノテーションを交換できません。",
- "Neither image has annotations, nothing to swap.": "どちらの画像にもアノテーションがないため、交換するものはありません。"
+ "Neither image has annotations, nothing to swap.": "どちらの画像にもアノテーションがないため、交換するものはありません。",
+ "Could not save the box resource.": "ボックス資源を保存できませんでした。",
+ "Could not save annotations.": "注釈を保存できませんでした。",
+ "Publish": "公開"
}
diff --git a/l10n/bundle.l10n.json b/l10n/bundle.l10n.json
index 52a8fdd..f3068c9 100644
--- a/l10n/bundle.l10n.json
+++ b/l10n/bundle.l10n.json
@@ -168,6 +168,7 @@
"Enter game window title pattern (regex), or leave empty for all windows": "Enter game window title pattern (regex), or leave empty for all windows",
"Failed to add to template assets: {error}": "Failed to add to template assets: {error}",
"Failed to delete: {name}": "Failed to delete: {name}",
+ "Deleted the record for {name}, but the temporary file is still at {path}.": "Deleted the record for {name}, but the temporary file is still at {path}.",
"Failed to save screenshot.": "Failed to save screenshot.",
"Import images to {dir}": "Import images to {dir}",
"Imported {count} image(s)": "Imported {count} image(s)",
@@ -216,5 +217,8 @@
"Failed to swap annotations.": "Failed to swap annotations.",
"Annotations changed while confirming. Retry the swap.": "Annotations changed while confirming. Retry the swap.",
"Cannot read image size, so annotations cannot be swapped.": "Cannot read image size, so annotations cannot be swapped.",
- "Neither image has annotations, nothing to swap.": "Neither image has annotations, nothing to swap."
+ "Neither image has annotations, nothing to swap.": "Neither image has annotations, nothing to swap.",
+ "Could not save the box resource.": "Could not save the box resource.",
+ "Could not save annotations.": "Could not save annotations.",
+ "Publish": "Publish"
}
diff --git a/l10n/bundle.l10n.ko.json b/l10n/bundle.l10n.ko.json
index 347927f..0bc3d12 100644
--- a/l10n/bundle.l10n.ko.json
+++ b/l10n/bundle.l10n.ko.json
@@ -159,6 +159,7 @@
"Enter game window title pattern (regex), or leave empty for all windows": "게임 창 제목 정규식을 입력하세요(비우면 모든 창)",
"Failed to add to template assets: {error}": "템플릿 에셋 추가 실패: {error}",
"Failed to delete: {name}": "삭제 실패: {name}",
+ "Deleted the record for {name}, but the temporary file is still at {path}.": "{name} 기록은 삭제했지만 임시 파일이 {path}에 남아 있습니다.",
"Failed to save screenshot.": "스크린샷 저장에 실패했습니다.",
"Import images to {dir}": "{dir} 로 이미지 가져오기",
"Imported {count} image(s)": "이미지 {count}개를 가져왔습니다",
@@ -216,5 +217,8 @@
"Failed to swap annotations.": "어노테이션 교환에 실패했습니다.",
"Annotations changed while confirming. Retry the swap.": "확인하는 동안 어노테이션이 변경되었습니다. 교환을 다시 시도하세요.",
"Cannot read image size, so annotations cannot be swapped.": "이미지 크기를 읽을 수 없어 어노테이션을 교환할 수 없습니다.",
- "Neither image has annotations, nothing to swap.": "두 이미지 모두 어노테이션이 없어 교환할 것이 없습니다."
+ "Neither image has annotations, nothing to swap.": "두 이미지 모두 어노테이션이 없어 교환할 것이 없습니다.",
+ "Could not save the box resource.": "박스 리소스를 저장하지 못했습니다.",
+ "Could not save annotations.": "주석을 저장하지 못했습니다.",
+ "Publish": "게시"
}
diff --git a/l10n/bundle.l10n.zh-cn.json b/l10n/bundle.l10n.zh-cn.json
index afb65b0..11ebba9 100644
--- a/l10n/bundle.l10n.zh-cn.json
+++ b/l10n/bundle.l10n.zh-cn.json
@@ -159,6 +159,7 @@
"Enter game window title pattern (regex), or leave empty for all windows": "输入游戏窗口标题正则,留空表示匹配所有窗口",
"Failed to add to template assets: {error}": "添加到模板素材失败: {error}",
"Failed to delete: {name}": "删除失败: {name}",
+ "Deleted the record for {name}, but the temporary file is still at {path}.": "已删除 {name} 的记录,但临时文件还在 {path}。",
"Failed to save screenshot.": "截图保存失败。",
"Import images to {dir}": "导入图片到 {dir}",
"Imported {count} image(s)": "已导入 {count} 张图片",
@@ -216,5 +217,8 @@
"Failed to swap annotations.": "交换标注失败。",
"Annotations changed while confirming. Retry the swap.": "确认期间标注已更改,请重新交换。",
"Cannot read image size, so annotations cannot be swapped.": "读不出图片尺寸,无法交换标注。",
- "Neither image has annotations, nothing to swap.": "两张图都没有标注,无需交换。"
+ "Neither image has annotations, nothing to swap.": "两张图都没有标注,无需交换。",
+ "Could not save the box resource.": "无法保存框资源。",
+ "Could not save annotations.": "无法保存标注。",
+ "Publish": "发布"
}
diff --git a/l10n/bundle.l10n.zh-tw.json b/l10n/bundle.l10n.zh-tw.json
index f2c3aeb..43fc94e 100644
--- a/l10n/bundle.l10n.zh-tw.json
+++ b/l10n/bundle.l10n.zh-tw.json
@@ -159,6 +159,7 @@
"Enter game window title pattern (regex), or leave empty for all windows": "輸入遊戲視窗標題正則,留空表示符合所有視窗",
"Failed to add to template assets: {error}": "加入模板素材失敗: {error}",
"Failed to delete: {name}": "刪除失敗: {name}",
+ "Deleted the record for {name}, but the temporary file is still at {path}.": "已刪除 {name} 的記錄,但暫存檔還在 {path}。",
"Failed to save screenshot.": "截圖儲存失敗。",
"Import images to {dir}": "匯入圖片到 {dir}",
"Imported {count} image(s)": "已匯入 {count} 張圖片",
@@ -216,5 +217,8 @@
"Failed to swap annotations.": "交換標註失敗。",
"Annotations changed while confirming. Retry the swap.": "確認期間標註已變更,請重新交換。",
"Cannot read image size, so annotations cannot be swapped.": "讀不出圖片尺寸,無法交換標註。",
- "Neither image has annotations, nothing to swap.": "兩張圖都沒有標註,無需交換。"
+ "Neither image has annotations, nothing to swap.": "兩張圖都沒有標註,無需交換。",
+ "Could not save the box resource.": "無法儲存框資源。",
+ "Could not save annotations.": "無法儲存標註。",
+ "Publish": "發布"
}
diff --git a/media/annotationPanel/app.js b/media/annotationPanel/app.js
index bb55803..71802a6 100644
--- a/media/annotationPanel/app.js
+++ b/media/annotationPanel/app.js
@@ -22,6 +22,20 @@
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');
+ 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;
@@ -29,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;
@@ -36,6 +51,9 @@
// 标注
let annotations = [];
+ /** 按图片文件名记住隐藏的分类。不进保存消息,撤销也不恢复。 */
+ const hiddenByFile = new Map();
+ let listSignature = '';
let nextId = 1;
let selectedIdx = -1;
let hoveredIdx = -1;
@@ -132,6 +150,16 @@
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');
+ const generateBoxBtn = document.getElementById('generateBoxBtn');
+ if (generateBoxBtn) generateBoxBtn.textContent = t('generateBox');
updateUndoRedoButtons();
}
@@ -198,8 +226,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 +245,120 @@
}
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 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', () => {
+ if (input.checked) hidden.delete(ann.category);
+ else hidden.add(ann.category);
+ listSignature = '';
+ paint();
+ });
+ const text = document.createElement('button');
+ text.type = 'button';
+ text.className = 'annotation-name';
+ text.textContent = ann.category;
+ 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 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 = '';
+ 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 +373,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);
@@ -813,7 +962,8 @@
/* ---------- 删除 ---------- */
function deleteSelected() {
- if (selectedIdx < 0) return;
+ const ann = annotations[selectedIdx];
+ if (!ann || !isShown(ann)) return;
pushUndo();
annotations.splice(selectedIdx, 1);
selectedIdx = -1;
@@ -893,7 +1043,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();
}
@@ -983,12 +1138,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);
}
});
@@ -1010,6 +1171,29 @@
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();
/* ---------- 接收消息 ---------- */
window.addEventListener('message', (e) => {
@@ -1022,14 +1206,24 @@
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 || [];
nextId = annotations.length ? Math.max(...annotations.map(a => a.id)) + 1 : 1;
selectedIdx = -1; hoveredIdx = -1;
+ listSignature = '';
undoStack = []; redoStack = [];
if (msg.imageBase64) {
@@ -1046,6 +1240,7 @@
img = null; imgData = null;
canvas.style.display = 'none';
emptyMsg.style.display = 'flex';
+ syncAnnotationList();
}
// 更新导航
diff --git a/media/annotationPanel/index.html b/media/annotationPanel/index.html
index 623da08..544fb58 100644
--- a/media/annotationPanel/index.html
+++ b/media/annotationPanel/index.html
@@ -12,6 +12,7 @@
+
@@ -19,9 +20,20 @@
-
-
-
+
@@ -47,6 +59,21 @@
Bounding Box
+
+
+
Create box
+
+
+
+
+
+
+
+
+
+
+
+
__I18N_JSON__
+
+
+
+
+
+
+ __I18N_JSON__
+ __MODE__
+
+
diff --git a/media/annotationPanel/style.css b/media/annotationPanel/style.css
index 19423e8..53c6754 100644
--- a/media/annotationPanel/style.css
+++ b/media/annotationPanel/style.css
@@ -21,10 +21,91 @@ 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: 168px;
+ flex: 0 1 168px;
+ max-width: 40%;
+ 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);
+}
+
+@media (max-width: 640px) {
+ .editor-body { flex-direction: column; }
+ .annotation-list {
+ width: auto;
+ max-width: none;
+ flex: 0 0 auto;
+ max-height: 180px;
+ border-left: none;
+ border-top: var(--border-width) solid var(--border);
+ }
+}
+
+.annotation-name {
+ flex: 1;
+ min-width: 0;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+ cursor: pointer;
+ background: none;
+ border: none;
+ color: inherit;
+ font: inherit;
+ text-align: left;
+ padding: 0;
}
canvas {
diff --git a/media/boxPanel/app.js b/media/boxPanel/app.js
new file mode 100644
index 0000000..446277d
--- /dev/null
+++ b/media/boxPanel/app.js
@@ -0,0 +1,79 @@
+(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' });
+
+ const cards = new Map();
+ function fillThumb(card, url) {
+ const box = card.querySelector('.thumb-box');
+ if (!box || box.querySelector('img')) return;
+ const img = document.createElement('img');
+ img.alt = '';
+ img.addEventListener('error', () => img.remove());
+ box.textContent = '';
+ box.append(img);
+ img.src = url;
+ }
+ window.addEventListener('message', (event) => {
+ const msg = event.data;
+ const rows = document.getElementById('rows');
+ if (msg.type === 'thumbs' && mode === 'assets') {
+ (msg.items || []).forEach((item) => {
+ const card = cards.get(item.id);
+ if (card) fillThumb(card, item.url);
+ });
+ return;
+ }
+ if (msg.type !== 'rows') return;
+ cards.clear();
+ while (rows.firstChild) rows.removeChild(rows.firstChild);
+ rows.className = mode === 'assets' ? 'asset-grid' : '';
+ (msg.rows || []).forEach((row) => {
+ const button = document.createElement('button');
+ button.type = 'button';
+ if (mode === 'assets') {
+ button.className = 'card asset-card';
+ button.title = row.name;
+ const box = document.createElement('div');
+ box.className = 'thumb-box';
+ box.textContent = '…';
+ const name = document.createElement('div');
+ name.className = 'asset-name';
+ name.textContent = row.name;
+ const count = document.createElement('div');
+ count.className = 'asset-count';
+ count.textContent = String(row.count);
+ button.append(box, name, count);
+ cards.set(row.id, button);
+ } else {
+ button.className = 'box-row';
+ button.textContent = row.label;
+ }
+ let clickTimer = 0;
+ button.onclick = () => {
+ window.clearTimeout(clickTimer);
+ clickTimer = window.setTimeout(() => vscode.postMessage({ type: 'activate', id: row.id, clicks: 1 }), 250);
+ };
+ button.ondblclick = () => {
+ window.clearTimeout(clickTimer);
+ 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..8e4b1ee
--- /dev/null
+++ b/media/boxPanel/index.html
@@ -0,0 +1,21 @@
+
+
+