diff --git a/jetbrains b/jetbrains index 7238b18..b78ec06 160000 --- a/jetbrains +++ b/jetbrains @@ -1 +1 @@ -Subproject commit 7238b18cd6ed47b59aea04fa9257e254c7a7efed +Subproject commit b78ec068e2340a9e9031cbb2500d4e566c02b4f1 diff --git a/media/annotationPanel/app.js b/media/annotationPanel/app.js index 63e456b..52ee399 100644 --- a/media/annotationPanel/app.js +++ b/media/annotationPanel/app.js @@ -344,7 +344,7 @@ if (problem.code === 'segment') return t('boxPathBadSegment', { segment: problem.bad || '' }); if (problem.code === 'duplicate') return t('boxPathExists'); if (problem.code === 'rule') return t('boxPathRuleMissing'); - return t('generateBoxFailed'); + return t('positionPathFailed'); } function syncAnnotationList() { @@ -546,8 +546,9 @@ document.getElementById('templateModeBtn').classList.toggle('active', annotationMode === 'template'); document.getElementById('rectModeBtn').classList.toggle('active', annotationMode === 'rect'); document.getElementById('pointModeBtn').classList.toggle('active', annotationMode === 'point'); - document.getElementById('generateBoxBtn').style.display = annotationMode === 'template' ? '' : 'none'; - document.getElementById('drawBtn').textContent = pointMode ? 'Point (' + keybindings.drawBbox.toUpperCase() + ')' : t('drawBbox'); + document.getElementById('drawBtn').textContent = pointMode + ? t('pointTool', { key: keybindings.drawBbox.toUpperCase() }) + : t('drawBbox'); document.getElementById('bboxWRow').style.display = pointMode ? 'none' : ''; document.getElementById('bboxHRow').style.display = pointMode ? 'none' : ''; updateUndoRedoButtons(); @@ -562,7 +563,10 @@ const wInput = document.getElementById('bboxW'); const hInput = document.getElementById('bboxH'); const ok = document.getElementById('bboxOk'); - document.getElementById('bboxTitle').textContent = category ? (pointMode ? 'Edit point' : t('editBboxTitle')) : (pointMode ? 'New point' : t('newBboxTitle')); + document.getElementById('bboxTitle').textContent = category + ? (pointMode ? t('editPointTitle') : t('editBboxTitle')) + : (pointMode ? t('newPointTitle') : t('newBboxTitle')); + document.getElementById('bboxCatLabel').textContent = isPositionMode() ? t('generatePath') + ':' : t('categoryLabel'); catInput.value = category; catInput.placeholder = isPositionMode() ? t('generatePathPlaceholder') : t('categoryLabel'); xInput.value = x; @@ -779,10 +783,10 @@ function pasteClipboardText(text) { if (!img || toolMode !== 'none' || modeLoading) return; const payload = parseClipboardPayload(text); - if (!payload) { pasteStatus('Clipboard is not a valid normalized coordinate tuple.'); return; } + if (!payload) { pasteStatus(t('clipboardInvalid')); return; } const zeroSize = payload.box.w === 0 && payload.box.h === 0; if (zeroSize && annotationMode !== 'point') { - pasteStatus('Zero-size coordinates can only be pasted in Point mode.'); + pasteStatus(t('clipboardPointOnly')); return; } let box = payload.box; @@ -1012,30 +1016,6 @@ offsetX = px - ix * scale; offsetY = py - iy * scale; recalcOffset(); e.preventDefault(); paint(); }, { passive:false }); - function openGenerateBox() { - if (annotationMode !== 'template') return; - const choices = document.getElementById('generateChoices'); choices.replaceChildren(); - const selected = new Set(selectedList()); - annotations.forEach((ann, index) => { - const label = document.createElement('label'); label.className = 'annotation-row'; - const input = document.createElement('input'); input.type = 'checkbox'; input.checked = selected.size === 0 || selected.has(index); input.dataset.index = String(index); - const text = document.createElement('span'); text.textContent = ann.category; label.append(input, text); choices.append(label); - }); - const seedAnn = annotations[selectedIdx] || annotations[0]; - const seed = seedAnn?.category || 'region'; - document.getElementById('generatePath').value = 'screen.' + String(seed).replace(/[^A-Za-z0-9_]/g, '_'); - document.getElementById('generateError').textContent = ''; - document.getElementById('generateModal').classList.add('visible'); - } - - function refreshGeneratePathState() { - const input = document.getElementById('generatePath'); - const problem = pathProblem(input.value) || pathOccupied(input.value, null); - document.getElementById('generateError').textContent = problem ? pathMessage(problem) : ''; - document.getElementById('generateOk').disabled = !!problem; - return problem; - } - function navigate(delta) { if (!imageData) return; const target = imageData.currentIndex + delta; @@ -1049,10 +1029,6 @@ document.addEventListener('keydown', (e) => { if (document.getElementById('bboxModal').classList.contains('visible')) return; - if (document.getElementById('generateModal').classList.contains('visible')) { - if (e.key === 'Enter') { e.preventDefault(); document.getElementById('generateOk').click(); } - return; - } if (matchKeybinding(e, keybindings.modeTemplate)) { e.preventDefault(); requestAnnotationMode('template'); return; } if (matchKeybinding(e, keybindings.modeRect)) { e.preventDefault(); requestAnnotationMode('rect'); return; } if (matchKeybinding(e, keybindings.modePoint)) { e.preventDefault(); requestAnnotationMode('point'); return; } @@ -1107,31 +1083,26 @@ annotations.forEach(a => { if (!categories.has(a.category)) hidden.add(a.category); }); listSignature=''; paint(); }; - document.getElementById('generateBoxBtn').onclick = openGenerateBox; - document.getElementById('generateCancel').onclick = () => document.getElementById('generateModal').classList.remove('visible'); - document.getElementById('generatePath').oninput = refreshGeneratePathState; - document.getElementById('generateOk').onclick = () => { - if (refreshGeneratePathState()) return; - const chosen = []; - document.querySelectorAll('#generateChoices input').forEach(input => { if (input.checked) chosen.push(annotations[Number(input.dataset.index)]); }); - if (!chosen.length) { document.getElementById('generateError').textContent = t('generateNeedSelection'); return; } - vscode.postMessage({ type:'generateBox', path:document.getElementById('generatePath').value.trim(), boxes:chosen.map(a=>({x:a.x,y:a.y,w:a.w,h:a.h})) }); - }; function updateStaticText() { - document.getElementById('bboxCatLabel').textContent = t('categoryLabel'); + document.getElementById('annotationModeGroup').setAttribute('aria-label', t('annotationModeLabel')); + document.getElementById('templateModeBtn').textContent = t('modeTemplate'); + document.getElementById('rectModeBtn').textContent = t('modeRect'); + document.getElementById('pointModeBtn').textContent = t('modePoint'); + document.getElementById('sharedHistoryLabel').textContent = t('sharedHistory'); + document.getElementById('sharedHistoryWrap').title = t('sharedHistoryTooltip'); + document.getElementById('coordPreferenceWrap').title = t('coordPreferenceTooltip'); + document.getElementById('bboxCatLabel').textContent = isPositionMode() ? t('generatePath') + ':' : t('categoryLabel'); + document.getElementById('bboxWLabel').textContent = t('widthLabel'); + document.getElementById('bboxHLabel').textContent = t('heightLabel'); document.getElementById('bboxCancel').textContent = t('cancel'); + document.getElementById('bboxOk').textContent = t('confirm'); document.getElementById('coordBtn').textContent = t('copyCoords'); document.getElementById('deleteBtn').textContent = t('deleteMode'); document.getElementById('annotationListTitle').textContent = t('annotationListTitle'); document.getElementById('showAllBtn').textContent = t('showAllAnnotations'); document.getElementById('hideAllBtn').textContent = t('hideAllAnnotations'); document.getElementById('onlyCurrentBtn').textContent = t('showOnlyCurrent'); - document.getElementById('generateBoxBtn').textContent = t('generateBox'); - document.getElementById('generateTitle').textContent = t('generateBoxTitle'); - document.getElementById('generatePathLabel').textContent = t('generatePath'); - document.getElementById('generateCancel').textContent = t('cancel'); - document.getElementById('generateOk').textContent = t('ok'); document.getElementById('prevBtn').title = t('prevImage'); document.getElementById('nextBtn').title = t('nextImage'); document.getElementById('emptyMsg').textContent = t('noImageLoaded'); @@ -1158,11 +1129,6 @@ pasteClipboardText(msg.text || ''); return; } - if (msg.type === 'generateBoxResult') { - if (msg.ok) document.getElementById('generateModal').classList.remove('visible'); - else document.getElementById('generateError').textContent = pathMessage(msg.error === 'duplicate' ? {code:'duplicate'} : {code:msg.error || 'unknown'}); - return; - } if (msg.type !== 'load') return; const incomingMode = msg.annotationMode || annotationMode; @@ -1182,7 +1148,7 @@ clearSelection(); hoveredIdx = -1; listSignature = ''; - updateModeUi(); + updateStaticText(); const needImageLoad = !img || imageChanged; if (msg.imageBase64 && needImageLoad) { @@ -1207,4 +1173,4 @@ resize(); updateStaticText(); vscode.postMessage({ type:'ready' }); -})(); +})(); \ No newline at end of file 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; +} diff --git a/media/annotationPanel/conflict.js b/media/annotationPanel/conflict.js new file mode 100644 index 0000000..ae4d67c --- /dev/null +++ b/media/annotationPanel/conflict.js @@ -0,0 +1,183 @@ +(() => { + const vscode = acquireVsCodeApi(); + const I18N = JSON.parse(document.getElementById('annotationPanelI18n')?.textContent || '{}'); + const t = (key, args = {}) => (I18N[key] || key).replace(/\{(\w+)\}/g, (_, name) => String(args[name] ?? '{' + name + '}')); + 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 overlay = document.createElement('canvas'); + overlay.id = 'conflictCanvas'; + overlay.setAttribute('aria-hidden', 'true'); + wrap.appendChild(overlay); + + let session = null; + let transform = null; + let submitting = false; + 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 = `${t('conflictFields')}: ${(conflict.fields || []).join(', ') || '—'}`; + + const current = document.createElement('button'); + current.type = 'button'; + current.className = 'mini-btn conflict-choice'; + current.dataset.choice = 'local'; + current.textContent = `${t('conflictCurrent')}: ${candidateLabel(conflict.local)}`; + + const external = document.createElement('button'); + external.type = 'button'; + external.className = 'mini-btn conflict-choice'; + external.dataset.choice = 'external'; + external.textContent = `${t('conflictExternal')}: ${candidateLabel(conflict.external)}`; + + const choose = choice => { + if (submitting) return; + 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; + submitting = false; + choices.clear(); + rows.replaceChildren(); + for (const conflict of message.conflicts || []) rows.appendChild(renderConflictRow(conflict)); + summary.textContent = `${t('conflictTitle')} · ${(message.conflicts || []).length}\n${t('conflictHint')}`; + apply.textContent = t('conflictApply'); + apply.disabled = true; + panel.hidden = false; + paintOverlay(); + } + + function clearConflicts() { + session = null; + submitting = false; + choices.clear(); + rows.replaceChildren(); + panel.hidden = true; + paintOverlay(); + } + + apply.addEventListener('click', () => { + if (!session || submitting || choices.size !== (session.conflicts?.length || 0)) return; + submitting = true; + apply.disabled = true; + vscode.postMessage({ + type: 'resolveAnnotationConflicts', + conflictSessionId: session.conflictSessionId, + 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 === 'clearAnnotationConflicts' + && (!message.conflictSessionId || message.conflictSessionId === session?.conflictSessionId)) clearConflicts(); + else if (message.type === 'conflictResolutionFailed' + && message.conflictSessionId === session?.conflictSessionId) { + submitting = false; + apply.disabled = choices.size !== (session?.conflicts?.length || 0); + } else if (message.type === 'load' && session) clearConflicts(); + }); + + window.addEventListener('resize', () => { + resizeOverlay(); + paintOverlay(); + }); +})(); \ No newline at end of file diff --git a/media/annotationPanel/externalSync.js b/media/annotationPanel/externalSync.js new file mode 100644 index 0000000..67ddf95 --- /dev/null +++ b/media/annotationPanel/externalSync.js @@ -0,0 +1,138 @@ +(() => { + const vscode = acquireVsCodeApi(); + const nativePostMessage = vscode.postMessage.bind(vscode); + const canvas = document.getElementById('canvas'); + const bboxModal = document.getElementById('bboxModal'); + const pendingModes = new Set(); + const annotationSnapshots = new Map(); + const editorVersions = new Map(); + const pendingSaveCounts = new Map(); + const rejectedLoads = new Map(); + let currentImagePath = ''; + let pointerActive = false; + let reportScheduled = false; + + const cloneAnnotations = values => Array.isArray(values) ? values.map(value => ({ ...value })) : []; + const stateKey = (imagePath, mode) => `${imagePath || ''}\n${mode || ''}`; + const currentKey = mode => stateKey(currentImagePath, mode); + + // app.js and this helper intentionally share the same VS Code API object. A + // save increments the editor version before the message leaves the Webview, + // closing the small window where an older host load could otherwise arrive + // before the extension has started processing that save. + vscode.postMessage = message => { + if ((message?.type === 'save' || message?.type === 'saveMode') && message.mode && Array.isArray(message.annotations)) { + const key = currentKey(message.mode); + const editorVersion = (editorVersions.get(key) || 0) + 1; + editorVersions.set(key, editorVersion); + pendingSaveCounts.set(key, (pendingSaveCounts.get(key) || 0) + 1); + annotationSnapshots.set(key, cloneAnnotations(message.annotations)); + return nativePostMessage({ ...message, imagePath: currentImagePath, editorVersion }); + } + return nativePostMessage(message); + }; + + function hasTransientEdit() { + return pointerActive || !!bboxModal?.classList.contains('visible'); + } + + function reportPending() { + reportScheduled = false; + if (!pendingModes.size) return; + const transient = hasTransientEdit(); + for (const mode of pendingModes) { + nativePostMessage({ + type: 'externalEditorState', + imagePath: currentImagePath, + mode, + transient, + annotations: transient ? undefined : cloneAnnotations(annotationSnapshots.get(currentKey(mode))), + }); + } + if (!transient) pendingModes.clear(); + } + + function scheduleReport() { + if (reportScheduled || !pendingModes.size) return; + reportScheduled = true; + queueMicrotask(reportPending); + } + + canvas?.addEventListener('mousedown', event => { + if (event.button === 0) pointerActive = true; + }, true); + window.addEventListener('mouseup', () => { + pointerActive = false; + scheduleReport(); + }, true); + window.addEventListener('blur', () => { + pointerActive = false; + scheduleReport(); + }); + document.addEventListener('click', scheduleReport, true); + document.addEventListener('keyup', scheduleReport, true); + + if (bboxModal) { + new MutationObserver(scheduleReport).observe(bboxModal, { attributes: true, attributeFilter: ['class'] }); + } + + window.addEventListener('message', event => { + const message = event.data || {}; + if (message.type === 'load' && message.annotationMode) { + const imagePath = message.imagePath || ''; + const mode = message.annotationMode; + const key = stateKey(imagePath, mode); + const editorVersion = editorVersions.get(key) || 0; + const expectedEditorVersion = Number.isInteger(message.expectedEditorVersion) + ? message.expectedEditorVersion + : editorVersion; + const pendingSaves = pendingSaveCounts.get(key) || 0; + + if (message.loadRequestId && (pendingSaves > 0 || editorVersion !== expectedEditorVersion)) { + rejectedLoads.set(key, { imagePath, mode }); + event.stopImmediatePropagation(); + nativePostMessage({ + type: 'loadRejected', + loadRequestId: message.loadRequestId, + imagePath, + mode, + editorVersion, + pendingSaves, + annotations: cloneAnnotations(annotationSnapshots.get(key)), + }); + return; + } + + currentImagePath = imagePath; + editorVersions.set(key, expectedEditorVersion); + annotationSnapshots.set(key, cloneAnnotations(message.annotations)); + rejectedLoads.delete(key); + if (message.loadRequestId) { + queueMicrotask(() => nativePostMessage({ + type: 'loadAccepted', + loadRequestId: message.loadRequestId, + imagePath, + mode, + editorVersion: expectedEditorVersion, + })); + } + return; + } + + if (message.type === 'annotationSaveProcessed' && message.mode) { + const key = stateKey(message.imagePath || '', message.mode); + const remaining = Math.max(0, (pendingSaveCounts.get(key) || 0) - 1); + pendingSaveCounts.set(key, remaining); + if (remaining === 0 && rejectedLoads.has(key) && message.saved !== false) { + const rejected = rejectedLoads.get(key); + rejectedLoads.delete(key); + nativePostMessage({ type: 'retryLoad', imagePath: rejected.imagePath, mode: rejected.mode }); + } + return; + } + + if (message.type !== 'externalSourceChanged' || !message.mode) return; + pendingModes.add(message.mode); + reportPending(); + }); +})(); diff --git a/media/annotationPanel/index.html b/media/annotationPanel/index.html index 01be16d..691ea8c 100644 --- a/media/annotationPanel/index.html +++ b/media/annotationPanel/index.html @@ -6,27 +6,27 @@ +
-