Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
56 changes: 56 additions & 0 deletions media/annotationPanel/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,62 @@ <h3 id="bboxTitle">Bounding Box</h3>
window.acquireVsCodeApi = () => sharedApi || (sharedApi = nativeAcquire());
})();
</script>
<script nonce="__CSP_NONCE__">
(() => {
const MODE_BUTTON_IDS = ['templateModeBtn', 'rectModeBtn', 'pointModeBtn'];
let cycleMode = 'm';

function parseKeybinding(binding) {
const parts = String(binding || '').toLowerCase().split('+').filter(Boolean);
const key = parts.pop() || '';
return {
key,
needPrimary: parts.includes('ctrl') || parts.includes('meta') || parts.includes('cmd'),
needShift: parts.includes('shift'),
needAlt: parts.includes('alt'),
};
}

function matchesKeybinding(event, binding) {
if (!binding) return false;
const parsed = parseKeybinding(binding);
const keyMatches = event.key.toLowerCase() === parsed.key || event.code.toLowerCase() === parsed.key;
return keyMatches
&& !!(event.ctrlKey || event.metaKey) === parsed.needPrimary
&& !!event.shiftKey === parsed.needShift
&& !!event.altKey === parsed.needAlt;
}

function cycleAnnotationMode() {
const buttons = MODE_BUTTON_IDS.map(id => document.getElementById(id));
const currentIndex = buttons.findIndex(button => button?.classList.contains('active'));
const nextIndex = currentIndex < 0 ? 0 : (currentIndex + 1) % buttons.length;
buttons[nextIndex]?.click();
}

window.addEventListener('message', event => {
if (event.data?.type !== 'config' || !event.data.keybindings) return;
const incoming = event.data.keybindings;
if (typeof incoming.cycleMode === 'string') cycleMode = incoming.cycleMode;
// This listener is registered before app.js. Blank the retired bindings on the
// shared config message so app.js cannot keep its historical 1/2/3 defaults or
// revive stale per-mode user settings. The released keys remain available to
// every other configurable command.
incoming.modeTemplate = '';
incoming.modeRect = '';
incoming.modePoint = '';
});

document.addEventListener('keydown', event => {
if (document.getElementById('bboxModal')?.classList.contains('visible')) return;
if (matchesKeybinding(event, cycleMode)) {
event.preventDefault();
event.stopImmediatePropagation();
cycleAnnotationMode();
}
}, true);
})();
</script>
<script src="__EXTERNAL_SYNC_SCRIPT_URI__"></script>
<script src="__CONFLICT_SCRIPT_URI__"></script>
<script src="__APP_SCRIPT_URI__"></script>
Expand Down
7 changes: 3 additions & 4 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -325,9 +325,7 @@
"deleteSelected": "Delete",
"prevImage": "ArrowLeft",
"nextImage": "ArrowRight",
"modeTemplate": "1",
"modeRect": "2",
"modePoint": "3"
"cycleMode": "m"
},
"additionalProperties": {
"type": "string"
Expand All @@ -350,7 +348,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-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": "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:annotation-mode-cycle && 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",
Expand All @@ -364,6 +362,7 @@
"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:annotation-mode-cycle": "node scripts/test_annotation_mode_cycle.js",
"test:position-resource": "node scripts/test_position_resource.js",
"test:point-resource": "node scripts/test_point_resource.js",
"test:asset-swap-picker": "node scripts/test_asset_swap_picker.js",
Expand Down
103 changes: 103 additions & 0 deletions scripts/test_annotation_mode_cycle.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
const fs = require('fs');
const path = require('path');
const assert = require('assert');
const { JSDOM } = require('jsdom');

const root = path.join(__dirname, '..');
const indexPath = path.join(root, 'media', 'annotationPanel', 'index.html');
const indexHtml = fs.readFileSync(indexPath, 'utf8');
const inlineScripts = [...indexHtml.matchAll(/<script nonce="__CSP_NONCE__">([\s\S]*?)<\/script>/g)];
const cycleScript = inlineScripts.find(match => match[1].includes('MODE_BUTTON_IDS'))?.[1];
assert(cycleScript, 'annotation mode cycle script should remain embedded in the annotation panel resource');

const dom = new JSDOM(`<!doctype html><body>
<div id="bboxModal"></div>
<button id="templateModeBtn" class="active"></button>
<button id="rectModeBtn"></button>
<button id="pointModeBtn"></button>
</body>`, { runScripts: 'outside-only' });
const { window } = dom;
const { document } = window;
const modeIds = ['templateModeBtn', 'rectModeBtn', 'pointModeBtn'];

for (const id of modeIds) {
document.getElementById(id).addEventListener('click', () => {
for (const otherId of modeIds) document.getElementById(otherId).classList.remove('active');
document.getElementById(id).classList.add('active');
});
}

window.eval(cycleScript);

function activeMode() {
return modeIds.find(id => document.getElementById(id).classList.contains('active'));
}

function configure(keybindings) {
window.dispatchEvent(new window.MessageEvent('message', {
data: { type: 'config', keybindings },
}));
}

function press(key, init = {}) {
const event = new window.KeyboardEvent('keydown', {
key,
code: key.length === 1 ? `Key${key.toUpperCase()}` : key,
bubbles: true,
cancelable: true,
...init,
});
document.dispatchEvent(event);
return event;
}

const defaultConfig = { cycleMode: 'm' };
configure(defaultConfig);
assert.strictEqual(defaultConfig.modeTemplate, '', 'retired Template binding should be blanked before app.js receives config');
assert.strictEqual(defaultConfig.modeRect, '', 'retired Box binding should be blanked before app.js receives config');
assert.strictEqual(defaultConfig.modePoint, '', 'retired Point binding should be blanked before app.js receives config');
assert.strictEqual(activeMode(), 'templateModeBtn');
press('m');
assert.strictEqual(activeMode(), 'rectModeBtn', 'first cycle should move Template -> Box');
press('m');
assert.strictEqual(activeMode(), 'pointModeBtn', 'second cycle should move Box -> Point');
press('m');
assert.strictEqual(activeMode(), 'templateModeBtn', 'third cycle should wrap Point -> Template');

let releasedKeyEvents = 0;
document.addEventListener('keydown', event => {
if (['1', '2', '3', 'x'].includes(event.key)) releasedKeyEvents += 1;
});
press('1');
assert.strictEqual(activeMode(), 'templateModeBtn', 'legacy number shortcuts must no longer switch modes');
assert.strictEqual(releasedKeyEvents, 1, 'released number shortcut must remain available to other commands');

const customConfig = { cycleMode: 'q', modeTemplate: '3', modeRect: 'x', modePoint: '2', nextImage: '1' };
configure(customConfig);
assert.strictEqual(customConfig.modeTemplate, '', 'persisted Template shortcut must be retired');
assert.strictEqual(customConfig.modeRect, '', 'persisted Box shortcut must be retired');
assert.strictEqual(customConfig.modePoint, '', 'persisted Point shortcut must be retired');
assert.strictEqual(customConfig.nextImage, '1', 'unrelated shortcut reuse must be preserved');
press('m');
assert.strictEqual(activeMode(), 'templateModeBtn', 'previous cycle key must stop working after reconfiguration');
press('q');
assert.strictEqual(activeMode(), 'rectModeBtn', 'configured cycle key should switch modes');
press('x');
assert.strictEqual(activeMode(), 'rectModeBtn', 'persisted legacy per-mode binding must not switch modes');
assert.strictEqual(releasedKeyEvents, 2, 'retired custom shortcut must remain available to other commands');
press('1');
assert.strictEqual(activeMode(), 'rectModeBtn', 'reused legacy number key must not switch annotation mode');
assert.strictEqual(releasedKeyEvents, 3, 'reused number key must propagate to the configured command handler');

document.getElementById('bboxModal').classList.add('visible');
press('q');
assert.strictEqual(activeMode(), 'rectModeBtn', 'mode switching must stay disabled while the edit dialog is open');

const pkg = JSON.parse(fs.readFileSync(path.join(root, 'package.json'), 'utf8'));
const defaults = pkg.contributes.configuration.properties['okScriptToolkit.annotationKeybindings'].default;
assert.strictEqual(defaults.cycleMode, 'm', 'default cycle shortcut should be M');
assert(!Object.prototype.hasOwnProperty.call(defaults, 'modeTemplate'));
assert(!Object.prototype.hasOwnProperty.call(defaults, 'modeRect'));
assert(!Object.prototype.hasOwnProperty.call(defaults, 'modePoint'));

console.log('annotation mode cycle shortcut tests passed');
Loading