Open In New Tab

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

当前为 2015-01-02 提交的版本,查看 最新版本

// ==UserScript==
// @name         Open In New Tab
// @namespace    https://greasyfork.org/users/1009-kengo321
// @version      3
// @description  新しいタブで開くリンクをCSSセレクタで選べるようにする
// @grant        GM_registerMenuCommand
// @grant        GM_getValue
// @grant        GM_setValue
// @grant        GM_openInTab
// @match        *://*/*
// @license      MIT
// @noframes
// ==/UserScript==

;(function() {
  'use strict'

  function getter(propName) {
    return function(o) { return o[propName] }
  }
  function not(func) {
    return function() { return !func.apply(null, arguments) }
  }
  function invoker(methodName) {
    var args = [].concat.apply([], arguments).slice(1)
    return function(o) { return o[methodName].apply(o, args) }
  }

  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 Config(doc) {
      this.doc = doc
      this.linkSelectors = Config.getLinkSelectors().sort(compareLinkSelector)
      this.updateUrlList()
      this.addCallbacks()
    }
    Config.srcdoc = '\
      <!DOCTYPE html>\
      <html><head><style>\
        body {\
          margin: 0;\
          padding: 5px;\
        }\
        p { margin: 0; }\
        #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; }\
      </style></head><body>\
        <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=confirm-p>\
          <button id=ok-button type=button>OK</button>\
          <button id=cancel-button type=button>キャンセル</button>\
        </p>\
      </body></html>\
    '
    Config.show = function(done) {
      var f = document.createElement('iframe')
      f.srcdoc = Config.srcdoc
      f.addEventListener('load', function() {
        var config = new Config(f.contentDocument)
        config.setIFrame(f)
        if (typeof(done) === 'function') done(config)
      })
      f.style.display = 'none'
      document.body.appendChild(f)
    }
    Config.getLinkSelectors = function() {
      return JSON.parse(GM_getValue('linkSelectors', '[]')).map(function(o) {
        return new LinkSelector(o)
      })
    }
    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.saveLinkSelectors.bind(this),
          LinkSelector.updateCallback,
          this.removeIFrame.bind(this),
        ]],
        ['cancel-button', 'click', this.removeIFrame.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.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.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
      var s = iframe.style
      s.display = ''
      s.backgroundColor = 'white'
      s.position = 'absolute'
      s.zIndex = '9999'
      s.borderWidth = 'medium'
      s.borderStyle = 'solid'
      s.borderColor = 'black'
      s.boxSizing = 'content-box'
      var w = iframe.width = 600
      var h = iframe.height = this.doc.documentElement.offsetHeight
      var v = iframe.ownerDocument.defaultView
      s.top = Math.max(v.innerHeight - h, 0) / 2 + v.pageYOffset + 'px'
      s.left = Math.max(v.innerWidth - w, 0) / 2 + v.pageXOffset + 'px'
      // Chrome39.0.2171.95m+Tampermonkey3.9.131
      // select要素のoption数が0から1以上、または1以上から0へ変更されたとき
      // そのselect要素の高さが変更される
      // select要素の高さを明示することで、これを防ぐ
      var w = iframe.contentWindow
      setComputedHeight(w, this.getUrlList())
      setComputedHeight(w, this.getSelectorList())
    }
    Config.prototype.removeIFrame = function() {
      var f = this.iframe
      if (f && f.parentNode) f.parentNode.removeChild(f)
    }
    Config.prototype.saveLinkSelectors = function() {
      GM_setValue('linkSelectors', 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
    }

    function LinkSelector(o) {
      o = o || {}
      this.url = o.url || ''
      this.selectors = o.selectors || []
      this.capture = !!o.capture
    }
    LinkSelector.getLocatedInstances = function() {
      return Config.getLinkSelectors().filter(invoker('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(invoker('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)) {
        GM_openInTab(link.href)
        return true
      }
      return false
    }
    return LinkSelector
  })()

  function main() {
    GM_registerMenuCommand('Open In New Tab 設定', Config.show)
    LinkSelector.addCallbackIfRequired()
  }

  main()
})()