From 760aae74b8c1020afdd931a9b69ac29653d142e1 Mon Sep 17 00:00:00 2001 From: AliceJump <149395013+AliceJump@users.noreply.github.com> Date: Mon, 28 Sep 2026 13:20:47 +0800 Subject: [PATCH 01/14] feat(annotations): swap template image annotation sets --- DEVELOPMENT.en.md | 3 +- DEVELOPMENT.md | 3 +- README.en.md | 1 + README.md | 1 + jetbrains | 2 +- l10n/bundle.l10n.es.json | 10 +- l10n/bundle.l10n.ja.json | 10 +- l10n/bundle.l10n.json | 10 +- l10n/bundle.l10n.ko.json | 10 +- l10n/bundle.l10n.zh-cn.json | 10 +- l10n/bundle.l10n.zh-tw.json | 10 +- media/templateAssetPanel/app.js | 141 ++++++++++- media/templateAssetPanel/index.html | 13 + media/templateAssetPanel/style.css | 74 ++++++ package.json | 4 +- scripts/test_annotation_swap.js | 211 ++++++++++++++++ scripts/test_asset_swap_picker.js | 357 ++++++++++++++++++++++++++++ scripts/test_save_to_assets.js | 69 ++++++ src/annotationPanel.ts | 13 + src/annotationSwapPure.ts | 91 +++++++ src/localization.ts | 38 ++- src/templateAssetData.ts | 97 +++++++- src/templateAssetPanel.ts | 165 +++++++++++-- 23 files changed, 1308 insertions(+), 35 deletions(-) create mode 100644 scripts/test_annotation_swap.js create mode 100644 scripts/test_asset_swap_picker.js create mode 100644 src/annotationSwapPure.ts diff --git a/DEVELOPMENT.en.md b/DEVELOPMENT.en.md index 0626902..0f796d4 100644 --- a/DEVELOPMENT.en.md +++ b/DEVELOPMENT.en.md @@ -17,7 +17,7 @@ The shared runtime scripts, protocol, and host boundaries are documented in [Sha ## Project Structure ```text -src/ VS Code extension host TypeScript source (32 modules; entry points and major ones listed) +src/ VS Code extension host TypeScript source (33 modules; entry points and major ones listed) projectConfig.ts Read side of the project convention file ok-script-toolkit.json (locate + cache + personal preference) projectConfigPure.ts Pure precedence-chain logic (no vscode dependency, unit-testable): parse + precedence + winning layer conventionSources.ts Data source for the "Project Convention vs My Settings" tracing panel @@ -39,6 +39,7 @@ src/ VS Code extension host TypeScript source (32 module saveToAssetsPure.ts Pure export-flow logic (target list + whether to prompt for the enum path) templatePanel.ts Template gallery panel annotationPanel.ts Annotation editor panel (draw/delete annotations + box-select normalized coords) + annotationSwapPure.ts Proportional remap for annotation swapping (pure: scale + clamp; one copy per host) tempScreenshotStore.ts Temp screenshot store (up to 10, isolated per workspace on disk) tempScreenshotPanel.ts Temp screenshot sidebar view (paste/screenshot, 0.1s carousel, box-select coords) tempDrag.ts Cross-Webview drag relay (temp shots -> annotation manager; ineffective on VS Code side, see below) diff --git a/DEVELOPMENT.md b/DEVELOPMENT.md index c84f739..18483e3 100644 --- a/DEVELOPMENT.md +++ b/DEVELOPMENT.md @@ -17,7 +17,7 @@ This document is for extension developers and covers the project structure, loca ## 项目结构 ```text -src/ VS Code 扩展宿主 TypeScript 源码(32 个模块,下列为入口与主要模块) +src/ VS Code 扩展宿主 TypeScript 源码(33 个模块,下列为入口与主要模块) projectConfig.ts 项目约定文件 ok-script-toolkit.json 的读盘侧(定位 + 缓存 + 个人偏好) projectConfigPure.ts 取值链纯逻辑(不依赖 vscode,可单测):解析 + 优先级 + 来源层 conventionSources.ts 「项目约定 vs 我的设置」溯源面板的数据源 @@ -39,6 +39,7 @@ src/ VS Code 扩展宿主 TypeScript 源码(32 个模 saveToAssetsPure.ts 导出流程的纯逻辑(目标列表 + 枚举路径要不要问) templatePanel.ts 模板画廊面板 annotationPanel.ts 标注编辑器面板(画框标注 + 框选复制归一化坐标) + annotationSwapPure.ts 标注交换的比例映射(纯逻辑:按比例缩放 + 越界钳制;两端各一份) tempScreenshotStore.ts 临时截图存储(最多 10 张,按工作区隔离落盘) tempScreenshotPanel.ts 临时截图侧边栏视图(粘贴/截屏、0.1s 轮播、框选坐标) tempDrag.ts 跨 Webview 拖拽中介(临时截图 → 标注管理;VS Code 端实测无效,见下) diff --git a/README.en.md b/README.en.md index dbdedb3..067b558 100644 --- a/README.en.md +++ b/README.en.md @@ -88,6 +88,7 @@ When editing Python code, the extension automatically detects ok-script-specific - **Template code hints**: Type `fL.` or `FeatureList.` to complete template names with size info. Hover shows thumbnail preview, dimensions, and source info. - You can also open a larger grid view in the editor area via the command **ok-script Toolkit: Open Template Panel in Editor**. - Supports both sidebar (template panel and template asset views) and large editor window browsing modes. +- **Swap annotations**: in the template assets panel (annotation manager), the **⇄** button at the bottom-right of a thumbnail card swaps that image's whole annotation set with another image in the same list — the fix for "annotated the wrong image / images are in the wrong order". The target is picked from thumbnails (template names are mostly numeric, so names alone are not enough). When the two images differ in size, box coordinates are **scaled proportionally** and the confirmation dialog spells out both sizes. The swap is written in one go and **cannot be undone**, so it always asks before saving. ```python self.wait_click_feature(feature=fL.give_gift, time_out=10) diff --git a/README.md b/README.md index 5658f55..f7ab381 100644 --- a/README.md +++ b/README.md @@ -88,6 +88,7 @@ PyCharm / IntelliJ IDEA 用户请装 JetBrains 版:[ok-script Toolkit for JetB - **模板代码提示**:输入 `fL.` 或 `FeatureList.` 补全模板名称并显示尺寸,悬停显示缩略图预览、尺寸和来源信息。 - 也可通过命令 **ok-script 工具箱: 在编辑器中打开模板面板** 在编辑器区打开更大的网格视图。 - 支持侧边栏(模板面板和模板素材两个视图)和编辑器大窗口两种浏览方式。 +- **交换标注**:在模板素材面板(标注管理)里,缩略图卡片右下角的 **⇄** 按钮可以把这张图与同一列表内另一张图的标注**整套互换** —— 修"标错了图 / 图片顺序反了"用。选择目标时按缩略图挑(模板名多是数字序号,光看名字选不出来)。两张图尺寸不同时,框坐标会**按比例缩放**,并在确认框里写明缩放前后的尺寸;交换是整体写盘、**不可撤销**的,所以落盘前一定会先确认。 ```python self.wait_click_feature(feature=fL.give_gift, time_out=10) diff --git a/jetbrains b/jetbrains index e755441..af4a585 160000 --- a/jetbrains +++ b/jetbrains @@ -1 +1 @@ -Subproject commit e75544126c349e19d68e727cebe246dcc9892bc1 +Subproject commit af4a585e7eff25d7c900933465aa4ad8b3c165ee diff --git a/l10n/bundle.l10n.es.json b/l10n/bundle.l10n.es.json index feb2577..91d898c 100644 --- a/l10n/bundle.l10n.es.json +++ b/l10n/bundle.l10n.es.json @@ -207,5 +207,13 @@ "Overwrite anyway": "Sobrescribir igualmente", "Not set — ask on save": "Sin definir — preguntar al guardar", "Derived from the file name": "Derivado del nombre del archivo", - "Account store update failed: {error}": "Error al actualizar el almacén de cuentas: {error}" + "Account store update failed: {error}": "Error al actualizar el almacén de cuentas: {error}", + "Swap": "Intercambiar", + "Swap annotations between '{first}' and '{second}'?": "¿Intercambiar las anotaciones de {first} y {second}?", + "Sizes differ ({from} → {to}); boxes are scaled proportionally.": "Los tamaños difieren ({from} → {to}); las cajas se escalan proporcionalmente.", + "{first}: {firstCount} boxes, {second}: {secondCount} boxes": "{first}: {firstCount} cajas, {second}: {secondCount} cajas", + "Swapped annotations between '{first}' and '{second}'.": "Se intercambiaron las anotaciones de {first} y {second}.", + "Failed to swap annotations.": "No se pudieron intercambiar las anotaciones.", + "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." } diff --git a/l10n/bundle.l10n.ja.json b/l10n/bundle.l10n.ja.json index b8ea888..31cb796 100644 --- a/l10n/bundle.l10n.ja.json +++ b/l10n/bundle.l10n.ja.json @@ -207,5 +207,13 @@ "Overwrite anyway": "それでも上書き", "Not set — ask on save": "未設定 — 保存時に確認", "Derived from the file name": "ファイル名から導出", - "Account store update failed: {error}": "アカウントストアの更新に失敗: {error}" + "Account store update failed: {error}": "アカウントストアの更新に失敗: {error}", + "Swap": "交換", + "Swap annotations between '{first}' and '{second}'?": "{first} と {second} のアノテーションを交換しますか?", + "Sizes differ ({from} → {to}); boxes are scaled proportionally.": "画像サイズが異なります({from} → {to})。アノテーションは比率に合わせて拡縮されます。", + "{first}: {firstCount} boxes, {second}: {secondCount} boxes": "{first}:{firstCount} 件、{second}:{secondCount} 件", + "Swapped annotations between '{first}' and '{second}'.": "{first} と {second} のアノテーションを交換しました。", + "Failed to swap annotations.": "アノテーションの交換に失敗しました。", + "Cannot read image size, so annotations cannot be swapped.": "画像サイズを読み取れないため、アノテーションを交換できません。", + "Neither image has annotations, nothing to swap.": "どちらの画像にもアノテーションがないため、交換するものはありません。" } diff --git a/l10n/bundle.l10n.json b/l10n/bundle.l10n.json index f41f231..7af74d0 100644 --- a/l10n/bundle.l10n.json +++ b/l10n/bundle.l10n.json @@ -207,5 +207,13 @@ "Overwrite anyway": "Overwrite anyway", "Not set — ask on save": "Not set — ask on save", "Derived from the file name": "Derived from the file name", - "Account store update failed: {error}": "Account store update failed: {error}" + "Account store update failed: {error}": "Account store update failed: {error}", + "Swap": "Swap", + "Swap annotations between '{first}' and '{second}'?": "Swap annotations between '{first}' and '{second}'?", + "Sizes differ ({from} → {to}); boxes are scaled proportionally.": "Sizes differ ({from} → {to}); boxes are scaled proportionally.", + "{first}: {firstCount} boxes, {second}: {secondCount} boxes": "{first}: {firstCount} boxes, {second}: {secondCount} boxes", + "Swapped annotations between '{first}' and '{second}'.": "Swapped annotations between '{first}' and '{second}'.", + "Failed to swap annotations.": "Failed to swap annotations.", + "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." } diff --git a/l10n/bundle.l10n.ko.json b/l10n/bundle.l10n.ko.json index 10308f8..a387d56 100644 --- a/l10n/bundle.l10n.ko.json +++ b/l10n/bundle.l10n.ko.json @@ -207,5 +207,13 @@ "Overwrite anyway": "그래도 덮어쓰기", "Not set — ask on save": "설정 안 됨 — 저장할 때 묻기", "Derived from the file name": "파일 이름에서 유도", - "Account store update failed: {error}": "계정 스토어 업데이트 실패: {error}" + "Account store update failed: {error}": "계정 스토어 업데이트 실패: {error}", + "Swap": "교환", + "Swap annotations between '{first}' and '{second}'?": "{first} 와(과) {second} 의 어노테이션을 교환할까요?", + "Sizes differ ({from} → {to}); boxes are scaled proportionally.": "이미지 크기가 다릅니다({from} → {to}). 어노테이션은 비율에 맞춰 조정됩니다.", + "{first}: {firstCount} boxes, {second}: {secondCount} boxes": "{first}: {firstCount}개, {second}: {secondCount}개", + "Swapped annotations between '{first}' and '{second}'.": "{first} 와(과) {second} 의 어노테이션을 교환했습니다.", + "Failed to swap annotations.": "어노테이션 교환에 실패했습니다.", + "Cannot read image size, so annotations cannot be swapped.": "이미지 크기를 읽을 수 없어 어노테이션을 교환할 수 없습니다.", + "Neither image has annotations, nothing to swap.": "두 이미지 모두 어노테이션이 없어 교환할 것이 없습니다." } diff --git a/l10n/bundle.l10n.zh-cn.json b/l10n/bundle.l10n.zh-cn.json index b68af84..242b36d 100644 --- a/l10n/bundle.l10n.zh-cn.json +++ b/l10n/bundle.l10n.zh-cn.json @@ -207,5 +207,13 @@ "Overwrite anyway": "仍然覆盖", "Not set — ask on save": "未设置 —— 保存时询问", "Derived from the file name": "由文件名推导", - "Account store update failed: {error}": "账号存储更新失败:{error}" + "Account store update failed: {error}": "账号存储更新失败:{error}", + "Swap": "交换", + "Swap annotations between '{first}' and '{second}'?": "在 {first} 与 {second} 之间交换标注?", + "Sizes differ ({from} → {to}); boxes are scaled proportionally.": "两张图尺寸不同({from} → {to}),标注框会按比例缩放。", + "{first}: {firstCount} boxes, {second}: {secondCount} boxes": "{first}:{firstCount} 个标注,{second}:{secondCount} 个标注", + "Swapped annotations between '{first}' and '{second}'.": "已交换 {first} 与 {second} 的标注。", + "Failed to swap annotations.": "交换标注失败。", + "Cannot read image size, so annotations cannot be swapped.": "读不出图片尺寸,无法交换标注。", + "Neither image has annotations, nothing to swap.": "两张图都没有标注,无需交换。" } diff --git a/l10n/bundle.l10n.zh-tw.json b/l10n/bundle.l10n.zh-tw.json index 649b3b2..fc0d171 100644 --- a/l10n/bundle.l10n.zh-tw.json +++ b/l10n/bundle.l10n.zh-tw.json @@ -207,5 +207,13 @@ "Overwrite anyway": "仍然覆蓋", "Not set — ask on save": "未設定 —— 儲存時詢問", "Derived from the file name": "由檔名推導", - "Account store update failed: {error}": "帳號儲存更新失敗:{error}" + "Account store update failed: {error}": "帳號儲存更新失敗:{error}", + "Swap": "交換", + "Swap annotations between '{first}' and '{second}'?": "在 {first} 與 {second} 之間交換標註?", + "Sizes differ ({from} → {to}); boxes are scaled proportionally.": "兩張圖尺寸不同({from} → {to}),標註框會按比例縮放。", + "{first}: {firstCount} boxes, {second}: {secondCount} boxes": "{first}:{firstCount} 個標註,{second}:{secondCount} 個標註", + "Swapped annotations between '{first}' and '{second}'.": "已交換 {first} 與 {second} 的標註。", + "Failed to swap annotations.": "交換標註失敗。", + "Cannot read image size, so annotations cannot be swapped.": "讀不出圖片尺寸,無法交換標註。", + "Neither image has annotations, nothing to swap.": "兩張圖都沒有標註,無需交換。" } diff --git a/media/templateAssetPanel/app.js b/media/templateAssetPanel/app.js index 5bff317..e22baa4 100644 --- a/media/templateAssetPanel/app.js +++ b/media/templateAssetPanel/app.js @@ -6,6 +6,8 @@ const countEl = document.getElementById('count'); const emptyEl = document.getElementById('empty'); const cards = new Map(); + /** name -> 缩略图 webview URI。交换目标选择器直接复用已加载的缩略图,不再问宿主要一遍。 */ + const thumbUrls = new Map(); let metas = []; document.title = t('templateAssetsTitle'); @@ -48,6 +50,15 @@ const actDiv = document.createElement('div'); actDiv.className = 'actions'; + // 交换标注:与「删除」并列,但排在左边 —— 删除是破坏性的,放最右侧不误触 + const swapBtn = document.createElement('button'); + swapBtn.textContent = '⇄'; + swapBtn.title = t('assetSwapTooltip'); + swapBtn.addEventListener('click', (e) => { + e.stopPropagation(); + openSwapPicker(meta); + }); + actDiv.appendChild(swapBtn); const delBtn = document.createElement('button'); delBtn.textContent = 'X'; delBtn.title = t('assetDeleteTooltip'); @@ -104,25 +115,133 @@ search.addEventListener('input', () => applyFilter()); - function attachThumb(name, url) { + /** + * 一张缩略图的**唯一落地口**:写缓存 + 落到网格卡片 + 落到打开中的交换选择器。 + * + * 三处共用同一份 `thumbUrls` ⇒ 选择器里的缩略图就是网格里那一张,不必自己再要一遍。 + * 宿主是**分批异步**推缩略图的,所以这里也必须能处理"推过来时选择器已经开着"的情形: + * 选择器的列表只在打开那一刻渲染一次,不补的话缺的那几张会永远停在占位符上。 + */ + function applyThumb(name, url) { + thumbUrls.set(name, url); const card = cards.get(name); - if (!card || card.dataset.thumbDone === '1') return; - card.dataset.thumbDone = '1'; + if (card && card.dataset.thumbDone !== '1') { + card.dataset.thumbDone = '1'; + fillThumbBox(card.querySelector('.thumb-box'), url, name, t('loadFailed')); + } + for (const thumb of swapList.querySelectorAll('.swap-thumb')) { + if (thumb.dataset.name === name) fillThumbBox(thumb, url, name, ''); + } + } + + /** 把占位符换成真实缩略图(幂等:已经有 img 就不再插一张)。 */ + function fillThumbBox(box, url, name, failureText) { + if (!box || box.querySelector('img')) return; + const ph = box.querySelector('.placeholder'); const img = document.createElement('img'); - img.src = url; img.alt = name; + img.alt = name; img.style.display = 'none'; img.addEventListener('load', () => { - const ph = card.querySelector('.placeholder'); if (ph) ph.remove(); img.style.display = 'block'; }); img.addEventListener('error', () => { - const ph = card.querySelector('.placeholder'); - if (ph) { ph.textContent = t('loadFailed'); ph.style.opacity = '.8'; } + img.remove(); + if (ph && failureText) { ph.textContent = failureText; ph.style.opacity = '.8'; } }); - card.querySelector('.thumb-box').prepend(img); + box.prepend(img); + img.src = url; } + /* ---------- 交换标注:选择目标图片 ---------- + 只负责「选哪张图」,落盘与缩放都在宿主侧(那边才知道真实尺寸与磁盘状态)。 + 选中的是**当前模板集里的另一张图**,不含自己。 */ + const swapModal = document.getElementById('swapModal'); + const swapList = document.getElementById('swapList'); + + function openSwapPicker(source) { + document.getElementById('swapTitle').textContent = t('assetSwapTitle'); + document.getElementById('swapHint').textContent = t('assetSwapHint'); + document.getElementById('swapCancel').textContent = t('cancel'); + swapList.innerHTML = ''; + + const others = metas.filter((m) => m.name !== source.name); + if (!others.length) { + const empty = document.createElement('div'); + empty.className = 'swap-empty'; + empty.textContent = t('assetSwapEmpty'); + swapList.appendChild(empty); + } else { + for (const other of others) swapList.appendChild(makeSwapItem(source, other)); + requestMissingThumbs(others); + } + swapModal.classList.add('visible'); + } + + /** + * 让宿主**现裁现推**还没有 URL 的那几张缩略图(走的是网格同一条管线)。 + * + * 不主动要的话,缺的那几张会一直停在占位符上:网格的缩略图是分批异步推的, + * 用户完全可能在推完之前就打开了选择器,而列表只在打开那一刻渲染一次。 + */ + function requestMissingThumbs(items) { + const missing = items.filter((m) => !thumbUrls.has(m.name)).map((m) => m.imagePath); + if (missing.length) vscode.postMessage({ type: 'requestThumbs', imagePaths: missing }); + } + + function makeSwapItem(source, other) { + const item = document.createElement('button'); + item.type = 'button'; + item.className = 'swap-item'; + item.title = other.name; + + const thumb = document.createElement('div'); + thumb.className = 'swap-thumb'; + // dataset.name 是宿主后补缩略图时的挂载点(applyThumb 按名字回填) + thumb.dataset.name = other.name; + const ph = document.createElement('span'); + ph.className = 'placeholder'; + ph.textContent = '...'; + thumb.appendChild(ph); + const url = thumbUrls.get(other.name); + if (url) fillThumbBox(thumb, url, other.name, ''); + + const name = document.createElement('div'); + name.className = 'swap-name'; + name.textContent = other.name; + const metaLine = document.createElement('div'); + metaLine.className = 'swap-meta'; + metaLine.textContent = other.width + 'x' + other.height + ' · ' + + (other.annotations ? t('assetSwapBoxes', { count: other.annotations }) : t('assetSwapNoBoxes')); + + item.appendChild(thumb); + item.appendChild(name); + item.appendChild(metaLine); + item.addEventListener('click', () => { + closeSwapPicker(); + vscode.postMessage({ + type: 'swapAnnotations', + imagePath: source.imagePath, + targetPath: other.imagePath, + }); + }); + return item; + } + + function closeSwapPicker() { + swapModal.classList.remove('visible'); + swapList.innerHTML = ''; + } + + document.getElementById('swapCancel').addEventListener('click', closeSwapPicker); + // 点遮罩关闭:只在按下遮罩本身时关,避免从列表里拖选时误关 + swapModal.addEventListener('mousedown', (e) => { + if (e.target === swapModal) closeSwapPicker(); + }); + document.addEventListener('keydown', (e) => { + if (e.key === 'Escape' && swapModal.classList.contains('visible')) closeSwapPicker(); + }); + document.getElementById('importBtn').addEventListener('click', () => { vscode.postMessage({ type: 'importFile' }); }); @@ -139,6 +258,10 @@ case 'templates': { grid.innerHTML = ''; cards.clear(); metas = msg.templates || []; + // 图片集合可能已变化 ⇒ 缩略图 URL 缓存整份作废。 + // ⚠️ 不能只按名字删:模板名就是数字序号、**删除后会被复用**(nextImageName 取第一个空号), + // 留着的旧 URL 会让选择器显示上一张同名图的缩略图 —— 看错图就会选错交换目标。 + thumbUrls.clear(); for (const meta of metas) { const card = makeCard(meta); cards.set(meta.name, card); @@ -148,7 +271,7 @@ break; } case 'thumbs': { - for (const it of (msg.items || [])) attachThumb(it.name, it.url); + for (const it of (msg.items || [])) applyThumb(it.name, it.url); break; } } diff --git a/media/templateAssetPanel/index.html b/media/templateAssetPanel/index.html index 71a4ba6..d4011e4 100644 --- a/media/templateAssetPanel/index.html +++ b/media/templateAssetPanel/index.html @@ -20,6 +20,19 @@
+ + + +