Files
MyIP/frontend/composables/use-shortcuts.js
T
Claude 96525d2fc7 Chore: trim the branch — leftovers, redundancy, comments
A pass over every file the branch touched, after the iOS animation hunt
left tries and reverts behind it. Removed what nothing reaches: the
view-transition rule for a transition no code starts, an unreachable
guard in the transition hook, a no-op KeepAlive key branch, a sign-in
guard the menu already enforces, usage-dialog wrappers that only
forwarded to the store, a duplicated modifier-click check and card
filter (isPlainClick / listedTools), a test that hand-rolled
listedTools, a drawer-era fixture parameter. auth-flow.js folded into
auth-redirect.js. Comments cut to what the code cannot say itself —
file headers of a few lines, the measured iOS reasons, non-obvious
orderings — and stale ones fixed (Nav under KeepAlive, Home.vue
reusing DEFAULT_META, "from Nav", enabledCards). AGENTS.md sections
tightened to the code as it is now.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013GbLTGMZ45MV78qd2tHoco
2026-10-05 16:50:16 +00:00

324 lines
13 KiB
JavaScript

// Keyboard shortcut registration
//
// Input:
// - refs: UI chrome the shortcut keys drive (see below for destructuring) —
// test sections aren't here, they're reached via the command bus
// - store: main store
// - t: i18n translation function
// - configs: computed(() => store.configs)
// - userPreferences: computed(() => store.userPreferences)
//
// Output:
// - loadShortcuts(): should be called onMounted; registers all mappings
// immediately and passes keyMap to helpModalRef (for help modal).
// Config-gated keys fill in reactively when /api/configs lands
//
// Note:
// - section keys scroll with scrollToElement; tool keys open the tool's page
// through advancedToolsRef.openTool(slug);
// test-running keys dispatch commands on utils/app-commands.js
// - `h` key infoMask switch only executes when isInfosLoaded is true
// - every entry here is a home-page action. Overlays (Dialog / Sheet /
// Drawer) suspend the whole map while they are open — see
// utils/shortcut.js — so nothing needs a per-key "is something covering
// the page?" check.
import { getCurrentScope, onScopeDispose, watch } from 'vue';
import { trackEvent } from '../utils/analytics.js';
import { emitAppEvent } from '../utils/app-events.js';
import { dispatchAppCommand } from '../utils/app-commands.js';
import { registerShortcuts, keyMap, navigateCards } from '../utils/shortcut.js';
import { scrollToElement, SECTION_SCROLL_OFFSET } from '../utils/scroll-to.js';
import { useRouteActive } from './use-route-active.js';
import { hasPulseBackend } from '../utils/features/pulse-beacon.js';
// A shortcut only kicks the run off — completion is the owner's business —
// so a failed dispatch just logs.
const runCommand = (name, payload) => {
dispatchAppCommand(name, payload).catch((error) => {
console.warn(`[shortcuts] ${name} failed:`, error);
});
};
const buildShortcutConfig = ({ refs, store, t, configs, userPreferences }) => {
const {
queryIPRef,
helpModalRef,
shareReportRef,
advancedToolsRef,
isInfosLoaded,
toggleInfoMask,
} = refs;
const goToAdvancedTool = (slug, trackName) => {
advancedToolsRef.value.openTool(slug);
trackEvent('Nav', 'NavClick', trackName);
};
const config = [
{
keys: 'g',
action: () => {
window.scrollTo({ top: 0, behavior: 'smooth' });
trackEvent('ShortCut', 'ShortCut', 'GoToTop');
},
description: t('shortcutKeys.GoToTop'),
},
{
keys: 'j',
action: () => { navigateCards('down'); trackEvent('ShortCut', 'ShortCut', 'GoNext'); },
description: t('shortcutKeys.GoNext'),
},
{
keys: 'k',
action: () => { navigateCards('up'); trackEvent('ShortCut', 'ShortCut', 'GoPrevious'); },
description: t('shortcutKeys.GoPrevious'),
},
{
// Open the currently J/K-highlighted card: an Advanced Tool card
// (data-adv-slug) opens its tool; a Connectivity card opens its
// website via the card's own <a data-card-open> — which only
// exists with the open-website App Setting on, so target/rel and
// the preference gate all ride the real link. No-op for any
// other card type — IP cards, WebRTC, etc. have their own
// refresh shortcuts rather than an "open" concept.
keys: 'o',
action: () => {
const highlighted = document.querySelector(
'.keyboard-shortcut-card[data-keyboard-hover="true"]'
);
const slug = highlighted?.getAttribute('data-adv-slug');
if (slug) {
advancedToolsRef.value.openTool(slug);
trackEvent('ShortCut', 'ShortCut', 'OpenHighlightedTool');
return;
}
const link = highlighted?.querySelector('a[data-card-open][href]');
if (!link) return;
link.click();
trackEvent('ShortCut', 'ShortCut', 'OpenHighlightedSite');
},
description: t('shortcutKeys.OpenHighlightedTool'),
},
{
keys: 'G',
action: () => {
window.scrollTo({ top: document.body.scrollHeight, behavior: 'smooth' });
trackEvent('ShortCut', 'ShortCut', 'GoToBottom');
},
description: t('shortcutKeys.GoToBottom'),
},
{
keys: 'R',
action: () => {
store.setRefreshEveryThing(true);
trackEvent('ShortCut', 'ShortCut', 'RefreshEverything');
},
description: t('shortcutKeys.RefreshEverything'),
},
{
keys: '([1-6])',
type: 'regex',
action: (num) => {
if (num > userPreferences.value.ipCardsToShow) return;
scrollToElement('IPInfoCard-' + num, SECTION_SCROLL_OFFSET);
runCommand('ipinfo:refresh', { index: num - 1 });
trackEvent('ShortCut', 'ShortCut', 'IPCheck');
},
description: t('shortcutKeys.RefreshIPCard'),
},
{
keys: 'c',
action: () => {
scrollToElement('Connectivity', SECTION_SCROLL_OFFSET);
runCommand('connectivity:run', { trigger: 'manual' });
trackEvent('ShortCut', 'ShortCut', 'Connectivity');
},
description: t('shortcutKeys.RefreshConnectivityTests'),
},
{
keys: 'w',
action: () => {
scrollToElement('WebRTC', SECTION_SCROLL_OFFSET);
runCommand('webrtc:run', { isRefresh: false });
trackEvent('ShortCut', 'ShortCut', 'WebRTC');
},
description: t('shortcutKeys.RefreshWebRTC'),
},
{
keys: 'd',
action: () => {
scrollToElement('DNSLeakTest', SECTION_SCROLL_OFFSET);
runCommand('dnsleak:run', { isRefresh: true });
trackEvent('ShortCut', 'ShortCut', 'DNSLeakTest');
},
description: t('shortcutKeys.RefreshDNSLeakTest'),
},
{
keys: 's',
action: () => {
scrollToElement('SpeedTest', SECTION_SCROLL_OFFSET);
runCommand('speedtest:toggle');
trackEvent('ShortCut', 'ShortCut', 'SpeedTest');
},
description: t('shortcutKeys.SpeedTestButton'),
},
{ keys: 'l', action: () => goToAdvancedTool('pingtest', 'PingTest'), description: t('shortcutKeys.PingTest') },
{ keys: 'M', action: () => goToAdvancedTool('macchecker', 'MacChecker'), description: t('shortcutKeys.MacChecker') },
{ keys: 'a', action: () => goToAdvancedTool('ipcalculator', 'IpCalculator'), description: t('shortcutKeys.IpCalculator') },
{ keys: 't', action: () => goToAdvancedTool('mtrtest', 'MTRTest'), description: t('shortcutKeys.MTRTest') },
{ keys: 'S', action: () => goToAdvancedTool('securitychecklist', 'SecurityChecklist'), description: t('shortcutKeys.SecurityChecklist') },
{ keys: 'r', action: () => goToAdvancedTool('ruletest', 'RuleTest'), description: t('shortcutKeys.RuleTest') },
{ keys: 'n', action: () => goToAdvancedTool('dnsresolver', 'DNSResolver'), description: t('shortcutKeys.DNSResolver') },
{ keys: 'C', action: () => goToAdvancedTool('censorshipcheck', 'CensorshipCheck'), description: t('shortcutKeys.CensorshipCheck') },
{ keys: 'b', action: () => goToAdvancedTool('browserinfo', 'BrowserInfo'), description: t('shortcutKeys.BrowserInfo') },
{ keys: 'W', action: () => goToAdvancedTool('whois', 'Whois'), description: t('shortcutKeys.Whois') },
{ keys: 'v', action: () => goToAdvancedTool('servicestatus', 'ServiceStatus'), description: t('shortcutKeys.ServiceStatus') },
{
keys: 'q',
// Async components (see Home.vue): ref is null until the chunk
// lands, so these actions optional-chain instead of throwing.
action: () => { queryIPRef.value?.openModal(); trackEvent('ShortCut', 'ShortCut', 'QueryIP'); },
description: t('shortcutKeys.IPCheck'),
},
{
keys: 'h',
action: () => {
if (isInfosLoaded.value) toggleInfoMask();
trackEvent('ShortCut', 'ShortCut', 'ToggleInfoMask');
},
description: t('shortcutKeys.ToggleInfoMask'),
},
{
keys: 'H',
action: () => {
if (userPreferences.value.ipHistoryEnabled === false) return;
store.toggleSheet('ipHistory');
trackEvent('ShortCut', 'ShortCut', 'IPHistory');
},
description: t('shortcutKeys.IPHistory'),
},
{
keys: 'e',
action: () => { shareReportRef.value?.openDialog(); trackEvent('ShortCut', 'ShortCut', 'ShareReport'); },
description: t('shortcutKeys.ShareReport'),
},
{
keys: '?',
action: () => {
helpModalRef.value?.openModal();
trackEvent('ShortCut', 'ShortCut', 'Help');
// Achievement rule (CleverTrickery) lives in data/achievement-rules.js.
emitAppEvent('shortcut:help-opened');
},
description: t('shortcutKeys.Help'),
},
];
if (configs.value.originalSite) {
config.push({
keys: 'i',
action: () => goToAdvancedTool('invisibilitytest', 'InvisibilityTest'),
description: t('shortcutKeys.InvisibilityTest'),
});
// Uppercase D mirrors lowercase `d` (refresh homepage DNS leak test) —
// `D` opens the in-depth version of the same feature. Gated on
// originalSite since the advanced card itself is gated the same way.
config.push({
keys: 'D',
action: () => goToAdvancedTool('enhanceddnsleaktest', 'EnhancedDnsLeakTest'),
description: t('shortcutKeys.EnhancedDnsLeakTest'),
});
// Uppercase B: lowercase `b` belongs to Browser Info.
config.push({
keys: 'B',
action: () => goToAdvancedTool('blocklist', 'IpBlocklist'),
description: t('shortcutKeys.IpBlocklist'),
});
// Uppercase P: lowercase `p` belongs to Earth Online.
config.push({
keys: 'P',
action: () => goToAdvancedTool('personacheck', 'PersonaCheck'),
description: t('shortcutKeys.PersonaCheck'),
});
}
// Uppercase A: lowercase `a` belongs to the IP calculator. Gated like the
// ASN Profile card itself (data/tools.js `requiresConfig`).
if (configs.value.cloudFlare) {
config.push({
keys: 'A',
action: () => goToAdvancedTool('asn', 'AsnProfile'),
description: t('shortcutKeys.AsnProfile'),
});
}
// Mirrors the Earth Online entry's visibility (widgets/Pulse.vue).
if (hasPulseBackend || configs.value.cloudFlare) {
config.push({
keys: 'p',
action: () => {
store.toggleSheet('pulse');
trackEvent('ShortCut', 'ShortCut', 'Pulse');
},
description: t('shortcutKeys.Pulse'),
});
}
return config;
};
export const useShortcuts = ({ refs, store, t, configs, userPreferences }) => {
// Suspending the map behind an overlay is the primitives' job
// (composables/use-overlay-shortcuts.js), so nothing is wired here.
//
// Home is the only route that registers shortcuts; the map is cleared
// when it is left (cached away or unmounted) and rebuilt on its return.
let disposed = false;
let loaded = false;
const active = useRouteActive();
if (getCurrentScope()) {
onScopeDispose(() => {
disposed = true;
registerShortcuts([]);
});
}
const registerShortcutKeys = () => {
registerShortcuts(buildShortcutConfig({ refs, store, t, configs, userPreferences }));
};
watch(active, (isActive) => {
if (!loaded || disposed) return;
if (isActive) registerShortcutKeys();
else registerShortcuts([]);
});
const loadShortcuts = () => {
// Register immediately so the base keys work from the first paint;
// config-gated keys (originalSite's i/D/P, pulse's p) fill in when
// /api/configs lands (`{}` → data, once per page load) and the watcher
// rebuilds the map. keyMap is mutated in place (utils/shortcut.js),
// so the help modal's reference stays current across rebuilds.
loaded = true;
if (active.value) registerShortcutKeys();
watch(configs, () => {
if (!disposed && active.value) registerShortcutKeys();
});
// Help is an async component (Home.vue): on slow networks its chunk
// may land later, so wait for the ref instead of silently skipping
// the keyMap hand-off.
if (refs.helpModalRef.value) {
refs.helpModalRef.value.keyMap = keyMap;
} else {
const stop = watch(refs.helpModalRef, (help) => {
if (!help) return;
help.keyMap = keyMap;
stop();
});
}
};
return { loadShortcuts };
};