From 9dcb60dced327e2623d6350c4771321e535991e9 Mon Sep 17 00:00:00 2001 From: AliceJump <2630923991@qq.com> Date: Mon, 5 Oct 2026 00:44:37 +0800 Subject: [PATCH 01/57] feat(annotation): add three-way merge core --- scripts/test_annotation_merge.js | 55 ++++++++ src/annotationMergePure.ts | 232 +++++++++++++++++++++++++++++++ 2 files changed, 287 insertions(+) create mode 100644 scripts/test_annotation_merge.js create mode 100644 src/annotationMergePure.ts diff --git a/scripts/test_annotation_merge.js b/scripts/test_annotation_merge.js new file mode 100644 index 0000000..5bc19e5 --- /dev/null +++ b/scripts/test_annotation_merge.js @@ -0,0 +1,55 @@ +const assert = require('assert'); +const { mergeAnnotations } = require('../out/annotationMergePure'); + +const ann = (id, category, x, y, w = 10, h = 10) => ({ id, category, x, y, w, h }); + +{ + const base = [ann(1, 'screen.a', 10, 10), ann(2, 'screen.b', 20, 20)]; + const local = [ann(1, 'screen.a', 11, 10), ann(2, 'screen.b', 20, 20)]; + const external = [ann(1, 'screen.a', 10, 10), ann(2, 'screen.b', 20, 21)]; + const result = mergeAnnotations('rect', base, local, external); + assert.deepStrictEqual(result.conflicts, []); + assert.strictEqual(result.merged.find(x => x.category === 'screen.a').x, 11); + assert.strictEqual(result.merged.find(x => x.category === 'screen.b').y, 21); +} + +{ + const base = [ann(7, 'title', 10, 10)]; + const local = [ann(7, 'title', 30, 10)]; + const external = [ann(7, 'title', 40, 10)]; + const result = mergeAnnotations('template', base, local, external); + assert.strictEqual(result.conflicts.length, 1); + assert.deepStrictEqual(result.conflicts[0].fields, ['x']); + assert.strictEqual(result.merged[0].x, 30, 'local candidate stays editable while external candidate is retained in conflict data'); +} + +{ + const base = [ann(1, 'screen.point', 10, 10, 0, 0)]; + const local = []; + const external = [ann(1, 'screen.point', 11, 10, 0, 0)]; + const result = mergeAnnotations('point', base, local, external); + assert.strictEqual(result.conflicts.length, 1); + assert.strictEqual(result.conflicts[0].kind, 'delete-modify'); + assert.strictEqual(result.conflicts[0].local, undefined); +} + +{ + const base = [ann(4, 'old_name', 10, 10)]; + const local = [ann(4, 'new_name', 10, 10)]; + const external = [ann(4, 'old_name', 10, 15)]; + const result = mergeAnnotations('template', base, local, external); + assert.strictEqual(result.conflicts.length, 0, 'template id lets rename and geometry edit merge'); + assert.strictEqual(result.merged[0].category, 'new_name'); + assert.strictEqual(result.merged[0].y, 15); +} + +{ + const base = []; + const local = [ann(1, 'screen.same', 10, 10)]; + const external = [ann(99, 'screen.same', 20, 10)]; + const result = mergeAnnotations('rect', base, local, external); + assert.strictEqual(result.conflicts.length, 1); + assert.strictEqual(result.conflicts[0].kind, 'add-add'); +} + +console.log('annotation merge tests passed'); diff --git a/src/annotationMergePure.ts b/src/annotationMergePure.ts new file mode 100644 index 0000000..a447e4f --- /dev/null +++ b/src/annotationMergePure.ts @@ -0,0 +1,232 @@ +export type AnnotationMergeMode = 'template' | 'rect' | 'point'; + +export interface MergeAnnotation { + id: number; + category: string; + x: number; + y: number; + w: number; + h: number; +} + +export type AnnotationConflictKind = 'modify-modify' | 'delete-modify' | 'add-add'; + +export interface AnnotationConflict { + key: string; + kind: AnnotationConflictKind; + fields: Array<'category' | 'x' | 'y' | 'w' | 'h'>; + base?: MergeAnnotation; + local?: MergeAnnotation; + external?: MergeAnnotation; +} + +export interface AnnotationMergeResult { + merged: MergeAnnotation[]; + conflicts: AnnotationConflict[]; +} + +const FIELDS: Array<'category' | 'x' | 'y' | 'w' | 'h'> = ['category', 'x', 'y', 'w', 'h']; + +function cloneAnnotation(value: MergeAnnotation | undefined): MergeAnnotation | undefined { + return value ? { ...value } : undefined; +} + +function sameValue(a: unknown, b: unknown): boolean { + return a === b; +} + +function sameAnnotation(a: MergeAnnotation | undefined, b: MergeAnnotation | undefined): boolean { + if (!a || !b) return a === b; + return FIELDS.every(field => sameValue(a[field], b[field])); +} + +function categoryKey(value: MergeAnnotation): string { + return value.category.trim(); +} + +function geometryEqual(a: MergeAnnotation, b: MergeAnnotation): boolean { + return a.x === b.x && a.y === b.y && a.w === b.w && a.h === b.h; +} + +function findMatch( + base: MergeAnnotation, + candidates: readonly MergeAnnotation[], + used: Set, + mode: AnnotationMergeMode, +): number { + const available = (predicate: (candidate: MergeAnnotation) => boolean): number => { + for (let index = 0; index < candidates.length; index++) { + if (!used.has(index) && predicate(candidates[index])) return index; + } + return -1; + }; + + if (mode === 'template') { + const byId = available(candidate => candidate.id === base.id); + if (byId >= 0) return byId; + } + + const key = categoryKey(base); + const byCategory = available(candidate => categoryKey(candidate) === key); + if (byCategory >= 0) return byCategory; + + if (mode === 'template') { + const byGeometry = available(candidate => geometryEqual(candidate, base)); + if (byGeometry >= 0) return byGeometry; + } + + return -1; +} + +function mergeExisting( + key: string, + base: MergeAnnotation, + local: MergeAnnotation | undefined, + external: MergeAnnotation | undefined, +): { annotation?: MergeAnnotation; conflict?: AnnotationConflict } { + if (!local && !external) return {}; + if (!local) { + if (sameAnnotation(base, external)) return {}; + return { + conflict: { + key, + kind: 'delete-modify', + fields: FIELDS.filter(field => external && !sameValue(base[field], external[field])), + base: cloneAnnotation(base), + external: cloneAnnotation(external), + }, + }; + } + if (!external) { + if (sameAnnotation(base, local)) return {}; + return { + annotation: cloneAnnotation(local), + conflict: { + key, + kind: 'delete-modify', + fields: FIELDS.filter(field => !sameValue(base[field], local[field])), + base: cloneAnnotation(base), + local: cloneAnnotation(local), + }, + }; + } + + if (sameAnnotation(local, external)) return { annotation: cloneAnnotation(local) }; + if (sameAnnotation(base, local)) return { annotation: cloneAnnotation(external) }; + if (sameAnnotation(base, external)) return { annotation: cloneAnnotation(local) }; + + const merged: MergeAnnotation = { ...local }; + const conflicts: AnnotationConflict['fields'] = []; + for (const field of FIELDS) { + const baseValue = base[field]; + const localValue = local[field]; + const externalValue = external[field]; + if (sameValue(localValue, externalValue)) merged[field] = localValue as never; + else if (sameValue(localValue, baseValue)) merged[field] = externalValue as never; + else if (sameValue(externalValue, baseValue)) merged[field] = localValue as never; + else conflicts.push(field); + } + + return conflicts.length + ? { + annotation: merged, + conflict: { + key, + kind: 'modify-modify', + fields: conflicts, + base: cloneAnnotation(base), + local: cloneAnnotation(local), + external: cloneAnnotation(external), + }, + } + : { annotation: merged }; +} + +function additionMatch( + value: MergeAnnotation, + candidates: readonly MergeAnnotation[], + used: Set, + mode: AnnotationMergeMode, +): number { + for (let index = 0; index < candidates.length; index++) { + if (used.has(index)) continue; + const candidate = candidates[index]; + if (categoryKey(candidate) === categoryKey(value)) return index; + } + if (mode === 'template') { + for (let index = 0; index < candidates.length; index++) { + if (used.has(index)) continue; + const candidate = candidates[index]; + if (candidate.id === value.id || geometryEqual(candidate, value)) return index; + } + } + return -1; +} + +/** + * Three-way merge for one image and one annotation mode. + * + * `base` is the last canonical snapshot shown by the editor, `local` is the + * current in-editor state, and `external` is the newest valid on-disk state. + * Non-overlapping edits are merged automatically. True collisions are returned + * separately and the merged list deliberately prefers the local candidate for + * conflicting fields so callers can keep editing without losing either side. + */ +export function mergeAnnotations( + mode: AnnotationMergeMode, + base: readonly MergeAnnotation[], + local: readonly MergeAnnotation[], + external: readonly MergeAnnotation[], +): AnnotationMergeResult { + const merged: MergeAnnotation[] = []; + const conflicts: AnnotationConflict[] = []; + const usedLocal = new Set(); + const usedExternal = new Set(); + + for (const baseItem of base) { + const localIndex = findMatch(baseItem, local, usedLocal, mode); + const externalIndex = findMatch(baseItem, external, usedExternal, mode); + if (localIndex >= 0) usedLocal.add(localIndex); + if (externalIndex >= 0) usedExternal.add(externalIndex); + const localItem = localIndex >= 0 ? local[localIndex] : undefined; + const externalItem = externalIndex >= 0 ? external[externalIndex] : undefined; + const result = mergeExisting( + mode === 'template' ? `template:${baseItem.id}:${baseItem.category}` : `${mode}:${categoryKey(baseItem)}`, + baseItem, + localItem, + externalItem, + ); + if (result.annotation) merged.push(result.annotation); + if (result.conflict) conflicts.push(result.conflict); + } + + for (let localIndex = 0; localIndex < local.length; localIndex++) { + if (usedLocal.has(localIndex)) continue; + const localItem = local[localIndex]; + const externalIndex = additionMatch(localItem, external, usedExternal, mode); + if (externalIndex < 0) { + merged.push({ ...localItem }); + continue; + } + usedExternal.add(externalIndex); + const externalItem = external[externalIndex]; + if (sameAnnotation(localItem, externalItem)) { + merged.push({ ...localItem }); + continue; + } + merged.push({ ...localItem }); + conflicts.push({ + key: mode === 'template' ? `template:new:${localItem.category}` : `${mode}:${categoryKey(localItem)}`, + kind: 'add-add', + fields: FIELDS.filter(field => !sameValue(localItem[field], externalItem[field])), + local: cloneAnnotation(localItem), + external: cloneAnnotation(externalItem), + }); + } + + for (let externalIndex = 0; externalIndex < external.length; externalIndex++) { + if (!usedExternal.has(externalIndex)) merged.push({ ...external[externalIndex] }); + } + + return { merged, conflicts }; +} From 035653f360b2eabbdfe08ba13ec4cc162f092a12 Mon Sep 17 00:00:00 2001 From: AliceJump <2630923991@qq.com> Date: Mon, 5 Oct 2026 00:49:29 +0800 Subject: [PATCH 02/57] feat(annotation): merge external edits before save --- src/annotationPanel.ts | 299 +++++++++++++++++++++++++++++++---------- 1 file changed, 228 insertions(+), 71 deletions(-) diff --git a/src/annotationPanel.ts b/src/annotationPanel.ts index 2119505..093836e 100644 --- a/src/annotationPanel.ts +++ b/src/annotationPanel.ts @@ -8,17 +8,18 @@ import { onAnnotationDataChanged, sameAnnotationFile } from './cocoAnnotationDat import { readImageSize } from './pngCrop'; import { injectWebviewLocalization, tr } from './localization'; import { applySharedAssets, getNonce } from './webviewHtml'; -import { POINT_AUTHORING_FILE, pointPathOccupancy, pointsForImage, savePointsForImage } from './pointResourceStore'; +import { POINT_AUTHORING_FILE, pointPathOccupancy, pointsForImage, readPoints, savePointsForImage } from './pointResourceStore'; +import { AnnotationConflict, MergeAnnotation, mergeAnnotations } from './annotationMergePure'; export type UnifiedAnnotationMode = 'template' | 'rect' | 'point'; -interface Annotation { - id: number; - category: string; - x: number; - y: number; - w: number; - h: number; +interface Annotation extends MergeAnnotation {} + +interface ModeSnapshot { + annotations: Annotation[]; + allCategories: Record; + revision: string | undefined; + errors: string[]; } function normalizeAnnotationMode(value: UnifiedAnnotationMode | boolean): UnifiedAnnotationMode { @@ -27,11 +28,30 @@ function normalizeAnnotationMode(value: UnifiedAnnotationMode | boolean): Unifie return value; } +function cloneAnnotations(values: readonly Annotation[]): Annotation[] { + return values.map(value => ({ ...value })); +} + class AnnotationController { private generation = 0; private disposed = false; private saving = false; - private sourceRevision: string | undefined; + private readonly sourceRevisions: Record = { + template: undefined, + rect: undefined, + point: undefined, + }; + private readonly baseSnapshots: Record = { + template: [], + rect: [], + point: [], + }; + private readonly localSnapshots: Record = { + template: [], + rect: [], + point: [], + }; + private readonly pendingExternal = new Set(); private readonly disposables: vscode.Disposable[] = []; private _currentImage: string | undefined; private _imageList: string[] = []; @@ -55,11 +75,15 @@ class AnnotationController { webview.onDidReceiveMessage((msg) => { void this.onMessage(msg); }), onAnnotationDataChanged(file => { if (this.saving || !this._currentImage) return; - if (this.activeSourceMatches(file)) { - let revision: string | undefined; - try { revision = fs.readFileSync(file, 'utf8'); } catch { /* deleted or unreadable source */ } - if (revision !== this.sourceRevision) this.reloadIfShowing([this._currentImage]); - } else if (this.isPositionSource(file)) { + const changedMode = this.modeForSource(file); + if (changedMode) { + const revision = this.readSourceRevision(changedMode); + if (revision !== this.sourceRevisions[changedMode]) { + this.pendingExternal.add(changedMode); + if (changedMode === this.mode && this.isVisible()) this.showExternalChangeNotice(changedMode); + } + } + if (this.isPositionSource(file)) { const positionPaths = this.positionOccupancy(); void this.webview.postMessage({ type: 'positionPaths', positionPaths }); // Transitional alias for already-open pre-unification webviews/tests. @@ -78,6 +102,15 @@ class AnnotationController { get root(): string { return this.templateData.root; } get currentImage(): string | undefined { return this._currentImage; } + private resetMergeState(): void { + for (const mode of ['template', 'rect', 'point'] as const) { + this.sourceRevisions[mode] = undefined; + this.baseSnapshots[mode] = []; + this.localSnapshots[mode] = []; + } + this.pendingExternal.clear(); + } + private dataForMode(mode = this.mode): TemplateAssetData | undefined { if (mode === 'template') return this.templateData; if (mode === 'rect') return this.boxData; @@ -91,6 +124,13 @@ class AnnotationController { private activeSourceFile(): string { return this.sourceFileForMode(this.mode); } + private modeForSource(file: string): UnifiedAnnotationMode | undefined { + for (const mode of ['template', 'rect', 'point'] as const) { + if (sameAnnotationFile(file, this.sourceFileForMode(mode))) return mode; + } + return undefined; + } + private activeSourceMatches(file: string): boolean { return sameAnnotationFile(file, this.activeSourceFile()); } @@ -100,6 +140,64 @@ class AnnotationController { || sameAnnotationFile(file, path.join(this.templateData.templatesDir, POINT_AUTHORING_FILE)); } + private readSourceRevision(mode: UnifiedAnnotationMode): string | undefined { + try { return fs.readFileSync(this.sourceFileForMode(mode), 'utf8'); } + catch { return undefined; } + } + + private snapshotForMode(imagePath: string, mode: UnifiedAnnotationMode): ModeSnapshot { + const activeData = this.dataForMode(mode); + if (activeData) { + activeData.load(); + const annotations = activeData.getAnnotationsForImage(imagePath, true).map(a => ({ + id: a.id, + category: a.categoryName, + x: a.bbox[0], y: a.bbox[1], w: a.bbox[2], h: a.bbox[3], + })); + const allCategories: Record = {}; + for (const img of activeData.data.images) { + const imgPath = path.join(activeData.templatesDir, img.file_name); + if (imgPath === imagePath) continue; + for (const name of activeData.getCategoriesForImage(imgPath)) allCategories[name] = img.file_name; + } + return { + annotations, + allCategories, + revision: activeData.revision, + errors: [...activeData.readErrors], + }; + } + + const result = readPoints(this.root, path.relative(this.root, this.templateData.templatesDir)); + const imageName = path.basename(imagePath); + const own = result.file.points.filter(point => point.image.toLowerCase() === imageName.toLowerCase()); + const annotations = own.map((point, index) => ({ + id: index + 1, + category: point.path, + x: point.x, + y: point.y, + w: 0, + h: 0, + })); + const ownNames = new Set(own.map(point => point.path)); + const allCategories = Object.fromEntries( + result.file.points.filter(point => !ownNames.has(point.path)).map(point => [point.path, point.image]), + ); + return { + annotations, + allCategories, + revision: this.readSourceRevision(mode), + errors: [...result.errors], + }; + } + + private showExternalChangeNotice(mode: UnifiedAnnotationMode): void { + const label = mode === 'template' ? 'Template' : mode === 'rect' ? 'Box' : 'Point'; + void vscode.window.showWarningMessage( + tr('{mode} annotations changed outside the editor. Your next save will merge both versions.', { mode: label }), + ); + } + reloadIfShowing(imagePaths: readonly string[]): void { if (this.disposed || !this._currentImage || !imagePaths.includes(this._currentImage)) return; void this.loadImage(this._currentImage); @@ -129,6 +227,7 @@ class AnnotationController { } open(imagePath: string, imageList: string[], mode?: UnifiedAnnotationMode | boolean): void { + if (this._currentImage && this._currentImage !== imagePath) this.resetMergeState(); if (mode !== undefined) this.mode = normalizeAnnotationMode(mode); this._imageList = [...imageList]; this._currentImage = imagePath; @@ -138,16 +237,14 @@ class AnnotationController { private async loadImage(imagePath: string): Promise { if (this.disposed) return; + if (this._currentImage && this._currentImage !== imagePath) this.resetMergeState(); this._currentImage = imagePath; const generation = ++this.generation; - const activeData = this.dataForMode(); - if (activeData) { - activeData.load(); - this.sourceRevision = activeData.revision; - } else { - try { this.sourceRevision = fs.readFileSync(this.activeSourceFile(), 'utf8'); } - catch { this.sourceRevision = undefined; } - } + const snapshot = this.snapshotForMode(imagePath, this.mode); + this.sourceRevisions[this.mode] = snapshot.revision; + this.baseSnapshots[this.mode] = cloneAnnotations(snapshot.annotations); + this.localSnapshots[this.mode] = cloneAnnotations(snapshot.annotations); + this.pendingExternal.delete(this.mode); let imageBase64 = ''; try { @@ -161,41 +258,13 @@ class AnnotationController { } catch { imageBase64 = ''; } if (this.disposed || generation !== this.generation) return; - let annotations: Annotation[] = []; - let allCategories: Record = {}; - if (activeData) { - const source = activeData.getAnnotationsForImage(imagePath, true); - annotations = source.map(a => ({ - id: a.id, - category: a.categoryName, - x: a.bbox[0], y: a.bbox[1], w: a.bbox[2], h: a.bbox[3], - })); - for (const img of activeData.data.images) { - const imgPath = path.join(activeData.templatesDir, img.file_name); - if (imgPath === imagePath) continue; - for (const name of activeData.getCategoriesForImage(imgPath)) allCategories[name] = img.file_name; - } - } else { - const points = pointsForImage(this.root, this.templateData.templatesDir, path.basename(imagePath)); - annotations = points.map((point, index) => ({ - id: index + 1, - category: point.path, - x: point.x, - y: point.y, - w: 0, - h: 0, - })); - allCategories = pointPathOccupancy(this.root, this.templateData.templatesDir); - for (const point of points) delete allCategories[point.path]; - } - const positionPaths = this.positionOccupancy(); await this.webview.postMessage({ type: 'load', imagePath, imageBase64, - annotations, - allCategories, + annotations: snapshot.annotations, + allCategories: snapshot.allCategories, positionPaths, boxPaths: positionPaths, annotationMode: this.mode, @@ -238,13 +307,13 @@ class AnnotationController { } break; case 'save': - if (this._currentImage && msg.annotations && this.persistAnnotationsForMode(this._currentImage, this.mode, msg.annotations)) { + if (this._currentImage && msg.annotations && await this.persistAnnotationsForMode(this._currentImage, this.mode, msg.annotations)) { this.onSaved(this._currentImage); } break; case 'saveMode': if (this._currentImage && msg.mode && msg.annotations - && this.persistAnnotationsForMode(this._currentImage, msg.mode, msg.annotations)) { + && await this.persistAnnotationsForMode(this._currentImage, msg.mode, msg.annotations)) { this.onSaved(this._currentImage); } break; @@ -286,7 +355,92 @@ class AnnotationController { } } - private persistAnnotationsForMode(imagePath: string, mode: UnifiedAnnotationMode, annotations: Annotation[]): boolean { + private conflictIdentityMatch(mode: UnifiedAnnotationMode, left: Annotation, right: Annotation): boolean { + if (mode === 'template') return left.id === right.id || left.category === right.category; + return left.category.trim() === right.category.trim(); + } + + private useExternalConflictCandidates( + mode: UnifiedAnnotationMode, + merged: readonly Annotation[], + conflicts: readonly AnnotationConflict[], + ): Annotation[] { + const resolved = cloneAnnotations(merged); + for (const conflict of conflicts) { + if (conflict.local) { + const index = resolved.findIndex(item => this.conflictIdentityMatch(mode, item, conflict.local as Annotation)); + if (index >= 0) resolved.splice(index, 1); + } + if (conflict.external) { + const external = { ...conflict.external } as Annotation; + const existing = resolved.findIndex(item => this.conflictIdentityMatch(mode, item, external)); + if (existing >= 0) resolved[existing] = external; + else resolved.push(external); + } + } + return resolved; + } + + private async mergeExternalBeforeSave( + imagePath: string, + mode: UnifiedAnnotationMode, + local: readonly Annotation[], + ): Promise<{ annotations: Annotation[]; mergedExternal: boolean } | undefined> { + const diskRevision = this.readSourceRevision(mode); + if (!this.pendingExternal.has(mode) && diskRevision === this.sourceRevisions[mode]) { + return { annotations: cloneAnnotations(local), mergedExternal: false }; + } + + const external = this.snapshotForMode(imagePath, mode); + if (external.errors.length) { + void vscode.window.showErrorMessage( + tr('The externally changed annotation file is not valid. Your editor copy was kept and nothing was overwritten.'), + ); + this.pendingExternal.add(mode); + return undefined; + } + + if (external.revision === this.sourceRevisions[mode]) { + this.pendingExternal.delete(mode); + return { annotations: cloneAnnotations(local), mergedExternal: false }; + } + + const result = mergeAnnotations(mode, this.baseSnapshots[mode], local, external.annotations); + let annotations = result.merged as Annotation[]; + if (result.conflicts.length) { + const choice = await vscode.window.showWarningMessage( + tr('The annotation file changed externally and {count} conflict(s) need a choice. Both versions are still preserved.', { + count: String(result.conflicts.length), + }), + { modal: true }, + tr('Keep editor conflicts'), + tr('Use external conflicts'), + ); + if (!choice) { + this.pendingExternal.add(mode); + return undefined; + } + if (choice === tr('Use external conflicts')) { + annotations = this.useExternalConflictCandidates(mode, annotations, result.conflicts); + } + } + + // The external snapshot becomes the new merge base. A later save still + // checks the revision again, so another external write cannot be lost. + this.baseSnapshots[mode] = cloneAnnotations(external.annotations); + this.sourceRevisions[mode] = external.revision; + this.pendingExternal.delete(mode); + return { annotations, mergedExternal: true }; + } + + private async persistAnnotationsForMode( + imagePath: string, + mode: UnifiedAnnotationMode, + localAnnotations: Annotation[], + ): Promise { + const prepared = await this.mergeExternalBeforeSave(imagePath, mode, localAnnotations); + if (!prepared) return false; + const annotations = prepared.annotations; try { this.saving = true; if (mode === 'point') { @@ -297,24 +451,27 @@ class AnnotationController { annotations.map(ann => ({ path: ann.category.trim(), x: ann.x, y: ann.y })), ); if (error) throw new Error(error); - if (mode === this.mode) { - try { this.sourceRevision = fs.readFileSync(this.sourceFileForMode(mode), 'utf8'); } - catch { this.sourceRevision = undefined; } - } - return true; + } else { + const data = this.dataForMode(mode)!; + data.load(); + if (data.readErrors.length) throw new Error('parse'); + const mapped = annotations.map(ann => ({ + category: mode === 'rect' ? ann.category.trim() : ann.category, + x: ann.x, y: ann.y, w: ann.w, h: ann.h, + })); + const namesError = mode === 'rect' ? boxNamesError(data, imagePath, mapped.map(ann => ann.category)) : undefined; + if (namesError) throw new Error(namesError); + if (!data.setAnnotationsForImage(imagePath, mapped)) throw new Error('geometry'); + data.save(); } - const data = this.dataForMode(mode)!; - data.load(); - const mapped = annotations.map(ann => ({ - category: mode === 'rect' ? ann.category.trim() : ann.category, - x: ann.x, y: ann.y, w: ann.w, h: ann.h, - })); - const namesError = mode === 'rect' ? boxNamesError(data, imagePath, mapped.map(ann => ann.category)) : undefined; - if (namesError) throw new Error(namesError); - if (!data.setAnnotationsForImage(imagePath, mapped)) throw new Error('geometry'); - data.save(); - if (mode === this.mode) this.sourceRevision = data.revision; + const canonical = this.snapshotForMode(imagePath, mode); + if (canonical.errors.length) throw new Error('canonical'); + this.sourceRevisions[mode] = canonical.revision; + this.baseSnapshots[mode] = cloneAnnotations(canonical.annotations); + this.localSnapshots[mode] = cloneAnnotations(canonical.annotations); + this.pendingExternal.delete(mode); + if (prepared.mergedExternal && mode === this.mode) await this.loadImage(imagePath); return true; } catch (error) { console.error('[ok-script] save annotations:', error); From 87e01c120847dc2cfc4749d761b51df757c58fcd Mon Sep 17 00:00:00 2001 From: AliceJump <2630923991@qq.com> Date: Mon, 5 Oct 2026 00:57:13 +0800 Subject: [PATCH 03/57] fix(annotation): keep conflicts pending for editor UI --- src/annotationPanel.ts | 79 ++++++++++-------------------------------- 1 file changed, 19 insertions(+), 60 deletions(-) diff --git a/src/annotationPanel.ts b/src/annotationPanel.ts index 093836e..ad13642 100644 --- a/src/annotationPanel.ts +++ b/src/annotationPanel.ts @@ -8,8 +8,8 @@ import { onAnnotationDataChanged, sameAnnotationFile } from './cocoAnnotationDat import { readImageSize } from './pngCrop'; import { injectWebviewLocalization, tr } from './localization'; import { applySharedAssets, getNonce } from './webviewHtml'; -import { POINT_AUTHORING_FILE, pointPathOccupancy, pointsForImage, readPoints, savePointsForImage } from './pointResourceStore'; -import { AnnotationConflict, MergeAnnotation, mergeAnnotations } from './annotationMergePure'; +import { POINT_AUTHORING_FILE, pointPathOccupancy, readPoints, savePointsForImage } from './pointResourceStore'; +import { MergeAnnotation, mergeAnnotations } from './annotationMergePure'; export type UnifiedAnnotationMode = 'template' | 'rect' | 'point'; @@ -80,7 +80,7 @@ class AnnotationController { const revision = this.readSourceRevision(changedMode); if (revision !== this.sourceRevisions[changedMode]) { this.pendingExternal.add(changedMode); - if (changedMode === this.mode && this.isVisible()) this.showExternalChangeNotice(changedMode); + if (changedMode === this.mode && this.isVisible()) this.showExternalChangeNotice(); } } if (this.isPositionSource(file)) { @@ -122,8 +122,6 @@ class AnnotationController { return data?.annotationFile ?? path.join(this.templateData.templatesDir, POINT_AUTHORING_FILE); } - private activeSourceFile(): string { return this.sourceFileForMode(this.mode); } - private modeForSource(file: string): UnifiedAnnotationMode | undefined { for (const mode of ['template', 'rect', 'point'] as const) { if (sameAnnotationFile(file, this.sourceFileForMode(mode))) return mode; @@ -131,10 +129,6 @@ class AnnotationController { return undefined; } - private activeSourceMatches(file: string): boolean { - return sameAnnotationFile(file, this.activeSourceFile()); - } - private isPositionSource(file: string): boolean { return sameAnnotationFile(file, this.boxData.annotationFile) || sameAnnotationFile(file, path.join(this.templateData.templatesDir, POINT_AUTHORING_FILE)); @@ -191,11 +185,8 @@ class AnnotationController { }; } - private showExternalChangeNotice(mode: UnifiedAnnotationMode): void { - const label = mode === 'template' ? 'Template' : mode === 'rect' ? 'Box' : 'Point'; - void vscode.window.showWarningMessage( - tr('{mode} annotations changed outside the editor. Your next save will merge both versions.', { mode: label }), - ); + private showExternalChangeNotice(): void { + void vscode.window.showWarningMessage(tr('Annotations changed while confirming. Retry the swap.')); } reloadIfShowing(imagePaths: readonly string[]): void { @@ -355,32 +346,6 @@ class AnnotationController { } } - private conflictIdentityMatch(mode: UnifiedAnnotationMode, left: Annotation, right: Annotation): boolean { - if (mode === 'template') return left.id === right.id || left.category === right.category; - return left.category.trim() === right.category.trim(); - } - - private useExternalConflictCandidates( - mode: UnifiedAnnotationMode, - merged: readonly Annotation[], - conflicts: readonly AnnotationConflict[], - ): Annotation[] { - const resolved = cloneAnnotations(merged); - for (const conflict of conflicts) { - if (conflict.local) { - const index = resolved.findIndex(item => this.conflictIdentityMatch(mode, item, conflict.local as Annotation)); - if (index >= 0) resolved.splice(index, 1); - } - if (conflict.external) { - const external = { ...conflict.external } as Annotation; - const existing = resolved.findIndex(item => this.conflictIdentityMatch(mode, item, external)); - if (existing >= 0) resolved[existing] = external; - else resolved.push(external); - } - } - return resolved; - } - private async mergeExternalBeforeSave( imagePath: string, mode: UnifiedAnnotationMode, @@ -393,9 +358,7 @@ class AnnotationController { const external = this.snapshotForMode(imagePath, mode); if (external.errors.length) { - void vscode.window.showErrorMessage( - tr('The externally changed annotation file is not valid. Your editor copy was kept and nothing was overwritten.'), - ); + void vscode.window.showErrorMessage(tr('Could not save annotations.')); this.pendingExternal.add(mode); return undefined; } @@ -406,23 +369,19 @@ class AnnotationController { } const result = mergeAnnotations(mode, this.baseSnapshots[mode], local, external.annotations); - let annotations = result.merged as Annotation[]; if (result.conflicts.length) { - const choice = await vscode.window.showWarningMessage( - tr('The annotation file changed externally and {count} conflict(s) need a choice. Both versions are still preserved.', { - count: String(result.conflicts.length), - }), - { modal: true }, - tr('Keep editor conflicts'), - tr('Use external conflicts'), - ); - if (!choice) { - this.pendingExternal.add(mode); - return undefined; - } - if (choice === tr('Use external conflicts')) { - annotations = this.useExternalConflictCandidates(mode, annotations, result.conflicts); - } + this.pendingExternal.add(mode); + await this.webview.postMessage({ + type: 'annotationConflicts', + annotationMode: mode, + base: cloneAnnotations(this.baseSnapshots[mode]), + local: cloneAnnotations(local), + external: cloneAnnotations(external.annotations), + merged: cloneAnnotations(result.merged as Annotation[]), + conflicts: result.conflicts, + }); + this.showExternalChangeNotice(); + return undefined; } // The external snapshot becomes the new merge base. A later save still @@ -430,7 +389,7 @@ class AnnotationController { this.baseSnapshots[mode] = cloneAnnotations(external.annotations); this.sourceRevisions[mode] = external.revision; this.pendingExternal.delete(mode); - return { annotations, mergedExternal: true }; + return { annotations: cloneAnnotations(result.merged as Annotation[]), mergedExternal: true }; } private async persistAnnotationsForMode( From 2adedaa6b2fd4b543ce0214dee60ff2cecb27dd0 Mon Sep 17 00:00:00 2001 From: AliceJump <2630923991@qq.com> Date: Mon, 5 Oct 2026 00:59:22 +0800 Subject: [PATCH 04/57] test(annotation): run merge regression suite --- package.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/package.json b/package.json index 209a168..b30bf8f 100644 --- a/package.json +++ b/package.json @@ -350,7 +350,7 @@ "verify:version": "node scripts/release/verify-version.js", "verify:l10n": "node scripts/release/verify-l10n.js", "release": "sh scripts/release.sh", - "test": "npm run verify:version && npm run verify:l10n && npm run compile && npm run test:webview-html && npm run test: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-history && npm run test:position-resource && 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: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 compile && 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:position-resource && 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: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", @@ -362,6 +362,7 @@ "test:annotation-coords": "node scripts/test_annotation_coords.js", "test:annotation-clipboard": "node scripts/test_annotation_clipboard.js", "test:annotation-swap": "node scripts/test_annotation_swap.js", + "test:annotation-merge": "node scripts/test_annotation_merge.js", "test:annotation-history": "node scripts/test_annotation_history.js", "test:position-resource": "node scripts/test_position_resource.js", "test:point-resource": "node scripts/test_point_resource.js", @@ -396,7 +397,6 @@ "devDependencies": { "@types/jpeg-js": "^0.3.0", "@types/node": "^20.11.0", - "@types/vscode": "1.85.0", "@vscode/vsce": "^3.9.2", "jsdom": "^26.1.0", "typescript": "^5.4.0" From 5f4653da93b071da50ac23da15b553ecd74a0761 Mon Sep 17 00:00:00 2001 From: AliceJump <2630923991@qq.com> Date: Mon, 5 Oct 2026 01:00:21 +0800 Subject: [PATCH 05/57] fix(test): preserve VS Code type dependency --- package.json | 1 + 1 file changed, 1 insertion(+) diff --git a/package.json b/package.json index b30bf8f..30aa10e 100644 --- a/package.json +++ b/package.json @@ -397,6 +397,7 @@ "devDependencies": { "@types/jpeg-js": "^0.3.0", "@types/node": "^20.11.0", + "@types/vscode": "1.85.0", "@vscode/vsce": "^3.9.2", "jsdom": "^26.1.0", "typescript": "^5.4.0" From 61e6c6016ee4ddfb33402dc32df101d123b840b0 Mon Sep 17 00:00:00 2001 From: AliceJump <2630923991@qq.com> Date: Mon, 5 Oct 2026 01:02:36 +0800 Subject: [PATCH 06/57] fix(assets): preserve source cards across annotation refreshes --- media/templateAssetPanel/app.js | 70 +++++++++++++++++++++++++-------- 1 file changed, 54 insertions(+), 16 deletions(-) diff --git a/media/templateAssetPanel/app.js b/media/templateAssetPanel/app.js index 74db08e..e6b1ecb 100644 --- a/media/templateAssetPanel/app.js +++ b/media/templateAssetPanel/app.js @@ -34,12 +34,21 @@ return n; } + function updateCardMeta(card, meta) { + card.title = meta.name + '\n' + meta.width + 'x' + meta.height + + (meta.categories.length ? '\n' + meta.categories.join(', ') : ''); + const name = card.querySelector('.name'); + const cats = card.querySelector('.cats'); + const size = card.querySelector('.size'); + if (name) name.textContent = meta.name; + if (cats) cats.textContent = meta.categories.length ? meta.categories.join(', ') : ''; + if (size) size.textContent = meta.width + 'x' + meta.height; + } + function makeCard(meta) { const card = document.createElement('div'); card.className = 'card'; card.dataset.name = meta.name; - card.title = meta.name + '\n' + meta.width + 'x' + meta.height + - (meta.categories.length ? '\n' + meta.categories.join(', ') : ''); const box = document.createElement('div'); box.className = 'thumb-box'; @@ -51,31 +60,28 @@ const actDiv = document.createElement('div'); actDiv.className = 'actions thumbnail-actions'; actDiv.append( - ThumbnailActions.button('✎', t('editAnnotations'), () => vscode.postMessage({ type: 'openAnnotation', imagePath: meta.imagePath })), ThumbnailActions.button('👁', t('openSourceImage'), () => vscode.postMessage({ type: 'openSource', imagePath: meta.imagePath })), ThumbnailActions.button('⇄', t('assetSwapTooltip'), () => openSwapPicker(meta)), ThumbnailActions.button('×', t('assetDeleteTooltip'), () => vscode.postMessage({ type: 'deleteImage', imagePath: meta.imagePath })), ); - ['edit', 'open', 'swap', 'delete'].forEach((action, index) => { actDiv.children[index].dataset.action = action; }); + ['open', 'swap', 'delete'].forEach((action, index) => { actDiv.children[index].dataset.action = action; }); box.appendChild(actDiv); const m = document.createElement('div'); m.className = 'meta'; const nm = document.createElement('div'); nm.className = 'name'; - nm.textContent = meta.name; const cats = document.createElement('div'); cats.className = 'cats'; - cats.textContent = meta.categories.length ? meta.categories.join(', ') : ''; const sz = document.createElement('div'); sz.className = 'size'; - sz.textContent = meta.width + 'x' + meta.height; m.appendChild(nm); m.appendChild(cats); m.appendChild(sz); card.appendChild(box); card.appendChild(m); + updateCardMeta(card, meta); card.addEventListener('click', () => { vscode.postMessage({ type: 'openAnnotation', imagePath: meta.imagePath }); }); @@ -115,12 +121,25 @@ function applyThumb(name, url) { thumbUrls.set(name, url); const card = cards.get(name); - if (card && card.dataset.thumbDone !== '1') { + if (card) { + const box = card.querySelector('.thumb-box'); + const current = box && box.querySelector('img'); + if (current) { + if (current.src !== url) current.src = url; + } else { + fillThumbBox(box, url, name, t('loadFailed')); + } 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, ''); + if (thumb.dataset.name === name) { + const current = thumb.querySelector('img'); + if (current) { + if (current.src !== url) current.src = url; + } else { + fillThumbBox(thumb, url, name, ''); + } + } } } @@ -244,14 +263,33 @@ const msg = e.data; switch (msg.type) { case 'templates': { - grid.innerHTML = ''; cards.clear(); - metas = msg.templates || []; - thumbUrls.clear(); - for (const meta of metas) { - const card = makeCard(meta); - cards.set(meta.name, card); + const incoming = msg.templates || []; + const nextNames = new Set(incoming.map(meta => meta.name)); + for (const [name, card] of [...cards]) { + if (nextNames.has(name)) continue; + card.remove(); + cards.delete(name); + thumbUrls.delete(name); + } + + const nextMetas = []; + for (const raw of incoming) { + const existingMeta = metas.find(meta => meta.name === raw.name); + const meta = existingMeta || raw; + if (existingMeta) Object.assign(existingMeta, raw); + let card = cards.get(meta.name); + if (!card) { + card = makeCard(meta); + cards.set(meta.name, card); + } else { + updateCardMeta(card, meta); + } + // appendChild moves an existing node without recreating it. This keeps the + // decoded thumbnail and hover state alive across annotation-only refreshes. grid.appendChild(card); + nextMetas.push(meta); } + metas = nextMetas; applyFilter(); break; } From 5e277120f0daa54c5106399b3852838f3148f1ad Mon Sep 17 00:00:00 2001 From: AliceJump <2630923991@qq.com> Date: Mon, 5 Oct 2026 01:05:20 +0800 Subject: [PATCH 07/57] test(assets): match card-first annotation editing --- scripts/test_thumbnail_actions.js | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/scripts/test_thumbnail_actions.js b/scripts/test_thumbnail_actions.js index 6e6af04..f4c2cca 100644 --- a/scripts/test_thumbnail_actions.js +++ b/scripts/test_thumbnail_actions.js @@ -103,11 +103,19 @@ const { dom, w, sent } = gallery('templateAssetPanel', { type: 'templates', temp name: 'source.png', imagePath: 'source.png', width: 20, height: 30, categories: [], annotations: 0, }] }); const card = w.document.querySelector('.card'); -assert.strictEqual(card.querySelectorAll('.thumbnail-actions button').length, 4); -card.querySelector('[data-action="edit"]').click(); +assert.strictEqual(card.querySelectorAll('.thumbnail-actions button').length, 3, 'asset card exposes only source, swap and delete actions'); +assert.strictEqual(card.querySelector('[data-action="edit"]'), null, 'clicking the card itself is the annotation edit action'); +card.dispatchEvent(new w.MouseEvent('click', { bubbles: true })); assert.deepStrictEqual(sent.pop(), { type: 'openAnnotation', imagePath: 'source.png' }); card.querySelector('[data-action="open"]').click(); assert.deepStrictEqual(sent.pop(), { type: 'openSource', imagePath: 'source.png' }); assert.strictEqual(sent.length, 0, 'direct asset actions never trigger a second card action'); + +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, +}] } })); +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'); + dom.window.close(); console.log('thumbnail action parity: unified template/rect/point preview and asset actions OK'); From 3c4e45afee71907dcf6022d35d7e507fa5eb3b19 Mon Sep 17 00:00:00 2001 From: AliceJump <2630923991@qq.com> Date: Mon, 5 Oct 2026 01:06:42 +0800 Subject: [PATCH 08/57] feat(annotation): render conflict candidates in editor --- media/annotationPanel/conflict.js | 197 ++++++++++++++++++++++++++++++ 1 file changed, 197 insertions(+) create mode 100644 media/annotationPanel/conflict.js diff --git a/media/annotationPanel/conflict.js b/media/annotationPanel/conflict.js new file mode 100644 index 0000000..3db7356 --- /dev/null +++ b/media/annotationPanel/conflict.js @@ -0,0 +1,197 @@ +(() => { + const vscode = acquireVsCodeApi(); + const canvas = document.getElementById('canvas'); + const wrap = canvas?.parentElement; + const panel = document.getElementById('conflictPanel'); + const rows = document.getElementById('conflictRows'); + const apply = document.getElementById('conflictApply'); + const summary = document.getElementById('conflictSummary'); + if (!canvas || !wrap || !panel || !rows || !apply || !summary) return; + + const lang = (navigator.language || 'en').toLowerCase(); + const copies = { + zh: { + title: '标注冲突', current: '当前编辑', external: '外部修改', fields: '冲突字段', + apply: '应用选择并保存', hint: '两边都会保留到你完成选择;未解决前不会写盘。', + }, + ja: { + title: 'アノテーション競合', current: '編集中', external: '外部変更', fields: '競合フィールド', + apply: '選択を適用して保存', hint: '選択が完了するまで両方を保持し、ディスクには書き込みません。', + }, + ko: { + title: '주석 충돌', current: '현재 편집', external: '외부 변경', fields: '충돌 필드', + apply: '선택 적용 및 저장', hint: '선택이 끝날 때까지 두 버전을 모두 유지하며 디스크에 쓰지 않습니다.', + }, + es: { + title: 'Conflicto de anotaciones', current: 'Edición actual', external: 'Cambio externo', fields: 'Campos en conflicto', + apply: 'Aplicar selección y guardar', hint: 'Ambas versiones se conservan hasta resolver el conflicto; no se escribe nada antes.', + }, + en: { + title: 'Annotation conflicts', current: 'Current edit', external: 'External change', fields: 'Conflicting fields', + apply: 'Apply choices and save', hint: 'Both versions are kept until every conflict is resolved; nothing is written before that.', + }, + }; + const text = lang.startsWith('zh') ? copies.zh + : lang.startsWith('ja') ? copies.ja + : lang.startsWith('ko') ? copies.ko + : lang.startsWith('es') ? copies.es + : copies.en; + + const overlay = document.createElement('canvas'); + overlay.id = 'conflictCanvas'; + overlay.setAttribute('aria-hidden', 'true'); + wrap.appendChild(overlay); + + let session = null; + let transform = null; + const choices = new Map(); + + const originalDrawImage = CanvasRenderingContext2D.prototype.drawImage; + CanvasRenderingContext2D.prototype.drawImage = function(...args) { + const result = originalDrawImage.apply(this, args); + if (this.canvas === canvas && args.length >= 5 && args[0] instanceof HTMLImageElement) { + const image = args[0]; + const imageWidth = image.naturalWidth || image.width || 1; + const imageHeight = image.naturalHeight || image.height || 1; + transform = { + x: Number(args[1]) || 0, + y: Number(args[2]) || 0, + sx: (Number(args[3]) || imageWidth) / imageWidth, + sy: (Number(args[4]) || imageHeight) / imageHeight, + }; + resizeOverlay(); + paintOverlay(); + } + return result; + }; + + function resizeOverlay() { + if (overlay.width !== canvas.width) overlay.width = canvas.width; + if (overlay.height !== canvas.height) overlay.height = canvas.height; + } + + function candidateLabel(candidate) { + if (!candidate) return '∅'; + const dims = candidate.w === 0 && candidate.h === 0 + ? `(${candidate.x}, ${candidate.y})` + : `[${candidate.x}, ${candidate.y}, ${candidate.w}, ${candidate.h}]`; + return `${candidate.category || '?'} ${dims}`; + } + + function paintCandidate(ctx, candidate, kind) { + if (!candidate || !transform) return; + const style = getComputedStyle(document.body); + const currentColor = style.getPropertyValue('--accent').trim() || '#4daafc'; + const externalColor = style.getPropertyValue('--err').trim() || '#f14c4c'; + const color = kind === 'local' ? currentColor : externalColor; + const x = transform.x + candidate.x * transform.sx; + const y = transform.y + candidate.y * transform.sy; + const w = candidate.w * transform.sx; + const h = candidate.h * transform.sy; + ctx.save(); + ctx.strokeStyle = color; + ctx.fillStyle = color; + ctx.lineWidth = 2; + ctx.setLineDash(kind === 'local' ? [] : [7, 4]); + if (candidate.w === 0 && candidate.h === 0) { + ctx.beginPath(); + ctx.arc(x, y, 7, 0, Math.PI * 2); + ctx.stroke(); + } else { + ctx.strokeRect(x, y, w, h); + } + ctx.setLineDash([]); + ctx.font = 'bold 11px sans-serif'; + ctx.fillText(kind === 'local' ? 'L' : 'E', x + 3, Math.max(11, y - 4)); + ctx.restore(); + } + + function paintOverlay() { + resizeOverlay(); + const ctx = overlay.getContext('2d'); + ctx.clearRect(0, 0, overlay.width, overlay.height); + if (!session) return; + for (const conflict of session.conflicts || []) { + paintCandidate(ctx, conflict.local, 'local'); + paintCandidate(ctx, conflict.external, 'external'); + } + } + + function renderConflictRow(conflict) { + const row = document.createElement('div'); + row.className = 'conflict-row'; + row.dataset.key = conflict.key; + + const name = document.createElement('div'); + name.className = 'conflict-name'; + name.textContent = conflict.key; + + const fields = document.createElement('div'); + fields.className = 'conflict-fields'; + fields.textContent = `${text.fields}: ${(conflict.fields || []).join(', ') || '—'}`; + + const current = document.createElement('button'); + current.type = 'button'; + current.className = 'mini-btn conflict-choice'; + current.dataset.choice = 'local'; + current.textContent = `${text.current}: ${candidateLabel(conflict.local)}`; + + const external = document.createElement('button'); + external.type = 'button'; + external.className = 'mini-btn conflict-choice'; + external.dataset.choice = 'external'; + external.textContent = `${text.external}: ${candidateLabel(conflict.external)}`; + + const choose = choice => { + choices.set(conflict.key, choice); + for (const button of row.querySelectorAll('.conflict-choice')) { + button.classList.toggle('active', button.dataset.choice === choice); + } + apply.disabled = choices.size !== (session?.conflicts?.length || 0); + }; + current.addEventListener('click', () => choose('local')); + external.addEventListener('click', () => choose('external')); + + row.append(name, fields, current, external); + return row; + } + + function showConflicts(message) { + session = message; + choices.clear(); + rows.replaceChildren(); + for (const conflict of message.conflicts || []) rows.appendChild(renderConflictRow(conflict)); + summary.textContent = `${text.title} · ${(message.conflicts || []).length}\n${text.hint}`; + apply.textContent = text.apply; + apply.disabled = true; + panel.hidden = false; + paintOverlay(); + } + + function clearConflicts() { + session = null; + choices.clear(); + rows.replaceChildren(); + panel.hidden = true; + paintOverlay(); + } + + apply.addEventListener('click', () => { + if (!session || choices.size !== (session.conflicts?.length || 0)) return; + vscode.postMessage({ + type: 'resolveAnnotationConflicts', + choices: [...choices].map(([key, choice]) => ({ key, choice })), + }); + }); + + window.addEventListener('message', event => { + const message = event.data || {}; + if (message.type === 'annotationConflicts') showConflicts(message); + else if (message.type === 'load' && session) clearConflicts(); + }); + + window.addEventListener('resize', () => { + resizeOverlay(); + paintOverlay(); + }); +})(); From 06e9f99e6d457ff635bbe9a2e5f91b9c8dd5fa5c Mon Sep 17 00:00:00 2001 From: AliceJump <2630923991@qq.com> Date: Mon, 5 Oct 2026 01:06:57 +0800 Subject: [PATCH 09/57] style(annotation): add conflict resolution panel --- media/annotationPanel/conflict.css | 74 ++++++++++++++++++++++++++++++ 1 file changed, 74 insertions(+) create mode 100644 media/annotationPanel/conflict.css diff --git a/media/annotationPanel/conflict.css b/media/annotationPanel/conflict.css new file mode 100644 index 0000000..490be09 --- /dev/null +++ b/media/annotationPanel/conflict.css @@ -0,0 +1,74 @@ +#conflictCanvas { + position: absolute; + inset: 0; + z-index: 4; + pointer-events: none; +} + +.conflict-panel { + display: flex; + flex-direction: column; + gap: var(--space-sm); + max-height: 52%; + padding: var(--space-sm); + border: var(--border-width) solid var(--border); + border-radius: var(--radius-sm); + background: var(--bg-container-raised); + overflow: hidden; +} + +.conflict-panel[hidden] { + display: none; +} + +.conflict-summary { + white-space: pre-line; + color: var(--text-muted); + font-size: var(--font-xs); +} + +.conflict-rows { + display: flex; + flex-direction: column; + gap: var(--space-sm); + overflow: auto; + min-height: 0; +} + +.conflict-row { + display: flex; + flex-direction: column; + gap: var(--space-xs); + padding: var(--space-xs); + border: var(--border-width) solid var(--border); + border-radius: var(--radius-sm); + background: var(--bg-row); +} + +.conflict-name { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + font-size: var(--font-sm); + font-weight: var(--weight-medium); +} + +.conflict-fields { + color: var(--text-muted); + font-size: var(--font-xs); +} + +.conflict-choice { + width: 100%; + height: auto; + min-height: var(--control-h-sm); + padding: var(--space-xs) var(--space-sm); + white-space: normal; + text-align: left; + line-height: 1.35; +} + +.conflict-apply { + width: 100%; + flex-shrink: 0; +} From cdd777ee4dcaeca909964838acf0205a11ab8d96 Mon Sep 17 00:00:00 2001 From: AliceJump <2630923991@qq.com> Date: Mon, 5 Oct 2026 01:07:28 +0800 Subject: [PATCH 10/57] feat(annotation): wire conflict overlay into editor --- media/annotationPanel/index.html | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/media/annotationPanel/index.html b/media/annotationPanel/index.html index 01be16d..4ff7f1f 100644 --- a/media/annotationPanel/index.html +++ b/media/annotationPanel/index.html @@ -6,6 +6,7 @@ +
@@ -41,6 +42,11 @@