// ==UserScript== // @name AbemaTV Screen Comment Scroller // @namespace knoa.jp // @description AbemaTV のコメントをニコニコ風にスクロールさせます。 // @include https://abema.tv/* // @version 2.6.2 // @grant none // @downloadURL none // ==/UserScript== // console.log('AbemaTV? => hireMe()'); (function(){ const SCRIPTNAME = 'ScreenCommentScroller'; const DEBUG = true;/* [update] コメントがスクロールしなくなるアベマ公式のバグへの対処を改善しました。 [to do] 4:3の時にずらす? greasemonkey 4 系対応? 累計500バグが解消されてるかどうか随時確認。 timeを活用して自然に流せるか確認。 番組変わった時点で何かを開いてるとコメが閉じられてしまう? クリック時は250ms遅延いやかも 通知を受け取るボタン z-indexどうにもならんのかな… 最悪左に移しちゃえばいいのかな>>ナビゲーション要素は右側に統一されてる 番組開始後も取り残されることがある? > 番宣CM中に通知を受け取るをクリックしても次に同じCMがあったときにまたボタンが出てくるのって前からだった? パフォーマンス 結局テキストトランジションなら軽い可能性? テキストならscaleが効く -webkit-text-strokeが使えるっぽい そこまでやるならiframeも復活だろうか>>せめてbody直下がよいみたい コメントペインの背景用divを挿入してopacity制御にすれば…? 同じレイヤーにしないと非効率。 番組表と通知 完成したら裏番組一覧のスクリーンショットはコメント一覧を重ねない&秒前なし&やや切り詰め&縁取り太め設定のものに差し替えか。 ついでに設定パネルのも差し替え。NGのも公式ブロック表示させつつ。 アドオン拡張化 Qiitaで解説 [possible] ブロックユーザーアイコンのアニメーション [requests] 設定のナビゲーションに「マウスを近づけたら表示する」 設定のスクロールコメントに「画面下部の専用領域に流す」「高さ(%)」 設定の一覧コメントに「コメントをひとつずつ表示する」 映像停止時に自動リロード [not to do] 新着コメント緑ボタン後の表示は現状簡単にはアニメーションさせられない 設定のスクロールコメントに「フォント」< 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.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 commentText = node.querySelector(selectors.board + ' > div:not([data-selector]) > div > div > div > p'); return (commentText) ? site.use(node) && Array.from(commentText.parentNode.parentNode.parentNode.children).map(site.addedNode.comment) : null;}, newComment: function(node){let commentText = node.querySelector(selectors.newComments + ' > div > div > div > p:first-child'); return (commentText) ? site.use(node, 'comment') : null;}, comment: function(node){let commentText = node.querySelector('div:not([data-selector]) > div > 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;}, comment: function(node){return (node.dataset.selector === 'comment') ? node : null;}, }, get: { comments: function(newComments){return newComments.firstElementChild.children;}, commentText: function(comment){return comment.firstElementChild.firstElementChild.textContent;}, commentTime: function(comment){return comment.querySelector('time').textContent;}, commentBlock: function(comment){return comment.querySelector('button[title="ブロック"]');}, commentBlockCancel: function(comment){return comment.querySelector('form button');}, latestTimeStamp: function(newComments){return newComments.querySelector('time').dateTime;}, timeStamp: function(comment){return comment.querySelector('time').dateTime;}, view: function(audience){return audience.querySelector('[data-selector="audience"] > span');}, comment: function(commentButton){return commentButton.querySelector('[data-selector="commentButton"] > span');}, statsApi: function(){ /* アベマの仕様に依存しまくり */ if(!window.dataLayer) return; const API = 'https://api.abema.io/v1/broadcast/slots/{id}/stats'; for(let i = window.dataLayer.length - 1; window.dataLayer[i]; i--){ if(window.dataLayer[i].slotId) return API.replace('{id}', window.dataLayer[i].slotId); } }, }, 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, lines = [];/*アニメーション関連は極力浅いオブジェクトに*/ let core = { initialize: function(){ let previousUrl = ''; /* 一度だけ */ html = document.documentElement; core.config.read(); core.ng.initialize(); core.listenUserActions(); window.addEventListener('resize', setTimeout.bind(null, core.modify, 500)); /* 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, target; target = site.targets[i]; i++){ if(target() === null){ if(!retry) return log(`Not found: ${target.name}, I give up.`); log(`Not found: ${target.name}, retrying...`); return retry-- && setTimeout(core.ready, 1000); } } log("I'm Ready."); /* すべての要素が出揃っていたので */ core.createCanvas(); core.listenComments(); core.ng.createButton(); core.config.createButton(); core.panel.createPanels(); core.addStyle(); html.classList.add(SCRIPTNAME); /* コメントを開けるようになったら自動で開く */ let url = null; let observer = observe(elements.commentButton, function(records){ if(elements.commentPane.attributes['aria-hidden'].value === 'false') return;/*既に表示中*/ if(getComputedStyle(elements.commentButton).cursor !== 'pointer') return;/*まだクリックできない*/ if(url !== location.href){/*チャンネル切り替え後の初回*/ elements.commentButton.click(); url = location.href; }else if(html.classList.contains('comment')){/*コメントを開いた状態で番組開始を迎えたとき*/ core.closeOpenCommentPane(); } }, {attributes: true}); }, gone: function(){ if(elements.style && elements.style.isConnected) document.head.removeChild(elements.style); html.classList.remove(SCRIPTNAME); }, closeOpenCommentPane: function(){ /* コメントが閉じられたと認識されたら即開き直す準備 */ let observer = observe(elements.commentPane, function(records){ if(elements.commentPane.attributes['aria-hidden'].value === 'false') return; observer.disconnect();/*一度だけ*/ elements.commentButton.click(); elements.commentPane.classList.remove('keep'); canvas.classList.remove('keep'); }, {attributes: true}); /* ユーザーには閉じたように見せない */ canvas.classList.add('keep'); elements.commentPane.classList.add('keep'); elements.commentButton.click(); }, updateStats: function(){ /* mはアベマの仕様に合わせて小文字。しかし小数第1位は0も表示する。 */ let formatNumber = function(number){ switch(true){ case(number < 1e3): return (number); case(number < 1e6): return (number/1e3).toFixed(1) + 'k'; default: return (number/1e6).toFixed(1) + 'm'; } }; let api = site.get.statsApi(); if(!api) return log('Failed: site.get.statsApi.'); let xhr = new XMLHttpRequest(); xhr.open('GET', api); xhr.responseType = 'json'; xhr.onreadystatechange = function(){ if(xhr.readyState !== 4 || xhr.status !== 200) return; if(!xhr.response.stats || !xhr.response.stats.view || !xhr.response.stats.comment) return log(`Not found: stats`); //log('xhr.response:', xhr.response); site.get.view(elements.audience).textContent = formatNumber(xhr.response.stats.view); site.get.comment(elements.commentButton).textContent = formatNumber(xhr.response.stats.comment); }; xhr.send(); }, listenUserActions: function(){ let id, timer = function(e){ clearTimeout(id), id = setTimeout(function(){ if(['input', 'textarea'].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; if(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}); }else{ elements.newCommentsButton.click(); } 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); /* アベマ公式ブロックをコメントそのもののクリックでトグルに差し替える */ window.addEventListener('click', function(e){ if(!e.isTrusted) return; let comment; for(let target = e.target; target; target = target.parentNode){ if(target.localName === 'form') return;/*アベマ公式ブロックフォーム*/ if(target === elements.board) return; if(target === document.body) return; if(target.dataset.selector === 'comment'){ comment = target; break; } } if(!comment) return; let cancel = site.get.commentBlockCancel(comment); if(!cancel){ /* ブロックフォームを開く */ site.get.commentBlock(comment).click(); comment.style.transition = 'background 500ms ease'; comment.classList.add('blockform'); let observer = observe(comment, function(records){ if(site.get.commentBlockCancel(comment)) return; comment.classList.remove('blockform'); observer.disconnect(); }); }else{ /* ブロックフォームを閉じる */ comment.classList.remove('blockform'); comment.addEventListener('transitionend', function(e){ comment.style.transition = 'none'; cancel.click(); }); } }, true); /* 番組開始のタイミングを挟んだバックグラウンドからの復帰でコメント取得が停止する現象を防ぐ */ document.addEventListener('visibilitychange', function(e){ if(document.hidden) return; if(elements.programName && elements.programName.textContent === '') return;/*CM中はクリックしない*/ if(html.classList.contains('comment')){ core.closeOpenCommentPane(); } }); /* コメントペインを隠す設定でもコメント入力中は表示させる */ if(configs.l_hide){ window.addEventListener('focusin', function(e){ if(e.target.form && e.target.form.dataset.selector === 'commentForm') elements.commentPane.classList.add('active'); }); window.addEventListener('focusout', function(e){ if(e.target.form && e.target.form.dataset.selector === 'commentForm') elements.commentPane.classList.remove('active'); }); } /* コメントペインの開閉でcanvasサイズを再計算 */ observe(html, function(records){ if(!configs.l_overlay) core.modify(); }, {attributes: true}); }, createCanvas: function(){ if(canvas && canvas.isConnected) elements.screen.removeChild(canvas); /* コメントcanvasたちを格納する親 */ 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 ${FONT}`; context.textBaseline = 'alphabetic'; context.fillStyle = 'white'; context.strokeStyle = 'black'; context.lineWidth = Math.round(canvas.fontsize * configs.owidth); context.lineJoin = 'round'; canvas.topDelta = ((canvas.fontsize * configs.linemargin) - context.lineWidth - (canvas.fontsize * MARGIN)) / 2;/*canvasのtop計算に使用する*/ }, 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, record; record = records[i]; i++){ switch(true){ /* 新着コメント集 */ case(record.addedNodes.length === 1 && site.addedNode.newComments(record.addedNodes[0]) !== null): core.receiveNewComments(elements.newComments); observe(elements.newComments.firstElementChild, function(records){ for(let j = 0, record; record = records[j]; j++){ switch(true){ /* 新着単一コメント */ case (record.addedNodes.length === 1 && site.addedNode.newComment(record.addedNodes[0]) !== null): break; } } core.receiveNewComments(elements.newComments); }); break; /* 差し替え単一コメント(newComments内のcommentたちがごっそり新しいNodeに差し替えられてしまうアベマの悲しい仕様) */ case(record.addedNodes.length === 1 && site.addedNode.comment(record.addedNodes[0]) !== null): core.ng.filter(record.addedNodes[0]);/*NGフィルタの再適用*/ replacedComments.push(record.addedNodes[0]); /* 開いていたアベマ公式ブロックフォームを再現する */ if(site.get.commentBlockCancel(record.addedNodes[0])){ animate(function(){ record.addedNodes[0].classList.add('blockform'); }); let observer = observe(record.addedNodes[0], function(records){ if(site.get.commentBlockCancel(record.addedNodes[0])) return; record.addedNodes[0].classList.remove('blockform'); observer.disconnect(); }); } break; /* 差し替えられたNodeの状態を再現する */ case(record.removedNodes.length === 1 && site.removedNode.newComments(record.removedNodes[0]) !== null): /* 開いていたNG登録フォーム */ if(elements.ngForm && elements.ngForm.parentNode.parentNode.parentNode === record.removedNodes[0]){ ngFormIndex = Array.from(site.get.comments(record.removedNodes[0])).indexOf(elements.ngForm.parentNode); } /* 選択していたテキスト(対応しない) */ break; /* コメント数がアベマ公式の仕様値に達して削除される(スクロールしなくなるアベマ公式のバグを回避するためのトリガとする) */ case(record.removedNodes.length === 1 && site.removedNode.comment(record.removedNodes[0]) !== null): return core.closeOpenCommentPane();/*1度だけ*/ } } if(ngFormIndex !== null) replacedComments[ngFormIndex].appendChild(elements.ngForm); }); }, receiveNewComments: function(newComments){ 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; } }; /* 取得したコメントが累計500を超えるとスクロールしなくなるアベマのバグに応急的に対応 */ /* (ただしCM中に500になるとCM明けまでコメントが取得できなくなる) */ const MAXLENGTH = 500, DURATION = 500; //log(site.get.comments(newComments).length, elements.board.children.length); if(MAXLENGTH <= site.get.comments(newComments).length + elements.board.children.length) setTimeout(core.closeOpenCommentPane, DURATION); /* コメントの取得間隔を計測する(AINTERVAL仕様の変更に備える) */ let now = Date.now(), commentInterval = (now - parseInt(newComments.dataset.received)) / 1000 || AINTERVAL; newComments.dataset.received = now;/*datasetを使うことでnewCommentsがなくなるときはいっしょになくなる*/ /* コメント表示中に停止してしまう視聴数とコメント数をこのタイミングで更新する */ if(!elements.commentButton.statsUpdated && elements.programName.textContent !== ''/*CM中は更新しない*/){ elements.commentButton.statsUpdated = true; core.updateStats(); setTimeout(function(){elements.commentButton.statsUpdated = false}, STATSUPDATE); } /* NGコメントをすぐ判定する */ core.ng.expire(); let filteredComments = Array.from(site.get.comments(newComments)).filter(core.ng.filter); /* コメントの再取得で重複コメントが流れるのを回避する(NG判定をすませた後で) */ let latest = parseInt(elements.board.dataset.latest); if(latest) filteredComments = filteredComments.filter(function(comment){ return latest < parseInt(site.get.timeStamp(comment)); }); elements.board.dataset.latest = site.get.latestTimeStamp(newComments); /* バックグラウンドならここで終了 */ if(document.hidden) return; /* スライドダウンアニメーションを上書きする */ core.slideDownNewComments(newComments); /* 投稿経過時間に合わせた自然なばらつきでコメントを流すためのスケジュールを作る */ let schedule = [];/*タイミングだけを格納する配列*/ for(let i = 0; filteredComments[i]; i++){ schedule.push(getDelay(site.get.commentTime(filteredComments[i]))); } 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(site.get.commentText(filteredComments[i])); }, 1000 * schedule[i]); } }, slideDownNewComments: function(newComments){ const duration = '500ms', easing = 'cubic-bezier(.215,.61,.355,1)';/*アベマ公式の挙動を尊重する*/ newComments.style.maxHeight = newComments.style.minHeight = '0px';/*heightの上書き戦争を避けてmaxHeight/minHeightが使えるのは幸運*/ animate(function(){ let naturalHeight = getComputedStyle(newComments.firstElementChild).height; newComments.style.transition = 'none'; newComments.firstElementChild.style.transition = 'none'; newComments.firstElementChild.style.transform = `translateY(-${naturalHeight})`; animate(function(){ newComments.style.transition = `max-height ${duration} ${easing}, min-height ${duration} ${easing}`; newComments.firstElementChild.style.transition = `transform ${duration} ${easing}`; animate(function(){ newComments.style.maxHeight = newComments.style.minHeight = naturalHeight; newComments.firstElementChild.style.transform = `translateY(0)`; }); }); }); }, attachComment: function(text){ if(canvas.children.length >= configs.maxcomments) return; /* 単一スクロールコメントcanvasを用意する */ let scrollComment, c; let width = Math.round(context.measureText(text).width + context.lineWidth); let height = Math.round(canvas.fontsize * (1 + MARGIN) + context.lineWidth); scrollComment = createElement(core.html.scrollComment(width, height)); c = scrollComment.getContext('2d'); c.font = context.font; c.textBaseline = context.textBaseline; c.fillStyle = context.fillStyle; c.strokeStyle = context.strokeStyle; c.lineWidth = context.lineWidth; c.lineJoin = context.lineJoin; let left = Math.round(context.lineWidth/2); let top = Math.round((canvas.fontsize * MARGIN + context.lineWidth)/2 + canvas.fontsize * BASELINE); c.strokeText(text, left, top); c.fillText(text, left, top); /* コメント位置データをまとめる */ 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);/*終了時刻*/ /* 追加されたコメントをどの行に流すかを決定する */ 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(DEBUG) scrollComment.dataset.former = JSON.stringify(lines[i][length - 1]); //if(DEBUG) scrollComment.dataset.self = JSON.stringify(record); lines[i].push(record); scrollComment.style.top = record.top + 'px'; canvas.appendChild(scrollComment); animate(function(){ scrollComment.style.transform = `translateX(-${canvas.width + width}px)`; scrollComment.addEventListener('transitionend', function(e){ canvas.removeChild(scrollComment); lines[i].shift(); }, {once: true}); }); return;/*行に追加したら終了*/ default: continue;/*条件に当てはまらなければforループを回して次の行に入れられるかの判定へ*/ } } }, 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.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){ for(let target = e.target; target.dataset; target = target.parentNode) if(target.dataset.selector === 'comment'){ 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}); return; } }); }, 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 append = function(comment, ngForm){ comment.insertBefore(ngForm, comment.firstElementChild.nextElementSibling);/*公式ブロックフォームが最後尾にある*/ }; 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; append(elements.ngForm.targetComment, elements.ngForm); slideDown(); }, {once: true}); }else{ elements.ngForm.slidingUp = false; append(elements.ngForm.targetComment, elements.ngForm); slideDown(); } }); }; let slideDown = function(){ elements.ngForm.slidingDown = true; if(elements.ngForm.parentNode !== elements.ngForm.targetComment) append(elements.ngForm.targetComment, 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*/ /* input */ let lis = elements.ngList.querySelectorAll('ul > li.edit'); for(let i = 0, li; li = lis[i]; i++){ let word = li.querySelector('p.word input'); let checked = li.querySelector('p.type input:checked'); let match = word.value.match(/^\/(.+)\/([a-z]+)?$/); new_ngwords[i] = {}; new_ngwords[i].original = word.value; 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(li.dataset.added) || null; new_ngwords[i].limit = (checked.value === 'for24h') ? parseInt(li.dataset.limit) : null; } /* textarea */ 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].original = lines[i]; 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.original < b.original); 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_ngword; new_ngword = 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_ngword.original || new_ngword.value/*移行用*/; if(new_ngword.type) li.querySelector(`p.type input[value="${new_ngword.type}"]`).checked = true; li.dataset.added = new_ngword.added || 0; li.dataset.limit = new_ngword.limit || 0; let for24h = li.querySelector('p.type label.for24h'); for24h.textContent = (new_ngword.limit) ? formatTime(new_ngword.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_ngword.limit; for24h.textContent = formatTime(new_ngword.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].original = word.value; 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; } Storage.save('ngwords', ngwords); }, read: function(){ /* 保存済みの設定を読む */ ngwords = Storage.read('ngwords') || []; /* 正規表現(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; Storage.save('ngwords', 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, ngword; ngword = ngwords[i]; i++){ switch(ngword.type){ case('forever'): case('for24h'): if(match(comment, ngword)){ comment.classList.add('ng-deleted'); return false; } break; case('trial'): if(match(comment, ngword)){ comment.classList.add('ng-trial'); comment.dataset.ngword = ngword.value; comment.addEventListener('click', function(e){ if(e.target === comment && window.getSelection().isCollapsed) core.ng.toggleForm(comment, e); }); } break; } } return true; }, }, config: { read: function(){ /* 保存済みの設定を読む */ configs = Storage.read('configs') || {}; /* 未定義項目をデフォルト値で上書きしていく */ Object.keys(CONFIGS).forEach((key) => {if(configs[key] === undefined) configs[key] = CONFIGS[key].DEFAULT}); }, save: function(new_config){ configs = {};/*CONFIGSに含まれた設定値のみ保存する*/ /* CONFIGSを元に文字列を型評価して値を格納していく */ Object.keys(CONFIGS).forEach((key) => { /* 値がなければデフォルト値 */ if(new_config[key] === "") return configs[key] = CONFIGS[key].DEFAULT; switch(CONFIGS[key].TYPE){ case 'bool': configs[key] = (new_config[key]) ? 1 : 0; break; case 'int': configs[key] = parseInt(new_config[key]); break; case 'float': configs[key] = parseFloat(new_config[key]); break; default: configs[key] = new_config[key]; break; } }); Storage.save('configs', 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('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, input; input = inputs[i]; i++){ switch(CONFIGS[input.name].TYPE){ case('bool'): new_configs[input.name] = (input.checked) ? 1 : 0; break; case('object'): if(!new_configs[input.name]) new_configs[input.name] = {}; new_configs[input.name][input.value] = (input.checked) ? 1 : 0; break; default: new_configs[input.name] = input.value; break; } } core.config.save(new_configs); core.panel.close('configPanel') /* 新しい設定値で再スタイリング */ core.addStyle(); core.createCanvas();/*modify含む*/ }, 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'); }); elements.panels.listeningKeypress = elements.panels.listeningKeypress || []; if(!elements.panels.listeningKeypress[key]){ elements.panels.listeningKeypress[key] = true; window.addEventListener('keypress', function(e){ if(['input', 'textarea'].includes(document.activeElement.localName)) return; if(elements[key] && e.key === 'Escape') core.panel.close(key); }); } }, close: function(key){ elements[key].classList.add('hidden'); elements[key].addEventListener('transitionend', function(e){ if(!elements[key]) return; 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 && elements.style.isConnected) document.head.removeChild(elements.style); elements.style = style; }, html: { canvasDiv: () => `
`, preCanvas: () => ` `, scrollComment: (width, height) => ` `, ngButton: () => ` `, ngForm: () => `

