Open In New Tab

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

You will need to install an extension such as Tampermonkey, Greasemonkey or Violentmonkey to install this script.

You will need to install an extension such as Tampermonkey or Violentmonkey to install this script.

You will need to install an extension such as Tampermonkey or Violentmonkey to install this script.

You will need to install an extension such as Tampermonkey or Userscripts to install this script.

You will need to install an extension such as Tampermonkey to install this script.

You will need to install a user script manager extension to install this script.

(I already have a user script manager, let me install it!)

You will need to install an extension such as Stylus to install this style.

You will need to install an extension such as Stylus to install this style.

You will need to install an extension such as Stylus to install this style.

You will need to install a user style manager extension to install this style.

You will need to install a user style manager extension to install this style.

You will need to install a user style manager extension to install this style.

(I already have a user style manager, let me install it!)

// ==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()
})()