From c58ee7d5e196773f139e2930f269a13b5ae52dc0 Mon Sep 17 00:00:00 2001 From: AliceJump <149395013+AliceJump@users.noreply.github.com> Date: Tue, 13 Oct 2026 18:40:37 +0800 Subject: [PATCH 1/4] feat(resources): improve search, source editing and position intelligence --- docs/box-resources.en.md | 10 ++- docs/box-resources.md | 10 ++- jetbrains | 2 +- l10n/bundle.l10n.es.json | 3 +- l10n/bundle.l10n.ja.json | 3 +- l10n/bundle.l10n.json | 3 +- l10n/bundle.l10n.ko.json | 3 +- l10n/bundle.l10n.zh-cn.json | 3 +- l10n/bundle.l10n.zh-tw.json | 3 +- l10n/webview.en.json | 2 +- l10n/webview.es.json | 2 +- l10n/webview.ja.json | 2 +- l10n/webview.ko.json | 2 +- l10n/webview.zh-cn.json | 2 +- l10n/webview.zh-tw.json | 2 +- media/annotationPanel/app.js | 23 +++++- media/templateAssetPanel/app.js | 2 +- media/templatePanel/app.js | 2 +- package.json | 5 +- scripts/test_annotation_history.js | 26 +++++- scripts/test_resource_navigation.js | 121 ++++++++++++++++++++++++++++ scripts/test_thumbnail_actions.js | 11 +++ src/annotatedImage.ts | 24 ++++-- src/annotationPanel.ts | 15 ++-- src/boxPanels.ts | 60 ++------------ src/pngCrop.ts | 4 +- src/providers.ts | 59 ++++++++++---- src/resourceAuthoring.ts | 54 +++++++++++++ src/templateAssetPanel.ts | 13 ++- src/templatePanel.ts | 40 +++++---- 30 files changed, 380 insertions(+), 131 deletions(-) create mode 100644 scripts/test_resource_navigation.js create mode 100644 src/resourceAuthoring.ts diff --git a/docs/box-resources.en.md b/docs/box-resources.en.md index a2485b5..ff0d27b 100644 --- a/docs/box-resources.en.md +++ b/docs/box-resources.en.md @@ -2,7 +2,7 @@ [简体中文](box-resources.md) | [English](box-resources.en.md) -Verified against current local implementations on 2026-10-08. Both hosts use unified annotation management and resource previews. This document describes the current data and operation contracts; see [Feature Parity](feature-parity.en.md) for entry points and verification status. +Verified against current local implementations on 2026-10-13. Both hosts use unified annotation management and resource previews. This document describes the current data and operation contracts; see [Feature Parity](feature-parity.en.md) for entry points and verification status. ## Authoring and Published Files @@ -33,7 +33,7 @@ Historical `version` / `boxes` and `version` / `points` authoring formats are no ## Annotation Management -The unified entry provides import, capture, temporary screenshot drops, filename or Template category search, editing, source-image viewing, Template annotation swaps, deletion, and publication. Swapping preserves the existing Template operation scope in both hosts; Rect / Point remain editable in the unified editor. +The unified entry provides import, capture, temporary screenshot drops, filename, Template, Box, or Point name search, editing, source-image viewing, Template annotation swaps, deletion, and publication. Swapping preserves the existing Template operation scope in both hosts; Rect / Point remain editable in the unified editor. Template swaps between differently sized images scale and clamp coordinates, show image sizes and annotation counts before confirmation, and verify source annotations and image sizes again before saving. @@ -53,10 +53,14 @@ Side and wide previews support Template / Rect / Point modes and name/expression | Rect | `self.pos..to_box()` | | Point | `self.pos.` | -Single click inserts; double click copies. Buttons expose the same actions and annotated source previews. Without a Python editor, the reference is copied with an explanation. Template previews never fill an absent runtime library with unpublished authoring data. Rect / Point hints follow current authoring data without requiring publication first. +Single click inserts; double click copies. Buttons expose the same actions. View original resolves the unique name against current authoring annotations, opens the existing unified editor in Template / Rect / Point mode, and selects and reveals the target. Missing annotations produce a resource warning without falling back to stale card or runtime atlas coordinates. Without a Python editor, the reference is copied with an explanation. Template previews never fill an absent runtime library with unpublished authoring data. Rect / Point hints follow current authoring data without requiring publication first. Both hosts use `Ctrl+Alt+T` for resource previews and `Ctrl+Alt+S` for capture into annotation management; macOS uses the corresponding `Cmd+Alt` combinations. Publication belongs to annotation management, with no separate publishing chain in previews. +Box / Point names in annotation management are search indexes only. They do not appear as image metadata or create persisted source fields. Lookup uses the existing `category_id` / `image_id` relationships. + +`self.pos` completion remains hierarchical by dot-separated path. Intermediate nodes contain only the path segment. Leaves show `Box · width×height` or `Point · (x, y)`. Source previews are generated only when a leaf is selected; completion details share the Hover formatter. Boxes use `.to_box()` and show bbox geometry. Points use direct position expressions and pixel coordinates, with a cross marking the location. Position hints work directly from unpublished authoring data. When Rect and Point share a conflicting name, code hints do not choose either resource arbitrarily. + ## Unified Publication Select Template / Rect / Point, then configure the selected resources. All configuration and cancellation decisions precede writes. Position failure or rejected overwrite stops subsequent Template writes. Templates retain existing packing and enum generation, including class-name and project-reference checks before overwriting enums. diff --git a/docs/box-resources.md b/docs/box-resources.md index 6a296b9..514a2be 100644 --- a/docs/box-resources.md +++ b/docs/box-resources.md @@ -2,7 +2,7 @@ [简体中文](box-resources.md) | [English](box-resources.en.md) -2026-10-08 按当前本地实现复核。两端使用统一标注管理和资源预览,本文说明当前数据与操作契约。完整入口及验证状态见 [功能对齐表](feature-parity.md)。 +2026-10-13 按当前本地实现复核。两端使用统一标注管理和资源预览,本文说明当前数据与操作契约。完整入口及验证状态见 [功能对齐表](feature-parity.md)。 ## 工作文件与发布文件 @@ -33,7 +33,7 @@ Rect / Point 名称至少包含两个由句点分隔的 Python 标识符,例 ## 标注管理 -统一入口提供导入、截图、临时截图拖入、按文件名或 Template 分类搜索、编辑、查看原图、交换 Template 标注、删除和发布。交换保持两端现有 Template 操作范围;Rect / Point 可在统一编辑器中修改。 +统一入口提供导入、截图、临时截图拖入、按文件名、Template、Box 或 Point 名称搜索、编辑、查看原图、交换 Template 标注、删除和发布。交换保持两端现有 Template 操作范围;Rect / Point 可在统一编辑器中修改。 交换不同尺寸原图的 Template 标注时按比例映射并钳制边界,确认前显示尺寸和标注数量;保存前核对源标注与图片尺寸是否变化。 @@ -53,10 +53,14 @@ Rect / Point 名称至少包含两个由句点分隔的 Python 标识符,例 | Rect | `self.pos.<路径>.to_box()` | | Point | `self.pos.<路径>` | -单击插入,双击复制;按钮提供相同动作和带位置提示的原图查看。没有 Python 编辑器时复制引用并说明原因。Template 预览不会用尚未发布的工作标注填充运行时库;Rect / Point 提示跟随当前工作标注,编辑后不需要先发布才能得到代码提示。 +单击插入,双击复制;按钮提供相同动作;“查看原图”按当前唯一名称从工作标注中反查原图,打开已有统一编辑器,切到对应 Template / Rect / Point 模式,选中并显示目标。找不到当前标注时提示资源缺失,不使用旧卡片或运行时图集坐标打开其他原图。没有 Python 编辑器时复制引用并说明原因。Template 预览不会用尚未发布的工作标注填充运行时库;Rect / Point 提示跟随当前工作标注,编辑后不需要先发布才能得到代码提示。 两端 `Ctrl+Alt+T` 打开资源预览,`Ctrl+Alt+S` 截图到标注管理;macOS 使用相应的 `Cmd+Alt` 组合。发布入口位于标注管理,资源预览不提供另一个发布链。 +标注管理的 Box / Point 名称只用于搜索筛选,不显示为图片元信息,也不写入额外来源字段。反查始终沿用现有 `category_id` / `image_id` 关系。 + +`self.pos` 继续按句点逐层补全。中间节点只有路径段;叶子显示 `Box · 宽×高` 或 `Point · (x, y)`。选中叶子后才生成原图位置预览,详情与 Hover 共用。Box 使用 `.to_box()` 表达式并显示 bbox;Point 使用直接位置表达式和像素坐标,预览以十字标记定位。未发布的工作标注可直接用于位置提示;同名 Rect / Point 在共用代码命名空间中无法消歧时不显示任意一种资源的详情。 + ## 统一发布 先选择 Template / Rect / Point,再完成所选资源的配置。全部配置与取消决定发生在写盘前;位置拒绝覆盖或发布失败后不继续写模板。模板仍使用已有打包与枚举生成链,覆盖已有枚举前核对类名变化和项目引用。 diff --git a/jetbrains b/jetbrains index f2f7b97..a97794d 160000 --- a/jetbrains +++ b/jetbrains @@ -1 +1 @@ -Subproject commit f2f7b97bd340e3446bcb0ac18728b05991f3021a +Subproject commit a97794d51ed305a51010624031809b4319883cee diff --git a/l10n/bundle.l10n.es.json b/l10n/bundle.l10n.es.json index c138962..b61c1cb 100644 --- a/l10n/bundle.l10n.es.json +++ b/l10n/bundle.l10n.es.json @@ -260,5 +260,6 @@ "Could not publish positions: {error}": "No se pudieron publicar las posiciones: {error}", "Published positions: {files}": "Posiciones publicadas: {files}", "Resource Preview": "Vista previa de recursos", - "No annotations available to publish.": "No hay anotaciones disponibles para publicar." + "No annotations available to publish.": "No hay anotaciones disponibles para publicar.", + "The resource was not found in current annotations: {name}": "No se encontró el recurso en las anotaciones actuales: {name}" } diff --git a/l10n/bundle.l10n.ja.json b/l10n/bundle.l10n.ja.json index 1898721..0ac282e 100644 --- a/l10n/bundle.l10n.ja.json +++ b/l10n/bundle.l10n.ja.json @@ -260,5 +260,6 @@ "Could not publish positions: {error}": "位置リソースを公開できませんでした: {error}", "Published positions: {files}": "位置リソースを公開しました: {files}", "Resource Preview": "リソースプレビュー", - "No annotations available to publish.": "公開できるアノテーションはありません。" + "No annotations available to publish.": "公開できるアノテーションはありません。", + "The resource was not found in current annotations: {name}": "現在のアノテーションにリソースが見つかりません: {name}" } diff --git a/l10n/bundle.l10n.json b/l10n/bundle.l10n.json index 657ce40..3044e9a 100644 --- a/l10n/bundle.l10n.json +++ b/l10n/bundle.l10n.json @@ -260,5 +260,6 @@ "Could not publish positions: {error}": "Could not publish positions: {error}", "Published positions: {files}": "Published positions: {files}", "Resource Preview": "Resource Preview", - "No annotations available to publish.": "No annotations available to publish." + "No annotations available to publish.": "No annotations available to publish.", + "The resource was not found in current annotations: {name}": "The resource was not found in current annotations: {name}" } diff --git a/l10n/bundle.l10n.ko.json b/l10n/bundle.l10n.ko.json index 62bf6bb..d74f290 100644 --- a/l10n/bundle.l10n.ko.json +++ b/l10n/bundle.l10n.ko.json @@ -260,5 +260,6 @@ "Could not publish positions: {error}": "위치 리소스를 게시할 수 없습니다: {error}", "Published positions: {files}": "게시된 위치 리소스: {files}", "Resource Preview": "리소스 미리보기", - "No annotations available to publish.": "게시할 주석이 없습니다." + "No annotations available to publish.": "게시할 주석이 없습니다.", + "The resource was not found in current annotations: {name}": "현재 어노테이션에서 리소스를 찾을 수 없습니다: {name}" } diff --git a/l10n/bundle.l10n.zh-cn.json b/l10n/bundle.l10n.zh-cn.json index ecd7c75..1d6b0ea 100644 --- a/l10n/bundle.l10n.zh-cn.json +++ b/l10n/bundle.l10n.zh-cn.json @@ -260,5 +260,6 @@ "Could not publish positions: {error}": "无法发布位置资源:{error}", "Published positions: {files}": "已发布位置资源:{files}", "Resource Preview": "资源预览", - "No annotations available to publish.": "没有可发布的标注。" + "No annotations available to publish.": "没有可发布的标注。", + "The resource was not found in current annotations: {name}": "当前标注中未找到资源:{name}" } diff --git a/l10n/bundle.l10n.zh-tw.json b/l10n/bundle.l10n.zh-tw.json index 47f5500..d776d41 100644 --- a/l10n/bundle.l10n.zh-tw.json +++ b/l10n/bundle.l10n.zh-tw.json @@ -260,5 +260,6 @@ "Could not publish positions: {error}": "無法發佈位置資源:{error}", "Published positions: {files}": "已發佈位置資源:{files}", "Resource Preview": "資源預覽", - "No annotations available to publish.": "沒有可發佈的標註。" + "No annotations available to publish.": "沒有可發佈的標註。", + "The resource was not found in current annotations: {name}": "目前標註中找不到資源:{name}" } diff --git a/l10n/webview.en.json b/l10n/webview.en.json index 3398866..eecd5b6 100644 --- a/l10n/webview.en.json +++ b/l10n/webview.en.json @@ -243,7 +243,7 @@ "accountStoreSeeOutput": "See the \"ok-script Console\" output channel for details.", "accountReadFailed": "Store file could not be parsed (see executor output).", "overriddenTasksLabel": "Overridden tasks", - "templatesSearch": "Search template names…", + "templatesSearch": "Search image / Template / Box / Point names…", "templatesTitle": "Template Gallery", "templatesHint": "Click=insert · Double-click=copy · Use the bottom-right buttons for direct actions", "templatesCount": "{shown}/{total} templates", diff --git a/l10n/webview.es.json b/l10n/webview.es.json index 74ebe99..b088e1d 100644 --- a/l10n/webview.es.json +++ b/l10n/webview.es.json @@ -243,7 +243,7 @@ "accountStoreSeeOutput": "Consulte el canal de salida \"ok-script Console\" para más detalles.", "accountReadFailed": "No se pudo analizar el archivo del almacén (ver la salida del ejecutor).", "overriddenTasksLabel": "Tareas con anulaciones", - "templatesSearch": "Buscar nombres de plantillas…", + "templatesSearch": "Buscar nombres de imagen / Template / Box / Point…", "templatesTitle": "Galería de plantillas", "templatesHint": "Clic=insertar · Doble clic=copiar · Use los botones inferiores derechos para acciones directas", "templatesCount": "{shown}/{total} plantillas", diff --git a/l10n/webview.ja.json b/l10n/webview.ja.json index 05c6ee8..787fc4e 100644 --- a/l10n/webview.ja.json +++ b/l10n/webview.ja.json @@ -243,7 +243,7 @@ "accountStoreSeeOutput": "詳細は出力チャンネル「ok-script Console」を参照。", "accountReadFailed": "ストアファイルを解析できません(実行器の出力を参照)。", "overriddenTasksLabel": "上書きありのタスク", - "templatesSearch": "テンプレート名を検索…", + "templatesSearch": "画像 / Template / Box / Point 名を検索…", "templatesTitle": "テンプレートギャラリー", "templatesHint": "クリック=挿入 · ダブルクリック=コピー · 右下のボタンで直接操作", "templatesCount": "{shown}/{total} テンプレート", diff --git a/l10n/webview.ko.json b/l10n/webview.ko.json index da49596..346ab5b 100644 --- a/l10n/webview.ko.json +++ b/l10n/webview.ko.json @@ -243,7 +243,7 @@ "accountStoreSeeOutput": "자세한 원인은 출력 채널 \"ok-script Console\"을 참조하세요.", "accountReadFailed": "스토어 파일을 해석할 수 없습니다(실행기 출력 참조).", "overriddenTasksLabel": "재정의가 있는 작업", - "templatesSearch": "템플릿 이름 검색…", + "templatesSearch": "이미지 / Template / Box / Point 이름 검색…", "templatesTitle": "템플릿 갤러리", "templatesHint": "클릭=삽입 · 더블클릭=복사 · 오른쪽 아래 버튼으로 바로 실행", "templatesCount": "{shown}/{total} 템플릿", diff --git a/l10n/webview.zh-cn.json b/l10n/webview.zh-cn.json index 9801983..619d0e6 100644 --- a/l10n/webview.zh-cn.json +++ b/l10n/webview.zh-cn.json @@ -243,7 +243,7 @@ "accountStoreSeeOutput": "具体原因见输出频道「ok-script Console」。", "accountReadFailed": "存储文件无法解析(详情见执行器输出)。", "overriddenTasksLabel": "有覆盖的任务", - "templatesSearch": "搜索模板名…", + "templatesSearch": "搜索图片 / Template / Box / Point 名称…", "templatesTitle": "模板面板", "templatesHint": "单击=插入 · 双击=复制 · 右下角按钮可直接操作", "templatesCount": "{shown}/{total} 个模板", diff --git a/l10n/webview.zh-tw.json b/l10n/webview.zh-tw.json index 2c273ce..df64749 100644 --- a/l10n/webview.zh-tw.json +++ b/l10n/webview.zh-tw.json @@ -243,7 +243,7 @@ "accountStoreSeeOutput": "具體原因見輸出頻道「ok-script Console」。", "accountReadFailed": "儲存檔案無法解析(詳見執行器輸出)。", "overriddenTasksLabel": "有覆寫的任務", - "templatesSearch": "搜尋模板名稱…", + "templatesSearch": "搜尋圖片 / Template / Box / Point 名稱…", "templatesTitle": "模板面板", "templatesHint": "單擊=插入 · 雙擊=複製 · 右下角按鈕可直接操作", "templatesCount": "{shown}/{total} 個模板", diff --git a/media/annotationPanel/app.js b/media/annotationPanel/app.js index 52ee399..7fc94a9 100644 --- a/media/annotationPanel/app.js +++ b/media/annotationPanel/app.js @@ -1132,7 +1132,8 @@ if (msg.type !== 'load') return; const incomingMode = msg.annotationMode || annotationMode; - if (pendingMode && incomingMode !== pendingMode) return; + if (pendingMode && incomingMode !== pendingMode && !msg.focusName) return; + if (msg.focusName) { pendingMode = null; modeLoading = false; } if (pendingMode === incomingMode) { pendingMode = null; modeLoading = false; } const newImageKey = msg.filename || ''; const imageChanged = currentImageKey && currentImageKey !== newImageKey; @@ -1150,16 +1151,30 @@ listSignature = ''; updateStaticText(); + function focusResource() { + if (!msg.focusName || !img) return; + const index = annotations.findIndex(ann => ann.category === msg.focusName); + if (index < 0) return; + const ann = annotations[index]; + hiddenSet().delete(ann.category); + selectOnly(index); + if (!pointMode) scale = Math.max(fitScale, Math.min(scale, + Math.max(1, canvas.width - 40) / ann.w, Math.max(1, canvas.height - 40) / ann.h)); + offsetX = canvas.width / 2 - (ann.x + (pointMode ? 0 : ann.w / 2)) * scale; + offsetY = canvas.height / 2 - (ann.y + (pointMode ? 0 : ann.h / 2)) * scale; + recalcOffset(); + canvas.focus(); + } const needImageLoad = !img || imageChanged; if (msg.imageBase64 && needImageLoad) { img = new Image(); img.onload = () => { emptyMsg.style.display = 'none'; canvas.style.display = 'block'; - recalcFit(); scale = fitScale; recalcOffset(); loadImgData(); paint(); + recalcFit(); scale = fitScale; recalcOffset(); loadImgData(); focusResource(); paint(); }; img.src = msg.imageBase64; } else if (msg.imageBase64 && img) { - emptyMsg.style.display = 'none'; canvas.style.display = 'block'; paint(); + emptyMsg.style.display = 'none'; canvas.style.display = 'block'; focusResource(); paint(); } else { img = null; imgData = null; canvas.style.display = 'none'; emptyMsg.style.display = 'flex'; syncAnnotationList(); } @@ -1173,4 +1188,4 @@ resize(); updateStaticText(); vscode.postMessage({ type:'ready' }); -})(); \ No newline at end of file +})(); diff --git a/media/templateAssetPanel/app.js b/media/templateAssetPanel/app.js index e6b1ecb..e4a697d 100644 --- a/media/templateAssetPanel/app.js +++ b/media/templateAssetPanel/app.js @@ -94,7 +94,7 @@ for (const [name, card] of cards) { const meta = metas.find(m => m.name === name); const ok = !q || name.toLowerCase().includes(q) || - (meta && meta.categories.some(c => c.toLowerCase().includes(q))); + (meta && [...meta.categories, ...(meta.searchNames || [])].some(c => c.toLowerCase().includes(q))); card.style.display = ok ? '' : 'none'; if (ok) shown++; } diff --git a/media/templatePanel/app.js b/media/templatePanel/app.js index f282d9d..3d9219c 100644 --- a/media/templatePanel/app.js +++ b/media/templatePanel/app.js @@ -63,7 +63,7 @@ ThumbnailActions.button('+', t('insertExpression'), () => vscode.postMessage({ type: 'insert', expression: meta.expression })), ThumbnailActions.button('⧉', t('copyExpression'), () => vscode.postMessage({ type: 'copy', expression: meta.expression })), ThumbnailActions.button('👁', t('viewOriginal'), () => vscode.postMessage({ - type: 'open', imagePath: meta.imagePath, name: meta.name, bbox: JSON.stringify(meta.bbox), + type: 'open', mode: meta.kind, name: meta.name, })), ); box.appendChild(actions); diff --git a/package.json b/package.json index a329b8d..b2d1cf5 100644 --- a/package.json +++ b/package.json @@ -435,7 +435,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 test:l10n-literals && npm run compile && npm run test:webview-l10n && npm run test:webview-html && npm run test:design-system && npm run test:thumbnail-actions && 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-clipboard && npm run test:annotation-swap && npm run test:annotation-merge && npm run test:annotation-history && npm run test:annotation-mode-cycle && npm run test:position-resource && npm run test:resource-host-contract && npm run test:position-publish-settings && npm run test:point-resource && 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:screenshot-capture && npm run test:screenshot-worker && npm run test:background-screenshot && npm run test:global-hotkey && npm run test:run-sandbox && npm run test:startup-patches && npm run test:executor-runtime && 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 test:l10n-literals && npm run compile && npm run test:webview-l10n && npm run test:webview-html && npm run test:design-system && npm run test:thumbnail-actions && 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-clipboard && npm run test:annotation-swap && npm run test:annotation-merge && npm run test:annotation-history && npm run test:annotation-mode-cycle && npm run test:position-resource && npm run test:resource-host-contract && npm run test:resource-navigation && npm run test:position-publish-settings && npm run test:point-resource && 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:screenshot-capture && npm run test:screenshot-worker && npm run test:background-screenshot && npm run test:global-hotkey && npm run test:run-sandbox && npm run test:startup-patches && npm run test:executor-runtime && 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:design-system": "node scripts/test_design_system.js", "test:thumbnail-actions": "node scripts/test_thumbnail_actions.js", @@ -486,7 +486,8 @@ "test:l10n-literals": "node scripts/test_l10n_literals.js", "test:position-publish-settings": "node scripts/test_position_publish_settings.js", "test:webview-l10n": "node scripts/test_webview_l10n.js", - "test:resource-host-contract": "node scripts/test_resource_host_contract.js" + "test:resource-host-contract": "node scripts/test_resource_host_contract.js", + "test:resource-navigation": "node scripts/test_resource_navigation.js" }, "devDependencies": { "@types/jpeg-js": "^0.3.0", diff --git a/scripts/test_annotation_history.js b/scripts/test_annotation_history.js index f84834a..930f216 100644 --- a/scripts/test_annotation_history.js +++ b/scripts/test_annotation_history.js @@ -29,11 +29,12 @@ let webviewState = {}; const virtualConsole = new VirtualConsole(); virtualConsole.on('jsdomError', error => { throw error; }); +const drawn = []; function makeContext() { const noop = () => {}; return { - clearRect: noop, fillRect: noop, strokeRect: noop, beginPath: noop, closePath: noop, - arc: noop, fill: noop, fillText: noop, drawImage: noop, setLineDash: noop, + clearRect: noop, fillRect: noop, strokeRect: (...args) => drawn.push(args), beginPath: noop, closePath: noop, + arc: (...args) => drawn.push(args), fill: noop, fillText: noop, drawImage: noop, setLineDash: noop, moveTo: noop, lineTo: noop, stroke: noop, save: noop, restore: noop, getImageData: () => ({ data: new Uint8ClampedArray(4) }), measureText: () => ({ width: 0 }), @@ -282,6 +283,27 @@ function load(mode, annotations, imagePath = 'x/a.png', extra = {}) { assert(controllerSource.includes('loadRequestId: requestId') && controllerSource.includes('expectedEditorVersion'), 'host versions every load request against the editor state it has already observed'); + // Resource jumps select matching shapes after decoding and reveal hidden targets. + const target = { id: 42, category: 'screen.focus', x: 850, y: 850, w: 80, h: 80 }; + load('rect', [target], 'x/focus.png', { focusName: target.category }); + await flush(); await flush(); + assert(document.querySelector('.annotation-row.is-selected'), 'new-image jump selects the resource after image decoding'); + document.getElementById('hideAllBtn').click(); + for (let i = 0; i < 12; i++) canvas.dispatchEvent(new window.WheelEvent('wheel', { deltaY: -1, clientX: 0, clientY: 0, bubbles: true, cancelable: true })); + const writesBeforeFocus = writes().length; + drawn.length = 0; + load('rect', [target], 'x/focus.png', { focusName: target.category }); + await flush(); + assert(document.querySelector('.annotation-row.is-selected input[type="checkbox"]').checked, 'same-image jump unhides the selected resource'); + assert(drawn.some(([x,y,w,h]) => w > 30 && h > 30 && x >= 0 && y >= 0 && x + w <= 800 && y + h <= 600), + 'zoomed viewport pans until the current box is visible'); + assert(writes().length === writesBeforeFocus, 'selection and panning never save annotations'); + const pointTarget = { ...target, category: 'screen.focus_point', w: 0, h: 0 }; + load('point', [pointTarget], 'x/focus.png', { focusName: pointTarget.category }); + await flush(); + assert(document.getElementById('pointModeBtn').classList.contains('active'), 'point jump switches editor mode'); + assert(document.querySelector('.annotation-row.is-selected').textContent.includes(pointTarget.category), 'point jump selects the point by name'); + console.log('annotation history runtime tests passed'); })().catch(error => { console.error(error && error.stack ? error.stack : error); diff --git a/scripts/test_resource_navigation.js b/scripts/test_resource_navigation.js new file mode 100644 index 0000000..d8d2d97 --- /dev/null +++ b/scripts/test_resource_navigation.js @@ -0,0 +1,121 @@ +const assert = require('assert'); +const fs = require('fs'); +const os = require('os'); +const path = require('path'); +const vm = require('vm'); +const root = path.resolve(__dirname, '..'); +function load(name, dependencies, extra = '') { + const exports = {}; + vm.runInNewContext(fs.readFileSync(path.join(root, 'out', name + '.js'), 'utf8') + extra, { + exports, require: key => dependencies[key] || require(key.startsWith('.') ? path.join(root, 'out', key) : key), + }); + return exports; +} +class CompletionItem { constructor(label, kind) { this.label = label; this.kind = kind; } } +class MarkdownString { + constructor() { this.value = ''; } + appendCodeblock(value) { this.value += value + '\n'; } + appendMarkdown(value) { this.value += value; } +} +const notices = []; +const vscode = { CompletionItem, MarkdownString, CompletionItemKind: { Field: 1, Value: 2 }, + Hover: class { constructor(contents) { this.contents = contents; } }, + window: { showWarningMessage: message => notices.push(message) }, +}; +const project = fs.mkdtempSync(path.join(os.tmpdir(), 'ok-resource-navigation-')); +const folder = path.join(project, 'ok_templates'); +fs.mkdirSync(folder); +const image = path.join(folder, 'source.png'); +const nextImage = path.join(folder, 'next.png'); +fs.writeFileSync(image, 'image'); fs.writeFileSync(nextImage, 'image'); +const config = { templatesDirectory: () => 'ok_templates', labelEnumAliases: () => ['Features'], + loadProjectConfig: () => ({}), ideSetting: () => undefined }; +const resources = load('resourceAuthoring', { './projectConfig': config }); +function write(kind, name, bbox, file = 'source.png') { + fs.writeFileSync(path.join(folder, kind + '.json'), JSON.stringify({ + images: [{ id: 1, file_name: file, width: 1000, height: 800 }], + categories: [{ id: 1, name, supercategory: '' }], + annotations: [{ id: 1, image_id: 1, category_id: 1, bbox, area: bbox[2] * bbox[3], iscrowd: 0 }], + })); +} +(async () => { + try { + write('boxes', 'screen.panel', [10, 20, 30, 40]); + write('points', 'screen.click', [90, 80, 0, 0]); + write('coco_annotations', 'button', [1, 2, 3, 4]); + const before = ['boxes', 'points', 'coco_annotations'].map(name => fs.readFileSync(path.join(folder, name + '.json'), 'utf8')); + assert.strictEqual(resources.findAuthoringResource(project, 'point', 'screen.click').imagePath, image); + assert.strictEqual(resources.findAuthoringResource(project, 'template', 'button').imagePath, image); + assert.strictEqual(resources.findAuthoringResource(project, 'rect', 'missing'), undefined); + let renders = 0; + const providers = load('providers', { + vscode, './projectConfig': config, './resourceAuthoring': resources, + './localization': { tr: key => key }, + './pngCrop': { annotatedImageToDataUrlCached: () => { renders++; return 'data:image/png;base64,preview'; } }, + }); + const features = { root: project, entry: name => name === 'screen' ? { name } : undefined }; + const provider = new providers.LangCompletionProvider({}, features); + const items = text => provider.provideCompletionItems({ lineAt: () => ({ text }) }, { line: 0, character: text.length }); + const namespace = items('self.pos.')[0]; + assert.strictEqual(namespace.label, 'screen'); + assert.strictEqual(namespace.detail, undefined); + provider.resolveCompletionItem(namespace); + assert.strictEqual(namespace.documentation, undefined, 'namespace must not resolve a same-name Template'); + const leaves = items('self.pos.screen.'); + assert.strictEqual(renders, 0, 'listing completion items does not decode any images'); + const box = leaves.find(item => item.label === 'panel'); + const point = leaves.find(item => item.label === 'click'); + assert.strictEqual(box.detail, 'Box · 30×40'); + assert.strictEqual(point.detail, 'Point · (90, 80)'); + provider.resolveCompletionItem(point); + assert(point.documentation.value.includes('self.pos.screen.click')); + assert(!point.documentation.value.includes('.to_box()')); + assert(point.documentation.value.includes('Point · (90, 80)')); + assert(point.documentation.value.includes('data:image/png')); + provider.resolveCompletionItem(box); + assert(box.documentation.value.includes('self.pos.screen.panel.to_box()')); + const hover = new providers.LangHoverProvider({}, features); + const doc = { lineAt: () => ({ text: 'self.pos.screen.click' }) }; + assert.strictEqual(hover.provideHover(doc, { line: 0, character: 18 }).contents.value, point.documentation.value, + 'completion and hover use the same resource details'); + assert.deepStrictEqual(['boxes', 'points', 'coco_annotations'].map(name => fs.readFileSync(path.join(folder, name + '.json'), 'utf8')), before, + 'lookup and documentation do not rewrite authoring data'); + + // Runtime atlas geometry and a stale card must never decide which image gets edited. + write('coco_annotations', 'button', [20, 30, 40, 50], 'next.png'); + const opened = []; + const gallery = load('templatePanel', { + vscode, './providers': { featureAliases: () => ['Features'] }, './resourceAuthoring': resources, + './localization': { tr: key => key }, + './annotationPanel': { AnnotationPanel: { show: (...args) => opened.push(args) } }, + './templateAssetData': { TemplateAssetData: class { load() {} listImages() { return [image, nextImage]; } } }, + }, '\nexports.Controller = GalleryController;').Controller.prototype; + const host = { features, mode: 'template', thumbDir: folder, extensionUri: {}, openOriginal: gallery.openOriginal }; + host.metas = () => [{ name: 'button', kind: 'template', imagePath: image, bbox: [0, 0, 1, 1] }]; + await gallery.onMessage.call(host, { type: 'open', name: 'button', imagePath: 'forged.png' }); + assert.strictEqual(opened[0][3], nextImage); + assert.strictEqual(opened[0][6], 'template'); assert.strictEqual(opened[0][7], 'button'); + write('coco_annotations', 'renamed', [20, 30, 40, 50], 'next.png'); + await gallery.onMessage.call(host, { type: 'open', name: 'button' }); + assert.strictEqual(opened.length, 1, 'removed resource does not open stale atlas coordinates'); + assert.strictEqual(notices.length, 1); + + write('points', 'screen.panel', [90, 80, 0, 0]); + const ambiguous = items('self.pos.screen.').find(item => item.label === 'panel'); + assert.strictEqual(ambiguous.detail, undefined, 'duplicate Box / Point names do not choose one type arbitrarily'); + provider.resolveCompletionItem(ambiguous); + assert.strictEqual(ambiguous.documentation, undefined); + assert.strictEqual(resources.findAuthoringResource(project, 'point', 'screen.panel').kind, 'point', + 'mode-specific original editing can still resolve a same-name point'); + + const { renderAnnotatedPixels } = require('../out/annotatedImage'); + const original = { width: 800, height: 800, rgba: Buffer.alloc(800 * 800 * 4, 80) }; + for (const bbox of [[400, 400, 0, 0], [0, 0, 0, 0], [800, 800, 0, 0]]) { + const pixels = renderAnnotatedPixels(original, bbox); + assert(pixels && pixels.width <= 400 && pixels.height <= 400, 'points at every image boundary have a visible preview'); + assert(pixels.rgba.some((value, index) => index % 4 === 0 && value === 255), 'point marker is rendered'); + } + assert.strictEqual(renderAnnotatedPixels(original, [800, 0, 2, 2]), undefined); + console.log('resource navigation: current authoring lookup, lazy leaf details, source editing and point previews passed'); + } finally { fs.rmSync(project, { recursive: true, force: true }); } +})().catch(error => { console.error(error); process.exitCode = 1; }); diff --git a/scripts/test_thumbnail_actions.js b/scripts/test_thumbnail_actions.js index f4c2cca..daaa98b 100644 --- a/scripts/test_thumbnail_actions.js +++ b/scripts/test_thumbnail_actions.js @@ -117,5 +117,16 @@ w.dispatchEvent(new w.MessageEvent('message', { data: { type: 'templates', templ assert.strictEqual(w.document.querySelector('.card'), card, 'annotation refresh preserves the existing source-card DOM node'); assert.strictEqual(card.querySelector('.cats').textContent, 'screen.updated', 'annotation metadata updates in place'); +w.dispatchEvent(new w.MessageEvent('message', { data: { type: 'templates', templates: [{ + name: 'source.png', imagePath: 'source.png', width: 20, height: 30, categories: ['screen.updated'], annotations: 1, + searchNames: ['screen.rect', 'screen.click'], +}] } })); +const search = w.document.getElementById('search'); +for (const [query, visible] of [['SCREEN.RECT', true], ['screen.click', true], ['unrelated', false], ['source.png', true], ['screen.updated', true]]) { + search.value = query; + search.dispatchEvent(new w.Event('input')); + assert.strictEqual(card.style.display !== 'none', visible, 'asset search indexes resource and image names: ' + query); +} +assert.strictEqual(card.querySelector('.cats').textContent, 'screen.updated', 'Box / Point names remain search-only metadata'); dom.window.close(); console.log('thumbnail action parity: unified template/rect/point preview and asset actions OK'); diff --git a/src/annotatedImage.ts b/src/annotatedImage.ts index f86dc97..a13f314 100644 --- a/src/annotatedImage.ts +++ b/src/annotatedImage.ts @@ -33,11 +33,13 @@ export interface AnnotatedPixels { width: number; height: number; rgba: Buffer } export function renderAnnotatedPixels( { width, height, rgba }: AnnotatedPixels, bbox: [number, number, number, number], ): AnnotatedPixels | undefined { - if (!bbox.every(Number.isFinite) || bbox[2] <= 0 || bbox[3] <= 0) return undefined; - const bx = Math.max(0, Math.round(bbox[0])); - const by = Math.max(0, Math.round(bbox[1])); - const bw = Math.min(width, Math.round(bbox[0] + bbox[2])) - bx; - const bh = Math.min(height, Math.round(bbox[1] + bbox[3])) - by; + const point = bbox[2] === 0 && bbox[3] === 0; + if (!bbox.every(Number.isFinite) || (!point && (bbox[2] <= 0 || bbox[3] <= 0))) return undefined; + if (point && (bbox[0] < 0 || bbox[1] < 0 || bbox[0] > width || bbox[1] > height)) return undefined; + const bx = Math.max(0, point ? Math.min(width - 1, Math.round(bbox[0])) : Math.round(bbox[0])); + const by = Math.max(0, point ? Math.min(height - 1, Math.round(bbox[1])) : Math.round(bbox[1])); + const bw = point ? 1 : Math.min(width, Math.round(bbox[0] + bbox[2])) - bx; + const bh = point ? 1 : Math.min(height, Math.round(bbox[1] + bbox[3])) - by; if (bw <= 0 || bh <= 0) return undefined; const pad = 200; const cropX = Math.max(0, bx - pad), cropY = Math.max(0, by - pad); @@ -67,13 +69,19 @@ export function renderAnnotatedPixels( } const thickness = Math.max(2, Math.round(2 * scale)); - drawRectOutline(outRgba, outW, outH, - Math.round((bx - cropX) * scale), Math.round((by - cropY) * scale), + const px = Math.min(outW - 1, Math.round((bx - cropX) * scale)); + const py = Math.min(outH - 1, Math.round((by - cropY) * scale)); + if (point) { + strokeRectInward(outRgba, outW, outH, px - 7, py - 2, 15, 5, 5, 255, 255, 255); + strokeRectInward(outRgba, outW, outH, px - 2, py - 7, 5, 15, 5, 255, 255, 255); + strokeRectInward(outRgba, outW, outH, px - 6, py - 1, 13, 3, 3, 255, 40, 40); + strokeRectInward(outRgba, outW, outH, px - 1, py - 6, 3, 13, 3, 255, 40, 40); + } else drawRectOutline(outRgba, outW, outH, px, py, Math.max(1, Math.round(bw * scale)), Math.max(1, Math.round(bh * scale)), thickness); return { width: outW, height: outH, rgba: outRgba }; } export function annotatedFileName(contentHash: string, bbox: [number, number, number, number]): string { - const key = crypto.createHash('sha1').update(`v3|${contentHash}|${bbox.join(',')}`).digest('hex').slice(0, 16); + const key = crypto.createHash('sha1').update(`v4|${contentHash}|${bbox.join(',')}`).digest('hex').slice(0, 16); return `a2_${key}.png`; } diff --git a/src/annotationPanel.ts b/src/annotationPanel.ts index 205e0fe..c75846a 100644 --- a/src/annotationPanel.ts +++ b/src/annotationPanel.ts @@ -110,6 +110,7 @@ class AnnotationController { private _currentImage: string | undefined; private _imageList: string[] = []; private mode: UnifiedAnnotationMode; + private focusName: string | undefined; private readonly templateData: TemplateAssetData; private readonly boxData: TemplateAssetData; @@ -368,10 +369,11 @@ class AnnotationController { }); } - open(imagePath: string, imageList: string[], mode?: UnifiedAnnotationMode | boolean): void { + open(imagePath: string, imageList: string[], mode?: UnifiedAnnotationMode | boolean, focusName?: string): void { if (this._currentImage && this._currentImage !== imagePath) this.resetMergeState(); if (mode !== undefined) this.mode = normalizeAnnotationMode(mode); this._imageList = [...imageList]; + this.focusName = focusName; this._currentImage = imagePath; this.sendConfig(); void this.loadImage(imagePath); @@ -417,6 +419,7 @@ class AnnotationController { currentIndex: this._imageList.indexOf(imagePath), totalImages: this._imageList.length, filename: path.basename(imagePath), + focusName: this.focusName, }); } @@ -472,6 +475,7 @@ class AnnotationController { this.pendingLoadRequests.delete(msg.loadRequestId); this.noteEditorVersion(msg.imagePath, msg.mode, msg.editorVersion); this.commitAcceptedLoad(request); + this.focusName = undefined; break; } case 'loadRejected': { @@ -781,14 +785,15 @@ export class AnnotationPanel { imagePath: string, imageList: string[], onSaved: (imagePath: string) => void, - boxMode = false, + mode: UnifiedAnnotationMode | boolean = false, + focusName?: string, ): void { - const initialMode: UnifiedAnnotationMode = boxMode ? 'rect' : 'template'; + const initialMode = normalizeAnnotationMode(mode); const current = AnnotationPanel.current; if (current && current.controller.root !== data.root) current.panel.dispose(); else if (current) { current.panel.reveal(); - current.controller.open(imagePath, imageList, initialMode); + current.controller.open(imagePath, imageList, initialMode, focusName); return; } const panel = vscode.window.createWebviewPanel( @@ -812,7 +817,7 @@ export class AnnotationPanel { ); const instance = new AnnotationPanel(panel, controller); AnnotationPanel.current = instance; - controller.open(imagePath, imageList, initialMode); + controller.open(imagePath, imageList, initialMode, focusName); } private constructor( diff --git a/src/boxPanels.ts b/src/boxPanels.ts index 06be009..7fe816a 100644 --- a/src/boxPanels.ts +++ b/src/boxPanels.ts @@ -1,13 +1,9 @@ -import * as fs from 'fs'; -import * as path from 'path'; import * as vscode from 'vscode'; import { AnnotationPanel } from './annotationPanel'; import { readAuthoringFile } from './boxResourceStore'; -import { AUTHORING_FILE_NAME, PixelBox } from './boxResourcePure'; -import { templatesDirectory } from './projectConfig'; -import { readImageSize } from './pngCrop'; +import { AUTHORING_FILE_NAME } from './boxResourcePure'; import { TemplateAssetData } from './templateAssetData'; -import { readPoints } from './pointResourceStore'; +import { positionResources } from './resourceAuthoring'; /** Cross-image rect path occupancy used by the unified annotation editor. */ export function boxPathOccupancy(authoring: ReturnType): Record { @@ -37,54 +33,12 @@ export function openBoxEditor( ); } -/** - * Position preview source comes directly from current authoring data. There is no separately - * indexed legacy box runtime file anymore. Rects use their exact bbox; points use a tiny marker - * box so existing hover rendering can still highlight their location. - */ -export function previewRectForPath( - root: string, - positionPath: string, -): { imagePath: string; bbox: [number, number, number, number] } | undefined { - const directory = templatesDirectory(root); - const authoring = readAuthoringFile(root, directory); - const rect = authoring.boxes.find((box) => box.path === positionPath); - if (rect) { - const imagePath = path.join(root, directory, rect.image); - if (!fs.existsSync(imagePath)) return undefined; - const size = readImageSize(fs.readFileSync(imagePath)); - if (!size) return undefined; - const pixel: PixelBox = { - x: rect.bbox[0], - y: rect.bbox[1], - w: rect.bbox[2], - h: rect.bbox[3], - }; - if (pixel.w <= 0 || pixel.h <= 0) return undefined; - return { imagePath, bbox: [pixel.x, pixel.y, pixel.w, pixel.h] }; - } - - const points = readPoints(root, directory); - if (points.errors.length) return undefined; - const point = points.file.points.find((item) => item.path === positionPath); - if (!point) return undefined; - const imagePath = path.join(root, directory, point.image); - if (!fs.existsSync(imagePath)) return undefined; - const size = readImageSize(fs.readFileSync(imagePath)); - if (!size) return undefined; - const marker = 10; - const left = Math.max(0, Math.min(size.width - 1, Math.round(point.x - marker / 2))); - const top = Math.max(0, Math.min(size.height - 1, Math.round(point.y - marker / 2))); - const width = Math.max(1, Math.min(marker, size.width - left)); - const height = Math.max(1, Math.min(marker, size.height - top)); - return { imagePath, bbox: [left, top, width, height] }; +/** Position previews and completion share the current authoring projection. */ +export function previewRectForPath(root: string, positionPath: string): { imagePath: string; bbox: [number, number, number, number] } | undefined { + const resource = positionResources(root).find(item => item.name === positionPath); + return resource ? { imagePath: resource.imagePath, bbox: resource.bbox } : undefined; } -/** Completion/hover indexes the current rect + point authoring namespace directly. */ export function posPaths(root: string): string[] { - const directory = templatesDirectory(root); - const rects = readAuthoringFile(root, directory).boxes.map((box) => box.path); - const pointResult = readPoints(root, directory); - const points = pointResult.errors.length ? [] : pointResult.file.points.map((point) => point.path); - return [...new Set([...rects, ...points])].sort((a, b) => a.localeCompare(b)); + return [...new Set(positionResources(root).map(item => item.name))].sort((a, b) => a.localeCompare(b)); } diff --git a/src/pngCrop.ts b/src/pngCrop.ts index 8cbd152..453c888 100644 --- a/src/pngCrop.ts +++ b/src/pngCrop.ts @@ -966,10 +966,10 @@ export function cropTemplateOriginalFile( export function annotatedImageToDataUrlCached( imagePath: string, bbox: [number, number, number, number], ): string | undefined { - if (!bbox.every(Number.isFinite) || bbox[2] <= 0 || bbox[3] <= 0) return undefined; + if (!bbox.every(Number.isFinite) || !((bbox[2] === 0 && bbox[3] === 0) || (bbox[2] > 0 && bbox[3] > 0))) return undefined; const contentHash = imageContentHash(imagePath); if (!contentHash) return undefined; - const key = `annotated-v3|${contentHash}|${bbox.join(',')}`; + const key = `annotated-v4|${contentHash}|${bbox.join(',')}`; const hit = CROP_CACHE.get(key); if (hit) return hit.url; try { diff --git a/src/providers.ts b/src/providers.ts index a810730..24b5f72 100644 --- a/src/providers.ts +++ b/src/providers.ts @@ -12,8 +12,7 @@ import { EffectData, EffectEntry } from './effectData'; import { DEFAULT_FEATURE_ALIASES, ideSetting, labelEnumAliases, loadProjectConfig } from './projectConfig'; import { annotatedImageToDataUrlCached, cropTemplateToDataUrlCached } from './pngCrop'; import { selectedProjectLocale, tr } from './localization'; -import { getProjectConfig } from './screenshotCapture'; -import { posPaths, previewRectForPath } from './boxPanels'; +import { AuthoringResource, positionResources, positionSummary, positionLeaf } from './resourceAuthoring'; /** 匹配 self.lang.<模块>.(支持 Unicode 标识符,如中文 OCR 文本;负向后视避免匹配 self.langx 之类) */ const EXPR_RE = /(?(); @@ -422,14 +439,8 @@ export class LangHoverProvider implements vscode.HoverProvider { } const pos = findPosMatch(line, position.character); if (pos) { - const preview = previewRectForPath(getProjectConfig().projectDir, pos.path); - const md = new vscode.MarkdownString(undefined, true); - md.appendCodeblock(`self.pos.${pos.path}.to_box()`, 'python'); - if (preview) { - const img = annotatedImageToDataUrlCached(preview.imagePath, preview.bbox); - if (img) md.appendMarkdown(`\n![box](${img})\n`); - } - return new vscode.Hover(md); + const resource = positionLeaf(positionResources(this.features.root), pos.path); + if (resource) return new vscode.Hover(formatPosition(resource)); } for (const mf of findFeatureMatches(line)) { if (position.character >= mf.start && position.character <= mf.end) { @@ -514,12 +525,22 @@ export class LangCompletionProvider implements vscode.CompletionItemProvider { if (posMatch) { const parent = posMatch[1].replace(/\.$/, ''); const prefix = parent ? `${parent}.` : ''; - const paths = posPaths(getProjectConfig().projectDir); + const resources = positionResources(this.features.root); + const paths = resources.map(item => item.name); const segments = [...new Set(paths .filter((item) => item.startsWith(prefix)) .map((item) => item.slice(prefix.length).split('.')[0]) .filter(Boolean))]; - return segments.map((segment) => new vscode.CompletionItem(segment, vscode.CompletionItemKind.Field)); + return segments.map(segment => { + const name = prefix + segment; + const leaf = positionLeaf(resources, name); + if (!leaf || paths.some(item => item.startsWith(name + '.'))) { + return new vscode.CompletionItem(segment, vscode.CompletionItemKind.Field); + } + const item = new ResourceCompletionItem(segment, name, 'position'); + item.detail = positionSummary(leaf); + return item; + }); } // 别名. -> 补全模板名(如 fL. / FeatureList.;缩略图懒加载) @@ -530,7 +551,8 @@ export class LangCompletionProvider implements vscode.CompletionItemProvider { if (/[\w.]$/.test(prefix)) continue; // 别名前是单词字符/点(如 xfL.、self.fL.),不匹配 return this.features.names().map((name, i) => { const ft = this.features.entry(name); - const item = new vscode.CompletionItem(name, vscode.CompletionItemKind.Value); + const item = new ResourceCompletionItem(name, name, 'template'); + item.kind = vscode.CompletionItemKind.Value; if (ft) { item.detail = `${ft.width}×${ft.height}`; item.sortText = '\u0000' + String(i).padStart(3, '0') + name; @@ -571,8 +593,13 @@ export class LangCompletionProvider implements vscode.CompletionItemProvider { /** 懒加载:用户选中/悬停某个补全项时,才生成该模板的缩略图预览 */ resolveCompletionItem(item: vscode.CompletionItem): vscode.CompletionItem { - const name = typeof item.label === 'string' ? item.label : ''; - const ft = name ? this.features.entry(name) : undefined; + if (!(item instanceof ResourceCompletionItem)) return item; + if (item.resourceKind === 'position') { + const resource = positionLeaf(positionResources(this.features.root), item.resourceName); + if (resource) item.documentation = formatPosition(resource); + return item; + } + const ft = this.features.entry(item.resourceName); if (ft) item.documentation = formatFeature(ft); return item; } diff --git a/src/resourceAuthoring.ts b/src/resourceAuthoring.ts new file mode 100644 index 0000000..ffa6e36 --- /dev/null +++ b/src/resourceAuthoring.ts @@ -0,0 +1,54 @@ +import * as path from 'path'; +import { CocoAnnotationData } from './cocoAnnotationData'; +import { readAuthoringFile } from './boxResourceStore'; +import { readPoints } from './pointResourceStore'; +import { templatesDirectory } from './projectConfig'; + +export type ResourceKind = 'template' | 'rect' | 'point'; + +/** A transient projection of current annotations, never written as another source of truth. */ +export interface AuthoringResource { + name: string; + kind: ResourceKind; + imagePath: string; + bbox: [number, number, number, number]; +} + +export function positionResources(root: string): AuthoringResource[] { + if (!root) return []; + const directory = templatesDirectory(root); + const boxes = readAuthoringFile(root, directory); + const points = readPoints(root, directory); + return [ + ...boxes.boxes.map(box => ({ name: box.path, kind: 'rect' as const, + imagePath: path.resolve(root, directory, box.image), bbox: box.bbox })), + ...(points.errors.length ? [] : points.file.points.map(point => ({ name: point.path, kind: 'point' as const, + imagePath: path.resolve(root, directory, point.image), bbox: [point.x, point.y, 0, 0] as [number, number, number, number] }))), + ]; +} + +export function findAuthoringResource(root: string, kind: ResourceKind, name: string): AuthoringResource | undefined { + if (!root) return undefined; + if (kind !== 'template') { + const matches = positionResources(root).filter(item => item.kind === kind && item.name === name); + return matches.length === 1 ? matches[0] : undefined; + } + const data = new CocoAnnotationData(root, templatesDirectory(root)); + data.load(); + const categories = new Map(data.data.categories.map(item => [item.id, item.name])); + const matches = data.data.annotations.filter(item => categories.get(item.category_id) === name); + if (matches.length !== 1) return undefined; + const annotation = matches[0]; + const image = data.data.images.find(item => item.id === annotation.image_id); + return image ? { name, kind, imagePath: path.resolve(data.templatesDir, image.file_name), bbox: annotation.bbox } : undefined; +} + +export function positionLeaf(resources: readonly AuthoringResource[], name: string): AuthoringResource | undefined { + const matches = resources.filter(item => item.name === name); + return matches.length === 1 ? matches[0] : undefined; +} + +export function positionSummary(resource: AuthoringResource): string { + const [x, y, w, h] = resource.bbox; + return resource.kind === 'point' ? `Point · (${x}, ${y})` : `Box · ${w}×${h}`; +} diff --git a/src/templateAssetPanel.ts b/src/templateAssetPanel.ts index 04eb77d..e14f7d9 100644 --- a/src/templateAssetPanel.ts +++ b/src/templateAssetPanel.ts @@ -15,6 +15,7 @@ import { onAnnotationDataChanged, sameAnnotationFile } from './cocoAnnotationDat import { isSameSize, scaleBoxes, SwapBox } from './annotationSwapPure'; import { boxAnnotationData } from './boxResourceStore'; import { readPoints } from './pointResourceStore'; +import { positionResources } from './resourceAuthoring'; import { applySharedAssets, getNonce } from './webviewHtml'; import { PositionPublishFormat, @@ -133,7 +134,7 @@ class AssetGalleryController { ) { liveControllers.add(this); this.disposables.push(onAnnotationDataChanged(file => { - if (sameAnnotationFile(file, this.data.annotationFile)) void this.update(); + if (sameAnnotationFile(path.dirname(file), this.data.templatesDir)) void this.update(); })); this.disposables.push( webview.onDidReceiveMessage((msg) => { void this.onMessage(msg); }), @@ -156,6 +157,15 @@ class AssetGalleryController { void vscode.window.showErrorMessage(tr('The annotation source is invalid. Fix the source file before saving or exporting.')); } this.lastReadErrors = authoringErrors.length > 0; + const positions = positionResources(this.data.root); + const searchIndex = new Map(); + const imageKey = (imagePath: string) => process.platform === 'win32' ? path.resolve(imagePath).toLowerCase() : path.resolve(imagePath); + for (const item of positions) { + const key = imageKey(item.imagePath); + const names = searchIndex.get(key) ?? []; + names.push(item.name); + searchIndex.set(key, names); + } const metas = imageFiles.map((imgPath) => { const size = this.imagePixelSize(imgPath); const cats = this.data.getCategoriesForImage(imgPath); @@ -165,6 +175,7 @@ class AssetGalleryController { width: size.width, height: size.height, categories: cats, + searchNames: searchIndex.get(imageKey(imgPath)) ?? [], annotations: this.data.getAnnotationsForImage(imgPath, true).length, }; }); diff --git a/src/templatePanel.ts b/src/templatePanel.ts index 7bfef15..d2c7a75 100644 --- a/src/templatePanel.ts +++ b/src/templatePanel.ts @@ -3,13 +3,17 @@ import * as path from 'path'; import * as vscode from 'vscode'; import { ensureEditorTracker, insertIntoPythonEditor } from './pythonEditor'; import { FeatureData } from './featureData'; -import { annotatedImageFile, cropTemplateThumbFileAsync, openAnnotatedImage, THUMB_HEIGHT } from './pngCrop'; +import { cropTemplateThumbFileAsync, THUMB_HEIGHT } from './pngCrop'; import { featureAliases } from './providers'; import { injectWebviewLocalization, tr } from './localization'; import { applySharedAssets, getNonce } from './webviewHtml'; import { readAuthoringFile } from './boxResourceStore'; import { readPoints } from './pointResourceStore'; import { templatesDirectory } from './projectConfig'; +import { findAuthoringResource } from './resourceAuthoring'; +import { AnnotationPanel } from './annotationPanel'; +import { TemplateAssetData } from './templateAssetData'; +import { sameAnnotationFile } from './cocoAnnotationData'; export type ResourcePreviewMode = 'template' | 'rect' | 'point'; @@ -199,8 +203,8 @@ class GalleryController { break; case 'open': if (typeof msg.name === 'string') { - const meta = this.metas(this.mode).find(item => item.name === msg.name); - if (meta) await this.openOriginalWithMarker(meta); + const kind = msg.mode ?? this.mode; + if (['template', 'rect', 'point'].includes(kind)) this.openOriginal({ kind, name: msg.name }); } break; default: @@ -208,20 +212,22 @@ class GalleryController { } } - private async openOriginalWithMarker(meta: ResourceMeta): Promise { - try { - const file = await vscode.window.withProgress( - { location: vscode.ProgressLocation.Notification, title: tr('ok-script-toolkit: Generating source image annotation…') }, - async () => meta.kind === 'template' - ? openAnnotatedImage(meta.imagePath, meta.name, meta.bbox, this.thumbDir, this.features.root) - : annotatedImageFile(meta.imagePath, meta.bbox, this.thumbDir), - ); - if (!file) { - void vscode.window.showWarningMessage(tr('Failed to generate annotated image: source image could not be decoded or was missing')); - return; - } - await vscode.commands.executeCommand('vscode.open', vscode.Uri.file(file)); - } catch { /* open failure is non-fatal */ } + private openOriginal(meta: Pick): void { + const resource = findAuthoringResource(this.features.root, meta.kind, meta.name); + if (!resource || !fs.existsSync(resource.imagePath)) { + void vscode.window.showWarningMessage(tr('The resource was not found in current annotations: {name}', { name: meta.name })); + return; + } + const data = new TemplateAssetData(this.features.root); + data.load(); + const images = data.listImages(); + const imagePath = images.find(item => sameAnnotationFile(item, resource.imagePath)); + if (!imagePath) { + void vscode.window.showWarningMessage(tr('The resource was not found in current annotations: {name}', { name: meta.name })); + return; + } + AnnotationPanel.show(this.extensionUri, data, this.thumbDir, imagePath, images, + () => repaintAllGalleries(), resource.kind, resource.name); } dispose(): void { From 64d0304829b4c534b85313fa0500bdda0803ad59 Mon Sep 17 00:00:00 2001 From: AliceJump <149395013+AliceJump@users.noreply.github.com> Date: Tue, 13 Oct 2026 18:47:15 +0800 Subject: [PATCH 2/4] test(resources): isolate editor mocks from local VS Code dependencies --- scripts/test_resource_navigation.js | 16 ++++++++++++---- 1 file changed, 12 insertions(+), 4 deletions(-) diff --git a/scripts/test_resource_navigation.js b/scripts/test_resource_navigation.js index d8d2d97..f487433 100644 --- a/scripts/test_resource_navigation.js +++ b/scripts/test_resource_navigation.js @@ -4,10 +4,14 @@ const os = require('os'); const path = require('path'); const vm = require('vm'); const root = path.resolve(__dirname, '..'); -function load(name, dependencies, extra = '') { +function load(name, dependencies, extra = '', isolated = false) { const exports = {}; vm.runInNewContext(fs.readFileSync(path.join(root, 'out', name + '.js'), 'utf8') + extra, { - exports, require: key => dependencies[key] || require(key.startsWith('.') ? path.join(root, 'out', key) : key), + exports, require: key => { + if (Object.hasOwn(dependencies, key)) return dependencies[key]; + if (isolated && key.startsWith('.')) throw new Error('Missing editor dependency mock: ' + key); + return require(key.startsWith('.') ? path.join(root, 'out', key) : key); + }, }); return exports; } @@ -50,9 +54,10 @@ function write(kind, name, bbox, file = 'source.png') { let renders = 0; const providers = load('providers', { vscode, './projectConfig': config, './resourceAuthoring': resources, + './langData': {}, './localization': { tr: key => key }, './pngCrop': { annotatedImageToDataUrlCached: () => { renders++; return 'data:image/png;base64,preview'; } }, - }); + }, '', true); const features = { root: project, entry: name => name === 'screen' ? { name } : undefined }; const provider = new providers.LangCompletionProvider({}, features); const items = text => provider.provideCompletionItems({ lineAt: () => ({ text }) }, { line: 0, character: text.length }); @@ -86,10 +91,13 @@ function write(kind, name, bbox, file = 'source.png') { const opened = []; const gallery = load('templatePanel', { vscode, './providers': { featureAliases: () => ['Features'] }, './resourceAuthoring': resources, + './pythonEditor': {}, './pngCrop': {}, './projectConfig': config, './webviewHtml': {}, + './boxResourceStore': {}, './pointResourceStore': {}, './localization': { tr: key => key }, './annotationPanel': { AnnotationPanel: { show: (...args) => opened.push(args) } }, './templateAssetData': { TemplateAssetData: class { load() {} listImages() { return [image, nextImage]; } } }, - }, '\nexports.Controller = GalleryController;').Controller.prototype; + './cocoAnnotationData': require('../out/cocoAnnotationData'), + }, '\nexports.Controller = GalleryController;', true).Controller.prototype; const host = { features, mode: 'template', thumbDir: folder, extensionUri: {}, openOriginal: gallery.openOriginal }; host.metas = () => [{ name: 'button', kind: 'template', imagePath: image, bbox: [0, 0, 1, 1] }]; await gallery.onMessage.call(host, { type: 'open', name: 'button', imagePath: 'forged.png' }); From 4b913a95a8cc3ff6390dc1cb3f86b335aef0303e Mon Sep 17 00:00:00 2001 From: AliceJump <149395013+AliceJump@users.noreply.github.com> Date: Tue, 13 Oct 2026 19:25:48 +0800 Subject: [PATCH 3/4] fix(resources): disambiguate position references and preserve shared completion paths --- docs/box-resources.en.md | 2 +- docs/box-resources.md | 2 +- jetbrains | 2 +- scripts/test_annotation_history.js | 2 +- scripts/test_resource_navigation.js | 10 ++++++++-- src/providers.ts | 24 +++++++++++++++--------- src/resourceAuthoring.ts | 10 ++++++++-- 7 files changed, 35 insertions(+), 17 deletions(-) diff --git a/docs/box-resources.en.md b/docs/box-resources.en.md index ff0d27b..a343959 100644 --- a/docs/box-resources.en.md +++ b/docs/box-resources.en.md @@ -59,7 +59,7 @@ Both hosts use `Ctrl+Alt+T` for resource previews and `Ctrl+Alt+S` for capture i Box / Point names in annotation management are search indexes only. They do not appear as image metadata or create persisted source fields. Lookup uses the existing `category_id` / `image_id` relationships. -`self.pos` completion remains hierarchical by dot-separated path. Intermediate nodes contain only the path segment. Leaves show `Box · width×height` or `Point · (x, y)`. Source previews are generated only when a leaf is selected; completion details share the Hover formatter. Boxes use `.to_box()` and show bbox geometry. Points use direct position expressions and pixel coordinates, with a cross marking the location. Position hints work directly from unpublished authoring data. When Rect and Point share a conflicting name, code hints do not choose either resource arbitrarily. +`self.pos` completion remains hierarchical by dot-separated path. Intermediate nodes contain only the path segment. Leaves show `Box · width×height` or `Point · (x, y)`. Source previews are generated only when a leaf is selected; completion details share the Hover formatter. Boxes use `.to_box()` and show bbox geometry. Points use direct position expressions and pixel coordinates, with a cross marking the location. Position hints work directly from unpublished authoring data. When Rect and Point share a name, existing references preserve the expression type: `.to_box()` selects Rect, while a direct position expression prefers Point. A direct reference remains supported when only Rect exists. Completion without a suffix keeps one path candidate and combines both resource details instead of duplicating namespace entries. ## Unified Publication diff --git a/docs/box-resources.md b/docs/box-resources.md index 514a2be..26f0c94 100644 --- a/docs/box-resources.md +++ b/docs/box-resources.md @@ -59,7 +59,7 @@ Rect / Point 名称至少包含两个由句点分隔的 Python 标识符,例 标注管理的 Box / Point 名称只用于搜索筛选,不显示为图片元信息,也不写入额外来源字段。反查始终沿用现有 `category_id` / `image_id` 关系。 -`self.pos` 继续按句点逐层补全。中间节点只有路径段;叶子显示 `Box · 宽×高` 或 `Point · (x, y)`。选中叶子后才生成原图位置预览,详情与 Hover 共用。Box 使用 `.to_box()` 表达式并显示 bbox;Point 使用直接位置表达式和像素坐标,预览以十字标记定位。未发布的工作标注可直接用于位置提示;同名 Rect / Point 在共用代码命名空间中无法消歧时不显示任意一种资源的详情。 +`self.pos` 继续按句点逐层补全。中间节点只有路径段;叶子显示 `Box · 宽×高` 或 `Point · (x, y)`。选中叶子后才生成原图位置预览,详情与 Hover 共用。Box 使用 `.to_box()` 表达式并显示 bbox;Point 使用直接位置表达式和像素坐标,预览以十字标记定位。未发布的工作标注可直接用于位置提示;同名 Rect / Point 的已有引用按表达式区分类型:`.to_box()` 指向 Rect,直接位置表达式优先匹配 Point;仅有 Rect 时仍支持直接引用。没有后缀信息的补全叶子保留一个路径候选,并汇总两类详情,不增加重复路径项。 ## 统一发布 diff --git a/jetbrains b/jetbrains index a97794d..f218158 160000 --- a/jetbrains +++ b/jetbrains @@ -1 +1 @@ -Subproject commit a97794d51ed305a51010624031809b4319883cee +Subproject commit f21815864dfbf3d6197f4e5ac95187feb140ec02 diff --git a/scripts/test_annotation_history.js b/scripts/test_annotation_history.js index 930f216..624582d 100644 --- a/scripts/test_annotation_history.js +++ b/scripts/test_annotation_history.js @@ -284,7 +284,7 @@ function load(mode, annotations, imagePath = 'x/a.png', extra = {}) { 'host versions every load request against the editor state it has already observed'); // Resource jumps select matching shapes after decoding and reveal hidden targets. - const target = { id: 42, category: 'screen.focus', x: 850, y: 850, w: 80, h: 80 }; + const target = { id: 42, category: 'screen.focus', x: 850, y: 550, w: 80, h: 400 }; load('rect', [target], 'x/focus.png', { focusName: target.category }); await flush(); await flush(); assert(document.querySelector('.annotation-row.is-selected'), 'new-image jump selects the resource after image decoding'); diff --git a/scripts/test_resource_navigation.js b/scripts/test_resource_navigation.js index f487433..fe2114f 100644 --- a/scripts/test_resource_navigation.js +++ b/scripts/test_resource_navigation.js @@ -110,9 +110,15 @@ function write(kind, name, bbox, file = 'source.png') { write('points', 'screen.panel', [90, 80, 0, 0]); const ambiguous = items('self.pos.screen.').find(item => item.label === 'panel'); - assert.strictEqual(ambiguous.detail, undefined, 'duplicate Box / Point names do not choose one type arbitrarily'); + assert.strictEqual(ambiguous.detail, 'Box · 30×40 / Point · (90, 80)', 'same-name leaf keeps both resource types in one namespace'); provider.resolveCompletionItem(ambiguous); - assert.strictEqual(ambiguous.documentation, undefined); + assert(ambiguous.documentation.value.includes('self.pos.screen.panel.to_box()')); + assert(ambiguous.documentation.value.includes('Point · (90, 80)')); + const typedHover = text => hover.provideHover({ lineAt: () => ({ text }) }, { line: 0, character: 18 }).contents.value; + assert(typedHover('self.pos.screen.panel.to_box()').includes('Box · 30×40')); + assert(!typedHover('self.pos.screen.panel.to_box()').includes('Point ·')); + assert(typedHover('self.pos.screen.panel').includes('Point · (90, 80)')); + assert(!typedHover('self.pos.screen.panel').includes('.to_box()')); assert.strictEqual(resources.findAuthoringResource(project, 'point', 'screen.panel').kind, 'point', 'mode-specific original editing can still resolve a same-name point'); diff --git a/src/providers.ts b/src/providers.ts index 24b5f72..13629c0 100644 --- a/src/providers.ts +++ b/src/providers.ts @@ -12,7 +12,7 @@ import { EffectData, EffectEntry } from './effectData'; import { DEFAULT_FEATURE_ALIASES, ideSetting, labelEnumAliases, loadProjectConfig } from './projectConfig'; import { annotatedImageToDataUrlCached, cropTemplateToDataUrlCached } from './pngCrop'; import { selectedProjectLocale, tr } from './localization'; -import { AuthoringResource, positionResources, positionSummary, positionLeaf } from './resourceAuthoring'; +import { AuthoringResource, positionResources, positionSummary, positionChoices, positionReferenceResource } from './resourceAuthoring'; /** 匹配 self.lang.<模块>.(支持 Unicode 标识符,如中文 OCR 文本;负向后视避免匹配 self.langx 之类) */ const EXPR_RE = /(?= match.index && character <= match.index + match[0].length) return { path: match[1] || match[2] }; + if (character >= match.index && character <= match.index + match[0].length) return { path: match[1] || match[2], boxed: !!match[1] }; } return undefined; } @@ -340,6 +340,12 @@ function formatPosition(resource: AuthoringResource): vscode.MarkdownString { return md; } +function formatPositionChoices(resources: readonly AuthoringResource[]): vscode.MarkdownString { + const md = new vscode.MarkdownString(undefined, true); + md.appendMarkdown(resources.map(resource => formatPosition(resource).value).join('\n---\n')); + return md; +} + class ResourceCompletionItem extends vscode.CompletionItem { constructor(label: string, readonly resourceName: string, readonly resourceKind: 'position' | 'template') { super(label, vscode.CompletionItemKind.Field); @@ -439,7 +445,7 @@ export class LangHoverProvider implements vscode.HoverProvider { } const pos = findPosMatch(line, position.character); if (pos) { - const resource = positionLeaf(positionResources(this.features.root), pos.path); + const resource = positionReferenceResource(positionResources(this.features.root), pos.path, pos.boxed); if (resource) return new vscode.Hover(formatPosition(resource)); } for (const mf of findFeatureMatches(line)) { @@ -533,12 +539,12 @@ export class LangCompletionProvider implements vscode.CompletionItemProvider { .filter(Boolean))]; return segments.map(segment => { const name = prefix + segment; - const leaf = positionLeaf(resources, name); - if (!leaf || paths.some(item => item.startsWith(name + '.'))) { + const choices = positionChoices(resources, name); + if (!choices.length || paths.some(item => item.startsWith(name + '.'))) { return new vscode.CompletionItem(segment, vscode.CompletionItemKind.Field); } const item = new ResourceCompletionItem(segment, name, 'position'); - item.detail = positionSummary(leaf); + item.detail = choices.map(positionSummary).join(' / '); return item; }); } @@ -595,8 +601,8 @@ export class LangCompletionProvider implements vscode.CompletionItemProvider { resolveCompletionItem(item: vscode.CompletionItem): vscode.CompletionItem { if (!(item instanceof ResourceCompletionItem)) return item; if (item.resourceKind === 'position') { - const resource = positionLeaf(positionResources(this.features.root), item.resourceName); - if (resource) item.documentation = formatPosition(resource); + const choices = positionChoices(positionResources(this.features.root), item.resourceName); + if (choices.length) item.documentation = formatPositionChoices(choices); return item; } const ft = this.features.entry(item.resourceName); diff --git a/src/resourceAuthoring.ts b/src/resourceAuthoring.ts index ffa6e36..87dc87e 100644 --- a/src/resourceAuthoring.ts +++ b/src/resourceAuthoring.ts @@ -43,9 +43,15 @@ export function findAuthoringResource(root: string, kind: ResourceKind, name: st return image ? { name, kind, imagePath: path.resolve(data.templatesDir, image.file_name), bbox: annotation.bbox } : undefined; } -export function positionLeaf(resources: readonly AuthoringResource[], name: string): AuthoringResource | undefined { +export function positionChoices(resources: readonly AuthoringResource[], name: string): AuthoringResource[] { const matches = resources.filter(item => item.name === name); - return matches.length === 1 ? matches[0] : undefined; + return matches.filter(item => matches.filter(other => other.kind === item.kind).length === 1); +} + +export function positionReferenceResource(resources: readonly AuthoringResource[], name: string, boxed: boolean): AuthoringResource | undefined { + const choices = positionChoices(resources, name); + if (boxed) return choices.find(item => item.kind === 'rect'); + return choices.find(item => item.kind === 'point') ?? (choices.length === 1 ? choices[0] : undefined); } export function positionSummary(resource: AuthoringResource): string { From 3995d6ad3aa2fd9b075163dc69f8c4c015765f41 Mon Sep 17 00:00:00 2001 From: AliceJump <149395013+AliceJump@users.noreply.github.com> Date: Wed, 14 Oct 2026 04:47:31 +0800 Subject: [PATCH 4/4] chore(jetbrains): pin merged resource navigation implementation --- jetbrains | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/jetbrains b/jetbrains index f218158..8c51484 160000 --- a/jetbrains +++ b/jetbrains @@ -1 +1 @@ -Subproject commit f21815864dfbf3d6197f4e5ac95187feb140ec02 +Subproject commit 8c5148487ae8bcae0d42727e614a36aee4fe069a