BETA

ブックマークレットでHit-a-Hint!

投稿日:2019-02-12
最終更新:2019-02-14
※この記事は外部サイト(https://qiita.com/okayu_tar_gz/items/92448...)からのクロス投稿です

はじめに

ものすごく頑張りました。

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) {  
                    // selectedElms[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',  
})

解説

  1. クリッカブルな要素を取得
  2. ヒント文字列を作る
  3. ヒントを表示する
  4. ユーザーの入力に応じてヒントを削除したりHitしたり

要素取得

まずはクリッカブルな要素を取得します。
何をもってクリック可能であるかを判別するかが問題です。
先人の知恵(Vimiumのソース)を見てみたところ、属性やタグなどから判別しているようです。

というわけで、settings.elm.allowにて取得する要素をCSSセレクターで列挙し、querySelectorAll()で取得します。
ただ、取得した要素すべてがクリック可能であるとは限りません。
そんなわけでfilter()にかけます。
(そのためにスプレッド演算子で配列化しています。便利!)

closest()では、その要素やその要素の先祖に、settings.elm.blockに該当する要素がないかどうかチェックしています。
今回は特に指定していません。

getBoundingClientRect()で、表示の情報を得ています。
getBoundingClientRect()では、viewportの左上を起点とした上・下・左・右・幅・高さ(とx座標・y座標)を得られます。
(xとyについてはわかりません。lefttopとなにが違うんでしょう?)
ページ上に表示され(widthheightがある)、かつ画面上に表示されている(ウィンドウサイズと比較)もののみを選んでいます。

詳しくは: 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',})

おわりに

拡張機能禁止縛りとかするなら役に立ちそうです。

技術ブログをはじめよう Qrunch(クランチ)は、プログラマの技術アプトプットに特化したブログサービスです
駆け出しエンジニアからエキスパートまで全ての方々のアウトプットを歓迎しております!
or 外部アカウントで 登録 / ログイン する
クランチについてもっと詳しく

この記事が掲載されているブログ

@okayuの大して技術的ではないブログ

よく一緒に読まれる記事

0件のコメント

ブログ開設 or ログイン してコメントを送ってみよう
目次をみる
技術ブログをはじめよう Qrunch(クランチ)は、プログラマの技術アプトプットに特化したブログサービスです
or 外部アカウントではじめる
10秒で技術ブログが作れます!