Open In New Tab

新しいタブで開くリンクをCSSセレクタで選べるようにする

// ==UserScript==
// @name         Open In New Tab
// @namespace    https://greasyfork.org/users/1009-kengo321
// @version      8
// @description  新しいタブで開くリンクをCSSセレクタで選べるようにする
// @grant        GM_registerMenuCommand
// @grant        GM_getValue
// @grant        GM_setValue
// @grant        GM_openInTab
// @grant        GM_setClipboard
// @grant        GM_info
// @grant        GM.getValue
// @grant        GM.setValue
// @grant        GM.openInTab
// @grant        GM.setClipboard
// @grant        GM.info
// @match        *://*/*
// @license      MIT
// @noframes
// @run-at       document-start
// ==/UserScript==

;(function() {
  'use strict'

  if (window.self !== window.top) return

  function getter(propName) {
    return function(o) { return o[propName] }
  }
  function not(func) {
    return function() { return !func.apply(null, arguments) }
  }
  const [gmGetValue, gmSetValue, gmOpenInTab, gmSetClipboard, gmInfo] =
    typeof GM_getValue === 'undefined'
    ? [GM.getValue, GM.setValue, GM.openInTab, GM.setClipboard, GM.info]
    : [GM_getValue, GM_setValue, GM_openInTab, GM_setClipboard, GM_info]
  async function gmGetLinkSelectors() {
    return JSON.parse(await gmGetValue('linkSelectors', '[]')).map(o => new LinkSelector(o))
  }

  var Config = (function() {

    function compareLinkSelector(o1, o2) {
      var m1 = o1.matchUrlForward(), m2 = o2.matchUrlForward()
      if (m1 && !m2) return -1
      if (!m1 && m2) return 1
      if (o1.url < o2.url) return -1
      if (o1.url > o2.url) return 1
      return 0
    }
    function setComputedHeight(win, elem) {
      elem.style.height = win.getComputedStyle(elem).height
    }
    function updateUrlOptionClass(option, linkSelector) {
      var p = linkSelector.matchUrlForward() ? 'add' : 'remove'
      option.classList[p]('matched')
      return option
    }
    function addAndSelectOption(selectElem, option) {
      selectElem.add(option)
      selectElem.selectedIndex = option.index
    }
    function getSelectedIndices(selectElem) {
      return [].map.call(selectElem.selectedOptions, getter('index'))
    }
    function removeSelectedOptions(selectElem) {
      ;[].slice.call(selectElem.selectedOptions).forEach(function(o) {
        o.parentNode.removeChild(o)
      })
    }
    function filterIndices(array, indices) {
      return array.filter(function(e, i) { return indices.indexOf(i) === -1 })
    }
    function optionAdder(selectElem, newOption) {
      return function(e) { selectElem.add(newOption(e)) }
    }
    function maxZIndex() {
      return '2147483647'
    }

    function Config(doc) {
      this.doc = doc
      this.doc.getElementById('insert-p').hidden = (gmInfo.scriptHandler === 'Greasemonkey')
      this.addCallbacks()
    }
    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; }',
      '  textarea { width: 100%; }',
      '  #url-list { width: 100%; }',
      '  #url-list option.matched { text-decoration: underline; }',
      '  #selector-list { width: 100%; }',
      '  #confirm-p { text-align: right; }',
      '  p.description { font-size: smaller; }',
      '  #import-export-container { display: none; }',
      '  #import-export-container.show { display: block; }',
      '</style></head><body><div id=dialog>',
      '<fieldset>',
      '  <legend>対象ページのURL(前方一致)</legend>',
      '  <p><select id=url-list multiple size=10></select></p>',
      '  <p>',
      '    <button id=url-add-button type=button>追加</button>',
      '    <button id=url-edit-button type=button disabled>編集</button>',
      '    <button id=url-remove-button type=button disabled>削除</button>',
      '  </p>',
      '</fieldset>',
      '<fieldset id=selector-fieldset disabled>',
      '  <legend>新しいタブで開くリンクのCSSセレクタ</legend>',
      '  <p class=description>',
      '    何も登録していないときは、すべてのリンクが対象になります',
      '  </p>',
      '  <p><select id=selector-list multiple size=5></select></p>',
      '  <p>',
      '    <button id=selector-add-button type=button>追加</button>',
      '    <button id=selector-edit-button type=button>編集</button>',
      '    <button id=selector-remove-button type=button>削除</button>',
      '  </p>',
      '  <p>',
      '    <label>',
      '      <input type=checkbox id=capture-checkbox>',
      '      キャプチャフェーズを使用して、イベント伝播を中断する',
      '    </label>',
      '  </p>',
      '  <p class=description>',
      '    正しく動作しないときは、これを有効にして試してください',
      '  </p>',
      '</fieldset>',
      '<p id=active-p><label>',
      '  <input id=active-checkbox type=checkbox>',
      '  新しいタブを開いたとき、すぐにそのタブに切り替える',
      '</label></p>',
      '<p id=insert-p><label>',
      '  <input id=insert-checkbox type=checkbox>',
      '  現在のタブの後ろに新しいタブを挿入する',
      '</label></p>',
      '<p>',
      '  インポート・エクスポート:',
      '  <small>',
      '    <label><input id=import-export-checkbox type=checkbox>表示</label>',
      '  </small>',
      '</p>',
      '<div id=import-export-container>',
      '  <p><textarea id=import-export-textarea rows=2></textarea></p>',
      '  <p>',
      '    <input id=import-button type=button value=インポート>',
      '    <input id=export-button type=button value=エクスポート>',
      '  </p>',
      '  <p><input id=export-to-clipboard-button type=button',
      '    value=クリップボードへエクスポート></p>',
      '</div>',
      '<p id=confirm-p>',
      '  <button id=ok-button type=button>OK</button>',
      '  <button id=cancel-button type=button>キャンセル</button>',
      '</p>',
      '</div></body></html>',
    ].join('\n')
    Config.show = function(done) {
      var background = document.createElement('div')
      background.style.backgroundColor = 'black'
      background.style.opacity = '0.5'
      background.style.zIndex = maxZIndex() - 1
      background.style.position = 'fixed'
      background.style.top = '0'
      background.style.left = '0'
      background.style.width = '100%'
      background.style.height = '100%'
      document.body.appendChild(background)
      var f = document.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.srcdoc
      f.addEventListener('load', async function() {
        const linkSelectors = await gmGetLinkSelectors()
        Config.setLinkSelectors(linkSelectors)
        var config = new Config(f.contentDocument)
        config.linkSelectors = linkSelectors.sort(compareLinkSelector)
        config.updateUrlList()
        config.getActiveCheckbox().checked = await Config.isNewTabActivation()
        config.getInsertCheckbox().checked = await Config.isNewTabInsertion()
        config.setIFrame(f)
        config.background = background
        if (typeof(done) === 'function') done(config)
      })
      document.body.appendChild(f)
    }
    Config.getLinkSelectors = function() {
      return Config._linkSelectors
    }
    Config.setLinkSelectors = function(linkSelectors) {
      Config._linkSelectors = linkSelectors
    }
    Config.isNewTabActivation = function() {
      return gmGetValue('active', true)
    }
    Config.isNewTabInsertion = function() {
      return gmGetValue('insert', false)
    }
    Config.prototype.addCallbacks = function() {
      var doc = this.doc
      ;[['url-list', 'change', [
          this.updateSelectorList.bind(this),
          this.updateCaptureCheckbox.bind(this),
          this.updateDisabled.bind(this),
        ]],
        ['selector-list', 'change', this.updateDisabled.bind(this)],
        ['url-add-button', 'click', [
          this.addUrl.bind(this),
          this.updateDisabled.bind(this),
          this.updateSelectorList.bind(this),
          this.updateCaptureCheckbox.bind(this),
        ]],
        ['url-edit-button', 'click', this.editUrl.bind(this)],
        ['url-remove-button', 'click', [
          this.removeUrl.bind(this),
          this.updateDisabled.bind(this),
          this.updateSelectorList.bind(this),
          this.updateCaptureCheckbox.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.removeSelector.bind(this),
          this.updateDisabled.bind(this),
        ]],
        ['capture-checkbox', 'change', this.updateCapture.bind(this)],
        ['ok-button', 'click', [
          this.save.bind(this),
          LinkSelector.updateCallback,
          this.removeIFrame.bind(this),
        ]],
        ['cancel-button', 'click', this.removeIFrame.bind(this)],
        [ 'import-export-checkbox',
          'change',
          this.importExportCheckboxChanged.bind(this),
        ],
        [ 'export-to-clipboard-button',
          'click',
          this.exportToClipboard.bind(this),
        ],
        ['import-button', 'click', [
          this.import.bind(this),
          this.updateSelectorList.bind(this),
          this.updateCaptureCheckbox.bind(this),
          this.updateDisabled.bind(this),
        ]],
        ['export-button', 'click', this.export.bind(this)],
      ].forEach(function(e) {
        ;[].concat(e[2]).forEach(function(callback) {
          doc.getElementById(e[0]).addEventListener(e[1], callback)
        })
      })
    }
    Config.prototype.getUrlList = function() {
      return this.doc.getElementById('url-list')
    }
    Config.prototype.getSelectorList = function() {
      return this.doc.getElementById('selector-list')
    }
    Config.prototype.getCaptureCheckbox = function() {
      return this.doc.getElementById('capture-checkbox')
    }
    Config.prototype.getActiveCheckbox = function() {
      return this.doc.getElementById('active-checkbox')
    }
    Config.prototype.getInsertCheckbox = function() {
      return this.doc.getElementById('insert-checkbox')
    }
    Config.prototype.getImpExpTextarea = function() {
      return this.doc.getElementById('import-export-textarea')
    }
    Config.prototype.newOption = function(text) {
      var result = this.doc.createElement('option')
      result.textContent = text
      return result
    }
    Config.prototype.newUrlOption = function(linkSelector) {
      return updateUrlOptionClass(this.newOption(linkSelector.url)
                                , linkSelector)
    }
    Config.prototype.updateUrlList = function() {
      this.getUrlList().length = 0
      this.linkSelectors.forEach(optionAdder(this.getUrlList()
                                           , this.newUrlOption.bind(this)))
    }
    Config.prototype.getSelectedLinkSelector = function() {
      return this.linkSelectors[this.getUrlList().selectedIndex]
    }
    Config.prototype.updateSelectorList = function() {
      this.clearSelectorList()
      if (this.getUrlList().selectedOptions.length !== 1) return
      this.getSelectedLinkSelector()
        .selectors
        .forEach(optionAdder(this.getSelectorList()
                           , this.newOption.bind(this)))
    }
    Config.prototype.clearSelectorList = function() {
      var s = this.getSelectorList()
      while (s.hasChildNodes()) s.removeChild(s.firstChild)
    }
    Config.prototype.addUrl = function() {
      var r = prompt('', document.location.href)
      if (!r) return
      var s = new LinkSelector({url: r})
      this.linkSelectors.push(s)
      addAndSelectOption(this.getUrlList(), this.newUrlOption(s))
    }
    Config.prototype.editUrl = function() {
      if (this.getUrlList().selectedOptions.length !== 1) return
      var r = prompt('', this.getSelectedLinkSelector().url)
      if (!r) return
      this.getUrlList().selectedOptions[0].textContent = r
      this.getSelectedLinkSelector().url = r
      updateUrlOptionClass(this.getUrlList().selectedOptions[0]
                         , this.getSelectedLinkSelector())
    }
    Config.prototype.removeUrl = function() {
      this.linkSelectors = filterIndices(this.linkSelectors
                                       , getSelectedIndices(this.getUrlList()))
      removeSelectedOptions(this.getUrlList())
    }
    Config.prototype.getErrorIfInvalidSelector = function(selector) {
      try {
        this.doc.querySelector(selector)
        return null
      } catch (e) {
        return e
      }
    }
    Config.prototype.promptUntilValidSelector = function(defaultValue) {
      var selector = defaultValue || ''
      var error = null
      do {
        selector = prompt((error || '').toString(), selector)
        if (!selector) return null
      } while (error = this.getErrorIfInvalidSelector(selector))
      return selector
    }
    Config.prototype.addSelector = function() {
      if (this.getUrlList().selectedOptions.length !== 1) return
      var r = this.promptUntilValidSelector()
      if (!r) return
      this.getSelectedLinkSelector().selectors.push(r)
      addAndSelectOption(this.getSelectorList(), this.newOption(r))
    }
    Config.prototype.getSelectedSelector = function() {
      return this.getSelectorList().selectedOptions[0].textContent
    }
    Config.prototype.setSelectedSelector = function(selector) {
      var o = this.getSelectorList().selectedOptions[0]
      o.textContent = selector
      this.getSelectedLinkSelector().selectors[o.index] = selector
    }
    Config.prototype.editSelector = function() {
      if (this.getSelectorList().selectedOptions.length !== 1) return
      var r = this.promptUntilValidSelector(this.getSelectedSelector())
      if (!r) return
      this.setSelectedSelector(r)
    }
    Config.prototype.removeSelector = function() {
      var s = this.getSelectedLinkSelector()
      s.selectors = filterIndices(s.selectors
                                , getSelectedIndices(this.getSelectorList()))
      removeSelectedOptions(this.getSelectorList())
    }
    Config.prototype.updateCaptureCheckbox = function() {
      var s = this.getSelectedLinkSelector()
      this.getCaptureCheckbox().checked = (s ? s.capture : false)
    }
    Config.prototype.updateCapture = function() {
      var s = this.getSelectedLinkSelector()
      if (s) s.capture = this.getCaptureCheckbox().checked
    }
    Config.prototype.updateDisabled = function() {
      var selectedUrlNum = this.getUrlList().selectedOptions.length
      var selectedSelectorNum = this.getSelectorList().selectedOptions.length
      ;[['url-edit-button', selectedUrlNum !== 1],
        ['url-remove-button', selectedUrlNum === 0],
        ['selector-fieldset', selectedUrlNum !== 1],
        ['selector-edit-button', selectedSelectorNum !== 1],
        ['selector-remove-button', selectedSelectorNum === 0],
      ].forEach(function(e) {
        this.doc.getElementById(e[0]).disabled = e[1]
      }, this)
    }
    Config.prototype.setIFrame = function(iframe) {
      this.iframe = iframe
      this.getUrlList().focus()
    }
    Config.prototype.removeIFrame = function() {
      var rm = function(e) {
        if (e && e.parentNode) e.parentNode.removeChild(e)
      }
      rm(this.iframe)
      rm(this.background)
    }
    Config.prototype.save = function() {
      gmSetValue('linkSelectors', JSON.stringify(this.linkSelectors))
      Config.setLinkSelectors(this.linkSelectors)
      gmSetValue('active', this.getActiveCheckbox().checked)
      gmSetValue('insert', this.getInsertCheckbox().checked)
    }
    Config.prototype.importExportCheckboxChanged = function() {
      var checkbox = this.doc.getElementById('import-export-checkbox')
      var container = this.doc.getElementById('import-export-container')
      container.classList[checkbox.checked ? 'add' : 'remove']('show')
      this.iframe.height = this.doc.documentElement.offsetHeight
    }
    Config.prototype.exportToClipboard = function() {
      gmSetClipboard(JSON.stringify(this.linkSelectors))
    }
    Config.prototype.import = function() {
      try {
        var ta = this.getImpExpTextarea()
        this.linkSelectors = JSON.parse(ta.value).map(function(o) {
          return new LinkSelector(o)
        })
        this.updateUrlList()
        ta.setCustomValidity('')
      } catch (e) {
        ta.setCustomValidity(e.toString())
      }
    }
    Config.prototype.export = function() {
      this.getImpExpTextarea().value = JSON.stringify(this.linkSelectors)
    }
    return Config
  })()

  var LinkSelector = (function() {

    function isLeftMouseButtonWithoutModifierKeys(mouseEvent) {
      var e = mouseEvent
      return !(e.button || e.altKey || e.shiftKey || e.ctrlKey || e.metaKey)
    }
    function isOpenableLink(elem) {
      return ['A', 'AREA'].indexOf(elem.tagName) >= 0
          && elem.href
          && elem.protocol !== 'javascript:'
    }
    function getAncestorOpenableLink(descendant) {
      for (var p = descendant.parentNode; p; p = p.parentNode) {
        if (isOpenableLink(p)) return p
      }
      return null
    }
    async function openInTab(url) {
      if (gmInfo.scriptHandler === 'Greasemonkey') {
        gmOpenInTab(url, !(await Config.isNewTabActivation()))
      } else {
        gmOpenInTab(url, {
          active: await Config.isNewTabActivation(),
          insert: await Config.isNewTabInsertion(),
        })
      }
    }

    function LinkSelector(o) {
      o = o || {}
      this.url = o.url || ''
      this.selectors = o.selectors || []
      this.capture = !!o.capture
    }
    LinkSelector.getLocatedInstances = function() {
      return Config.getLinkSelectors().filter(s => s.matchUrlForward())
    }
    LinkSelector.addCallbackIfRequired = function() {
      var i = LinkSelector.getLocatedInstances()
      if (i.some(not(getter('capture')))) {
        document.addEventListener('click', LinkSelector.callback, false)
      }
      if (i.some(getter('capture'))) {
        document.addEventListener('click', LinkSelector.callback, true)
      }
    }
    LinkSelector.updateCallback = function() {
      document.removeEventListener('click', LinkSelector.callback, false)
      document.removeEventListener('click', LinkSelector.callback, true)
      LinkSelector.addCallbackIfRequired()
    }
    LinkSelector.callback = function(mouseEvent) {
      var e = mouseEvent
      if (!isLeftMouseButtonWithoutModifierKeys(e)) return

      var link = isOpenableLink(e.target) ? e.target
                                          : getAncestorOpenableLink(e.target)
      if (!link) return

      var opened = LinkSelector.getLocatedInstances()
        .some(s => s.openInTabIfMatch(link, e.eventPhase))
      if (!opened) return

      e.preventDefault()
      if (e.eventPhase === Event.CAPTURING_PHASE) e.stopPropagation()
    }
    LinkSelector.prototype.matchUrlForward = function() {
      return document.location.href.indexOf(this.url) === 0
    }
    LinkSelector.prototype.matchEventPhase = function(eventPhase) {
      return this.capture ? eventPhase === Event.CAPTURING_PHASE
                          : eventPhase === Event.BUBBLING_PHASE
    }
    LinkSelector.prototype.matchLink = function(link) {
      return !this.selectors.length
          || this.selectors.some(link.matches.bind(link))
    }
    LinkSelector.prototype.openInTabIfMatch = function(link, eventPhase) {
      if (this.matchEventPhase(eventPhase) && this.matchLink(link)) {
        openInTab(link.href)
        return true
      }
      return false
    }
    return LinkSelector
  })()

  function addConfigButtonIfScriptPage() {
    if (!location.href.startsWith('https://greasyfork.org/ja/scripts/5591-open-in-new-tab'))
      return
    const add = () => {
      const e = document.createElement('button')
      e.type = 'button'
      e.textContent = '設定'
      e.addEventListener('click', Config.show)
      document.querySelector('#script-info > header > h2').appendChild(e)
    }
    if (['interactive', 'complete'].includes(document.readyState))
      add()
    else
      document.addEventListener('DOMContentLoaded', add)
  }
  async function main() {
    Config.setLinkSelectors(await gmGetLinkSelectors())
    LinkSelector.addCallbackIfRequired()
    if (typeof GM_registerMenuCommand !== 'undefined')
      GM_registerMenuCommand('Open In New Tab 設定', Config.show)
    addConfigButtonIfScriptPage()
  }

  main()
})()