Greasy Fork is available in English.

AbemaTV Screen Comment Scroller 2

AbemaTV のコメントをニコニコ風にスクロールさせます。

La data de 16-10-2017. Vezi ultima versiune.

// ==UserScript==
// @name        AbemaTV Screen Comment Scroller 2
// @namespace   knoa.jp
// @description AbemaTV のコメントをニコニコ風にスクロールさせます。
// @include     https://abema.tv/*
// @version     2.0.1
// @grant       none
// ==/UserScript==

// console.log('AbemaTV? => hireMe()');
(function(){
  const SCRIPTNAME = 'ScreenCommentScroller';
  const DEBUG = true;/**/
  if(window === top) console.time(SCRIPTNAME);
  const CONFIGS = [
    /* スクロールコメント */
    {KEY: 'maxlines',      TYPE: 'int',   DEFAULT: 10   },/*最大行数(文字サイズ連動)*/
    {KEY: 'linemargin',    TYPE: 'float', DEFAULT: 0.20 },/*行間(比率)*/
    {KEY: 'opacity',       TYPE: 'float', DEFAULT: 0.50 },/*不透明度*/
    {KEY: 'owidth',        TYPE: 'float', DEFAULT: 0.05 },/*縁取りの太さ(比率)*/
    {KEY: 'duration',      TYPE: 'float', DEFAULT: 5.00 },/*横断にかける秒数*/
    {KEY: 'fps',           TYPE: 'int',   DEFAULT: 60   },/*秒間コマ数*/
    /* 一覧コメント */
    {KEY: 'l_hide',        TYPE: 'bool',  DEFAULT: 0    },/*操作していない時は画面外に隠す*/
    {KEY: 'l_overlay',     TYPE: 'bool',  DEFAULT: 1    },/*映像に重ねる*/
    {KEY: 'l_showtime',    TYPE: 'bool',  DEFAULT: 1    },/*投稿時刻を表示する*/
    {KEY: 'l_width',       TYPE: 'float', DEFAULT: 16.5 },/*横幅(%)*/
    {KEY: 'lc_maxlines',   TYPE: 'int',   DEFAULT: 30   },/*最大行数(文字サイズ連動)*/
    {KEY: 'lc_linemargin', TYPE: 'float', DEFAULT: 0.50 },/*改行されたコメントの行間(比率)*/
    {KEY: 'lc_margin',     TYPE: 'float', DEFAULT: 1.65 },/*コメント同士の間隔(比率)*/
    {KEY: 'lc_opacity',    TYPE: 'float', DEFAULT: 0.75 },/*文字の不透明度*/
    {KEY: 'lb_opacity',    TYPE: 'float', DEFAULT: 0.25 },/*背景の不透明度*/
    /* アベマのナビゲーション */
    {KEY: 'n_clickonly',   TYPE: 'bool',  DEFAULT: 0    },/*画面クリック時のみ表示する*/
    {KEY: 'n_delay',       TYPE: 'float', DEFAULT: 4.00 },/*隠れるまでの時間(秒)*/
    {KEY: 'n_opacity',     TYPE: 'float', DEFAULT: 0.50 },/*不透明度*/
  ];
  const AINTERVAL = 5;/*AbemaTVのコメント取得間隔の仕様値*/
  /* サイト定義 */
  let site = {
    targets: [
      /* 構造 */
      function header(){let header = $('body > div > div > header'); return (header) ? site.use(header) : null;},
      function footer(){let fullscreen = $('button[aria-label^="フルスクリーン"]'); return (fullscreen) ? site.use(fullscreen.parentNode.parentNode) : null;},
      function board(){let board = $('div[aria-hidden] form + div > div'); return (board) ? site.use(board) : null;},
      function screen(){let loading = $('img[src="/images/misc/feed_loading.gif"]'); return (loading) ? site.use(loading.parentNode.parentNode) : null;},
      /* ペイン */
      function commentPane(){let form = $('form:not([role="search"])'); return (form) ? site.use(form.parentNode.parentNode) : null;},
      function channelPane(){let list = $('use[*|href^="/images/icons/list.svg"]'); return (list) ? site.use(list.parentNode.parentNode.parentNode.parentNode.nextElementSibling) : null;},
      function programPane(){let list = $('use[*|href^="/images/icons/list.svg"]'); return (list) ? site.use(list.parentNode.parentNode.parentNode.parentNode.nextElementSibling.nextElementSibling) : null;},
      /* ボタン */
      function channelButtons(){let list = $('use[*|href^="/images/icons/list.svg"]'); return (list) ? site.use(list.parentNode.parentNode.parentNode.parentNode) : null;},
      function channelButton(){let list = $('use[*|href^="/images/icons/list.svg"]'); return (list) ? site.use(list.parentNode.parentNode) : null;},
      function commentButton(){let svg = $('use[*|href^="/images/icons/comment.svg"]'); return (svg) ? site.use(svg.parentNode.parentNode) : null;},
      function programButton(){let button = $('button[aria-label^="フルスクリーン"] + div + div > div > div'); return (button) ? site.use(button) : null;},
      function fullscreenButton(){let fullscreen = $('button[aria-label^="フルスクリーン"]'); return (fullscreen) ? site.use(fullscreen) : null;},
      function closer(){let commentForm = $('form:not([role="search"])'); return (commentForm) ? site.use(commentForm.parentNode.parentNode.nextElementSibling) : null;},
      /* 要素 */
      function caution(){let header = $('header'); return (header) ? site.use(header.nextElementSibling) : null;},
      function commentForm(){let form = $('form:not([role="search"])'); return (form) ? site.use(form) : null;},
      function notice(){let buttons = elements.screen.querySelectorAll(selectors.screen + ' > div > div:last-child > button'); for(let i = 0; buttons[i]; i++) site.use(buttons[i].parentNode); return (buttons) ? true : null;},
      function audienceTop(){let loading = $('img[src="/images/misc/feed_loading.gif"]'); return (loading) ? site.use(loading.parentNode.previousElementSibling) : null;},
      function audience(){let loading = $('img[src="/images/misc/feed_loading.gif"]'); return (loading) ? site.use(loading.parentNode.previousElementSibling.firstElementChild) : null;},
      function loading(){let loading = $('img[src="/images/misc/feed_loading.gif"]'); return (loading) ? site.use(loading.parentNode) : null;},
      function programName(){let name = $('button[aria-label^="フルスクリーン"] + div + div div > p > span > span:last-child'); return (name) ? site.use(name) : null;},
      /* セレクタ定義のみ */
      function newCommentsButton(){return site.use(null);},
      function newComments(){return site.use(null);},
      function comment(){return site.use(null);},
    ],
    addedNodes: {
      newCommentsButton: function(nodes){let button = nodes[0].parentNode.querySelector(selectors.commentPane + ' > div > button'); return (button) ? site.use(nodes[0]) : null;},
      newComments: function(nodes){let commentText = nodes[0].querySelector(selectors.board + ' > div > div > div > p:first-child'); return (commentText) ? site.use(nodes[0]) : null;},
      comment: function(nodes){let commentText = nodes[0].querySelector(selectors.board + ' > div:not([data-selector]) > p:first-child'); return (commentText) ? site.use(nodes[0]) : null;},
    },
    get: {
      comments: function(newComments){return site.get.comment(newComments.firstElementChild.children);},
      comment: function(comments){for(let i = 0; comments[i]; i++) site.use(comments[i]); return comments;},
      commentText: function(comment){return comment.firstElementChild.textContent;},
    },
    use: function use(target){
      if(target) target.dataset.selector = use.caller.name;
      selectors[use.caller.name] = `[data-selector="${use.caller.name}"]`;
      elements[use.caller.name] = target;
      return true;
    },
  };
  /* 処理本体 */
  let html, elements = {}, selectors = {}, ngwords = [], configs = {};
  let canvas, context, interval, lines = [];/*アニメーション関連は極力浅いオブジェクトに*/
  let core = {
    /* 初期化 */
    initialize: function(){
      let previousUrl = '';
      /* 一度だけ */
      html = document.documentElement;
      core.config.read();
      core.ng.initialize();
      core.listenUserActions();
      window.addEventListener('resize', core.modify);
      /* URLの変化を見守る */
      setInterval(function(){
        if(location.href === previousUrl) return;/*URLが変わってない*/
        /* テレビ視聴ページ */
        if(location.href.startsWith('https://abema.tv/now-on-air/')){
          /* チャンネルを変えただけ */
          if(previousUrl.startsWith('https://abema.tv/now-on-air/')){
            html.classList.remove('comment');
            html.classList.remove('ng');
          /* テレビ視聴ページになった */
          }else{
            core.ready();
          }
        /* テレビ視聴ページではない */
        }else{
          core.gone();
        }
        previousUrl = location.href;
      }, 1000);
    },
    /* テレビ視聴ページになるたびに呼ぶ */
    ready: function(){
      /* 必要な要素が出揃うまで粘る */
      for(let i = 0; site.targets[i]; i++){
        if(site.targets[i]() === null){
          log(`Not found: ${site.targets[i].name}, retrying...`);
          return setTimeout(core.ready, 1000);
        }
      }
      /* すべての要素が出揃っていたので */
      core.createCanvas();
      core.listenComments();
      core.ng.createButton();
      core.config.createButton();
      core.scrollComments();
      core.panel.createPanels();
      core.addStyle();
      html.classList.add(SCRIPTNAME);
      /* コメントを開けるようになったら自動で開く */
      let url = null;
      let observer = observe(elements.commentButton, function(records){
        if(getComputedStyle(this).cursor === 'pointer' && elements.commentPane.attributes['aria-hidden'].value === 'true'){
          if(url !== location.href){/*チャンネル切り替え後の初回*/
            elements.commentButton.click();
            url = location.href;
          }else if(html.classList.contains('comment')){/*コメントを開いた状態で番組開始を迎えたとき*/
            setTimeout(function(){elements.commentButton.click()}, 1000);
            setTimeout(function(){elements.commentButton.click()}, 2000);
          }
        }
      }, {attributes: true});
    },
    /* テレビ視聴ページから離れたときに呼ぶ */
    gone: function(){
      if(elements.style) document.head.removeChild(elements.style);
      html.classList.remove(SCRIPTNAME);
    },
    /* キーボードとマウスイベントを見守る */
    listenUserActions: function(){
      let id;
      let timer = function(e){
        clearTimeout(id), id = setTimeout(function(){
          html.classList.remove('active');
        }, configs.n_delay * 1000);
      };
      let activate = function(){
        if(!html.classList.contains('active')) html.classList.add('active');
        timer();
      };
      window.addEventListener('keydown', function(e){
        if(['input', 'textarea'].includes(e.target.localName)) e.stopPropagation();
      }, true);
      window.addEventListener('mousemove', function(e){
        if(configs.n_clickonly) return;
        activate();
      });
      /* クリックを捉える */
      window.addEventListener('click', function(e){/*アベマより先にwindowでキャプチャ*/
        switch(e.target){
          case(elements.channelButton):
            html.classList.toggle('channel');
            e.stopPropagation();
            break;
          case(elements.programButton):
            html.classList.toggle('program');
            e.stopPropagation();
            break;
          case(elements.commentButton):
            if(html.classList.contains('comment')){
              animate(function(){elements.closer.click()});/*すぐクリックすると競合してしまうのでanimate()*/
            }else{
              html.classList.add('comment');
              if(!configs.l_overlay) core.modify();
              /* デフォルトのボタン動作が実行される */
            }
            break;
          case(elements.newCommentsButton):
            if(e.isTrusted){/*実クリックのみで処理*/
              elements.newCommentsButton.style.height = '0';
              /* スクロールをなめらかにする */
              let scrollTop = elements.board.parentNode.scrollTop;
              elements.board.style.transition = '500ms ease';
              elements.board.style.transform = `translateY(${scrollTop}px)`;
              elements.board.addEventListener('transitionend', function(e){
                elements.board.style.transition = 'none';
                elements.board.style.transform = 'translateY(0)';
                elements.newCommentsButton.click();
              }, {once: true});
              e.stopPropagation();
            }else{
              /* デフォルトのボタン動作が実行される */
            }
            break;
          case(elements.closer):
            switch(true){
              case(html.classList.contains('channel')):
                html.classList.remove('channel');
                e.stopPropagation();
                break;
              case(html.classList.contains('program')):
                html.classList.remove('program');
                e.stopPropagation();
                break;
              case(html.classList.contains('comment')):
                core.ng.closeForm();/*NGフォームを開いているなら閉じる*/
              default:
                if(e.isTrusted){/*実クリックではコメントは閉じない*/
                  e.stopPropagation();
                  html.classList.toggle('active');
                  timer();
                }else{/*スクリプトのelements.closer.click()でのみ閉じる*/
                  html.classList.toggle('comment');
                  if(!configs.l_overlay) core.modify();
                }
                break;
            }
            break;
          default:
            break;/*デフォルトの動作に任せる*/
        }
      }, true);
      /* コメントペインの開閉でcanvasサイズを再計算 */
      observe(html, function(records){
        if(!configs.l_overlay) core.modify();
      }, {attributes: true});
    },
    /* canvas作成 */
    createCanvas: function(){
      if(canvas) return;
      canvas = document.createElement('canvas');
      canvas.id = SCRIPTNAME;
      elements.screen.appendChild(canvas);
      context = canvas.getContext('2d');
      core.modify();
    },
    /* スクリーンサイズを適切に変化させる */
    modify: function(){
      if(!elements.screen) return;/*フルスクリーン遷移時に対応*/
      let fullsize = (configs.l_overlay || !html.classList.contains('comment') || (configs.l_hide && html.classList.contains('comment') && !html.classList.contains('active')));
      let width = (fullsize) ? window.innerWidth : Math.round(window.innerWidth * (1 - configs.l_width / 100));
      let height = window.innerHeight;
      elements.screen.style.width = width + 'px';
      elements.screen.style.height = height + 'px';
      canvas.width = width;
      canvas.height = height;
      canvas.fontsize = (canvas.height / configs.maxlines) / (1 + configs.linemargin);
      context.font = `bold ${canvas.fontsize}px sans-serif`;
      context.fillStyle = 'white';
      context.strokeStyle = 'black';
      context.lineWidth = canvas.fontsize * configs.owidth;
      context.lineJoin = 'round';
      /* スクロールコメントの再計算 */
      for(let i=0; lines[i]; i++){
        for(let j=0; lines[i][j]; j++){
          lines[i][j].width = context.measureText(lines[i][j].text).width;
          lines[i][j].ppms = (canvas.width + lines[i][j].width) / (configs.duration * 1000);
          lines[i][j].top = ((canvas.height / configs.maxlines) * i) + canvas.fontsize;
        }
      }
      core.scrollComments();
    },
    /* コメントの新規追加を見守る */
    listenComments: function(){
      if(elements.commentPane.isListening) return;
      elements.commentPane.isListening = true;
      observe(elements.commentPane.firstElementChild, function(records){
        for(let i = 0; records[i]; i++){
          switch(true){
            /* 新着コメント表示ボタン */
            case (records[i].addedNodes.length === 1 && site.addedNodes.newCommentsButton(records[i].addedNodes) !== null):{
              let newCommentsButton = records[i].addedNodes[0];
              if(elements.board.classList.contains('mousedown')){
                window.addEventListener('mouseup', function(){/*テキスト選択を邪魔しないための配慮*/
                  animate(function(){newCommentsButton.classList.add('shown')});
                }, {once: true});
              }else{
                animate(function(){newCommentsButton.classList.add('shown')});
              }
              break;
            }
          }
        }
      });
      observe(elements.board, function(records){
        for(let i = 0; records[i]; i++){
          switch(true){
            /* 新着コメント集 */
            case (records[i].addedNodes.length === 1 && site.addedNodes.newComments(records[i].addedNodes) !== null):{
              let newComments = records[i].addedNodes[0];
              let comments = site.get.comments(newComments);
              /* 新着コメント表示ボタンを押した場合のみ発生するnewComments内のDOM挿入に対応 */
              observe(newComments.firstElementChild, function(records){
                for(let i = 0; records[i]; i++){
                  if(records[i].addedNodes.length === 1 && site.addedNodes.comment(records[i].addedNodes) !== null){
                    core.ng.filter(records[i].addedNodes[0]);
                  }
                }
              });
              /* NGフォームがずっと下にスクロールしてしまわないように閉じる */
              core.ng.closeForm();
              /* NGコメントをすぐ判定する */
              core.ng.expire();
              filteredComments = Array.from(comments).filter(core.ng.filter);
              /* スライドダウンアニメーションを上書きする */
              core.slideDownNewComments(newComments);
              /* 投稿経過時間に合わせた自然なばらつきでコメントを流すためのスケジュールを作る */
              let schedule = [];/*タイミングだけを格納する配列*/
              let getDelay = function(text){
                switch(true){
                  case(text === '今'): return 0;
                  case(text.endsWith('秒前')): return parseInt(text);
                  case(text.endsWith('分前')): return parseInt(text) * 60;
                  case(text.endsWith('時間前')): return parseInt(text) * 60 * 60;
                  default: return 60 * 60 * 24;
                }
              };
              for(let i = 0, latest = getDelay(filteredComments[0].lastElementChild.textContent); filteredComments[i]; i++){
                schedule.push(Math.min(getDelay(filteredComments[i].lastElementChild.textContent) - latest, AINTERVAL - 1));/*時間差を(AINTERVAL - 1)に制限*/
              }
              schedule = schedule.map(
                (delay, i ,s) => s[i] = s[i] + Math.random()/*random()を加えて散らす*/
              ).sort((a, b) => b - a).map(
                (delay, i, s) => delay + (1 - s[0]%1) * ((s.length - i) / s.length)/*最初のコメントをちょうど(AINTERVAL)秒前にしつつなだらかに下駄を履かせる*/
              ).map(
                (delay, i, s) => s[0] - delay/*最初のコメントの値を0に調整してすぐに流す*/
              ).reverse();
              /* スケジュールに沿って配列末尾の古いコメントから順に流す */
              for(let i = filteredComments.length - 1; filteredComments[i]; i--){
                let text = filteredComments[i].firstElementChild.textContent;
                window.setTimeout(function(){
                  core.attachComment(text);
                }, 1000 * schedule[i]);
              }
              break;
            }
            /* 単一コメント(newComments内のcommentたちがごっそり差し替えられてしまう悲しい仕様) */
            case (records[i].addedNodes.length === 1 && site.addedNodes.comment(records[i].addedNodes) !== null):{
              let newComment = records[i].addedNodes[0];
              /* NGフィルタの再適用 */
              core.ng.filter(newComment);
              if(records[records.length - 1].removedNodes.length){
                /* 開いていたNG登録フォーム */
                let removedComment = records[records.length - 1].removedNodes[0].firstElementChild.children[i];/*newCommentに対応して置き換えられたコメント*/
                if(elements.ngForm && elements.ngForm.parentNode === removedComment){
                  newComment.appendChild(elements.ngForm);
                }
                /* 選択していたテキスト(対応しない) */
              }
              break;
            }
            /* そのほか */
            default:
              break;
          }
        }
      });
    },
    /* スライドダウンアニメーションを上書きする */
    slideDownNewComments: function(newComments){
      let slideDown = function(){
        newComments.classList.add('shown');
        newComments.style.height = naturalHeight;
      };
      let hasHeight = (getComputedStyle(newComments).height !== '0px');
      newComments.style.height = 'auto';
      let naturalHeight = getComputedStyle(newComments).height;
      newComments.style.height = '0px';
      if(hasHeight){
        slideDown(newComments);
      }else{
        observe(newComments, function(records){
          if(!newComments.classList.contains('shown')){
            newComments.style.height = '0px';
            slideDown(newComments);
          }
        }, {attributes: true, attributeFilter: ['style']});
      }
    },
    /* コメントが追加されるたびにスクロールキューに追加 */
    attachComment: function(text){
      let record = {};
      record.text = text;/*流れる文字列*/
      record.width = context.measureText(record.text).width;/*文字列の幅*/
      record.ppms = (canvas.width + record.width) / (configs.duration * 1000);/*ミリ秒あたり移動距離*/
      record.start = Date.now();/*開始時刻*/
      record.reveal = record.start + (record.width / record.ppms);/*文字列が右端から抜ける時刻*/
      record.touch = record.start + (canvas.width / record.ppms);/*文字列が左端に触れる時刻*/
      record.end = record.start + (configs.duration * 1000);/*終了時刻*/
      record.left = canvas.width;/*左端からの距離(初期描画位置)*/
      /* 追加されたコメントをどの行に流すかを決定する */
      for(let i=0; i < configs.maxlines; i++){
        let length = lines[i] ? lines[i].length : 0;/*同じ行に詰め込まれているコメント数*/
        switch(true){
          /* 行がなければ行を追加して流す */
          case(length === 0):
            lines[i] = [];
          /* ひとつ先行するコメントより遅い(短い)文字列なら、現時点で先行コメントがすでに右端から抜けていれば流す */
          case(record.ppms < lines[i][length - 1].ppms && lines[i][length - 1].reveal < record.start):
          /* ひとつ先行するコメントより速い(長い)文字列なら、左端に触れる瞬間までに先行コメントが終了するなら流す */
          case(lines[i][length - 1].ppms < record.ppms && lines[i][length - 1].end < record.touch):
            record.top = ((canvas.height / configs.maxlines) * i) + canvas.fontsize;
            return lines[i].push(record);/*行に追加したら終了*/
          default:
            continue;/*条件に当てはまらなければforループを回して次の行に入れられるかの判定へ*/
        }
      }
    },
    /* FPSタイマー駆動 */
    scrollComments: function(){
      /* アニメーション関連は極力浅いオブジェクトに */
      let width = canvas.width, height = canvas.height, fps = configs.fps;
      clearInterval(interval), interval = setInterval(function(){
        let now = Date.now();
        /* Canvas描画 */
        context.clearRect(0, 0, width, height);
        for(let i=0; lines[i]; i++){
          let line = lines[i];
          for(let j=0; line[j]; j++){
            let comment = line[j];
            /* 描画位置を計算 */
            comment.left = width - ((now - comment.start) * comment.ppms);
            /* 視認性を向上させるスクロール文字の縁取りは、幸いにもパフォーマンスにほぼ影響しない */
            context.strokeText(comment.text, comment.left, comment.top);
            context.fillText(comment.text, comment.left, comment.top);
          }
          if(line[0] && line[0].end < now) line.shift();
        }
      }, 1000 / fps);
    },
    /* NGワード */
    ng: {
      initialize: function(){
        core.ng.read();
        core.ng.listenSelection();
      },
      listenSelection: function(){
        /* コメント上でmousedownした状態からのmousemove,mouseupでのみselect() */
        let select = function(e){
          let selection = window.getSelection(), selected = selection.toString(), comment = (selection.anchorNode) ? selection.anchorNode.parentNode.parentNode : null;
          /* テキスト選択なしなら登録フォームを閉じる */
          if(selection.isCollapsed && e.type === 'mouseup' && !e.target.dataset.ngword) return core.ng.closeForm();
          /* テキスト選択を邪魔しない場合にのみ登録フォームを表示 */
          if(!elements.ngForm || elements.ngForm.classList.contains('hidden') || e.target.offsetTop < elements.ngForm.offsetTop || e.type === 'mouseup') core.ng.openForm(comment, e);
          /* テキスト選択があれば初期値に */
          if(!selection.isCollapsed) elements.ngForm.querySelector('input[type="text"]').value = selected;
        };
        window.addEventListener('mousedown', function(e){
          if(e.target.dataset.selector !== 'comment' && e.target.parentNode.dataset.selector !== 'comment') return;
          elements.board.classList.add('mousedown');
          window.addEventListener('mousemove', select);
          window.addEventListener('mouseup', function(e){
            animate(function(){select(e)});/*ダブルクリックでのテキスト選択をanimateで確実に補足*/
            window.removeEventListener('mousemove', select);
            elements.board.classList.remove('mousedown');
          }, {once: true});
        });
      },
      createButton: function(){
        if(elements.ngButton) return;
        /* フルスクリーンボタンを元にNG一覧ボタンを追加する */
        elements.ngButton = createElement(core.html.ngButton());
        elements.ngButton.className = elements.fullscreenButton.className;
        elements.ngButton.addEventListener('click', core.panel.toggle.bind(null, 'ngList', core.ng.createList));
        elements.fullscreenButton.parentNode.insertBefore(elements.ngButton, elements.fullscreenButton);
      },
      createForm: function(comment){
        elements.ngForm = createElement(core.html.ngForm());
        elements.ngForm.querySelector('button.list').addEventListener('click', core.panel.toggle.bind(null, 'ngList', core.ng.createList));
        elements.ngForm.querySelector('button.help').addEventListener('click', core.panel.toggle.bind(null, 'ngHelp', core.ng.createHelp));
        elements.ngForm.querySelector('p.type').addEventListener('click', function(e){
          let word = elements.ngForm.querySelector('p.word input');
          if(word.value === '') return;
          if(e.target.localName !== 'button') return;
          core.ng.add(word, e.target);
          core.ng.closeForm();
          if(elements.ngList) core.ng.buildList();
        });
      },
      openForm: function(comment, e){
        let slideUpDown = function(){
          elements.ngForm.slidingUp = true;
          animate(function(){
            elements.ngForm.classList.add('hidden');
            if(elements.ngForm.isConnected){
              elements.ngForm.addEventListener('transitionend', function(e){
                elements.ngForm.slidingUp = false;
                elements.ngForm.targetComment.appendChild(elements.ngForm);
                slideDown();
              }, {once: true});
            }else{
              elements.ngForm.slidingUp = false;
              elements.ngForm.targetComment.appendChild(elements.ngForm);
              slideDown();
            }
          });
        };
        let slideDown = function(){
          elements.ngForm.slidingDown = true;
          if(elements.ngForm.parentNode !== elements.ngForm.targetComment) elements.ngForm.targetComment.appendChild(elements.ngForm);
          animate(function(){
            elements.ngForm.classList.remove('hidden');
            elements.ngForm.addEventListener('transitionend', function(e){
              elements.ngForm.slidingDown = false;
            }, {once: true});
          });
          let ngword = elements.ngForm.targetComment.dataset.ngword;
          if(ngword && e.type === 'click') elements.ngForm.querySelector('input[type="text"]').value = ngword;
          if(!html.classList.contains('ng')) html.classList.add('ng');/*チャンネル切り替えナビゲーションを隠すなど*/
        };
        if(elements.board.parentNode.scrollTop === 0) elements.board.parentNode.scrollTop = 1;/*新着コメントを停止する*/
        if(elements.ngForm){/*表示位置の移し替え*/
          elements.ngForm.targetComment = comment;/*既にslideDown中の処理も含めてターゲットを差し替える*/
          if(elements.ngForm.classList.contains('hidden')){
            if(elements.ngForm.slidingUp){/*Up中*/
              if(elements.ngForm.parentNode === comment){
                slideDown();/*UpをやめてDownさせる*/
              }else{
                /*予定通りUp後にDownさせる*/
                elements.ngForm.addEventListener('transitionend', function(e){
                  slideDown();
                }, {once: true});
              }
            }else{/*hidden状態*/
              slideDown();
            }
          }else{
            if(elements.ngForm.slidingDown){/*Down中*/
              if(elements.ngForm.parentNode === comment){
                /*なにもしなくてもよい*/
              }else{
                slideUpDown();/*Downをやめて改めてUpDownさせる*/
              }
            }else{/*表示状態*/
              if(elements.ngForm.parentNode === comment){
                /*なにもしなくてもよい*/
              }else{
                slideUpDown();
              }
            }
          }
        }else{/*新規*/
          core.ng.createForm(comment);
          elements.ngForm.classList.add('hidden');
          elements.ngForm.targetComment = comment;
          slideDown();
        }
      },
      closeForm: function(){
        if(!elements.ngForm) return;
        if(elements.ngForm.classList.contains('hidden')) return;
        elements.ngForm.slidingUp = true;
        animate(function(){
          elements.ngForm.classList.add('hidden');
          if(elements.ngForm.isConnected){
            elements.ngForm.addEventListener('transitionend', function(e){
              elements.ngForm.slidingUp = false;
            }, {once: true});
          }else{
            elements.ngForm.slidingUp = false;
          }
        });
        html.classList.remove('ng');/*チャンネル切り替えナビゲーションを隠すなど*/
      },
      toggleForm: function(comment, e){
        if(!elements.ngForm) return core.ng.openForm(comment, e);
        if(elements.ngForm.classList.contains('hidden')) return core.ng.openForm(comment, e);
        if(elements.ngForm.parentNode !== comment) return core.ng.openForm(comment, e);
        core.ng.closeForm();
      },
      createList: function(){
        let ngList = elements.ngList = createElement(core.html.ngList());
        ngList.querySelector('button.help').addEventListener('click', core.panel.toggle.bind(null, 'ngHelp', core.ng.createHelp));
        ngList.querySelector('button.cancel').addEventListener('click', core.panel.close.bind(null, 'ngList'));
        ngList.querySelector('button.save').addEventListener('click', function(e){
          core.ng.save(core.ng.getNewNgwords().filter((ngword) => (ngword.type !== 'remove')));
          core.panel.close('ngList');
        });
        ngList.querySelector('ul > li.add > p.words > textarea').addEventListener('keypress', function check(e){
          animate(function(){
            let checked = ngList.querySelector('ul > li.add > p.type input:checked');
            if(e.target.value === '') return checked && (checked.checked = false);
            if(!checked) ngList.querySelector('ul > li.add > p.type input[value="forever"]').checked = true;
          });
        });
        /* 並べ替え */
        configs.ng_sort = configs.ng_sort || {key: 'date', reverse: false};
        ngList.querySelector('p.sort').addEventListener('click', function(e){
          if(e.target.localName !== 'label') return;
          let input = document.getElementById(e.target.htmlFor);
          if(input.checked) input.classList.toggle('reverse');
          configs.ng_sort = {key: input.value, reverse: input.classList.contains('reverse')};
          core.ng.buildList();
        });
        /* リスト構築 */
        core.ng.buildList();
        /* 表示 */
        core.panel.open('ngList');
      },
      getNewNgwords: function(){
        let new_ngwords = Array.from(ngwords);/*clone*/
        let lis = elements.ngList.querySelectorAll('ul > li.edit');
        for(let i = 0; lis[i]; i++){
          let word = lis[i].querySelector('p.word input');
          let checked = lis[i].querySelector('p.type input:checked');
          let match = word.value.match(/^\/(.+)\/([a-z]+)?$/);
          new_ngwords[i] = {};
          new_ngwords[i].value = (match) ? word.value : normalize(word.value).toLowerCase();
          new_ngwords[i].regex = (match) ? new RegExp(match[1], match[2]) : null;
          new_ngwords[i].type = checked.value;
          new_ngwords[i].added = parseInt(lis[i].dataset.added) || null;
          new_ngwords[i].limit = (checked.value === 'for24h') ? parseInt(lis[i].dataset.limit) : null;
        }
        let add = elements.ngList.querySelector('ul > li.add');
        let textarea = add.querySelector('p.words textarea');
        let lines = textarea.value.split('\n');
        for(let i = 0; lines[i] !== undefined; i++){
          let checked = add.querySelector('p.type input:checked');
          let match = lines[i].match(/^\/(.+)\/([a-z]+)?$/);
          let index = new_ngwords.length;
          new_ngwords[index] = {};
          new_ngwords[index].value = (match) ? lines[i] : normalize(lines[i]).toLowerCase();
          new_ngwords[index].regex = (match) ? new RegExp(match[1], match[2]) : null;
          new_ngwords[index].type = (checked) ? checked.value : null;
          new_ngwords[index].added = Date.now() + i;/*並べ替え用に同一時刻を避ける*/
          new_ngwords[index].limit = (checked && checked.value === 'for24h') ? new_ngwords[index].added + 1000*60*60*24 : null;
        }
        textarea.value = '';
        return new_ngwords.filter((ngword, index) => {
          if(ngword.value === '') return false;/*空欄除外*/
          for(let i = index + 1; new_ngwords[i]; i++) if(ngword.value === new_ngwords[i].value) return false;/*重複除外*/
          return true;
        });
      },
      buildList: function(){
        /* 編集中の既存のリストがあればそのまま使う */
        let new_ngwords = core.ng.getNewNgwords();
        /* 並べ替え */
        if(new_ngwords.length < 2){
          elements.ngList.querySelector('p.sort').classList.add('disabled');
        }else{
          elements.ngList.querySelector('p.sort').classList.remove('disabled');
          let sort = elements.ngList.querySelector(`p.sort input[value="${configs.ng_sort.key}"]`);
          sort.checked = true;
          if(configs.ng_sort.reverse) sort.classList.add('reverse');
        }
        new_ngwords.sort(function(a, b){
          let types = {trial: 1, for24h: 2, forever: 3, remove: 4};
          switch(configs.ng_sort.key){
            case('date'): return (a.added < b.added);
            case('word'): return (a.value < b.value);
            case('type'): return (a.limit && b.limit) ? (a.limit < b.limit) : (types[a.type] < types[b.type]);
          }
        });
        if(configs.ng_sort.reverse) new_ngwords.reverse();
        /* リスト構築 */
        let ul = elements.ngList.querySelector('ul');
        while(2 < ul.children.length) ul.removeChild(ul.children[1]);/*冒頭のテンプレートと追加登録のみ残す*/
        let template = ul.querySelector('li.template');
        let now = Date.now();
        let formatTime = function(limit){
          let left = limit - now;
          switch(true){
            case(1000*60*60 <= left): return Math.floor(left/(1000*60*60)) + '時間';
            case(0 <= left): return Math.floor(left/(1000*60)) + '分';
            case(left < 0): return '0分';
          }
        };
        for(let i = 0; new_ngwords[i]; i++){
          let li = template.cloneNode(true);
          li.className = 'edit';
          li.innerHTML = li.innerHTML.replace(/\{i\}/g, i);
          li.querySelector('p.word input').value = new_ngwords[i].value;
          if(new_ngwords[i].type) li.querySelector(`p.type input[value="${new_ngwords[i].type}"]`).checked = true;
          li.dataset.added = new_ngwords[i].added || 0;
          li.dataset.limit = new_ngwords[i].limit || 0;
          let for24h = li.querySelector('p.type label.for24h');
          for24h.textContent = (new_ngwords[i].limit) ? formatTime(new_ngwords[i].limit) : '24時間';
          for24h.addEventListener('click', function(e){
            animate(function(){/*checked処理の後に*/
              if(li.querySelector('p.type input[value="for24h"]').checked){
                if(for24h.classList.toggle('extended')){
                  li.dataset.limit = Date.now() + 1000*60*60*24;
                  for24h.textContent = '24時間';
                }else{
                  li.dataset.limit = new_ngwords[i].limit;
                  for24h.textContent = formatTime(new_ngwords[i].limit);
                }
              }
            });
          });
          ul.insertBefore(li, template.nextElementSibling);
        }
      },
      createHelp: function(){
        elements.ngHelp = createElement(core.html.ngHelp());
        elements.ngHelp.querySelector('button.ok').addEventListener('click', core.panel.close.bind(null, 'ngHelp'));
        core.panel.open('ngHelp');
      },
      add: function(word, type){
        let index = ngwords.length;
        for(let i = 0; ngwords[i]; i++) if(ngwords[i].value === word.value) index = i;/*重複させない*/
        let match = word.value.match(/^\/(.+)\/([a-z]+)?$/);
        if(!ngwords[index]) ngwords[index] = {};
        ngwords[index].value = (match) ? word.value : normalize(word.value).toLowerCase();
        ngwords[index].regex = (match) ? new RegExp(match[1], match[2]) : null;
        ngwords[index].type = type.classList[0];
        ngwords[index].added = ngwords[index].added || Date.now();
        switch(true){
          case(type.classList.contains('for24h') && !ngwords[index].limit):
          case(type.classList.contains('for24h') && type.classList.contains('extended')):
            ngwords[index].limit = ngwords[index].added + 1000*60*60*24;
            break;
          case(type.classList.contains('for24h')):
            ngwords[index].limit = ngwords[index].limit;
            break;
          default:
            ngwords[index].limit = null;
            break;
        }
        localStorage[SCRIPTNAME + '-ngwords'] = JSON.stringify(ngwords);
      },
      read: function(){
        /* 保存済みの設定を読む */
        let ls = localStorage[SCRIPTNAME + '-ngwords'];
        if(ls) ngwords = JSON.parse(ls);
        /* 正規表現(word.regex)はJSONに保存されないので復活させる */
        for(let i = 0; ngwords[i]; i++){
          let match = ngwords[i].value.match(/^\/(.+)\/([a-z]+)?$/);
          ngwords[i].regex = (match) ? new RegExp(match[1], match[2]) : null;
        }
      },
      save: function(new_ngwords){
        ngwords = new_ngwords;
        localStorage[SCRIPTNAME + '-ngwords'] = JSON.stringify(ngwords);
      },
      expire: function(){
        let now = Date.now();
        ngwords = ngwords.filter(function(ngword, i, ngwords){
          if(!ngword.limit || now < ngword.limit) return true;
        });
      },
      filter: function(comment){
        const match = function(comment, ngword){
          let commentText = site.get.commentText(comment);
          if(ngword.regex && ngword.regex.test(commentText)) return true;
          if(normalize(commentText).toLowerCase().includes(ngword.value)) return true;
        };
        for(let i = 0; ngwords[i]; i++){
          switch(ngwords[i].type){
            case('forever'):
            case('for24h'):
              if(match(comment, ngwords[i])){
                comment.classList.add('ng-deleted');
                return false;
              }
              break;
            case('trial'):
              if(match(comment, ngwords[i])){
                comment.classList.add('ng-trial');
                comment.dataset.ngword = ngwords[i].value;
                comment.addEventListener('click', function(e){
                  if(e.target === comment && window.getSelection().isCollapsed) core.ng.toggleForm(comment, e);
                });
              }
              break;
          }
        }
        return true;
      },
    },
    /* 設定 */
    config: {
      read: function(){
        /* 保存済みの設定を読む */
        let ls = localStorage[SCRIPTNAME + '-configs'];
        if(ls) configs = JSON.parse(ls);
        /* 未定義項目をデフォルト値で上書きしていく */
        for(let i = 0; CONFIGS[i]; i++) if(configs[CONFIGS[i].KEY] === undefined) configs[CONFIGS[i].KEY] = CONFIGS[i].DEFAULT;
      },
      save: function(new_config){
        configs = {};/*CONFIGSに含まれた設定値のみ保存する*/
        /* CONFIGSを元に文字列を型評価して値を格納していく */
        for(let i = 0; CONFIGS[i]; i++){
          /* 値がなければデフォルト値 */
          if(new_config[CONFIGS[i].KEY] === ""){
            configs[CONFIGS[i].KEY] = CONFIGS[i].DEFAULT;
            continue;
          }
          switch(CONFIGS[i].TYPE){
            case 'bool':
              configs[CONFIGS[i].KEY] = (new_config[CONFIGS[i].KEY]) ? 1 : 0;
              break;
            case 'int':
              configs[CONFIGS[i].KEY] = parseInt(new_config[CONFIGS[i].KEY]);
              break;
            case 'float':
              configs[CONFIGS[i].KEY] = parseFloat(new_config[CONFIGS[i].KEY]);
              break;
            case 'string':
            default:
              configs[CONFIGS[i].KEY] = new_config[CONFIGS[i].KEY];
              break;
          }
        }
        localStorage[SCRIPTNAME + '-configs'] = JSON.stringify(configs);
      },
      createButton: function(){
        if(elements.configButton) return;
        /* フルスクリーンボタンを元に設定ボタンを追加する */
        elements.configButton = createElement(core.html.configButton());
        elements.configButton.className = elements.fullscreenButton.className;
        elements.configButton.addEventListener('click', core.panel.toggle.bind(null, 'configPanel', core.config.createPanel));
        elements.fullscreenButton.parentNode.insertBefore(elements.configButton, elements.ngButton);
      },
      createPanel: function(){
        elements.configPanel = createElement(core.html.configPanel());
        window.addEventListener('keypress', function(e){
          if(elements.configPanel && e.key === 'Escape') core.config.closePanel();
        }, {once: true});
        elements.configPanel.querySelector('button.cancel').addEventListener('click', core.panel.close.bind(null, 'configPanel'));
        elements.configPanel.querySelector('button.save').addEventListener('click', function(){
          let inputs = elements.configPanel.querySelectorAll('input'), new_configs = {};
          for(let i = 0; inputs[i]; i++){
            if(inputs[i].type === 'checkbox') new_configs[inputs[i].name] = (inputs[i].checked) ? 1 : 0;
            else new_configs[inputs[i].name] = inputs[i].value;
          }
          core.config.save(new_configs);
          core.panel.close('configPanel')
          /* 新しい設定値で再スタイリング */
          core.modify();
          core.addStyle();
        }, true);
        core.panel.open('configPanel');
      },
    },
    /* パネル共通 */
    panel: {
      createPanels: function(){
        if(elements.panels) return;
        elements.panels = document.createElement('div');
        elements.panels.className = 'panels';
        elements.panels.dataset.panels = 0;
        document.body.appendChild(elements.panels);
      },
      open: function(key){
        let order = ['configPanel', 'ngList', 'ngHelp'], target = null;
        for(let i = order.indexOf(key) + 1; order[i] && !target; i++) if(elements[order[i]]) target = elements[order[i]];
        elements[key].classList.add('hidden');
        elements.panels.insertBefore(elements[key], target);
        animate(function(){
          elements.panels.dataset.panels = parseInt(elements.panels.children.length);
          elements[key].classList.remove('hidden');
        });
      },
      close: function(key){
        elements[key].classList.add('hidden');
        elements[key].addEventListener('transitionend', function(){
          elements.panels.dataset.panels = parseInt(elements.panels.children.length - 1);
          elements.panels.removeChild(elements[key]);
          elements[key] = null;
        }, {once: true});
      },
      toggle: function(key, create){
        (!elements[key]) ? create() : core.panel.close(key);
      },
    },
    addStyle: function(){
      let style = createElement(core.html.style());
      document.head.appendChild(style);
      if(elements.style) document.head.removeChild(elements.style);
      elements.style = style;
    },
    html: {
      ngButton: () => `
        <button id="${SCRIPTNAME}-ng-button" title="${SCRIPTNAME} 登録NGワード一覧"><svg width="20" height="20"><use xlink:href="/images/icons/list.svg#svg-body"></use></svg></button>
      `,
      ngForm: () => `
        <div id="${SCRIPTNAME}-ng-form">
          <h1><span>NGワード登録</span><button class="list"><svg width="14" height="16"><use xlink:href="/images/icons/list.svg#svg-body"></use></svg></button></h1>
          <p class="word"><input type="text" value=""><button class="help">?</button></p>
          <p class="type"><button class="trial">お試し</button><button class="for24h">24時間</button><button class="forever">無期限</button></p>
        </div>
      `,
      ngList: () => `
        <div class="panel" id="${SCRIPTNAME}-ng-list">
          <h1><span>登録NGワード一覧</span><button class="help">?</button></h1>
          <p class="sort">
            <input type="radio" name="sort" id="ngwords-sort-date" value="date"><label for="ngwords-sort-date">登録日時順</label>
            <input type="radio" name="sort" id="ngwords-sort-word" value="word"><label for="ngwords-sort-word">NGワード順</label>
            <input type="radio" name="sort" id="ngwords-sort-type" value="type"><label for="ngwords-sort-type">期限順</label>
          </p>
          <ul>
            <li class="template">
              <p class="word"><input type="text" name="ngwords[{i}][value]" value=""></p>
              <p class="type">
                <input type="radio" name="ngwords[type][{i}]" id="ngwords-type-{i}-trial"   value="trial"  ><label class="trial"   for="ngwords-type-{i}-trial"  >お試し</label>
                <input type="radio" name="ngwords[type][{i}]" id="ngwords-type-{i}-for24h"  value="for24h" ><label class="for24h"  for="ngwords-type-{i}-for24h" >24時間</label>
                <input type="radio" name="ngwords[type][{i}]" id="ngwords-type-{i}-forever" value="forever"><label class="forever" for="ngwords-type-{i}-forever">無期限</label>
                <input type="radio" name="ngwords[type][{i}]" id="ngwords-type-{i}-remove"  value="remove" ><label class="remove"  for="ngwords-type-{i}-remove" >削除</label>
              </p>
            </li>
            <li class="add">
              <p class="words"><textarea name="ngwords[add][value]" placeholder="追加"></textarea></p>
              <p class="type">
                <input type="radio" name="ngwords[type][add]" id="ngwords-type-add-trial"   value="trial"  ><label class="trial"   for="ngwords-type-add-trial"  >お試し</label>
                <input type="radio" name="ngwords[type][add]" id="ngwords-type-add-for24h"  value="for24h" ><label class="for24h"  for="ngwords-type-add-for24h" >24時間</label>
                <input type="radio" name="ngwords[type][add]" id="ngwords-type-add-forever" value="forever"><label class="forever" for="ngwords-type-add-forever">無期限</label>
                <input type="radio" name="ngwords[type][add]" id="ngwords-type-add-remove"  value="remove" ><label class="remove"  for="ngwords-type-add-remove" >削除</label>
              </p>
            </li>
          </ul>
          <p class="buttons"><button class="cancel">キャンセル</button><button class="save primary">保存</button></p>
        </div>
      `,
      ngHelp: () => `
        <div class="panel" id="${SCRIPTNAME}-ng-help">
          <h1>NGワードについて</h1>
          <p>登録したワードを含むコメントを削除します。</p>
          <p>お試しの場合はコメント一覧でハイライトされます。</p>
          <p>右下の一覧ボタンやコメントのテキスト選択から登録できます。</p>
          <p>英数字と記号とカタカナは全角半角や大文字小文字を区別しません。</p>
          <p>下記のような正規表現も使えます。</p>
          <section>
            <h2>「NGです」を消す登録例:</h2>
            <dl>
              <dt><code>NG</code></dt><dd>通常のNGワード</dd>
              <dt><code>/^NG/</code></dt><dd>前方一致</dd>
              <dt><code>/です$/</code></dt><dd>後方一致</dd>
              <dt><code>/^NGです$/</code></dt><dd>完全一致</dd>
            </dl>
            <h2>そのほかの例:</h2>
            <dl>
              <dt><code>/^.$/</code></dt><dd>1文字だけのコメント</dd>
              <dt><code>/.{30}/</code></dt><dd>30文字以上のコメント</dd>
              <dt><code>/^[a-z]+$/i</code></dt><dd>アルファベットだけのコメント</dd>
              <dt><code>/[0-9]{3}/</code></dt><dd>3桁以上の数字を含むコメント</dd>
            </dl>
          </section>
          <p class="buttons"><button class="ok primary">OK</button></p>
        </div>
      `,
      configButton: () => `
        <button id="${SCRIPTNAME}-config-button" title="${SCRIPTNAME} 設定">
          <svg class="mJ_u eJ_b eJ_e" height="20" role="img" width="20"><use xlink:href="/images/icons/config.svg#svg-body"></use></svg>
        </button>
      `,
      configPanel: () => `
        <div class="panel" id="${SCRIPTNAME}-config-panel">
          <h1>${SCRIPTNAME}設定</h1>
          <fieldset>
            <legend>スクロールコメント</legend>
            <p><label>最大行数(文字サイズ連動):       <input type="number"   name="maxlines"      value="${configs.maxlines}"      min="1"  max="50"  step="1"></label></p>
            <p><label>行間(比率):                     <input type="number"   name="linemargin"    value="${configs.linemargin}"    min="0"  max="1"   step="0.05"></label></p>
            <p><label>不透明度:                       <input type="number"   name="opacity"       value="${configs.opacity}"       min="0"  max="1"   step="0.05"></label></p>
            <p><label>縁取りの太さ(比率):             <input type="number"   name="owidth"        value="${configs.owidth}"        min="0"  max="0.5" step="0.01"></label></p>
            <p><label>横断にかける秒数:               <input type="number"   name="duration"      value="${configs.duration}"      min="1"  max="30"  step="1"></label></p>
            <p><label>秒間コマ数:                     <input type="number"   name="fps"           value="${configs.fps}"           min="1"  max="240" step="1"></label></p>
          </fieldset>
          <fieldset>
            <legend>一覧コメント</legend>
            <p><label>操作していない時は画面外に隠す: <input type="checkbox" name="l_hide"        value="${configs.l_hide}"        ${configs.l_hide      ? 'checked' : ''}></label></p>
            <p><label>映像に重ねる:                   <input type="checkbox" name="l_overlay"     value="${configs.l_overlay}"     ${configs.l_overlay   ? 'checked' : ''}></label></p>
            <p><label>投稿時刻を表示する:             <input type="checkbox" name="l_showtime"    value="${configs.l_showtime}"    ${configs.l_showtime  ? 'checked' : ''}></label></p>
            <p><label>横幅(%):                        <input type="number"   name="l_width"       value="${configs.l_width}"       min="0"  max="100" step="0.5"></label></p>
            <p><label>最大行数(文字サイズ連動):       <input type="number"   name="lc_maxlines"   value="${configs.lc_maxlines}"   min="10" max="100" step="1"></label></p>
            <p><label>改行されたコメントの行間(比率): <input type="number"   name="lc_linemargin" value="${configs.lc_linemargin}" min="0"  max="1"   step="0.05"></label></p>
            <p><label>コメント同士の間隔(比率):       <input type="number"   name="lc_margin"     value="${configs.lc_margin}"     min="0"  max="2"   step="0.05"></label></p>
            <p><label>文字の不透明度:                 <input type="number"   name="lc_opacity"    value="${configs.lc_opacity}"    min="0"  max="1"   step="0.05"></label></p>
            <p><label>背景の不透明度:                 <input type="number"   name="lb_opacity"    value="${configs.lb_opacity}"    min="0"  max="1"   step="0.05"></label></p>
          </fieldset>
          <fieldset>
            <legend>アベマのナビゲーション</legend>
            <p><label>画面クリック時のみ表示する:     <input type="checkbox" name="n_clickonly"   value="${configs.n_clickonly}"   ${configs.n_clickonly ? 'checked' : ''}></label></p>
            <p><label>隠れるまでの時間(秒):           <input type="number"   name="n_delay"       value="${configs.n_delay}"       min="1"  max="30"  step="0.5"></label></p>
            <p><label>不透明度:                       <input type="number"   name="n_opacity"     value="${configs.n_opacity}"     min="0"  max="1"   step="0.05"></label></p>
          </fieldset>
          <p class="buttons"><button class="cancel">キャンセル</button><button class="save primary">保存</button></p>
        </div>
      `,
      style: () => `
        <style type="text/css">
          /* 共通変数 */
          /* font-size:             ${configs.lc_fontsize = (100 / (configs.lc_maxlines + 1)) / (1 + configs.lc_margin)} */
          /* header_height:         ${configs.header_height = configs.header_height || elements.header.firstElementChild.clientHeight} */
          /* footer_height:         ${configs.footer_height = configs.footer_height || elements.footer.firstElementChild.clientHeight} */
          /* channelButtons_size:   ${configs.channelButtons_size = configs.channelButtons_size || elements.channelButtons.firstElementChild.clientWidth} */
          /* canvas_zIndex:         ${configs.canvas_zIndex         =   3} */
          /* screen_zIndex:         ${configs.screen_zIndex         =   3} */
          /* header_zIndex:         ${configs.header_zIndex         =   8} */
          /* commentPane_zIndex:    ${configs.commentPane_zIndex    =   9} */
          /* headerHover_zIndex:    ${configs.headerHover_zIndex    =  10} */
          /* footer_zIndex:         ${configs.footer_zIndex         =  10} */
          /* channelPane_zIndex:    ${configs.channelPane_zIndex    =  11} */
          /* programPane_zIndex:    ${configs.programPane_zIndex    =  11} */
          /* channelButtons_zIndex: ${configs.channelButtons_zIndex =  12} */
          /* panel_zIndex:          ${configs.panel_zIndex          = 100} */
          /* スクロールコメント */
          canvas#${SCRIPTNAME}{
            z-index: ${configs.canvas_zIndex};
            pointer-events: none;
            position: absolute;
            top: 0;
            left: 0;
            opacity: 0;/*コメント非表示なら速やかに消える*/
            transition: opacity 500ms ease;
          }
          html.comment canvas#${SCRIPTNAME}{
            opacity: ${configs.opacity};
          }
          /* 映像 */
          ${selectors.screen}{
            transition: 500ms ease;
          }
          ${selectors.screen} > div{
            width: 100% !important;
            height: 100% !important;
            transition: 500ms ease;
          }
          /* コメントペインの表示非表示 */
          ${selectors.commentPane}{
            width: ${configs.l_width}%;
            transform: translateX(100%);
          }
          html.comment ${selectors.commentPane}{
            transform: translateX(${configs.l_hide ? '100%' : '0'});
          }
          html.comment ${selectors.commentPane}:hover,
          html.comment.active ${selectors.commentPane}{
            transform: translateX(0);
          }
          /* コメントペインの透過 */
          ${selectors.commentPane}{
            background: rgba(0,0,0,${configs.l_overlay ? configs.lb_opacity : 1});
            -webkit-mask-image: linear-gradient(black 50%, transparent);/*まだ-webkit取れない*/
            mask-image: linear-gradient(black 50%, transparent);
            height: ${configs.l_overlay ? '100%' : '200%'};/*映像に重ねているときのみグラデーション効果の範囲内にする*/
            z-index: ${configs.commentPane_zIndex};
            transition: 500ms ease;
          }
          ${selectors.commentPane}:hover{
            background: rgba(0,0,0,${configs.l_overlay ? ((1 + configs.lb_opacity) / 2) : 1});
            height: ${configs.l_overlay ? '200%' : '200%'};/*常に見やすく*/
          }
          ${selectors.footer}:hover ~ ${selectors.commentPane},
          html.active ${selectors.commentPane}{
            height: 100%;/*gradientでtransitionが効かないのでheightで代用*/
          }
          ${selectors.commentPane} *{
            color: rgba(255,255,255,${configs.l_overlay ? configs.lc_opacity : 1});
            background: transparent;
          }
          ${selectors.commentPane}:hover *{
            color: rgba(255,255,255,${configs.l_overlay ? ((1 + configs.lc_opacity) / 2) : 1});
          }
          /* コメントペインの統一フォントサイズ */
          /* (設定値の表現をわかりやすくする代償はここで支払う) */
          ${selectors.commentPane} *{
            font-size: ${configs.lc_fontsize}vh;
          }
          /* コメント投稿フォーム*/
          ${selectors.commentForm},
          ${selectors.commentForm} */*リセット*/{
            padding: 0;
            margin: 0;
          }
          ${selectors.commentForm}{
            width: auto;
            padding: 0 .75vw;
          }
          ${selectors.commentForm} > div:first-child/*textarea*/{
            background: rgba(32,32,32,${configs.l_overlay ? configs.lb_opacity : 1});
            border-radius: .2vw;
            padding: .5vw;
            margin: .75vw 0;
          }
          ${selectors.commentForm} > div:last-child > div:first-child > div:first-child/*(Twitter)連携する/連携中*/{
            width: 100%;
            padding-bottom: 1vw;
          }
          ${selectors.commentForm} > div:last-child > div:first-child > div:first-child > div/*(Twitter)連携する/連携中*/{
            width: calc(100% - 1vw);
            border-radius: .2vw;
            padding: 0 .5vw;
            height: ${configs.lc_fontsize * 2}vh;
            line-height: ${configs.lc_fontsize * 2}vh;
            overflow: hidden;
          }
          ${selectors.commentForm} > div:last-child > div:first-child > div:first-child > div[class*=" "]/*(Twitter)連携中*/{
            background: rgba(80,163,225,${configs.l_overlay ? ((1 + configs.lb_opacity) / 2) : 1});
          }
          ${selectors.commentForm} > div:last-child > div:first-child > div:first-child > div > svg/*(Twitter)アイコン*/{
            width: ${configs.lc_fontsize * (17/13)}vh;
            height: ${configs.lc_fontsize}vh;
            margin-right: 0.2vw;
          }
          ${selectors.commentForm} > div:last-child > div:last-child > span/*残り文字数*/{
            padding: .5vw;
          }
          ${selectors.commentForm} > div:last-child > div:last-child > button/*投稿する*/{
            border-radius: .2vw;
            padding: 0 .5vw;
            height: ${configs.lc_fontsize * 2}vh;
            line-height: ${configs.lc_fontsize * 2}vh;
            overflow: hidden;
            background: rgba(81,195,0,${configs.l_overlay ? ((1 + configs.lb_opacity) / 2) : 1});
          }
          ${selectors.commentForm} > div:last-child > div:first-child > div:nth-child(2)/*(Twitterアカウントパネル)*/{
            border-radius: .2vw;
            background: rgba(0,0,0,${((1 + configs.lb_opacity) / 2)});
            width: calc(100% - 1.5vw);
            bottom: -${configs.lc_fontsize * 3}vh;
            display: block;
            opacity: 0;
            pointer-events: none;
            transition: 500ms ease;
          }
          ${selectors.commentForm} > div:last-child > div:first-child > div:hover + div,
          ${selectors.commentForm} > div:last-child > div:first-child > div + div:hover/*(Twitterアカウントパネル)*/{
            opacity: 1;
            pointer-events: auto;
          }
          ${selectors.commentForm} > div:last-child > div:first-child > div:nth-child(2) > div:first-child > div > div,
          ${selectors.commentForm} > div:last-child > div:first-child > div:nth-child(2) > div:first-child > div > div > img/*アイコン*/{
            width: ${configs.lc_fontsize * 3}vh !important;
            height: ${configs.lc_fontsize * 3}vh !important;
          }
          ${selectors.commentForm} > div:last-child > div:first-child > div:nth-child(2) > div:last-child/*アカウント情報*/{
            padding: .1vw .5vw;
            bottom: 0;
          }
          ${selectors.commentForm} > div:last-child > div:first-child > div:nth-child(2) > p:last-child/*ログアウト*/{
            padding: .1vw .2vw;
            position: absolute;
            bottom: 0;
            right: 0;
          }
          /* 新着コメント表示ボタン */
          ${selectors.newCommentsButton}{
            background: rgba(81,195,0,${configs.l_overlay ? ((1 + configs.lb_opacity) / 2) : 1});
            border: none;
            padding: 0;
            line-height: 3em;
            height: 0;/*デフォルトで非表示*/
            overflow: hidden;
            transition: height 500ms ease;
          }
          ${selectors.newCommentsButton}.shown{
            height: 3em;
          }
          /* コメント一覧 */
          /* (${selectors.comment}は後付けなのでheightのスライドダウンアニメーションに影響しないように注意する) */
          ${selectors.board}{
            margin: 0;
          }
          ${selectors.board} > div > div > div:not(${selectors.comment}),
          ${selectors.comment}{
            padding: 0 .75vw;
          }
          ${selectors.comment} > p/*コメント,経過時間*/{
            margin: ${configs.lc_fontsize * (configs.lc_margin - configs.lc_linemargin) / 2}vh 0;
            line-height: ${1 + configs.lc_linemargin};
          }
          ${selectors.comment} > p:first-child/*コメント*/{
            width: ${(configs.l_showtime) ? 'calc(100% - 4em)' : '100%'};
            overflow-wrap: break-word;
          }
          ${selectors.comment} > p:nth-child(2)/*経過時間*/{
            display: ${(configs.l_showtime) ? 'block' : 'none'};
            filter: opacity(75%);
            width: 4em;/*00秒前*/
          }
          /* コメント一覧のスクロールバー */
          ${selectors.commentPane} > div > div{
            overflow-y: scroll;
            margin-right: -${getScrollbarWidth()}px;/*スクロールバーを隠す*/
            transition: margin-right 0ms;
          }
          /* 上下ナビゲーションの表示非表示 */
          html.${SCRIPTNAME} ${selectors.header}{
            transform: translateY(calc(-100% + ${configs.header_height}px));/*隠れているときもマウスオーバー領域を確保する*/
            height: auto;
            padding-bottom: ${configs.header_height}px;
            visibility: visible;
            z-index: ${configs.header_zIndex};
            background: transparent;/*hover用paddingを持たせたいのでbackgroundはdivに移譲*/
            transition: 500ms ease;
          }
          html.${SCRIPTNAME} ${selectors.header} > div{
            height: ${configs.header_height}px;
          }
          html.${SCRIPTNAME}.active ${selectors.commentPane}{
            padding-top: ${configs.header_height}px;/*右コメント一覧を映像に重ねたせいで上部ナビゲーションと重なるのを避ける*/
          }
          html.${SCRIPTNAME} ${selectors.footer}{
            transform: translateY(calc(100% - ${configs.footer_height}px));/*隠れているときもマウスオーバー領域を確保する*/
            padding-top: ${configs.footer_height}px;
            z-index: ${configs.footer_zIndex};
            visibility: visible;
            transition: 500ms ease;
          }
          html.${SCRIPTNAME}:not(.active) ${selectors.footer}:not(:hover) > div > *{
            bottom: 0;/*フルスクリーンボタンと音量ボタンが突然消えないようにアベマが指定すべき値*/
          }
          html.${SCRIPTNAME} ${selectors.header}:hover,
          html.${SCRIPTNAME}.active ${selectors.header}{
            padding-bottom: ${configs.header_height * (1/2)}px;
            z-index: 11;
          }
          html.${SCRIPTNAME} ${selectors.footer}:hover,
          html.${SCRIPTNAME}.active ${selectors.footer}{
            padding-top: ${configs.footer_height * (1/2)}px;
          }
          html.${SCRIPTNAME} ${selectors.header}:hover,
          html.${SCRIPTNAME}.active ${selectors.header},
          html.${SCRIPTNAME} ${selectors.footer}:hover,
          html.${SCRIPTNAME}.active ${selectors.footer}{
            transform: translateY(0%);
          }
          html.${SCRIPTNAME}.active ${selectors.header},
          html.${SCRIPTNAME}.active ${selectors.footer}{
            padding-top: 0;
            padding-bottom: 0;
          }
          html.${SCRIPTNAME} ${selectors.footer} > div > div:last-child > div:first-child:hover{/*ここにだけ追加して背景色を指定してるのはアベマのミスだろう*/
            background: transparent;
          }
          /* 上下ナビゲーションの透過 */
          /* (番組視聴ページ(html.${SCRIPTNAME})にだけ適用する) */
          html.${SCRIPTNAME} ${selectors.header} > div,/*上部(hover用padding付き透明ラッパに包みたいのでdivに適用)*/
          html.${SCRIPTNAME} ${selectors.header} button + div > div,/*その他ドロップダウン*/
          html.${SCRIPTNAME} ${selectors.footer} > div > div:last-child/*下部*/{
            background: rgba(0,0,0,${configs.n_opacity}) !important;
            transition: 500ms ease;
          }
          html.${SCRIPTNAME} ${selectors.header}:hover > div,
          html.${SCRIPTNAME} ${selectors.header} button + div > div:hover,
          html.${SCRIPTNAME} ${selectors.footer} > div > div:last-child:hover{
            background: rgba(0,0,0,${(1 + configs.n_opacity) / 2}) !important;
          }
          html.${SCRIPTNAME} ${selectors.footer} > div > div:last-child{
            border-top: none;
          }
          /* ブラウザ警告の透過 */
          ${selectors.caution}{
            opacity: ${configs.n_opacity};
          }
          ${selectors.caution},
          ${selectors.caution} *{
            color: white;
            background: transparent;
          }
          /* 通知を受け取るボタン・視聴数・ローディングの表示非表示 */
          ${selectors.notice}{
            transition: 500ms ease;
          }
          html.comment ${selectors.notice}[class*=" "]/*デフォルトのクラスに表示用のクラスが追加された場合*/{
            right: ${configs.l_overlay ? configs.l_width : '0'}%;
            bottom: ${configs.footer_height}px !important;
            transform: translate(-.75vw, -.75vw);
          }
          ${selectors.audienceTop}{/*基準親要素*/
            top: 0;
            width: 100% !important;
            overflow: hidden;
          }
          ${selectors.audience}{
            top: ${configs.header_height}px;
            right: 0%;
            transform: translate(100%, .75vw);
            visibility: visible;
          }
          ${selectors.audience}:hover,/*コメント一覧がない場合*/
          html.active ${selectors.audience}{
            transform: translate(-.75vw, .75vw);
          }
          html.comment ${selectors.audience}:hover,/*コメント一覧が表示されている場合*/
          html.comment.active ${selectors.audience}{
            right: ${configs.l_overlay ? configs.l_width : '0'}%;
          }
          ${selectors.loading}{
            transform: translateY(${configs.footer_height}px);
          }
          /* 通知を受け取るボタン・視聴数・ローディングの透過 */
          ${selectors.notice} > button,
          ${selectors.audience}{
            background: rgba(0,0,0,${configs.n_opacity}) !important;
            transition: 500ms ease;
            pointer-events: auto;
          }
          ${selectors.notice} > button:hover,
          ${selectors.audience}:hover{
            background: rgba(0,0,0,${(1 + configs.n_opacity) / 2}) !important;
          }
          html.comment ${selectors.notice} > button{
            border-right: 1px solid #444;
          }
          ${selectors.screen}/*視聴数をマウスオーバーにちゃんと反応させる工夫*/{
            z-index: ${configs.screen_zIndex};
            pointer-events: none;
          }
          /* チャンネル一覧の表示非表示 */
          ${selectors.channelPane}{
            z-index: ${configs.channelPane_zIndex};
            transform: translateX(100%);
          }
          html.channel ${selectors.channelPane}{
            transform: translateX(0);
          }
          /* チャンネル一覧の透過 */
          ${selectors.channelPane} > div > a{
            background: rgba(0,0,0,${(1 + configs.n_opacity) / 2});
            transition: 500ms ease;
          }
          ${selectors.channelPane} > div > a:hover{
            background: rgba(34,34,34,${(1 + configs.n_opacity) / 2});
          }
          ${selectors.channelPane} > div,
          ${selectors.channelPane} > div > a > div{
            background: transparent;
          }
          ${selectors.channelPane} *{
            color: white !important;/*!important付けないと効いてくれない*/
          }
          /* 番組情報の表示非表示 */
          ${selectors.programPane}{
            z-index: ${configs.programPane_zIndex};
            transform: translateX(100%);
          }
          html.program ${selectors.programPane}{
            transform: translateX(0);
          }
          /* 番組情報の透過 */
          ${selectors.programPane}{
            color: white;
            background: rgba(0,0,0,${(1 + configs.n_opacity) / 2});
            transition: 500ms ease;
          }
          ${selectors.programPane} svg{
            fill: white;
          }
          /* ボタン共通 */
          ${selectors.channelButtons} button *,
          ${selectors.commentButton} *,
          ${selectors.programButton} *{
            pointer-events: none;/*クリックイベント発生箇所を親のボタン要素に統一する*/
          }
          #${SCRIPTNAME}-ng-button svg,
          #${SCRIPTNAME}-config-button svg{
            fill: white;
            vertical-align: middle;
          }
          html.${SCRIPTNAME} ${selectors.footer} > div > *:not(:last-child)/*各ボタン*/{
            transition: 500ms ease;
            filter: drop-shadow(0 0 2.5px rgba(0,0,0,.75));/*このくらいアベマにやっておいてほしい*/
          }
          /* チャンネル一覧・切り替えボタン */
          ${selectors.channelButtons}{
            transform: translate(calc(100% - ${configs.channelButtons_size}px), -50%);
            padding: ${configs.channelButtons_size}px 0 ${configs.channelButtons_size}px ${configs.channelButtons_size}px;/*隠れているときもサイズ3/4まではマウスオーバー領域を確保する*/
            transition: 500ms ease;/*アベマの指定漏れ?*/
            z-index:${configs.channelButtons_zIndex};
          }
          ${selectors.channelButtons}:hover,
          html.active ${selectors.channelButtons}{
            padding: ${configs.channelButtons_size * (1/2)}px 0 ${configs.channelButtons_size * (1/2)}px ${configs.channelButtons_size * (1/2)}px;
            transform: translate(0%, -50%);
          }
          html.ng ${selectors.channelButtons}/*NGワード登録中は控えて出しゃばらない*/{
            padding: 0;
            transform: translate(100%, -50%);
          }
          /* コメントボタン */
          ${selectors.commentButton}{
            transition: 500ms ease;/*アベマの指定漏れ?*/
          }
          html.comment.active ${selectors.commentButton} svg,
          html.comment ${selectors.footer}:hover ${selectors.commentButton} svg{
            animation: spin 1s infinite alternate cubic-bezier(.45,.05,.55,.95)/*sin*/;
          }
          @keyframes spin{/*GPU処理されるはずなのにCPU食うので注意*/
            from{
              transform: scaleX(1);
            }
            to{
              transform: scaleX(-1);
           }
          }
          /* 登録NGワード一覧ボタン */
          #${SCRIPTNAME}-ng-button{
            right: 125px;
          }
          /* 設定ボタン */
          #${SCRIPTNAME}-config-button{
            right: 175px;
          }
          /* NGワード登録フォーム */
          ${selectors.comment}{
            flex-wrap: wrap;
          }
          #${SCRIPTNAME}-ng-form{
            border-radius: .5vw;
            margin-bottom: .75vw;/*お試しNGワードでハイライトされた場合に内包されるように*/
            width: 100%;
            background: rgba(32,32,32,${configs.l_overlay ? configs.lb_opacity : 1});
            height: calc(${configs.lc_fontsize}vh + 2 * ${configs.lc_fontsize * 2}vh + 4 * .5vw);
            overflow: hidden;
            transition: 500ms ease;
          }
          #${SCRIPTNAME}-ng-form.hidden{
            height: 0;
            margin-bottom: 0;
          }
          #${SCRIPTNAME}-ng-form h1,
          #${SCRIPTNAME}-ng-form p{
            color: white;
            width: auto;
            margin: .5vw;
            display: flex;
          }
          #${SCRIPTNAME}-ng-form h1{
            line-height: ${configs.lc_fontsize}vh;
          }
          #${SCRIPTNAME}-ng-form p{
            line-height: ${configs.lc_fontsize * 2}vh;
          }
          #${SCRIPTNAME}-ng-form h1 span{
            flex-grow: 1;
          }
          #${SCRIPTNAME}-ng-form h1 button.list{
            width: ${configs.lc_fontsize * 2}vh;
            padding: ${configs.lc_fontsize / 2}vh 0;
            margin: -${configs.lc_fontsize / 2}vh 0;
          }
          #${SCRIPTNAME}-ng-form h1 button.list svg{
            vertical-align: top;
            width: ${configs.lc_fontsize}vh;
            height: ${configs.lc_fontsize}vh;
            fill: white;
          }
          #${SCRIPTNAME}-ng-form button.help{
            width: ${configs.lc_fontsize * 2}vh;
            margin-left: .5vw;
            background: rgba(0,0,0,${configs.lb_opacity});
            border-radius: .25vw;
          }
          #${SCRIPTNAME}-ng-form p.word input{
            color: white;
            border: none;
            border-radius: .25vw;
            background: rgba(0,0,0,${configs.lb_opacity});
            height: ${configs.lc_fontsize * 2}vh;
            padding: 0 .5vw;
            width: 50%;
            flex-grow: 1;
          }
          #${SCRIPTNAME}-ng-form p.type{
            border-radius: .25vw;
            overflow: hidden;
            display: flex;
          }
          #${SCRIPTNAME}-ng-form p.type button{
            color: white;
            font-weight: bold;
            width: 100%;
            margin-left: 1px;
            flex-grow: 1;
            height: ${configs.lc_fontsize * 2}vh;
          }
          #${SCRIPTNAME}-ng-form p.type button.trial{
            margin-left: 0;
            background: rgba(255,224,32,${configs.l_overlay ? configs.lb_opacity : .5});
          }
          #${SCRIPTNAME}-ng-form p.type button.for24h,
          #${SCRIPTNAME}-ng-form p.type button.forever{
            background: rgba(255,32,32,${configs.l_overlay ? configs.lb_opacity : .5});
          }
          #${SCRIPTNAME}-ng-form p.type button.trial:hover,
          #${SCRIPTNAME}-ng-form p.type button.trial:focus{
            color: black;
            background: rgba(255,224,32,${configs.l_overlay ? ((1 + configs.lb_opacity) / 2) : 1});
          }
          #${SCRIPTNAME}-ng-form p.type button.for24h:hover,
          #${SCRIPTNAME}-ng-form p.type button.for24h:focus,
          #${SCRIPTNAME}-ng-form p.type button.forever:hover,
          #${SCRIPTNAME}-ng-form p.type button.forever:focus{
            background: rgba(255,32,32,${configs.l_overlay ? ((1 + configs.lb_opacity) / 2) : 1});
          }
          #${SCRIPTNAME}-ng-form h1 button.list:hover svg,
          #${SCRIPTNAME}-ng-form h1 button.list:focus svg,
          #${SCRIPTNAME}-ng-form p.word button.help:hover,
          #${SCRIPTNAME}-ng-form p.word button.help:focus{
            filter: brightness(.5);
          }
          /* NGワード一覧 */
          #${SCRIPTNAME}-ng-list h1{
            display: flex;
          }
          #${SCRIPTNAME}-ng-list h1 span{
            flex: 1;
          }
          #${SCRIPTNAME}-ng-list h1 button.help{
            color: white;
            width: 20px;
            background: rgba(0,0,0,.5);
            border-radius: 5px;
          }
          #${SCRIPTNAME}-ng-list h1 button.help:hover,
          #${SCRIPTNAME}-ng-list h1 button.help:focus{
            filter: brightness(.5);
          }
          #${SCRIPTNAME}-ng-list p.sort{
            width: 80%;
            height: 20px;
            padding: 0;
            margin: 5px auto;
            border-radius: 5px;
            overflow: hidden;
            display: flex;
          }
          #${SCRIPTNAME}-ng-list p.sort.disabled{
            filter: brightness(.5);
            pointer-events: none;
          }
          #${SCRIPTNAME}-ng-list p.sort input{
            display: none;
          }
          #${SCRIPTNAME}-ng-list p.sort label{
            color: white;
            background: rgba(128,128,128,.25);
            font-size: 10px;
            text-align: center;
            width: 100%;
            margin-left: 1px;
          }
          #${SCRIPTNAME}-ng-list p.sort label:first-of-type{
            margin-left: 0;
          }
          #${SCRIPTNAME}-ng-list p.sort input + label::after{
            font-size: 75%;
            vertical-align: top;
            content: " ▼";
          }
          #${SCRIPTNAME}-ng-list p.sort input.reverse + label::after{
            content: " ▲";
          }
          #${SCRIPTNAME}-ng-list p.sort input:checked + label,
          #${SCRIPTNAME}-ng-list p.sort label:hover,
          #${SCRIPTNAME}-ng-list p.sort label:focus{
            background: rgba(128,128,128,.75);
          }
          #${SCRIPTNAME}-ng-list ul{
            max-height: calc(${window.innerHeight}px - (5px + 24px + 30px + 42px + 5px) - 20px);
            overflow-y: auto;
          }
          #${SCRIPTNAME}-ng-list ul > li{
            padding: 2px 10px;
            display: flex;
          }
          #${SCRIPTNAME}-ng-list ul > li.template{
            display: none;
          }
          #${SCRIPTNAME}-ng-list p.word,
          #${SCRIPTNAME}-ng-list p.words{
            padding: 0;
            flex: 1;
          }
          #${SCRIPTNAME}-ng-list p.word input,
          #${SCRIPTNAME}-ng-list p.words textarea{
            font-size: 12px;
            width: 100%;
          }
          #${SCRIPTNAME}-ng-list p.word input{
            height: 20px;
          }
          #${SCRIPTNAME}-ng-list p.words textarea{
            height: 40px;
            resize: vertical;
          }
          #${SCRIPTNAME}-ng-list p.type{
            height: 20px;
            border-radius: 5px;
            overflow: hidden;
            padding: 0;
            margin-left: 10px;
            flex: 1;
            display: flex;
          }
          #${SCRIPTNAME}-ng-list p.type input{
            display: none;
          }
          #${SCRIPTNAME}-ng-list p.type label{
            text-align: center;
            font-size: 10px;
            font-weight: bold;
            width: 100%;
            margin-left: 1px;
          }
          #${SCRIPTNAME}-ng-list p.type label.trial{
            margin-left: 0;
            background: rgba(255,224,32,.25);
          }
          #${SCRIPTNAME}-ng-list p.type label.for24h,
          #${SCRIPTNAME}-ng-list p.type label.forever{
            background: rgba(255,32,32,.25);
          }
          #${SCRIPTNAME}-ng-list p.type input:checked + label.trial,
          #${SCRIPTNAME}-ng-list p.type label.trial:hover,
          #${SCRIPTNAME}-ng-list p.type label.trial:focus{
            color: black;
            background: rgba(255,224,32,.75);
          }
          #${SCRIPTNAME}-ng-list p.type input:checked + label.for24h,
          #${SCRIPTNAME}-ng-list p.type label.for24h:hover,
          #${SCRIPTNAME}-ng-list p.type label.for24h:focus,
          #${SCRIPTNAME}-ng-list p.type input:checked + label.forever,
          #${SCRIPTNAME}-ng-list p.type label.forever:hover,
          #${SCRIPTNAME}-ng-list p.type label.forever:focus{
            background: rgba(255,32,32,.75);
          }
          #${SCRIPTNAME}-ng-list p.type label.remove{
            background: rgba(128,128,128,.25);
          }
          #${SCRIPTNAME}-ng-list p.type input:checked + label.remove,
          #${SCRIPTNAME}-ng-list p.type label.remove:hover,
          #${SCRIPTNAME}-ng-list p.type label.remove:focus{
            background: rgba(128,128,128,.75);
          }
          #${SCRIPTNAME}-ng-list li.add p.type label.remove{
            visibility: hidden;
          }
          #${SCRIPTNAME}-ng-list input + label{
            cursor: pointer;
          }
          /* NGワードコメント */
          ${selectors.comment}.ng-trial{
            background: rgba(255,224,32,${configs.l_overlay ? ((1 + configs.lb_opacity) / 2) : .75});
            cursor: pointer;
          }
          ${selectors.comment}.ng-trial:hover{
            background: rgba(255,224,32,${configs.l_overlay ? configs.lb_opacity : .5});
          }
          ${selectors.comment}.ng-trial > p{
            pointer-events: none;/*イベントはcommentで発生させる*/
          }
          ${selectors.comment}.ng-deleted{
            display: none;
          }
          /* パネル共通 */
          body{
            overflow: hidden;
          }
          div.panel{
            position: absolute;
            width: 360px;
            max-height: 100%;/*小さなウィンドウに対応*/
            overflow: auto;
            left: 50%;
            bottom: 50%;
            transform: translate(-50%, 50%);
            z-index: ${configs.panel_zIndex};
            background: rgba(0,0,0,.75);
            transition: 500ms ease;
            padding: 5px 0;
          }
          div.panel.hidden{
            bottom: 0;
            transform: translate(-50%, 100%) !important;
          }
          div.panel h1,
          div.panel h2,
          div.panel legend,
          div.panel dl,
          div.panel code,
          div.panel p{
            color: rgba(255,255,255,1);
            font-size: 14px;
            padding: 2px 10px;
            line-height:20px;
          }
          div.panel p.buttons{
            text-align: right;
            padding: 5px 10px;
          }
          div.panel p.buttons button{
            width: 120px;
            padding: 5px 10px;
            margin-left: 10px;
            border-radius: 5px;
            color: rgba(255,255,255,1);
            background: rgba(64,64,64,1);
            border: 1px solid rgba(255,255,255,1);
          }
          div.panel p.buttons button.primary{
            font-weight: bold;
            background: rgba(0,0,0,1);
          }
          div.panel p.buttons button:hover,
          div.panel p.buttons button:focus{
            background: rgba(128,128,128,.75);
          }
          div.panels[data-panels="2"] div.panel:nth-child(1){
            transform: translate(-100%, 50%);
          }
          div.panels[data-panels="2"] div.panel:nth-child(2){
            transform: translate(0%, 50%);
          }
          div.panels[data-panels="3"] div.panel:nth-child(1){
            transform: translate(-150%, 50%);
          }
          div.panels[data-panels="3"] div.panel:nth-child(3){
            transform: translate(50%, 50%);
          }
          /* NGヘルプパネル */
          #${SCRIPTNAME}-ng-help{
            width: 360px;
          }
          #${SCRIPTNAME}-ng-help h2{
            margin-top: 10px;
          }
          #${SCRIPTNAME}-ng-help dl{
            display: flex;
            flex-wrap: wrap;
          }
          #${SCRIPTNAME}-ng-help dl dt{
            width: 100px;
            margin: 2.5px 10px 2.5px 0;
            background: rgba(0,0,0,.5);
            border-radius: 5px;
          }
          #${SCRIPTNAME}-ng-help dl dt code{
            padding:0 5px;
          }
          #${SCRIPTNAME}-ng-help dl dd{
            width: 230px;
            margin: 2.5px 0;
          }
          /* 設定パネル */
          #${SCRIPTNAME}-config-panel{
            width: 360px;
          }
          #${SCRIPTNAME}-config-panel fieldset p{
            padding-left: calc(10px + 1em);
          }
          #${SCRIPTNAME}-config-panel fieldset p:hover{
            background: rgba(255,255,255,.25);
          }
          #${SCRIPTNAME}-config-panel input{
            width: 80px;
            height: 20px;
            position: absolute;
            right: 10px;
          }
          #${SCRIPTNAME}-config-panel p.license,
          #${SCRIPTNAME}-config-panel p.license a{
            font-size: 10px;
            color: rgba(255,255,255,.25);
          }
        </style>
      `,
    },
  };
  let $ = function(s){return document.querySelector(s)};
  let animate = function(callback, ...params){requestAnimationFrame(() => requestAnimationFrame(() => callback(...params)))};
  let sequence = function(){
    let chain = [], defer = function(callback, delay, ...params){(delay) ? setTimeout(callback, delay, ...params) : animate(callback, ...params)};
    for(let i = arguments.length - 1, delay = 0; 0 <= i; i--, delay = 0){
      if(typeof arguments[i] === 'function'){
        for(let j = i - 1; typeof arguments[j] === 'number'; j--) delay += arguments[j];
        let f = arguments[i], d = delay, callback = chain[chain.length - 1];
        chain.push(function(pass){defer(function(ch){ch ? ch(f(pass)) : f(pass);}, d, callback)});/*nearly black magic*/
      }
    }
    chain[chain.length - 1]();
  };
  let observe = function(element, callback, config = {childList: true}){
    let observer = new MutationObserver(callback.bind(element));
    observer.observe(element, config);
    return observer;
  };
  let createElement = function(html){
    let outer = document.createElement('div');
    outer.innerHTML = html;
    return outer.firstElementChild;
  };
  let getScrollbarWidth = function(){
    let div = document.createElement('div');
    document.body.appendChild(div);
    div.style.overflowY = 'scroll';
    let clientWidth = div.clientWidth;
    div.style.overflowY = 'hidden';
    let offsetWidth = div.offsetWidth;
    document.body.removeChild(div);
    return offsetWidth - clientWidth;
  };
  let normalize = function(string){
    return string.replace(/[!-~]/g, function(s){
      return String.fromCharCode(s.charCodeAt(0) - 0xFEE0);
    }).replace(normalize.RE, function(s){
      return normalize.KANA[s];
    });
  };
  normalize.KANA = {
    ガ:'ガ', ギ:'ギ', グ:'グ', ゲ:'ゲ', ゴ: 'ゴ',
    ザ:'ザ', ジ:'ジ', ズ:'ズ', ゼ:'ゼ', ゾ: 'ゾ',
    ダ:'ダ', ヂ:'ヂ', ヅ:'ヅ', デ:'デ', ド: 'ド',
    バ:'バ', ビ:'ビ', ブ:'ブ', ベ:'ベ', ボ: 'ボ',
    パ:'パ', ピ:'ピ', プ:'プ', ペ:'ペ', ポ: 'ポ',
    ヷ:'ヷ', ヺ:'ヺ', ヴ:'ヴ',
    ア:'ア', イ:'イ', ウ:'ウ', エ:'エ', オ:'オ',
    カ:'カ', キ:'キ', ク:'ク', ケ:'ケ', コ:'コ',
    サ:'サ', シ:'シ', ス:'ス', セ:'セ', ソ:'ソ',
    タ:'タ', チ:'チ', ツ:'ツ', テ:'テ', ト:'ト',
    ナ:'ナ', ニ:'ニ', ヌ:'ヌ', ネ:'ネ', ノ:'ノ',
    ハ:'ハ', ヒ:'ヒ', フ:'フ', ヘ:'ヘ', ホ:'ホ',
    マ:'マ', ミ:'ミ', ム:'ム', メ:'メ', モ:'モ',
    ヤ:'ヤ', ユ:'ユ', ヨ:'ヨ',
    ラ:'ラ', リ:'リ', ル:'ル', レ:'レ', ロ:'ロ',
    ワ:'ワ', ヲ:'ヲ', ン:'ン',
    ァ:'ァ', ィ:'ィ', ゥ:'ゥ', ェ:'ェ', ォ:'ォ',
    ッ:'ッ', ャ:'ャ', ュ:'ュ', ョ:'ョ',
    "。":'。', "、":'、', "ー":'ー', "「":'「', "」":'」', "・":'・',
  };
  normalize.RE = new RegExp('(' + Object.keys(normalize.KANA).join('|') + ')', 'g');
  let log = function(){
    if(!DEBUG) return;
    let l = log.last = log.now || new Date(), n = log.now = new Date();
    let stack = new Error().stack, callers = stack.match(/^([^/<]+(?=<?@))/gm) || stack.match(/[^. ]+(?= \(<anonymous)/gm) || [];
    console.log(
      SCRIPTNAME + ':',
      /* 00:00:00.000  */ n.toLocaleTimeString() + '.' + n.getTime().toString().slice(-3),
      /* +0.000s       */ '+' + ((n-l)/1000).toFixed(3) + 's',
      /* :00           */ ':' + stack.match(/:[0-9]+:[0-9]+/g)[1].split(':')[1],/*LINE*/
      /* caller.caller */ (callers[2] ? callers[2] + '() => ' : '') +
      /* caller        */ (callers[1] || '')  + '()',
      ...arguments
    );
  };
  core.initialize();
  if(window === top) console.timeEnd(SCRIPTNAME);
})();