JavaScript
bookmarklet

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


追記

Vimiumするブックマークレットを作りました。

ブックマークレットでVimium!

素直に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) {
// 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',})


おわりに

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