// ==UserScript==
// @name AbemaTV Screen Comment Scroller
// @namespace knoa.jp
// @description AbemaTV のコメントをニコニコ風にスクロールさせます。
// @include https://abema.tv/*
// @version 2.0.11
// @grant none
// ==/UserScript==
// console.log('AbemaTV? => hireMe()');
(function(){
const SCRIPTNAME = 'ScreenCommentScroller';
const DEBUG = true;/*
[update]
コメントがより自然にばらついて流れるようにしました。
番組切り替え時のコメント自動表示のバグを修正しました。
[to do]
前座研究!
animationがまさる?
Transitionスクロール
iframe意味あんの?ほか改善策模索
attach時のsetTimeoutをdelayに効果あるか
即アタッチしつつdelayを渡すようにすればいいよね
背景タブ時に重なりまくる問題
CPUと体感カクカク
transition-delay: .1s をセレロンでためす
そもそもtransitionがだいぶ最適化されてきて全面Canvasと変わらなくなってきた
Transitionに統一しつつTransition特有のバグをつぶすか
Windowsでフォントがわずかに欠けてた
7限定なら無視する手もあるが
番組表と通知
アドオン拡張化
[not to do]
新着コメント緑ボタン後の表示は現状簡単にはアニメーションさせられない
画面外だといつの間にかコメントが止まるとかそもそも発動しないのはアベマ自体のバグ
[番組表.js]
window.result.dataにデータ揃ってる。検索もしやすそう
>>揃わなくなった\(^o^)/
var token = localStorage.getItem('abm_token');
var date = '20171231';
xhr.open('GET', 'https://api.abema.io/v1/media?dateFrom=' + date + '&dateTo=' + date);
xhr.setRequestHeader('Authorization', 'bearer ' + token);
まずは非表示チャンネルの指定
上部バーを隠すように
横スクロールや通知はこっちでやるのかどうか?
おすすめ、米盛り上がりなどザッピング的サポート可能?
[通知] は別スクリプトではあると思うけど…
通知予定一覧は必要かも
番組チャンネルの非表示とスキップもこちらで担当。
例のキーワード登録とかいろいろやりたいことはある。
ScreenWidget方式の番組表にしてやればいいのでは…非表示チャンネルも可能にして。
いやそれはそれでいいけどだったらテレビとニコ生、その他放送サービスも統合した素晴らしいのを作ろう
…と思ったけど、ぐりもんだからこそ番組情報を取得しても許されるというのはあるかもな…。
> ネトフリでアニメ見てると新しい話数が来る度に通知がくるのが便利で仕方ない
> Abemaも結構いいんだけど番組表を漁る面倒さがじわじわ効いてくる
> Abemaはシリーズで枠とってるアニメなら毎週通知設定にも出来るはずなんだけど
> いくつかのタイトルは何故かそれが出来なかったりと不明な点がちょいちょいあって惜しい
*/
if(window === top && console.time) 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: 'canvas', TYPE: 'bool', DEFAULT: 0 },/*全面Canvasで描画(高性能PC向け)*/
{KEY: 'fps', TYPE: 'int', DEFAULT: 60 },/*全面Canvasでの秒間描画コマ数*/
/* 一覧コメント */
{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 PANELS = ['configPanel', 'ngList', 'ngHelp'];/*パネルの表示順*/
const AINTERVAL = 7;/*AbemaTVのコメント取得間隔の仕様値*/
const MAXCOMMENT = 100;/*コメント最大数(特に CSS Transition 時に有効)*/
/* サイト定義 */
let retry = 10;/*必要な要素が見つからずあきらめるまでの試行回数*/
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 button = $('button[aria-label="放送中の裏番組"]'); return (button) ? site.use(button.parentNode.parentNode.nextElementSibling) : null;},
function programPane(){let button = $('button[aria-label="放送中の裏番組"]'); return (button) ? site.use(button.parentNode.parentNode.nextElementSibling.nextElementSibling) : null;},
/* ボタン */
function channelButtons(){let button = $('button[aria-label="放送中の裏番組"]'); return (button) ? site.use(button.parentNode.parentNode) : null;},
function channelButton(){let button = $('button[aria-label="放送中の裏番組"]'); return (button) ? site.use(button) : 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 VolumeController(){let mute = $('button[aria-label="音声オンオフ切り替え"]'); return (mute) ? site.use(mute.parentNode.parentNode) : 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);},
],
addedNode: {
newCommentsButton: function(node){let button = node.parentNode.querySelector(selectors.commentPane + ' > div > button'); return (button) ? site.use(node) : null;},
newComments: function(node){let wrapper = node.querySelector(selectors.board + ' > div:not([data-selector]) > div'); return (wrapper) ? site.use(node) && Array.from(wrapper.children).map(site.addedNode.comment) : null;},
newComment: function(node){let commentText = node.querySelector(selectors.newComments + ' > div > div > p:first-child'); return (commentText) ? site.use(node, 'comment') : null;},
comment: function(node){let commentText = node.querySelector('div:not([data-selector]) > p:first-child'); return (commentText) ? site.use(node) : null;},
progressbar: function(node){let circle = node.querySelector('svg circle'); return (circle) ? site.use(circle.parentNode.parentNode) : null;},
},
removedNode: {
newComments: function(node){return (node.dataset.selector === 'newComments') ? node : null;},
},
get: {
comments: function(newComments){return newComments.firstElementChild.children;},
commentText: function(comment){return comment.firstElementChild.textContent;},
},
use: function use(target, key = use.caller.name){
if(target) target.dataset.selector = key;
selectors[key] = `[data-selector="${key}"]`;
elements[key] = 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 retry-- && setTimeout(core.ready, 1000);
}
}
/* すべての要素が出揃っていたので */
log("I'm Ready.");
core.createCanvas();
core.listenComments();
core.ng.createButton();
core.config.createButton();
if(configs.canvas) core.scrollComments();
core.panel.createPanels();
core.addStyle();
html.classList.add(SCRIPTNAME);
/* コメントを開けるようになったら自動で開く */
let url = null;
let observer = observe(elements.commentButton, function(records){
if(getComputedStyle(elements.commentButton).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(){
if(['input', 'textarea', 'button'].includes(document.activeElement.loaclName)) return;/*入力中はアクティブのまま*/
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):
return html.classList.toggle('channel');
case(elements.programButton):
return html.classList.toggle('program');
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();
/* デフォルトのボタン動作が実行される */
}
return;
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{
/* デフォルトのボタン動作が実行される */
}
return;
case(elements.closer):
switch(true){
case(html.classList.contains('channel')):
html.classList.remove('channel');
return e.stopPropagation();
case(html.classList.contains('program')):
html.classList.remove('program');
return e.stopPropagation();
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();
}
return;
}
default:
return;/*デフォルトの動作に任せる*/
}
}, true);
/* コメントペインの開閉でcanvasサイズを再計算 */
observe(html, function(records){
if(!configs.l_overlay) core.modify();
}, {attributes: true});
},
/* canvas作成 */
createCanvas: function(){
if(canvas) elements.screen.removeChild(canvas);
if(configs.canvas){
canvas = createElement(core.html.canvas());
context = canvas.getContext('2d', {alpha: false});
}else{
canvas = createElement(core.html.canvasDiv());
/* テキストサイズ計測に使用 */
elements.preCanvas = createElement(core.html.preCanvas());
context = elements.preCanvas.getContext('2d', {alpha: false});
}
elements.screen.insertBefore(canvas, elements.screen.firstElementChild);
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 = canvas.style.width = width + 'px';
elements.screen.style.height = canvas.style.height = height + 'px';
canvas.width = width;
canvas.height = height;
canvas.fontsize = Math.round((canvas.height / configs.maxlines) / (1 + configs.linemargin));
context.font = `bold ${canvas.fontsize}px sans-serif`;
context.textBaseline = 'middle';
context.fillStyle = 'white';
context.strokeStyle = 'black';
context.lineWidth = Math.round(canvas.fontsize * configs.owidth);
context.lineJoin = 'round';
canvas.topDelta = (configs.canvas) ? ((canvas.height / configs.maxlines) / 2) : (((canvas.fontsize * configs.linemargin) - context.lineWidth) / 2);/*canvasのtop計算に使用する*/
if(configs.canvas){
/* スクロールコメントの再計算 */
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 = Math.round(((canvas.height / configs.maxlines) * i) + canvas.topDelta);
}
}
core.scrollComments();
}
},
/* コメントの新規追加を見守る */
listenComments: function(){
if(elements.commentPane.isListening) return;
elements.commentPane.isListening = true;
observe(elements.commentPane.firstElementChild, function(records){
/* 新着コメント表示ボタン */
if (records[0].addedNodes.length === 1 && site.addedNode.newCommentsButton(records[0].addedNodes[0]) !== null){
let newCommentsButton = records[0].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')});
}
}
});
observe(elements.board, function(records){
let replacedComments = [], ngFormIndex = null;
for(let i = 0; records[i]; i++){
switch(true){
/* 新着コメント集 */
case (records[i].addedNodes.length === 1 && site.addedNode.newComments(records[i].addedNodes[0]) !== null):
core.receiveNewComments(elements.newComments);
observe(elements.newComments.firstElementChild, function(records){
for(let j = 0; records[j]; j++){
switch(true){
/* 新着単一コメント */
case (records[j].addedNodes.length === 1 && site.addedNode.newComment(records[j].addedNodes[0]) !== null):
break;
}
}
core.receiveNewComments(elements.newComments);
});
break;
/* 差し替え単一コメント(newComments内のcommentたちがごっそり新しいNodeに差し替えられてしまうアベマの悲しい仕様) */
case (records[i].addedNodes.length === 1 && site.addedNode.comment(records[i].addedNodes[0]) !== null):
core.ng.filter(records[i].addedNodes[0]);/*NGフィルタの再適用*/
replacedComments.push(records[i].addedNodes[0]);
break;
/* 差し替えられたNodeの状態を再現する */
case (records[i].removedNodes.length === 1 && site.removedNode.newComments(records[i].removedNodes[0]) !== null):
/* 開いていたNG登録フォーム */
if(elements.ngForm && elements.ngForm.parentNode.parentNode.parentNode === records[i].removedNodes[0]){
ngFormIndex = Array.from(site.get.comments(records[i].removedNodes[0])).indexOf(elements.ngForm.parentNode);
}
/* 選択していたテキスト(対応しない) */
break;
}
}
if(ngFormIndex !== null) replacedComments[ngFormIndex].appendChild(elements.ngForm);
});
},
/* 新着コメントを受け取ったときの処理 */
receiveNewComments: function(newComments){
/* コメントの取得間隔を計測する */
let now = Date.now(), commentInterval = (now - parseInt(newComments.dataset.received)) / 1000 || AINTERVAL;
newComments.dataset.received = now;/*datasetを使うことでnewCommentsがなくなるときはいっしょになくなる*/
/* NGコメントをすぐ判定する */
core.ng.expire();
let filteredComments = Array.from(site.get.comments(newComments)).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; filteredComments[i]; i++){
schedule.push(getDelay(filteredComments[i].lastElementChild.textContent));
}
let lastIndex = schedule.length - 1, scale = (commentInterval) / (schedule[lastIndex] - schedule[0] + 1);
schedule = schedule.map(/*最古のコメントを0として何秒後に流すべきかの配列を作る*/
(delay, i, s) => s[lastIndex] - delay
).map(/*randomを加えて散らす*/
(delay, i, s) => delay + (Math.random() * ((lastIndex - i) / lastIndex))
).sort(/*randomで乱れたぶんをソート*/
(a, b) => b - a
).map(/*次のAINTERVALまでばらつきを平準化する*/
(delay, i, s) => delay * scale
);
/* スケジュールに沿って配列末尾の古いコメントから順に流す */
for(let i = filteredComments.length - 1; filteredComments[i]; i--){
window.setTimeout(function(){
core.attachComment(filteredComments[i].firstElementChild.textContent);
}, 1000 * schedule[i]);
}
},
/* スライドダウンアニメーションを上書きする */
slideDownNewComments: function(newComments){
newComments.style.maxHeight = '0px';/*heightの上書き戦争を避けてmaxHeightが使えるのは幸運*/
newComments.dataset.naturalHeight = getComputedStyle(newComments.firstElementChild).height;
animate(function(){newComments.style.maxHeight = newComments.dataset.naturalHeight});
},
/* コメントが追加されるたびにスクロールキューに追加 */
attachComment: function(text){
if(canvas.children.length > MAXCOMMENT) return;
let scrollComment, c;
let width = Math.round(context.measureText(text).width + context.lineWidth);
let height = Math.round(canvas.fontsize + context.lineWidth);
if(!configs.canvas){
scrollComment = createElement(core.html.scrollComment(width, height));
c = scrollComment.getContext('2d');
c.font = `bold ${canvas.fontsize}px sans-serif`;
c.textBaseline = context.textBaseline;
c.fillStyle = context.fillStyle;
c.strokeStyle = context.strokeStyle;
c.lineWidth = context.lineWidth;
c.lineJoin = context.lineJoin;
let padding = Math.round(context.lineWidth / 2);
let middle = Math.round(height / 2);
c.strokeText(text, padding, middle);
c.fillText(text, padding, middle);
}
let record = {};
record.text = text;/*流れる文字列*/
record.width = 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 = Math.round(((canvas.height / configs.maxlines) * i) + canvas.topDelta);
//if(!configs.canvas) scrollComment.dataset.former = JSON.stringify(lines[i][length - 1]);
//if(!configs.canvas) scrollComment.dataset.self = JSON.stringify(record);
lines[i].push(record);
if(!configs.canvas){
scrollComment.style.top = record.top + 'px';
canvas.appendChild(scrollComment);
animate(function(){
scrollComment.classList.add('scroll');
scrollComment.addEventListener('transitionend', function(e){
canvas.removeChild(scrollComment);
lines[i].shift();
}, {once: true});
});
}
return;/*行に追加したら終了*/
default:
continue;/*条件に当てはまらなければforループを回して次の行に入れられるかの判定へ*/
}
}
},
/* Canvas 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, e.target.parentNode.dataset.selector].includes('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());
elements.configPanel.querySelector('input[name="canvas"]').addEventListener('click', function(e){
let fps = elements.configPanel.querySelector('input[name="fps"]');
fps.disabled = !fps.disabled;
});
elements.configPanel.querySelector('button.cancel').addEventListener('click', core.panel.close.bind(null, 'configPanel'));
elements.configPanel.querySelector('button.save').addEventListener('click', function(e){
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.createCanvas();
core.addStyle();
}, true);
core.panel.open('configPanel');
},
},
/* パネル共通 */
panel: {
createPanels: function(){
if(elements.panels) return;
elements.panels = createElement(core.html.panels());
elements.panels.dataset.panels = 0;
document.body.appendChild(elements.panels);
},
open: function(key){
let target = null;
for(let i = PANELS.indexOf(key) + 1; PANELS[i] && !target; i++) if(elements[PANELS[i]]) target = elements[PANELS[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');
});
window.addEventListener('keypress', function(e){
if(elements[key] && e.key === 'Escape') core.panel.close(key);
}, {once: true});
},
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: {
canvas: () => `
<canvas id="${SCRIPTNAME}-canvas"></canvas>
`,
canvasDiv: () => `
<div id="${SCRIPTNAME}-canvas"></div>
`,
preCanvas: () => `
<canvas width="0" height="0"></canvas>
`,
scrollComment: (width, height) => `
<canvas class="comment" width="${width}" height="${height}"></canvas>
`,
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">
<header>
<h1>登録NGワード一覧</h1>
<p class="buttons"><button class="help">?</button></p>
</header>
<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>全面Canvasで描画(高性能PC向け): <input type="checkbox" name="canvas" value="${configs.canvas}" ${configs.canvas ? 'checked' : ''}></label></p>
<p><label>全面Canvasでの秒間描画コマ数: <input type="number" name="fps" value="${configs.fps}" min="1" max="240" step="1" ${configs.canvas ? '' : 'disabled'}></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="60" step="1"></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>
`,
panels: () => `
<div class="panels ${SCRIPTNAME}-panels"></div>
`,
style: () => `
<style type="text/css">
/* 共通変数 */
/* font-size: ${configs.fontsize = (100 / configs.maxlines) / (1 + configs.linemargin)} (設定値の表現をわかりやすくする代償はここで支払う) */
/* lc_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} */
/* スクロールコメント */
#${SCRIPTNAME}-canvas{
z-index: ${configs.canvas_zIndex};
pointer-events: none;
position: absolute;
top: 0;
left: 0;
opacity: 0;/*コメント非表示なら速やかに消える*/
transition: opacity 500ms ease;
}
html.comment #${SCRIPTNAME}-canvas{
opacity: ${configs.opacity};
}
#${SCRIPTNAME}-canvas > canvas{
position: absolute;
left: 100%;
transform: translateX(0%);
transition: left ${configs.duration}s linear, transform ${configs.duration}s linear;
will-change: left, transform;
}
#${SCRIPTNAME}-canvas > canvas.scroll{
left: 0%;
transform: translateX(-100%);
}
/* 映像 */
${selectors.screen}{
transition: 500ms ease;
}
${selectors.screen} > div{
width: 100% !important;
height: 100% !important;
transition: 500ms ease;
}
/* コメントペインの表示非表示 */
${selectors.commentPane}{
width: auto;
padding-left: ${configs.l_hide ? configs.l_width : 0}vw;
transform: translateX(100%);
z-index: ${configs.commentPane_zIndex};
transition: 500ms ease;
}
html.comment ${selectors.commentPane}{
transform: translateX(${configs.l_hide ? 50 : 0}%);
}
html.comment ${selectors.commentPane}:hover,
html.comment.active ${selectors.commentPane}{
transform: translateX(0);
padding-left: ${configs.l_hide ? configs.l_width * (1/4) : 0}vw;/*隠れているときもマウスオーバー領域を確保する*/
}
${selectors.commentPane} > div{
width: ${configs.l_width}vw;
}
/* コメントペインの透過 */
${selectors.commentPane} > div{
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%'};/*映像に重ねているときのみグラデーション効果の範囲内にする*/
transition: 500ms ease;
}
${selectors.commentPane}:hover > div{
height: ${configs.l_overlay ? '200%' : '200%'};/*常に見やすく*/
background: rgba(0,0,0,${configs.l_overlay ? ((1 + configs.lb_opacity) / 2) : 1});
}
${selectors.footer}:hover ~ ${selectors.commentPane} > div,
html.active ${selectors.commentPane} > div{
height: 100%;/*gradientでtransitionが効かないのでheightで代用*/
}
${selectors.commentPane},
${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.newComments}{
transition: max-height 500ms cubic-bezier(.215,.61,.355,1) !important;/*アベマの仕様に合わせなきゃならんのでやや心もとないが*/
}
/* コメント一覧 */
/* (${selectors.comment}は後付けなのでスライドダウンアニメーションに影響する) */
/* セレクタがNGワード登録フォームと合致しないように気を付ける */
${selectors.board}{
margin: 0;
}
${selectors.board} > div{
padding: 0 .75vw;
}
${selectors.board} > div/*newComments*/ > div > div/*comment*/{/*打ち消しハック*/
padding: 0 .75vw;
margin: 0 -.75vw;
}
${selectors.comment}{
flex-wrap: wrap;/*NGワード登録フォームの配置用*/
}
${selectors.board} div:not([id]) > p/*コメント,経過時間*/{
margin: ${configs.lc_fontsize * (configs.lc_margin - configs.lc_linemargin) / 2}vh 0;
line-height: ${1 + configs.lc_linemargin};
}
${selectors.board} div:not([id]) > p:first-child/*コメント*/{
width: ${(configs.l_showtime) ? 'calc(100% - 4em)' : '100%'};
overflow-wrap: break-word;
}
${selectors.board} div:not([id]) > p:nth-child(2)/*経過時間*/{
display: ${(configs.l_showtime) ? 'block' : 'none'};
filter: opacity(75%);
width: 4em;/*00秒前*/
white-space: nowrap;
}
/* コメント一覧のスクロールバー */
${selectors.commentPane} > div > div{
overflow-y: scroll;
margin-right: -${getScrollbarWidth()}px;/*スクロールバーを隠す*/
transition: margin-right 0ms;
}
/* 上下ナビゲーションの表示非表示 */
${selectors.header}{
background: transparent;/*hover用paddingを持たせたいのでbackgroundはdivに移譲*/
height: auto;
padding-bottom: ${configs.header_height}px;
transform: translateY(calc(-100% + ${configs.header_height}px)) !important;/*隠れているときもマウスオーバー領域を確保する*/
visibility: visible !important;
z-index: ${configs.header_zIndex};
transition: 500ms ease;
}
${selectors.header} > div{
height: ${configs.header_height}px;
}
html.active ${selectors.commentPane} > div{
padding-top: ${configs.header_height}px;/*右コメント一覧を映像に重ねたせいで上部ナビゲーションと重なるのを避ける*/
}
${selectors.footer}{
transform: translateY(calc(100% - ${configs.footer_height}px));/*隠れているときもマウスオーバー領域を確保する*/
padding-top: ${configs.footer_height}px;
z-index: ${configs.footer_zIndex};
visibility: visible !important;
transition: 500ms ease;
}
html:not(.active) ${selectors.footer}:not(:hover) > div > *{
bottom: 0;/*フルスクリーンボタンと音量ボタンが突然消えないようにアベマが指定すべき値*/
}
${selectors.header}:hover,
html.active ${selectors.header}{
padding-bottom: ${configs.header_height * (1/2)}px;
z-index: 11;
}
${selectors.footer}:hover,
html.active ${selectors.footer}{
padding-top: ${configs.footer_height * (1/2)}px;
}
${selectors.header}:hover,
html.active ${selectors.header},
${selectors.footer}:hover,
html.active ${selectors.footer}{
transform: translateY(0%) !important;
}
html.active ${selectors.header},
html.active ${selectors.footer}{
padding-top: 0;
padding-bottom: 0;
}
${selectors.footer} > div > div:last-child > div:first-child:hover{/*ここにだけ追加して背景色を指定してるのはアベマのミスだろう*/
background: transparent;
}
/* 上下ナビゲーションの透過 */
${selectors.header} > div,/*上部(hover用padding付き透明ラッパに包みたいのでdivに適用)*/
${selectors.header} button + div > div,/*その他ドロップダウン*/
${selectors.footer} > div > div:last-child/*下部*/{
background: rgba(0,0,0,${configs.n_opacity}) !important;
transition: 500ms ease;
}
${selectors.header}:hover > div,
${selectors.header} button + div > div:hover,
${selectors.footer} > div > div:last-child:hover{
background: rgba(0,0,0,${(1 + configs.n_opacity) / 2}) !important;
}
${selectors.footer} > div > div:last-child{
border-top: none;
}
${selectors.programButton} div{/*チャンネル画像の背景が透過されていないアベマの仕様に対応*/
background: transparent !important;
}
/* ブラウザ警告の透過 */
${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 > use:not([*|href*="_rect.svg"]){/*rectは赤背景*/
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;
}
${selectors.footer} > div > *:not(:last-child)/*各ボタン*/{
transition: 500ms ease;
filter: drop-shadow(0 0 2.5px rgba(0,0,0,.75));/*白い背景で見にくいアベマの悲しい仕様を回避*/
padding: 30px 15px 15px;/*クリック判定範囲を広くしてあげる*/
margin: -30px -15px -15px;
}
/* チャンネル一覧・切り替えボタン */
${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.VolumeController} > div > div::after{/*一瞬フォーカスが外れるアベマの悲しい仕様を回避*/
border-width: 16px 12px 16px;
bottom: 8px;
}
/* コメントボタン */
${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ワード登録フォーム */
#${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 button.help{
color: white;
width: 20px;
background: rgba(0,0,0,.5);
border-radius: 5px;
}
#${SCRIPTNAME}-ng-list button.help:hover,
#${SCRIPTNAME}-ng-list 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 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;
}
.${SCRIPTNAME}-panels 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;
}
.${SCRIPTNAME}-panels div.panel.hidden{
bottom: 0;
transform: translate(-50%, 100%) !important;
}
.${SCRIPTNAME}-panels h1,
.${SCRIPTNAME}-panels h2,
.${SCRIPTNAME}-panels legend,
.${SCRIPTNAME}-panels dl,
.${SCRIPTNAME}-panels code,
.${SCRIPTNAME}-panels p{
color: rgba(255,255,255,1);
font-size: 14px;
padding: 2px 10px;
line-height:20px;
}
.${SCRIPTNAME}-panels header{
display: flex;
}
.${SCRIPTNAME}-panels header h1{
flex: 1;
}
.${SCRIPTNAME}-panels > div.panel > p.buttons{
text-align: right;
padding: 5px 10px;
}
.${SCRIPTNAME}-panels > 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);
}
.${SCRIPTNAME}-panels > div.panel > p.buttons button.primary{
font-weight: bold;
background: rgba(0,0,0,1);
}
.${SCRIPTNAME}-panels > div.panel > p.buttons button:hover,
.${SCRIPTNAME}-panels > div.panel > p.buttons button:focus{
background: rgba(128,128,128,.75);
}
.${SCRIPTNAME}-panels .template{
display: none !important;
}
.${SCRIPTNAME}-panels[data-panels="2"] div.panel:nth-child(1){
transform: translate(-100%, 50%);
}
.${SCRIPTNAME}-panels[data-panels="2"] div.panel:nth-child(2){
transform: translate(0%, 50%);
}
.${SCRIPTNAME}-panels[data-panels="3"] div.panel:nth-child(1){
transform: translate(-150%, 50%);
}
.${SCRIPTNAME}-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.time) console.timeEnd(SCRIPTNAME);
})();