Google Tabindexer

Google 的搜索结果允许使用 [TAB] 键进行舒适的键盘操作。

  1. // ==UserScript==
  2. // @name Google Tabindexer
  3. // @name:ja Google Tabindexer
  4. // @name:zh-CN Google Tabindexer
  5. // @description Enable comfortable [TAB] key navigation by keyboard shortcut on Google search.
  6. // @description:ja Google の検索結果で [TAB] キーによる快適なキーボード操作を可能にします。
  7. // @description:zh-CN Google 的搜索结果允许使用 [TAB] 键进行舒适的键盘操作。
  8. // @namespace knoa.jp
  9. // @include https://www.google.*/search?*
  10. // @version 1.2.4
  11. // @grant none
  12. // ==/UserScript==
  13.  
  14. (function(){
  15. const SCRIPTNAME = 'GoogleTabindexer';
  16. const DEBUG = false;/*
  17. [update] 1.2.4
  18. fix for google's update.
  19.  
  20. [todo]
  21. ターゲット要素に背景色?単純なリンク要素以外だと適用しにくいかな。
  22. ブロック要素の指定も含めて定義するか。
  23. shift戻るときのbottom判定にも使える。
  24.  
  25. Twitterのみ特殊な件、テキトー対応しただけ
  26. https://www.google.co.jp/search?q=%22%E5%86%AC%E9%87%8E%E3%83%A6%E3%83%9F%22
  27. */
  28. if(window === top && console.time) console.time(SCRIPTNAME);
  29. const POSITION = (50/100);/* anchor target position scroll to */
  30. const SELECTORS = [
  31. 'input[title]',/* search */
  32. '#hdtbSum a:not([tabindex="-1"])',/* top navigations */
  33. '.zTpPx g-link > a',/* special twitter heading */
  34. '#search div.g [data-hveid] > div > a',/**** main headings!! ****/
  35. /* sub pages vary too much */
  36. '[aria-label^="Page"]',/* paging */
  37. '#pnprev',/* paging */
  38. '#pnnext',/* paging */
  39. '#tads a:not([style])[id]',/* ads */
  40. 'h3[role="heading"] a',/* images */
  41. '[data-init-vis="true"] g-inner-card a',/* videos */
  42. 'lazy-load-item a',/* news */
  43. ];
  44. const FOCUSFIRST = '#search div.g [data-hveid] > div > a';
  45. const INDEX = '1';/* set 1 to prevent default tab focuses */
  46. const FLAGNAME = 'tabindexer';/* should be lowercase */
  47. let elements = {}, indexedElements = [];
  48. let core = {
  49. initialize: function(){
  50. core.addTabindex(document.body);
  51. core.focusFirst();
  52. core.observe();
  53. core.tabToScroll();
  54. core.addStyle();
  55. },
  56. addTabindex: function(node){
  57. for(let i = 0; SELECTORS[i]; i++){
  58. let es = node.querySelectorAll(SELECTORS[i]);
  59. if(es === null) log('Not found:', SELECTORS[i]);
  60. for(let j = 0; es[j]; j++){
  61. es[j].tabIndex = INDEX;
  62. es[j].dataset[FLAGNAME] = SELECTORS[i];
  63. }
  64. }
  65. indexedElements = document.querySelectorAll(`[data-${FLAGNAME}]`);
  66. for(let i = 0; indexedElements[i]; i++){
  67. indexedElements[i].previousTabindexElement = indexedElements[i - 1];
  68. indexedElements[i].nextTabindexElement = indexedElements[i + 1];
  69. }
  70. },
  71. focusFirst: function(){
  72. let target = document.querySelector(FOCUSFIRST);
  73. core.showTarget(target);
  74. target.focus();
  75. },
  76. observe: function(){
  77. let body = document.body;
  78. observe(body, function(records){
  79. core.addTabindex(body);
  80. }, {childList: true, subtree: true});
  81. },
  82. tabToScroll: function(){
  83. window.addEventListener('keydown'/*keypress doesn't fire on tab key*/, function(e){
  84. if(e.key !== 'Tab') return;/* catch only Tab key */
  85. if(e.altKey || e.ctrlKey || e.metaKey) return;
  86. let target = (e.shiftKey) ? e.target.previousTabindexElement : e.target.nextTabindexElement;
  87. if(target) core.showTarget(target, e.shiftKey);
  88. }, true);
  89. },
  90. showTarget: function(target, shiftKey){
  91. let scroll = function(x, y, deltaY){
  92. setTimeout(function(){window.scrollTo(x, y + deltaY*(100 - 9**2)/100)}, 0*(1000/60));
  93. setTimeout(function(){window.scrollTo(x, y + deltaY*(100 - 8**2)/100)}, 1*(1000/60));
  94. setTimeout(function(){window.scrollTo(x, y + deltaY*(100 - 7**2)/100)}, 2*(1000/60));
  95. setTimeout(function(){window.scrollTo(x, y + deltaY*(100 - 6**2)/100)}, 3*(1000/60));
  96. setTimeout(function(){window.scrollTo(x, y + deltaY*(100 - 5**2)/100)}, 4*(1000/60));
  97. setTimeout(function(){window.scrollTo(x, y + deltaY*(100 - 4**2)/100)}, 5*(1000/60));
  98. setTimeout(function(){window.scrollTo(x, y + deltaY*(100 - 3**2)/100)}, 6*(1000/60));
  99. setTimeout(function(){window.scrollTo(x, y + deltaY*(100 - 2**2)/100)}, 7*(1000/60));
  100. setTimeout(function(){window.scrollTo(x, y + deltaY*(100 - 1**2)/100)}, 8*(1000/60));
  101. setTimeout(function(){window.scrollTo(x, y + deltaY*(100 - 0**2)/100)}, 9*(1000/60));
  102. };
  103. let innerHeight = window.innerHeight, scrollX = window.scrollX, scrollY = window.scrollY;
  104. let rect = target.getBoundingClientRect()/* rect.top: from top of the window */;
  105. switch(true){
  106. case(shiftKey === true && rect.bottom < innerHeight*POSITION):/* target is above the POSITION */
  107. scroll(scrollX, scrollY, rect.bottom - innerHeight*POSITION);/* position the target to (POSITION) from top */
  108. break;
  109. case(shiftKey === false && innerHeight*(1 - POSITION) < rect.top):/* target is below the POSITION */
  110. scroll(scrollX, scrollY, rect.top - innerHeight*(1 - POSITION));/* position the target to (1 - POSITION) from top */
  111. break;
  112. default:
  113. /* stay scrollY */
  114. break;
  115. }
  116. },
  117. addStyle: function(name = 'style'){
  118. let style = createElement(core.html[name]());
  119. document.head.appendChild(style);
  120. if(elements[name] && elements[name].isConnected) document.head.removeChild(elements[name]);
  121. elements[name] = style;
  122. },
  123. html: {
  124. style: () => `
  125. <style type="text/css">
  126. div.g [data-hveid] > div > a:focus,
  127. g-link a:focus,
  128. g-inner-card a:focus{
  129. outline: 0;
  130. }
  131. div.g [data-hveid] > div > a:focus h3,
  132. g-inner-card a:focus [role="heading"]{
  133. text-decoration: underline;
  134. }
  135. div.g [data-hveid] > div,
  136. g-link,
  137. g-inner-card a [role="heading"]{
  138. position: relative;
  139. overflow: visible !important;
  140. }
  141. div.g [data-hveid] > div > a:focus:before,
  142. g-link a:focus:before,
  143. g-inner-card a:focus [role="heading"]:before{
  144. content: "▶";
  145. font-size: medium;
  146. color: lightgray;
  147. position: absolute;
  148. left: -1.25em;
  149. top: 0.1em;
  150. }
  151. /* pagenation */
  152. a:focus > h3 > div{
  153. background: #eee;
  154. border: 1px solid #ccc;
  155. }
  156. </style>
  157. `,
  158. },
  159. };
  160. const createElement = function(html){
  161. let outer = document.createElement('div');
  162. outer.innerHTML = html;
  163. return outer.firstElementChild;
  164. };
  165. const observe = function(element, callback, options = {childList: true, characterData: false, subtree: false, attributes: false, attributeFilter: undefined}){
  166. let observer = new MutationObserver(callback.bind(element));
  167. observer.observe(element, options);
  168. return observer;
  169. };
  170. const log = function(){
  171. if(!DEBUG) return;
  172. let l = log.last = log.now || new Date(), n = log.now = new Date();
  173. let error = new Error(), line = log.format.getLine(error), callers = log.format.getCallers(error);
  174. //console.log(error.stack);
  175. console.log(
  176. SCRIPTNAME + ':',
  177. /* 00:00:00.000 */ n.toLocaleTimeString() + '.' + n.getTime().toString().slice(-3),
  178. /* +0.000s */ '+' + ((n-l)/1000).toFixed(3) + 's',
  179. /* :00 */ ':' + line,
  180. /* caller.caller */ (callers[2] ? callers[2] + '() => ' : '') +
  181. /* caller */ (callers[1] || '') + '()',
  182. ...arguments
  183. );
  184. };
  185. log.formats = [{
  186. name: 'Firefox Scratchpad',
  187. detector: /MARKER@Scratchpad/,
  188. getLine: (e) => e.stack.split('\n')[1].match(/([0-9]+):[0-9]+$/)[1],
  189. getCallers: (e) => e.stack.match(/^[^@]*(?=@)/gm),
  190. }, {
  191. name: 'Firefox Console',
  192. detector: /MARKER@debugger/,
  193. getLine: (e) => e.stack.split('\n')[1].match(/([0-9]+):[0-9]+$/)[1],
  194. getCallers: (e) => e.stack.match(/^[^@]*(?=@)/gm),
  195. }, {
  196. name: 'Firefox Greasemonkey 3',
  197. detector: /\/gm_scripts\//,
  198. getLine: (e) => e.stack.split('\n')[1].match(/([0-9]+):[0-9]+$/)[1],
  199. getCallers: (e) => e.stack.match(/^[^@]*(?=@)/gm),
  200. }, {
  201. name: 'Firefox Greasemonkey 4+',
  202. detector: /MARKER@user-script:/,
  203. getLine: (e) => e.stack.split('\n')[1].match(/([0-9]+):[0-9]+$/)[1] - 500,
  204. getCallers: (e) => e.stack.match(/^[^@]*(?=@)/gm),
  205. }, {
  206. name: 'Firefox Tampermonkey',
  207. detector: /MARKER@moz-extension:/,
  208. getLine: (e) => e.stack.split('\n')[1].match(/([0-9]+):[0-9]+$/)[1] - 6,
  209. getCallers: (e) => e.stack.match(/^[^@]*(?=@)/gm),
  210. }, {
  211. name: 'Chrome Console',
  212. detector: /at MARKER \(<anonymous>/,
  213. getLine: (e) => e.stack.split('\n')[2].match(/([0-9]+):[0-9]+\)$/)[1],
  214. getCallers: (e) => e.stack.match(/[^ ]+(?= \(<anonymous>)/gm),
  215. }, {
  216. name: 'Chrome Tampermonkey',
  217. detector: /at MARKER \((userscript\.html|chrome-extension:)/,
  218. getLine: (e) => e.stack.split('\n')[2].match(/([0-9]+):1\)$/)[1] - 4,
  219. getCallers: (e) => e.stack.match(/[^ ]+(?= \((userscript\.html|chrome-extension:))/gm),
  220. }, {
  221. name: 'Edge Console',
  222. detector: /at MARKER \(eval/,
  223. getLine: (e) => e.stack.split('\n')[2].match(/([0-9]+):[0-9]+\)$/)[1],
  224. getCallers: (e) => e.stack.match(/[^ ]+(?= \(eval)/gm),
  225. }, {
  226. name: 'Edge Tampermonkey',
  227. detector: /at MARKER \(Function/,
  228. getLine: (e) => e.stack.split('\n')[2].match(/([0-9]+):[0-9]+\)$/)[1] - 4,
  229. getCallers: (e) => e.stack.match(/[^ ]+(?= \(Function)/gm),
  230. }, {
  231. name: 'Safari',
  232. detector: /^MARKER$/m,
  233. getLine: (e) => 0,/*e.lineが用意されているが最終呼び出し位置のみ*/
  234. getCallers: (e) => e.stack.split('\n'),
  235. }, {
  236. name: 'Default',
  237. detector: /./,
  238. getLine: (e) => 0,
  239. getCallers: (e) => [],
  240. }];
  241. log.format = log.formats.find(function MARKER(f){
  242. if(!f.detector.test(new Error().stack)) return false;
  243. //console.log('//// ' + f.name + '\n' + new Error().stack);
  244. return true;
  245. });
  246. core.initialize();
  247. if(window === top && console.timeEnd) console.timeEnd(SCRIPTNAME);
  248. })();