Расширения Google Chrome: делаем горячие клавиши

  • Tutorial
Здравствуйте, уважаемые хабравчане.
Продолжаю серию постов с личным опытом и советами по разработке расширений для браузера Google Chrome.
В этом выпуске – «глобальные» горячие клавиши на уровне браузера.

Итак, задача – реализовать в расширении поддержку глобальных сочетаний клавиш для выполнения каких-то функций расширения с возможностью настройки каждым пользователем под себя.
Начнём с того, что в браузере нет нативной поддержки глобальных горячих клавиш.
Уточнение
Так было во время написания моих расширений, так я считал до момента написания статьи. Сейчас API расширилось, добавилась поддержка команд. Но они жёстко прописываются в манифесте и не предоставляют достаточной гибкости. Также, в настройках расширений в браузере настраиваются горячие клавиши для активации окон расширений.
Это выходит за рамки поста, потому отправляю в Google и документацию.

Но задачу можно решить добавлением обработки событий нажатий клавиш во все окна браузера. Этот вариант не полностью функционален, так как зависит от наличия фокуса в теле документа, но в большинстве случаев работоспособен.
Важно. Нужно учитывать, что встраиваемые таким образом обработчики перекрывают большинство стандартных горячих клавиш браузера. Например, если в документе стоит обработка клика Ctrl+цифра с preventDefault, то аналогичная горячая клавиша браузера для переключения по вкладкам не будет работать при наличии фокуса в документе с таким обработчиком. Об этом же нужно предупреждать пользователя, если ему предоставляется возможность индивидуальной настройки сочетаний.

Для реализации понадобится создать content-script, который будет внедряться в страницы, прописать его в манифесте, прописать обработку в background-странице расширения, и реализовать возможность настройки сочетаний клавиш. К сожалению, оформить это в более удобном и переносимом виде не представляется возможным.

Выглядит всё это примерно следующим образом.