NGワード登録

`, ngList: () => `

登録NGワード一覧

`, ngHelp: () => `

NGワードについて

登録したワードを含むコメントを削除します。

お試しの場合はコメント一覧でハイライトされます。

右下の一覧ボタンやコメントのテキスト選択から登録できます。

英数字と記号とカタカナは全角半角や大文字小文字を区別しません。

下記のような正規表現も使えます。

「NGです」を消す登録例:

NG
通常のNGワード
/^NG/
前方一致
/です$/
後方一致
/^NGです$/
完全一致

そのほかの例:

/^.$/
1文字だけのコメント
/.{30}/
30文字以上のコメント
/^[a-z]+$/i
アルファベットだけのコメント
/[0-9]{3}/
3桁以上の数字を含むコメント

`, configButton: () => ` `, configPanel: () => `

${SCRIPTNAME}設定

スクロールコメント

一覧コメント

アベマのナビゲーション

`, panels: () => `
`, style: () => ` `, }, }; class Storage{ static key(key){ return (SCRIPTNAME) ? (SCRIPTNAME + '-' + key) : key; } static save(key, value, expire = null){ key = Storage.key(key); localStorage[key] = JSON.stringify({ expire: expire, value: value, }); } static read(key){ key = Storage.key(key); if(localStorage[key] === undefined) return undefined; let data = JSON.parse(localStorage[key]); if(data.value === undefined) return data; if(data.expire === undefined) return data; if(data.expire === null) return data.value; if(data.expire < Date.now()) return localStorage.removeItem(key); return data.value; } } 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, options = {childList: true, attributes: false, characterData: false}){ let observer = new MutationObserver(callback.bind(element)); observer.observe(element, options); 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]; }).replace(/ /g, ' ').replace(/~/g, '〜'); }; 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(/^([^/<]+(?= ' : '') + /* caller */ (callers[1] || '') + '()', ...arguments ); }; core.initialize(); if(window === top && console.timeEnd) console.timeEnd(SCRIPTNAME); })();