ブックマークレットでHit-a-Hint!
ブックマークレットでHit-a-Hint!:
ものすごく頑張りました。
VimiumではLinkHintと呼ばれているあれです。
画面上のクリッカブルな要素に対応した『ヒント』と呼ばれる一意の文字列をキータイプすると、その要素が開かれたり選択されたりするあれです。
あれをブックマークレットだけでやってみました。
まずはクリッカブルな要素を取得します。
何をもってクリック可能であるかを判別するかが問題です。
先人の知恵(Vimiumのソース)を見てみたところ、属性やタグなどから判別しているようです。
というわけで、
ただ、取得した要素すべてがクリック可能であるとは限りません。
そんなわけで
(そのためにスプレッド演算子で配列化しています。便利!)
今回は特に指定していません。
(xとyについてはわかりません。
ページ上に表示され(
詳しくは: JavaScriptで画面上のクリッカブルな要素を列挙してみた
それぞれの要素に対応した一意のなるべく短い文字列を生成します。
いい方法が思い浮かばなかったので力技です。
毎回ランダムに生成しているので、同じ要素でもブックマークレットを発動するたびにヒントが変わります。
Vimiumとかどうやってるんでしょうか…。
いざ、ヒントの表示です。
もっと早く知りたかったですねこれ。ブックマークレット開発のときにめちゃくちゃ便利じゃないですか。
特殊キーとの同時押しは無視します。
入力にマッチしないヒントは即
入力にマッチしたら要素に
拡張機能禁止縛りとかするなら役に立ちそうです。
はじめに
ものすごく頑張りました。
Hit-a-Hintとは
キーボードのみでリンク選択するのを簡単にするために、ページ内のリンクの前後に番号やアルファベット (ヒント) を表示させ、そのヒントを打鍵することによってリンクを選択できるようにする機能。キーバインド系拡張機能に統合されていることの多いあれです。
Hit-a-Hintとは - はてなキーワードより
VimiumではLinkHintと呼ばれているあれです。
画面上のクリッカブルな要素に対応した『ヒント』と呼ばれる一意の文字列をキータイプすると、その要素が開かれたり選択されたりするあれです。
あれをブックマークレットだけでやってみました。
全体像
javascript: ((settings) => { // console.log('要素取得はじめ'); const clickableElms = [...document.querySelectorAll(settings.elm.allow.join(',') || undefined)] .filter(elm => elm.closest(settings.elm.block.join(',') || undefined) === null) .map(elm => { const domRect = elm.getBoundingClientRect(); return { bottom: Math.floor(domRect.bottom), elm: elm, height: Math.floor(domRect.height), left: Math.floor(domRect.left || domRect.x), right: Math.floor(domRect.right), top: Math.floor(domRect.top || domRect.y), width: Math.floor(domRect.width), } }) .filter(data => { const windowH = window.innerHeight, windowW = window.innerWidth ; return ( data.width > 0 && data.height > 0 && data.bottom > 0 && data.top < windowH && data.right > 0 && data.left < windowW ); }) ; // console.log('要素取得おわり'); // console.log('ヒント生成はじめ'); const hintCh = [...new Set(settings.hintCh.toUpperCase())]; let hintLen = 1; while (clickableElms.length > Math.pow(hintCh.length, hintLen)) { hintLen++; } const hints = []; while (hints.length < clickableElms.length) { const hint = [...Array(hintLen)] .map(() => hintCh[Math.floor(Math.random() * hintCh.length)]) .join('') ; if (!hints.includes(hint)) { hints.push(hint); } } // console.log('ヒント生成おわり'); // console.log('ヒント表示はじめ'); const viewData = clickableElms .map((data, index) => { data.hintCh = hints[index]; return data; }) .map(data => { const hintElm = document.createElement('div'); const style = hintElm.style; style.all = 'initial'; style.backgroundColor = 'yellow'; style.color = 'black'; style.fontFamily = 'menlo'; style.fontSize = '16px'; style.left = `${data.left}px`; style.padding = '2px'; style.position = 'fixed'; style.top = `${data.top}px`; style.zIndex = '99999'; hintElm.textContent = data.hintCh; document.body.appendChild(hintElm); data.hintElm = hintElm; return data; }) ; // console.log('ヒント表示おわり'); // console.log('入力処理はじめ'); let input = ''; const onkeydown = (e) => { const fin = () => { window.removeEventListener('keydown', onkeydown); viewData.forEach(data => { if (data.hintElm) { data.hintElm.remove(); } }); // console.log('さようなら世界'); }; if (!(e.ctrlKey || e.metaKey || e.shiftKey || e.shiftKey)) { e.preventDefault(); if (e.key === 'Escape') { fin(); } else { input += e.key.toUpperCase(); viewData .filter(data => !data.hintCh.startsWith(input)) .forEach(data => { data.hintElm.remove(); }) ; const selectedElms = viewData.filter(data => data.hintCh.startsWith(input)); if (selectedElms.length === 1 && selectedElms[0].hintCh === input) { // viewData[0].elm.click(); selectedElms[0].elm.focus(); fin(); } } } }; window.addEventListener('keydown', onkeydown); // console.log('入力処理おわり'); })({ elm: { allow: [ 'a', 'button:not([disabled])', 'details', 'input:not([type="disabled" i]):not([type="hidden" i]):not([type="readonly" i])', 'select:not([disabled])', 'textarea:not([disabled]):not([readonly])', '[contenteditable=""]', '[contenteditable="true" i]', '[onclick]', '[onmousedown]', '[onmouseup]', '[role="button" i]', '[role="checkbox" i]', '[role="link" i]', '[role="menuitemcheckbox" i]', '[role="menuitemradio" i]', '[role="option" i]', '[role="radio" i]', '[role="switch" i]', ], block: [ ], }, hintCh: 'abcdefghijklmnopqrstuvwxyz', })
解説
- クリッカブルな要素を取得
- ヒント文字列を作る
- ヒントを表示する
- ユーザーの入力に応じてヒントを削除したりHitしたり
要素取得
まずはクリッカブルな要素を取得します。何をもってクリック可能であるかを判別するかが問題です。
先人の知恵(Vimiumのソース)を見てみたところ、属性やタグなどから判別しているようです。
というわけで、
settings.elm.allow
にて取得する要素をCSSセレクターで列挙し、querySelectorAll()
で取得します。ただ、取得した要素すべてがクリック可能であるとは限りません。
そんなわけで
filter()
にかけます。(そのためにスプレッド演算子で配列化しています。便利!)
closest()
では、その要素やその要素の先祖に、settings.elm.block
に該当する要素がないかどうかチェックしています。今回は特に指定していません。
getBoundingClientRect()
で、表示の情報を得ています。getBoundingClientRect()
では、viewportの左上を起点とした上・下・左・右・幅・高さ(とx座標・y座標)を得られます。(xとyについてはわかりません。
left
とtop
となにが違うんでしょう?)ページ上に表示され(
width
・height
がある)、かつ画面上に表示されている(ウィンドウサイズと比較)もののみを選んでいます。詳しくは: JavaScriptで画面上のクリッカブルな要素を列挙してみた
ヒント生成
それぞれの要素に対応した一意のなるべく短い文字列を生成します。いい方法が思い浮かばなかったので力技です。
while
ループとか久しぶりに触りました。毎回ランダムに生成しているので、同じ要素でもブックマークレットを発動するたびにヒントが変わります。
Vimiumとかどうやってるんでしょうか…。
ヒント表示
いざ、ヒントの表示です。hintElm
はただのdiv
要素です。all: initial;
するとCSSをリセットできるそうです。もっと早く知りたかったですねこれ。ブックマークレット開発のときにめちゃくちゃ便利じゃないですか。
入力処理
window.addEventListener()
です。fin()
は自決用の関数です。特殊キーとの同時押しは無視します。
Escape
キーだった場合は即自決。入力にマッチしないヒントは即
remove()
。入力にマッチしたら要素に
focus()
して自決。click()
でもよかったですが、command + Enter
がしたかったので今回はfocus()
で。
一行(ブックマークレット用)
javascript:((settings)=>{const clickableElms=[...document.querySelectorAll(settings.elm.allow.join(',')||undefined)].filter(elm=>elm.closest(settings.elm.block.join(',')||undefined)===null).map(elm=>{const domRect=elm.getBoundingClientRect();return{bottom:Math.floor(domRect.bottom),elm:elm,height:Math.floor(domRect.height),left:Math.floor(domRect.left||domRect.x),right:Math.floor(domRect.right),top:Math.floor(domRect.top||domRect.y),width:Math.floor(domRect.width),}}).filter(data=>{const windowH=window.innerHeight,windowW=window.innerWidth;return(data.width>0&&data.height>0&&data.bottom>0&&data.top<windowH&&data.right>0&&data.left<windowW);});const hintCh=[...new Set(settings.hintCh.toUpperCase())];let hintLen=1;while(clickableElms.length>Math.pow(hintCh.length,hintLen)){hintLen++;}const hints=[];while(hints.length<clickableElms.length){const hint=[...Array(hintLen)].map(()=>hintCh[Math.floor(Math.random()*hintCh.length)]).join('');if(!hints.includes(hint)){hints.push(hint);}}const viewData=clickableElms.map((data,index)=>{data.hintCh=hints[index];return data;}).map(data=>{const hintElm=document.createElement('div');const style=hintElm.style;style.all='initial';style.backgroundColor='yellow';style.color='black';style.fontFamily='menlo';style.fontSize='16px';style.left=`${data.left}px`;style.padding='2px';style.position='fixed';style.top=`${data.top}px`;style.zIndex='99999';hintElm.textContent=data.hintCh;document.body.appendChild(hintElm);data.hintElm=hintElm;return data;});let input='';const onkeydown=(e)=>{const fin=()=>{window.removeEventListener('keydown',onkeydown);viewData.forEach(data=>{if(data.hintElm){data.hintElm.remove();}});};if(!(e.ctrlKey||e.metaKey||e.shiftKey||e.shiftKey)){e.preventDefault();if(e.key==='Escape'){fin();}else{input+=e.key.toUpperCase();viewData.filter(data=>!data.hintCh.startsWith(input)).forEach(data=>{data.hintElm.remove();});const selectedElms=viewData.filter(data=>data.hintCh.startsWith(input));if(selectedElms.length===1&&selectedElms[0].hintCh===input){selectedElms[0].elm.focus();fin();}}}};window.addEventListener('keydown',onkeydown);})({elm:{allow:['a','button:not([disabled])','details','input:not([type="disabled" i]):not([type="hidden" i]):not([type="readonly" i])','select:not([disabled])','textarea:not([disabled]):not([readonly])','[contenteditable=""]','[contenteditable="true" i]','[onclick]','[onmousedown]','[onmouseup]','[role="button" i]','[role="checkbox" i]','[role="link" i]','[role="menuitemcheckbox" i]','[role="menuitemradio" i]','[role="option" i]','[role="radio" i]','[role="switch" i]',],block:[],},hintCh:'abcdefghijklmnopqrstuvwxyz',})
コメント
コメントを投稿