mainfest.json
{
	...
	"content_scripts": [ {
		"all_frames": true,	//обязательно, в пределах документа клавиши могут не работать, если фокус уйдёт в iframe
		"js": ["js/hotkeys.js" ],	//скрипт, добавляющий поддержку горячих клавиш
		"matches": [ "http://*/*", "https://*/*" ],	//вставляем на все страницы
		"run_at": "document_end"
	} ],
	...

Тут всё понятно. Прописано добавление обработчика во все открываемые страницы.

Общая структура hotkeys.js
if(!EXT_HOTKEY_JS_INSERTED){
	var EXT_HOTKEY_JS_INSERTED = true;
	var hotkeys = '';
	var messages = '';
	var timeout = '';
	chrome.extension.sendRequest({operation: "hotkeys"}, function(response) {	//запрос текущих настроек горячих клавиш у расширения
		if (response)
		if (response.hotkeys){
			hotkeys = JSON.parse(response.hotkeys);
			var d = document.createElement('DIV'); //элемент для индикации реакции на горячие клавиши
			d.id = "hotkeyresponder";
			//+стили: положение, вид, высокий z-index
			document.body.appendChild(d);
		}
	});
	document.addEventListener('keydown', event_handleKeyDownEvent, true); //собственно, главный обработчик нажатий клавиш в окне документа
	function event_handleKeyDownEvent(e){
		if (!hotkeys) return true;
		var c=0; var a=0; var s=0;
		var k = e.keyCode;
		if (e.shiftKey) s = 1;
		if (e.altKey) a = 1;
		if (e.ctrlKey) c = 1;
		if (k==27 && !c && !a && !s){
			if (document.getElementById('hotkeyresponder').style.display!='none'){
				// скрытие индикатора по Esc
				document.getElementById('hotkeyresponder').style.display="none";
				e.preventDefault(); e.cancelBubble = true; e.bubbles = false;
				return false;
			}
		}
		for (var name in hotkeys){
			if (hotkeys.hasOwnProperty(name)){
				if (hotkeys[name].c == c && hotkeys[name].a == a && hotkeys[name].s == s){
					//модификаторы совпадают, получена горячая клавиша, обрабатываем
					if (name == 'selectProfile' && k > 48 && k < 58 && (c || a || s)){
						//универсальная горячая клавиша <модификатор+цифра>
						chrome.extension.sendRequest({operation: "hotkey", key:name, id:(k-48)}, function(response) {
							responseHotkey(name,response.status,response.message);
						});
						e.preventDefault(); e.cancelBubble = true; e.bubbles = false;
						return false;
					}
					else if (hotkeys[name].k==k){
						//во всех случаях работаем только с полным совпадением. Модификатор совпал в условии выше, здесь проверили совпадение клавиши
						if (name=='toggleBodyi'){
							document.getElementById('togglebody').onclick();
						}
						//аналогично другие клавиши, которые выполняются непосредственно в теле страницы (toggle каких-то настроек, элементов DOM, etc.)
						else{
							//горячие клавиши, которые выполняются только внутри расширения
							chrome.extension.sendRequest({operation: "hotkey", key:name}, function(response) {
								responseHotkey('pp',response.status,response.message);
							});
						}
						e.preventDefault(); e.cancelBubble = true; e.bubbles = false;
						return false;
					}
					else{
						continue;
					}
				}
			}
		}
	}
	function responseHotkey(type,status,message){
		//отображение реакции на горячую клавишу
		if (status == 'OK' && message){
			document.getElementById('hotkeyresponder').innerHTML = message;
			document.getElementById('hotkeyresponder').style.display = 'block';
			if (timeout) clearTimeout(timeout);
			timeout = setTimeout(function(){document.getElementById('hotkeyresponder').style.display='none';},2000);
		}
	}
}

Приведённый скрипт – синтез немного разных скриптов горячих клавиш из двух расширений. Ясное дело, что при повторном использовании нужно модифицировать под свои нужды. Например, если все клавиши задаются однозначно (без универсальных модификаторов), нужно сделать только один блок if, где сравнить все 4 параметра горячей клавиши. Если в вашем расширении не выполняется никаких действий в DOM – ясно, что условные блоки в зависимости от названия горячей клавиши нужно упразднить до простого запроса к расширению.

Фрагмент background.js
chrome.extension.onRequest.addListener(function(request, sender, sendResponse) {
	loadSettings();	//обновление настроек
	if (request.operation == 'hotkeys'){	//запрос текущих настроек горячих клавиш
		sendResponse({hotkeys:localStorage.hotkeys});
	}
	else if (request.operation == 'hotkey'){	//обработка горячей клавиши
		if (request.key == 'selectProfile'){
			//нужные действия
		}
		//аналогично для остальных клавиш
	}
	else{
		//sendResponse({});
	}
});

Говорят, что sendRequest и onRequest deprecated в пользу sendMessage с похожим синтаксисом и функционалом. На момент написания поста я этого до конца не проверял, но мои расширения по-прежнему работают без видимых проблем.

В таком виде (если вручную вбить настройки в расширение) клавиши уже работоспособны.



Остались настройки. Здесь, в целом, вариантов много, всё на ваше усмотрение. Если клавиш много, есть смысл максимально это дело автоматизировать.
На случай, если кто-то собирается повторять в своих творениях – приведу фрагменты кода.
Сама страница настроек делается добавлением параметра в манифесте:
{
	...
	"options_page": "options.html",
	...
}

В HTML ничего особого нет, просто placeholder. Всё формируется на JavaScript.
options.html
<html>
<head>
	<title>Hotkeys</title>
	<script type="text/javascript" src="js/tools.js"></script>
	<script type="text/javascript" src="js/options.js"></script>
</head>
<body>
<!-- всякие header'ы и пр. -->
<div id="tab1">
	<p style="margin-top:0;">тут какая-либо информация или предупреждения</p>
</div>
</body>
</html>

options.js
Нужные данные: имена клавиш, текущие настройки горячих клавиш, и просто массив внутренних имён поддерживаемых сочетаний.
...
var keycodes = ['','','','','','','','','','Tab','','','','Enter','','','','','','','','','','','','','','Escape','','','','','Space','Page Up','Page Down','End','Home','Left','Up','Right','Down','','','','','Insert','Delete','','0','1','2','3','4','5','6','7','8','9','','','','','','','','A','B','C','D','E','F','G','H','I','J','K','L','M','N','O','P','Q','R','S','T','U','V','W','X','Y','Z','MainM Left','MainM Right','Menu','','','NumPad 0','NumPad 1','NumPad 2','NumPad 3','NumPad 4','NumPad 5','NumPad 6','NumPad 7','NumPad 8','NumPad 9','NumPad *','NumPad +','','NumPad -','NumPad ,','NumPad /','F1','F2','F3','F4','F5','F6','F7','F8','F9','F10','F11','F12','','','','','','','','','','','','','','','','','','','','','','','','','','','','','','','','','','','','','','','','','','','','','','','','','','','','','','','','','','','','','','','','=','','-','','','~','','','','','','','','','','','','','','','','','','','','','','','','','','','','\\'];
var hotkeys = JSON.parse(localStorage.hotkeys);
var phk = ['prevProfile', 'nextProfile', 'selectProfile', 'openOpts', 'toggleHideWidget', 'toggleWidget', 'toggleInCT', 'toggleBody', 'openWidget', 'toggleBodyi', 'actMC', 'actMG'];

Функция инициализации страницы: заполнение HTML, навешивание обработчиков, отображение текущих настроек.
initt = function(){
	...
	for (var i = 0; i < phk.length;i++){
		var d = document.createElement('DIV');
		d.innerHTML = '<h4 style="margin:0;">'+_getMessage(phk[i]+'_title')+'</h4><input type="checkbox" id="'+phk[i]+'_c"><label for="'+phk[i]+'_c">Ctrl <b>+</b></label><input type="checkbox" id="'+phk[i]+'_a"><label for="'+phk[i]+'_a">Alt <b>+</b></label><input type="checkbox" id="'+phk[i]+'_s"><label for="'+phk[i]+'_s">Shift <b>+</b></label>'+((phk[i]=='selectProfile')?('<key 1-9><input type="hidden" id="'+phk[i]+'_k" value="$"> '):('<input type="text" id="'+phk[i]+'_k">'))+'<img src="img/delete.gif" id="'+phk[i]+'_imgerr" class="err_disp" style="height:16px;display:none;" /><img src="img/ok.png" id="'+phk[i]+'_imgok" class="err_disp" style="height:16px;display:none;" /><br/><span id="result_'+phk[i]+'" class="err_disp" style="color:red"></span>';
		document.getElementById('tab1').appendChild(d);
	}
	document.getElementById('tab1').innerHTML+='<button>'+_getMessage('opts_13')+'</button>';
	for (var i = 0; i < phk.length;i++){
		if (hotkeys[phk[i]]){
			document.getElementById(phk[i]+'_c').checked = (hotkeys[phk[i]]['c']?true:false);
			document.getElementById(phk[i]+'_a').checked = (hotkeys[phk[i]]['a']?true:false);
			document.getElementById(phk[i]+'_s').checked = (hotkeys[phk[i]]['s']?true:false);
			if (phk[i]!='selectProfile')
				document.getElementById(phk[i]+'_k').value = keycodes[hotkeys[phk[i]]['k']];
		}
	}
	...
	var m = document.querySelectorAll('input[type=text]')
	for (var i = 0; i < m.length; i++){
		m[i].addEventListener('keydown',function(event){this.value=(keycodes[event.keyCode]?keycodes[event.keyCode]:'');prevDef(event);return false},false);
	}
	...
}

Функция сохранения. Здесь же реализована проверка на валидность и конфликты. Причём корректные комбинации сразу сохраняются, а ошибочные отмечаются соответствующим образом.
save = function(){
	//скрытие всех сообщений об ошибках
	var err_els = document.getElementsByClassName('err_disp');
	for (var i = 0; i < err_els.length; i++){
		if (i%3==2) err_els[i].innerHTML = '';
		else err_els.style.display = 'none';
	}
	var hktp = {}; var hkts = {};
	//сбор введённых пользователем данных и проверки
	for (var i = 0; i < phk.length; i++){
		if ((document.getElementById(phk[i]+'_c').checked || document.getElementById(phk[i]+'_a').checked || document.getElementById(phk[i]+'_s').checked) && document.getElementById(phk[i]+'_k').value){	//всё ОК
			hktp[phk[i]] = {
				c:document.getElementById(phk[i]+'_c').checked,
				a:document.getElementById(phk[i]+'_a').checked,
				s:document.getElementById(phk[i]+'_s').checked,
				k:document.getElementById(phk[i]+'_k').value,
			};
		}
		else{	//пустое значение или какая-то ошибка
			if (document.getElementById(phk[i]+'_c').checked || document.getElementById(phk[i]+'_a').checked || document.getElementById(phk[i]+'_s').checked || (document.getElementById(phk[i]+'_k').value && document.getElementById(phk[i]+'_k').value!='$')){
				document.getElementById('result_'+phk[i]).innerHTML = _getMessage('opts_12');
				document.getElementById(phk[i]+'_imgerr').style.display = 'inline';
			}
		}
	}
	//проверка конфликтов
	for (var i = 0; i < phk.length-1; i++){
		for (var j = i+1; j < phk.length; j++){
			if (hktp[phk[i]] && hktp[phk[j]])
			if (hktp[phk[i]].c == hktp[phk[j]].c && hktp[phk[i]].a == hktp[phk[j]].a && hktp[phk[i]].s == hktp[phk[j]].s && (hktp[phk[i]].k == hktp[phk[j]].k || ((hktp[phk[i]].k == '$' || hktp[phk[j]].k == '$') && ((hktp[phk[i]].k*1>0 && hktp[phk[i]].k*1<=9) || (hktp[phk[j]].k*1>0 && hktp[phk[j]].k*1<=9))))){
				document.getElementById('result_'+phk[i]).innerHTML = _getMessage('opts_11');
				document.getElementById(phk[i]+'_imgerr').style.display = 'inline';
				document.getElementById('result_'+phk[j]).innerHTML = _getMessage('opts_11');
				document.getElementById(phk[j]+'_imgerr').style.display = 'inline';
				hktp[phk[i]] = false;
				hktp[phk[j]] = false;
			}
		}
	}
	//отображение "галочек" для корректных клавиш
	for (var name in hktp){
		if (hktp.hasOwnProperty(name) && hktp[name]){
			document.getElementById(phk[i]+'_imgok').style.display = 'inline';
			hkts[name] = {
				c:(document.getElementById(name+'_c').checked?1:0),
				a:(document.getElementById(name+'_a').checked?1:0),
				s:(document.getElementById(name+'_s').checked?1:0),
				k:keycodes.in_array(document.getElementById(name+'_k').value),
			}
		}
	}
	localStorage.hotkeys = JSON.stringify(hkts);	//сохранение
}

Если вас смущают функции типа _getMessage – это обёртки для функций i18n (см. мой предыдущий пост)
Вот как это выглядит в работе.


На этом всё. Из этих исходников вполне реально делать любые функциональные решения. В таком виде системы поддержки горячих клавиш с честью работают в моих расширениях, пользователи не жалуются. Единственное, чего не умеет предложенный мной вариант – вызывать popup-окно. Я не нашёл соответствующих методов API, но и мои расширения не из тех, где непременно нужна комбинация клавиш на отображение всплывающего окна, а предлагаемый арсенал полностью покрывает функционал расширения.
Метки:
  • +4
  • 28,2k
  • 7
Поделиться публикацией
Похожие публикации
Комментарии 7
  • 0
    document.getElementById('result_'+phk[i]).parentNode.getElementsByTagName('img')[0].style.display = 'inline'
    


    Такой код заставляет меня плакать горькими слезами.
    • 0
      Согласен, равно как и теперь замечаю изобилие getElement*. Но писалось давно и на скорую руку.
      • 0
        Я к тому, что статья нечитабельна с таким кодом. Может стоит ее отформатировать как следует, раз давно писалось?
        • 0
          Частично поправил и добавил комментарии. На бо́льшее пока нет времени.
    • 0
      Программно вызвать Popup окно расширения невозможно, к сожалению.
      • 0
        Меня давно интересует вопрос, есть ли горячие клавиши(расширение с подобным функционалом?), которые позволяют открывать ссылки из панели закладок? В этом месте всегда тянусь за мышкой. Меня это огорчает. Спасибо.

      Только полноправные пользователи могут оставлять комментарии. Войдите, пожалуйста.