返回首頁 

Focus Navigator

タブキーによるフォーカス移動の対象と順番をCSSセレクタで設定できるようにする


Install this script?
// ==UserScript==// @name        Focus Navigator// @namespace   https://greasyfork.org/users/1009-kengo321// @description タブキーによるフォーカス移動の対象と順番をCSSセレクタで設定できるようにする// @match       *://*/*// @version     5// @grant       GM_getValue// @grant       GM_setValue// @grant       GM_registerMenuCommand// @grant       GM_setClipboard// @grant       GM.getValue// @grant       GM.setValue// @grant       GM.setClipboard// @license     MIT License// @noframes// @run-at      document-start// ==/UserScript==;(function() {'use strict'const [gmGetValue, gmSetValue, gmSetClipboard] =typeof GM_getValue === 'undefined'? [GM.getValue, GM.setValue, GM.setClipboard]: [GM_getValue, GM_setValue, GM_setClipboard]var Config = (function() {var byId = function(id) {return function() { return this._doc.getElementById(id) }}var addOption = function(selectElem) {return function(optionText) {var o = selectElem.ownerDocument.createElement('option')o.textContent = optionTextselectElem.appendChild(o)}}var isValidSelector = function(selector) {try {document.querySelector(selector)return true} catch (e) {return false}}var matchForward = function(s1, s2) {return s1.indexOf(s2) === 0}var comparator = function(url) {return function(o1, o2) {var matched1 = matchForward(url, o1.url)var matched2 = matchForward(url, o2.url)if (matched1 && !matched2) return -1if (!matched1 && matched2) return 1if (o1.url < o2.url) return -1if (o1.url > o2.url) return 1return 0}}var maxZIndex = function() {return '2147483647'}var maxFrameWidth = function() {return 600}var selectedIndices = function(selectElem) {return [].map.call(selectElem.selectedOptions, function(o) {return o.index})}var removeAt = function(array, indices) {return array.filter(function(e, i) { return indices.indexOf(i) === -1 })}var removeSelectedOptions = function(selectElem) {;[].slice.call(selectElem.selectedOptions).forEach(function(o) {o.parentNode.removeChild(o)})}var moveSelectedSelector = function(op) {return function() {var l = this._selectorList()var i = l.selectedIndexvar t = l.options[i].textContentl.options[i].textContent = l.options[op(i)].textContentl.options[op(i)].textContent = tl.selectedIndex = op(i)var d = this._data[this._urlList().selectedIndex]var s = d.selectors[i]d.selectors[i] = d.selectors[op(i)]d.selectors[op(i)] = s}}var Config = function(doc) {this._doc = docthis._data = Config.focusSelectors().sort(comparator(this._topUrl()))this._updateUrlList()this._styleMatchedUrl()this._addAllCallbacks()this._urlList().focus()}Config.prototype._urlList = byId('url-list')Config.prototype._urlEditButton = byId('url-edit-button')Config.prototype._urlRemoveButton = byId('url-remove-button')Config.prototype._selectorList = byId('selector-list')Config.prototype._selectorAddButton = byId('selector-add-button')Config.prototype._selectorEditButton = byId('selector-edit-button')Config.prototype._selectorRemoveButton = byId('selector-remove-button')Config.prototype._selectorUpButton = byId('selector-up-button')Config.prototype._selectorDownButton = byId('selector-down-button')Config.prototype._scrollPositionFieldSet = byId('scroll-position-fieldset')Config.prototype._coordinateCheckbox = byId('coordinate-checkbox')Config.prototype._lowerSpaceInput = byId('lower-space-input')Config.prototype._upperSpaceInput = byId('upper-space-input')Config.prototype._customCssTextArea = byId('custom-css-textarea')Config.prototype._frame = function() {return this._doc.defaultView.frameElement}Config.prototype._topUrl = function() {return this._frame().ownerDocument.location.href}Config.prototype._updateUrlList = function() {this._urlList().length = 0this._data.map(function(s) { return s.url }).forEach(addOption(this._urlList()))}Config.prototype._styleMatchedUrl = function() {var url = this._topUrl()this._data.map(function(d) {return matchForward(url, d.url)}).forEach(function(matched, i) {this._urlList().options[i].classList[matched ? 'add' : 'remove']('matched')}, this)}Config.prototype._setComputedHeight = function(elem) {elem.style.height = this._doc.defaultView.getComputedStyle(elem).height}Config.prototype._addCallbacks = function(id, type, callbacks) {var target = this._doc.getElementById(id)callbacks.forEach(function(callback) {target.addEventListener(type, callback)})}Config.prototype._addAllCallbacks = function() {;[['url-list', 'change', [this.updateValueBySelectedUrl.bind(this),this.updateDisabled.bind(this)]],['selector-list', 'change', [this.updateDisabled.bind(this)]],['url-add-button', 'click', [this.addUrl.bind(this),this.updateValueBySelectedUrl.bind(this),this.updateDisabled.bind(this),]],['url-edit-button', 'click', [this.editUrl.bind(this)]],['url-remove-button', 'click', [this.removeSelectedUrls.bind(this),this.updateValueBySelectedUrl.bind(this),this.updateDisabled.bind(this),]],['selector-add-button', 'click', [this.addSelector.bind(this),this.updateDisabled.bind(this),]],['selector-edit-button', 'click', [this.editSelector.bind(this),]],['selector-remove-button', 'click', [this.removeSelectedSelectors.bind(this),this.updateDisabled.bind(this),]],['selector-up-button', 'click', [this.upSelectedSelector.bind(this),this.updateDisabled.bind(this),]],['selector-down-button', 'click', [this.downSelectedSelector.bind(this),this.updateDisabled.bind(this),]],['coordinate-checkbox', 'change', [this.coordinateCheckboxChanged.bind(this),]],['lower-space-input', 'input', [this.lowerSpaceInputChanged.bind(this),]],['upper-space-input', 'input', [this.upperSpaceInputChanged.bind(this),]],['custom-css-textarea', 'input', [this.cssTextAreaChanged.bind(this),]],['import-export-checkbox', 'change', [this.impExpCheckboxChanged.bind(this),]],['export-button', 'click', [this.export.bind(this)]],['import-button', 'click', [this.import.bind(this),this.updateValueBySelectedUrl.bind(this),this.updateDisabled.bind(this),]],['ok-button', 'click', [this.save.bind(this),FocusSelector.update.bind(null, this._frame().ownerDocument),this.removeFrameFromParent.bind(this),]],['cancel-button', 'click', [this.removeFrameFromParent.bind(this)]],].forEach(this._addCallbacks.apply.bind(this._addCallbacks, this))}Config.prototype._updateSelectorList = function() {this._selectorList().length = 0if (this._urlList().selectedOptions.length !== 1) returnvar i = this._urlList().selectedIndex;(this._data[i].selectors || []).forEach(addOption(this._selectorList()))}Config.prototype._updateScrollPositionElements = function() {if (this._urlList().selectedOptions.length === 1) {var d = this._data[this._urlList().selectedIndex]this._coordinateCheckbox().checked = d.coordinatedthis._lowerSpaceInput().value = d.lowerSpace || 0this._upperSpaceInput().value = d.upperSpace || 0} else {this._coordinateCheckbox().checked = falsethis._lowerSpaceInput().value = 0this._upperSpaceInput().value = 0}}Config.prototype._updateCustomCssTextArea = function() {if (this._urlList().selectedOptions.length === 1) {var d = this._data[this._urlList().selectedIndex]this._customCssTextArea().value = d.cssText || ''} else {this._customCssTextArea().value = ''}}Config.prototype.updateValueBySelectedUrl = function() {this._updateSelectorList()this._updateScrollPositionElements()this._updateCustomCssTextArea()}Config.prototype._updateDisabledBySelectedUrl = function() {var len = this._urlList().selectedOptions.lengththis._urlEditButton().disabled = len !== 1this._urlRemoveButton().disabled = len === 0this._selectorList().disabled = len !== 1this._selectorAddButton().disabled = len !== 1this._coordinateCheckbox().disabled = len !== 1this._scrollPositionFieldSet().disabled =!(len === 1 && this._coordinateCheckbox().checked)this._customCssTextArea().disabled = len !== 1}Config.prototype._updateDisabledBySelectedSelector = function() {var list = this._selectorList()var len = list.selectedOptions.lengththis._selectorEditButton().disabled = len !== 1this._selectorRemoveButton().disabled = len === 0var top = list.selectedIndex === 0this._selectorUpButton().disabled = len !== 1 || topvar last = list.selectedIndex === list.length - 1this._selectorDownButton().disabled = len !== 1 || last}Config.prototype.updateDisabled = function() {this._updateDisabledBySelectedUrl()this._updateDisabledBySelectedSelector()}Config.prototype.addUrl = function() {var url = ''do {url = prompt(url ? '"' + url + '"は登録済みです。' : '', url || this._topUrl())if (url === null) return} while (this._data.some(function(d) { return d.url === url}))this._data.push({url: url})addOption(this._urlList())(url)this._urlList().selectedIndex = this._urlList().length - 1this._styleMatchedUrl()}Config.prototype.editUrl = function() {var i = this._urlList().selectedIndexvar data  = this._data[i]var url = ''do {url = prompt(url ? '"' + url + '"は登録済みです。' : '', url || data.url)if (url === null) return} while (this._data.some(function(d) { return d.url === url}))data.url = urlthis._urlList().options[i].textContent = urlthis._styleMatchedUrl()}Config.prototype.save = function() {gmSetValue('focusSelectors', JSON.stringify(this._data))Config.focusSelectors.set(this._data)}Config.prototype.removeSelectedUrls = function() {this._data = removeAt(this._data, selectedIndices(this._urlList()))removeSelectedOptions(this._urlList())}Config.prototype.addSelector = function() {var selector = ''do {selector = prompt(selector ? '構文エラー' : '', selector)if (selector === null) return} while (!isValidSelector(selector))addOption(this._selectorList())(selector)var d = this._data[this._urlList().selectedIndex]d.selectors = (d.selectors || []).concat(selector)this._selectorList().selectedIndex = this._selectorList().length - 1}Config.prototype.editSelector = function() {var d = this._data[this._urlList().selectedIndex]var i = this._selectorList().selectedIndexvar current = d.selectors[i]var selector = ''do {selector = prompt(selector ? '構文エラー' : '', selector || current)if (selector === null) return} while (!isValidSelector(selector))d.selectors[i] = selectorthis._selectorList().options[i].textContent = selector}Config.prototype.removeSelectedSelectors = function() {var d = this._data[this._urlList().selectedIndex]d.selectors = removeAt(d.selectors, selectedIndices(this._selectorList()))removeSelectedOptions(this._selectorList())}Config.prototype.upSelectedSelector =moveSelectedSelector(function(v) { return v - 1 })Config.prototype.downSelectedSelector =moveSelectedSelector(function(v) { return v + 1 })Config.prototype.coordinateCheckboxChanged = function() {var checked = this._coordinateCheckbox().checkedthis._data[this._urlList().selectedIndex].coordinated = checkedthis._scrollPositionFieldSet().disabled = !checked}Config.prototype.lowerSpaceInputChanged = function() {if (this._lowerSpaceInput().validity.valid) {this._data[this._urlList().selectedIndex].lowerSpace =this._lowerSpaceInput().valueAsNumber}}Config.prototype.upperSpaceInputChanged = function() {if (this._upperSpaceInput().validity.valid) {this._data[this._urlList().selectedIndex].upperSpace =this._upperSpaceInput().valueAsNumber}}Config.prototype.cssTextAreaChanged = function() {this._data[this._urlList().selectedIndex].cssText =this._customCssTextArea().value.trim()}Config.prototype.removeFrameFromParent = function() {this._frame().parentNode.removeChild(this._frame())var b = this.backgroundif (b && b.parentNode) b.parentNode.removeChild(b)}Config.prototype.impExpCheckboxChanged = function() {var checkbox = this._doc.getElementById('import-export-checkbox')var container = this._doc.getElementById('import-export-container')container.classList[checkbox.checked ? 'add' : 'remove']('show')}Config.prototype.export = function() {gmSetClipboard(JSON.stringify(this._data))}Config.prototype.import = function() {try {var ta = this._doc.getElementById('import-textarea')this._data = JSON.parse(ta.value)this._updateUrlList()this._styleMatchedUrl()ta.setCustomValidity('')} catch (e) {ta.setCustomValidity(e.toString())}}Config.focusSelectors = function() {return Config._focusSelectors}Config.focusSelectors.set = function(focusSelectors) {Config._focusSelectors = focusSelectors}Config.show = function(doc) {var background = doc.createElement('div')background.style.backgroundColor = 'black'background.style.opacity = '0.5'background.style.zIndex = maxZIndex() - 1background.style.position = 'fixed'background.style.top = '0'background.style.left = '0'background.style.width = '100%'background.style.height = '100%'doc.body.appendChild(background)var f = doc.createElement('iframe')f.style.position = 'fixed'f.style.top = '0'f.style.left = '0'f.style.width = '100%'f.style.height = '100%'f.style.zIndex = maxZIndex()f.srcdoc = Config.srcdocf.addEventListener('load', async function() {Config.focusSelectors.set(JSON.parse(await gmGetValue('focusSelectors', '[]')))var config = new Config(f.contentDocument)config.background = background})doc.body.appendChild(f)}Config.srcdoc = ['<!doctype html><html><head><style>','  html {','    margin: 0 auto;','    max-width: 50em;','    height: 100%;','    line-height: 1.5em;','  }','  body {','    height: 100%;','    margin: 0;','    display: flex;','    flex-direction: column;','    justify-content: center;','  }','  #dialog {','    overflow: auto;','    padding: 8px;','    background-color: white;','  }','  p { margin: 0; }','  select { width: 100%; }','  textarea { width: 100%; }','  .label-p, #scroll-position-fieldset, #ok-cancel-p {','    margin-top: 10px;','  }','  #custom-css-desc { line-height: 1.1em; }','  #ok-cancel-p { text-align: right; }','  #url-list .matched { text-decoration: underline; }','  #import-export-container { display: none; }','  #import-export-container.show { display: block; }','</style></head><body><div id=dialog>','<p><label for=url-list>対象ページのURL一覧(前方一致):</label></p>','<p><select id=url-list size=10 multiple></select></p>','<p>','  <input id=url-add-button type=button value=追加>','  <input id=url-edit-button type=button value=編集 disabled>','  <input id=url-remove-button type=button value=削除 disabled>','</p>','<p><small>','  複数一致するときは、一番長いURLの設定を使用します。','</small></p>','<p class=label-p><label for=selector-list>','  フォーカス対象と移動順のCSSセレクタ一覧:','</label></p>','<p><select id=selector-list size=5 multiple disabled></select></p>','<p>','  <input id=selector-add-button type=button value=追加 disabled>','  <input id=selector-edit-button type=button value=編集 disabled>','  <input id=selector-remove-button type=button value=削除 disabled>','  <input id=selector-up-button type=button value=上へ disabled>','  <input id=selector-down-button type=button value=下へ disabled>','</p>','<fieldset id=scroll-position-fieldset disabled>','  <legend><label>','    <input id=coordinate-checkbox type=checkbox disabled>','    スクロールの位置を調整する','  </label></legend>','  <p><label>','    表示#域の上とフォーカスとの最小間隔:','    <input id=upper-space-input type=number value=0 required>','  </label></p>','  <p><label>','    表示#域の下とフォーカスとの最小間隔:','    <input id=lower-space-input type=number value=0 required>','  </label></p>','</fieldset>','<p class=label-p>','  <label for=custom-css-textarea>カスタムCSS:</label>','</p>','<p><textarea id=custom-css-textarea rows=3 disabled></textarea></p>','<p id=custom-css-desc><small>','  この入力欄の内容を持つstyle要素をhead要素の末尾に追加します。何も入力していないときは追加しません。フォーカスのアウトラインが非表示のときに利用してください。','</small></p>','<p class=label-p>','  インポート・エクスポート:','  <small>','    <label><input id=import-export-checkbox type=checkbox>表示</label>','  </small>','</p>','<div id=import-export-container>','  <p><textarea id=import-textarea rows=2></textarea></p>','  <p><input id=import-button type=button value=インポート></p>','  <p><input id=export-button type=button','    value=クリップボードへエクスポート></p>','</div>','<p id=ok-cancel-p>','  <input id=ok-button type=button value=OK>','  <input id=cancel-button type=button value=キャンセル>','</p>','</div></body></html>',].join('\n')return Config})()var FocusSelector = (function() {var tabKeyCode = 9var isTabKey = function(e) {return e.which === tabKeyCode && !(e.ctrlKey || e.metaKey || e.altKey)}var ring = function(array, start, reverse) {var a = array.slice(start).concat(array.slice(0, start))return reverse ? a.reverse() : a}var matchedFocusSelectors = function(doc) {return Config.focusSelectors().map(FocusSelector.new).filter(function(focusSelector) {return focusSelector._matchUrlForward(doc.location.href)})}var longestUrlFocusSelector = function(doc) {var s = matchedFocusSelectors(doc)if (!s.length) returnreturn s.reduce(function(previous, current) {return previous._hasLongerUrl(current) ? previous : current})}var focusNext = function(requestFocus) {return function(keyDownEvent) {if (!isTabKey(keyDownEvent)) returnvar d = keyDownEvent.target.ownerDocumentvar focusSelector = longestUrlFocusSelector(d)if (!focusSelector) returnvar selected = focusSelector._querySelectors(d)if (!selected.length) returnvar i = selected.indexOf(d.activeElement)var start = keyDownEvent.shiftKey ? Math.max(i, 0): (i + 1) % selected.lengthvar r = ring(selected, start, keyDownEvent.shiftKey)requestFocus(keyDownEvent, r.some.bind(r, focusSelector._focus.bind(focusSelector)))}}var FocusSelector = function(o) {this._url = o.urlthis._selectors = o.selectors || []this._coordinated = Boolean(o.coordinated)this._lowerSpace = o.lowerSpace || 0this._upperSpace = o.upperSpace || 0this._cssText = o.cssText}FocusSelector.prototype._matchUrlForward = function(url) {return url.indexOf(this._url) === 0}FocusSelector.prototype._hasLongerUrl = function(other) {return this._url.length >= other._url.length}FocusSelector.prototype._querySelectors = function(doc) {return this._selectors.reduce(function(selected, selector) {return selected.concat.apply(selected, doc.querySelectorAll(selector))}, [])}FocusSelector.prototype._focus = function(elem) {var preFocus = elem.getBoundingClientRect()elem.focus()var r###lt = elem.ownerDocument.activeElement === elemif (r###lt && this._coordinated) {this._coordinateScroll(elem, preFocus)}return r###lt}FocusSelector.prototype._coordinateScroll = function(elem, preFocus) {var win = elem.ownerDocument.defaultViewvar postFocus = elem.getBoundingClientRect()if (preFocus.bottom > win.innerHeight - this._lowerSpace) {var y = win.innerHeight - postFocus.bottom - this._lowerSpacewin.scrollBy(0, -y)} else if (preFocus.top < this._upperSpace) {win.scrollBy(0, postFocus.top - this._upperSpace)}}FocusSelector.prototype._hasCssText = function() {return Boolean(this._cssText)}FocusSelector.prototype._createStyleElem = function(doc) {var r###lt = doc.createElement('style')r###lt.id = 'focus-navigator-style'r###lt.textContent = this._cssTextreturn r###lt}FocusSelector.new = function(o) {return new FocusSelector(o)}FocusSelector.addCallbackIfRequired = function(doc) {if (matchedFocusSelectors(doc).length) {doc.addEventListener('keydown', FocusSelector.firstCallback)}}// Firefox36.0 + Greasemonkey2.3// タブキーの keydown イベントをすべてキャンセルして// デフォルトの処理を一度も実行させなかった場合、// フォーカスされた要素のアウトラインが表示されない。// これの対策として、フォーカス処理をあとのイベントループで実行させて、// 最初のイベントだけキャンセルせずにデフォルトの処理をさせることで、// アウトラインを表示。FocusSelector.firstCallback = focusNext(function(event, focus) {setTimeout(focus, 0)var d = event.target.ownerDocumentd.removeEventListener('keydown', FocusSelector.firstCallback)d.addEventListener('keydown', FocusSelector.callback)})FocusSelector.callback = focusNext(function(event, focus) {if (focus()) event.preventDefault()})FocusSelector.addStyleElemIfRequired = function(doc) {var s = longestUrlFocusSelector(doc)if (s && s._hasCssText()) {if (doc.head) {doc.head.appendChild(s._createStyleElem(doc))} else {doc.addEventListener('DOMContentLoaded', () => {doc.head.appendChild(s._createStyleElem(doc))})}}}FocusSelector.update = function(doc) {doc.removeEventListener('keydown', FocusSelector.firstCallback)doc.removeEventListener('keydown', FocusSelector.callback)var style = doc.getElementById('focus-navigator-style')if (style) style.parentNode.removeChild(style)FocusSelector.addCallbackIfRequired(doc)FocusSelector.addStyleElemIfRequired(doc)}return FocusSelector})()function addConfigButtonIfScriptPage() {if (!location.href.startsWith('https://greasyfork.org/ja/scripts/8736-focus-navigator'))returnconst add = () => {const e = document.createElement('button')e.type = 'button'e.textContent = '設定'e.addEventListener('click', Config.show.bind(Config, document))document.querySelector('#script-info > header > h2').appendChild(e)}if (['interactive', 'complete'].includes(document.readyState))add()elsedocument.addEventListener('DOMContentLoaded', add)}async function main() {Config.focusSelectors.set(JSON.parse(await gmGetValue('focusSelectors', '[]')))FocusSelector.addCallbackIfRequired(document)FocusSelector.addStyleElemIfRequired(document)if (typeof GM_registerMenuCommand !== 'undefined') {GM_registerMenuCommand('Focus Navigator 設定', Config.show.bind(Config, document))}addConfigButtonIfScriptPage()}main()})()