您需要先安装一个扩展,例如 篡改猴、Greasemonkey 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 Userscripts ,之后才能安装此脚本。
您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey,才能安装此脚本。
您需要先安装用户脚本管理器扩展后才能安装此脚本。
NG id and post on komica
// ==UserScript== // @name Komica NGID // @description NG id and post on komica // @namespace https://github.com/usausausausak // @match https://*.komica1.org/*/* // @match https://2cha.org/*/* // @match http://gzone-anime.info/UnitedSites/* // @version 2.4.2 // @grant GM.setValue // @grant GM.getValue // @grant GM.addStyle // ==/UserScript== const Komica = {}; (function komicaHostMatcher(exports) { 'use strict' const MATCHER = [ { name: 'komica', matcher: /^([^\.]*\.)?komica[12]?\.(org|net|cc)$/ }, { name: 'komica', matcher: /^2cha\.org$/ }, { name: '2cat', matcher: /^2cat\.tk$/ }, { name: 'gzone-anime', matcher: /^gzone-anime\.info$/ }, ]; function hostMatcher(location) { const host = location.host.replace(/:\d+$/, ''); for (const { name, matcher } of MATCHER) { if (matcher.test(host)) { return name; } } return null; } function hostMatcherOr(location, err) { const host = hostMatcher(location); return (host) ? host : err; } Object.entries({ hostMatcher, hostMatcherOr, }).forEach(([key, fn]) => { exports[key] = fn; }); })(Komica); (function komicaPostQueryer(exports) { 'use strict' const POST_NO_FROM_POST_EL_ID_REGEXP = /^r(\d+)$/; const ID_FROM_NOWID_TEXT_REGEXP = /ID:([^\s]+)(?:\].*)?/; function idFromNowIdText(nowIdText) { const matches = ID_FROM_NOWID_TEXT_REGEXP.exec(nowIdText); return (matches) ? matches[1] : null; } function idWithTailFromNowIdText(nowIdText) { const matches = ID_FROM_NOWID_TEXT_REGEXP.exec(nowIdText); if (!matches) { return null; } else { // Maybe has a id tail code, but we just ignore that. return matches[1].substr(0, 8); } } const QUERYERS_KOMICA = { queryThreads: function queryThreadsKomica() { return document.getElementsByClassName('thread'); }, queryPosts: function queryPostsKomica() { return document.getElementsByClassName('post'); }, queryNo: function queryNoKomica(post) { if (post.dataset) { return post.dataset.no; } else { return null; } }, queryId: function queryIdKomica(post) { const idEl = post.querySelector('.post-head .id'); if (idEl) { return idEl.dataset.id; } else { const nowEl = post.querySelector('.post-head .now'); if (nowEl) { return idFromNowIdText(nowEl.innerHTML); } else { return null; } } }, queryThreadTitle: function queryThreadTitleKomica(post) { const titleEl = post.querySelector('span.title'); if (titleEl) { return titleEl.innerText; } else { return null; } }, queryName: function queryNameKomica(post) { const nameEl = post.querySelector('span.name'); if (nameEl) { return nameEl.innerText; } else { return null; } }, queryBody: function queryBodyKomica(post) { const bodyEl = post.querySelector('.quote'); if (bodyEl) { return bodyEl.innerText; } else { return null; } }, isThreadPost: function isThreadPostKomica(post) { return ((post.classList) && (post.classList.contains('threadpost'))); }, isReplyPost: function isReplyPostKomica(post) { return ((post.classList) && (post.classList.contains('reply'))); }, postNoEl: function postNoElKomica(post) { return post.querySelector('.post-head [data-no]'); }, }; const QUERYERS_2CAT = { queryThreads: function queryThreads2Cat() { return document.getElementsByClassName('threadpost'); }, queryPosts: function queryPosts2Cat() { return document.querySelectorAll('.threadpost, .reply'); }, queryNo: function queryNo2Cat(post) { const matches = POST_NO_FROM_POST_EL_ID_REGEXP.exec(post.id); if (matches) { return matches[1]; } else { return null; } }, queryId: function queryId2Cat(post) { const postHeadEl = post.querySelector('div:first-child label'); if (postHeadEl) { return idWithTailFromNowIdText(postHeadEl.innerText); } else { return null; } }, queryThreadTitle: function queryThreadTitle2Cat(post) { const titleEl = post.querySelector('span.title'); if (titleEl) { return titleEl.innerText; } else { return null; } }, queryName: function queryName2Cat(post) { const nameEl = post.querySelector('span.name'); if (nameEl) { return nameEl.innerText; } else { return null; } }, queryBody: function queryBody2Cat(post) { const bodyEl = post.querySelector('div:first-child .quote'); if (bodyEl) { return bodyEl.innerText; } else { return null; } }, isThreadPost: function isThreadPost2Cat(post) { return ((post.classList) && (post.classList.contains('threadpost'))); }, isReplyPost: function isReplyPost2Cat(post) { return ((post.classList) && (post.classList.contains('reply'))); }, postNoEl: function postNoEl2Cat(post) { return post.querySelector('div:first-child .qlink'); }, }; const QUERYERS_GZONE_ANIME = { ...QUERYERS_2CAT, queryId: function queryIdGzoneAnime(post) { const postHeadEl = post.querySelector('span.name').nextSibling; if ((postHeadEl) && (postHeadEl.nodeType === 3)) { return idFromNowIdText(postHeadEl.nodeValue); } else { return null; } }, queryBody: function queryBodyGzoneAnime(post) { const bodyEl = post.querySelector('div:first-child .quote'); if (bodyEl) { const body = bodyEl.innerText; const pushPostEl = bodyEl.querySelector('.pushpost'); if (pushPostEl) { return body.substr(0, body.length - pushPostEl.innerText.length); } else { return body; } } else { return null; } }, }; const NULL_QUERYER = { queryThreads: function queryThreadsNull() { return []; }, queryPosts: function queryPostsNull() { return []; }, queryNo: function queryNoNull(post) { return null; }, queryId: function queryIdNull(post) { return null; }, queryThreadTitle: function queryThreadTitleNull(post) { return null; }, queryName: function queryNameNull(post) { return null; }, queryBody: function queryBodyNull(post) { return null; }, isThreadPost: function isThreadPostNull(post) { return false; }, isReplyPost: function isReplyPostNull(post) { return false; }, postNoEl: function postNoElNull(post) { return null; }, }; const MAPPER = { 'komica': QUERYERS_KOMICA, '2cat': QUERYERS_2CAT, 'gzone-anime': QUERYERS_GZONE_ANIME, }; function postQueryer(host) { const ret = (MAPPER[host]) ? MAPPER[host] : NULL_QUERYER; return Object.assign({}, ret); } exports.postQueryer = postQueryer; })(Komica); (function komicaDialog(exports) { 'use strict' const TAG = '[Komica_Dialog]'; function insertDialog(name, id, namespace) { // WORKAROUND: GM4 double insert if (document.querySelector(`#${id}`)) { return; } const tabBox = createTabBox(namespace); function toggleDialog() { dialog.classList.toggle(`${namespace}-dialog-show`); if (dialog.classList.contains(`${namespace}-dialog-show`)) { tabBox.currentSelected = 0; } } const dialog = document.createElement('div'); dialog.id = id; dialog.className = `${namespace}-dialog`; tabBox.appendTo(dialog); const footer = document.createElement('div'); footer.className = `${namespace}-dialog-footer`; dialog.appendChild(footer); const closeBut = document.createElement('button'); closeBut.className = `${namespace}-dialog-close-button`; closeBut.innerHTML = '閉じる'; closeBut.addEventListener('click', toggleDialog, false); dialog.appendChild(closeBut); document.body.insertBefore(dialog, document.body.firstChild); // Insert toggle button to top links area. const toggleButton = document.createElement('a'); toggleButton.className = 'text-button'; toggleButton.innerHTML = name; toggleButton.addEventListener('click', toggleDialog, false); const anchor = document.querySelector('#toplink a:last-of-type'); const parent = anchor.parentElement; const insertPoint = anchor.nextSibling; parent.insertBefore(document.createTextNode('] ['), insertPoint); parent.insertBefore(toggleButton, insertPoint); return { tabBox, footer }; } function createTabBox(namespace) { const eventListener = { onswitch: [] }; function addEventListener(name, cb) { if (!eventListener[name]) { // ignore unknown event return; } if (typeof cb === 'function') { eventListener[name].push(cb); } else { console.warn(TAG, 'event listener not a function'); } } function emitEvent(name, ...args) { try { eventListener[name].forEach(cb => cb(...args)); } catch (e) { console.error(TAG, e); } } const tabBox = document.createElement('div'); tabBox.className = `${namespace}-tabbox-header`; const pageBox = document.createElement('div'); pageBox.className = `${namespace}-tabbox-container`; const groups = new Map(); const pageInfos = []; let currentSelected = -1; function addPage(title = null, groupTitle = null) { const index = pageInfos.length; const page = document.createElement('div'); page.className = `${namespace}-tabbox-page`; pageBox.appendChild(page); function getOrAddGroup(groupTitle) { let group = groups.get(groupTitle); if (!group) { const header = document.createElement('div'); header.className = `${namespace}-tabbox-group-title`; header.innerHTML = groupTitle; tabBox.appendChild(header); group = document.createElement('div'); group.className = `${namespace}-tabbox-group`; tabBox.appendChild(group); groups.set(groupTitle, group); } return group; } function addTab(title, parent) { const tab = document.createElement('div'); tab.className = `${namespace}-tabbox-tab`; tab.innerHTML = title; tab.addEventListener('click', () => switchTab(index), false); parent.appendChild(tab); return tab; } const group = (groupTitle == null) ? null : getOrAddGroup(groupTitle); const tab = (title == null) ? null : addTab(title, group ?? tabBox); const newInfo = { index, page, tab, group }; pageInfos.push(newInfo); return newInfo; } function getPage(index) { if ((index < 0) || (index >= pageInfos.length)) { console.error(TAG, `invalid tab index: ${index}`); return null; } return pageInfos[index].page; } function switchTab(index) { if ((index < 0) || (index >= pageInfos.length)) { console.error(TAG, `invalid tab index: ${index}`); return; } else if (currentSelected == index) { return; } const prevIndex = currentSelected; const { page, tab } = pageInfos[index]; // emit before show to make time to render currentSelected = index; emitEvent('onswitch', index, page); // hide current tab if (prevIndex >= 0) { // hide current tab const { page, tab } = pageInfos[prevIndex]; if (tab) { tab.classList.remove(`${namespace}-tabbox-selected`); } page.classList.remove(`${namespace}-tabbox-selected`); } if (tab) { tab.classList.add(`${namespace}-tabbox-selected`); } page.classList.add(`${namespace}-tabbox-selected`); } function getCurrentPage() { if ((currentSelected < 0) || (currentSelected >= pageInfos.length)) { return null; } else { return pageInfos[currentSelected].page; } } return { get currentSelected() { return currentSelected; }, set currentSelected(index) { switchTab(index); }, getCurrentPage, addPage, getPage, appendTo(parent) { parent.appendChild(tabBox); parent.appendChild(pageBox); }, on(eventName, cb) { addEventListener(`on${eventName}`, cb); }, }; } exports.insertDialog = insertDialog; })(Komica); // from https://greasemonkey.github.io/gm4-polyfill/gm4-polyfill.js if (typeof GM == 'undefined') { this.GM = {}; } if (typeof GM_addStyle == 'undefined') { this.GM_addStyle = (aCss) => { 'use strict'; let head = document.getElementsByTagName('head')[0]; if (head) { let style = document.createElement('style'); style.setAttribute('type', 'text/css'); style.textContent = aCss; head.appendChild(style); return style; } return null; }; } if (typeof GM['addStyle'] == 'undefined') { GM['addStyle'] = function(...args) { return new Promise((resolve, reject) => { try { resolve(GM_addStyle.apply(this, args)); } catch (e) { reject(e); } }); }; } (async function () { "use strict"; const TAG = '[Komica_NGID]'; const DEFAULT_STLYE_VARS = ` :root { --ngid-primary-background-color: #FFFFEE; --ngid-secondary-background-color: #F0E0D6; --ngid-highlight-background-color: #EEAA88; --ngid-highlight-color: #800000; --ngid-text-button-color: #00E; --ngid-text-button-hover-color: #D00; --ngid-separator-color: #000; --ngid-primary-shadow-color: #5f5059; --ngid-warning-color: #D00; } `; const GLOBAL_STYLE = ` .ngid-destroy { display: none; } .ngid-transparent-ng { display: none; } .ngid-ngpost { opacity: 0.3; } .ngid-text-button { cursor: pointer; color: var(--ngid-text-button-color); } .ngid-text-button:hover { color: var(--ngid-text-button-hover-color); } .ngid-context-menu { display: inline-flex; flex-direction: column; visibility: hidden; position: absolute; padding: 5px 10px; border-radius: 5px; margin-top: calc(-1.7em - 10px); transition: margin 100ms; width: max-content; background-color: var(--ngid-highlight-background-color); } .ngid-context:hover .ngid-context-menu { visibility: visible; margin-top: -1.7em;; } .ngid-ngpost .ngid-context-menu { color: var(--ngid-warning-color); } .popup_area .ngid-context { display: none; } .ngid-context { cursor: pointer; display: inline-block; } .ngid-context summary { list-style: none; } .ngid-context summary::-webkit-details-marker { display: none; } .ngid-context-menu-close-button { text-align: center; display: none; } @media screen and (max-device-width: 600px) { .ngid-context-menu { visibility: visible; margin: -1.7em 6px 0 6px; width: calc(100% - 32px); left: 0; } .ngid-context-menu-close-button { display: unset; align-self: center; } } `; const DIALOG_STYLE = ` .ngid-dialog { visibility: hidden; position: fixed; top: -10px; z-index: 1; opacity: 0; display: grid; grid-template: "h c c" auto "f f b" min-content / min-content auto; width: 40%; height: 50%; margin: 0 30%; overflow: hidden; border-radius: 5px; box-shadow: 0 0 15px 5px var(--ngid-primary-shadow-color); background-color: var(--ngid-primary-background-color); transition: top 100ms, visibility 100ms, opacity 100ms; } .ngid-dialog-show { visibility: visible; opacity: 1; top: 30px; } .ngid-dialog-footer { grid-area: f; place-self: center end; margin: 10px 20px; } .ngid-dialog-close-button { place-self: center end; margin: 10px 20px; } .ngid-tabbox-header { grid-area: h; display: flex; flex-direction: column; justify-content: start; padding: 3px 6px; background-color: var(--ngid-secondary-background-color); } .ngid-tabbox-group { display: flex; flex-direction: column; margin-bottom: 12px; } .ngid-tabbox-group-title { cursor: pointer; font-weight: bold; } .ngid-tabbox-tab { cursor: pointer; flex: 1; padding: 3px 12px; } .ngid-tabbox-tab:hover { background-color: var(--ngid-highlight-background-color); color: var(--ngid-highlight-color); } .ngid-tabbox-tab.ngid-tabbox-selected { background-color: var(--ngid-highlight-background-color); color: var(--ngid-highlight-color); } .ngid-tabbox-container { grid-area: c; display: flex; overflow-y: auto; } .ngid-tabbox-page { width: 0; opacity: 0; overflow-y: scroll; overflow-x: hidden; transition: opacity 200ms; } .ngid-tabbox-page.ngid-tabbox-selected { width: 100%; opacity: 1; padding: 0 10px; } .ngid-listitem { cursor: pointer; display: flex; justify-content: space-between; padding: 5px 10px; margin: 2px 0; } .ngid-listitem:hover { background-color: var(--ngid-highlight-background-color); color: var(--ngid-highlight-color); } .ngid-inputfield { display: flex; justify-content: center; padding: 7px 5px; border-bottom: 1px solid var(--ngid-separator-color); } .ngid-inputfield input { flex: 1; } .ngid-lineedit-button { margin-left: 10px; } .ngid-lineedit-saveview { display: flex; justify-content: space-between; padding: 7px 5px; } .ngid-lineedit-textview { flex: 1; } .ngid-listitem span { max-width: 90%; overflow-wrap: break-word; } @media screen and (max-device-width: 600px) { .ngid-dialog { width: calc(100vw - 20px); margin: 0 10px; grid-template: "h h" min-content "c c" auto "f b" min-content / auto max-content; } .ngid-tabbox-container { width: calc(100vw - 20px); } .ngid-tabbox-group { flex-direction: row; flex-wrap: wrap; width: calc(90vw); } } `; // We need diffence style at diffence host. const POLYFILL_STYLE = ` #toplink .text-button { cursor: pointer; color: var(--ngid-text-button-color); text-decoration: underline; } #toplink .text-button:hover { color: var(--ngid-text-button-hover-color); } .ngid-context { cursor: pointer; color: var(--ngid-text-button-color); margin-left: 0.2em; /* Nice try! */ } .ngid-context .text-button:hover { color: var(--ngid-text-button-hover-color); } `; const HOST_SETTINGS = { 'komica': { hostStyle: ` /* * All reply posts of the NGed thread post also be NGed. */ .ngid-ngthread > .reply { display: none !important; /* override hidePoliticalPosts */ } .ngid-ngpost > *:not(.post-head), .ngid-ngpost > .post-head > .title, .ngid-ngpost > .post-head > .name { display: none; } .ngid-ngimage > .file-text, .ngid-ngimage > .file-thumb { display: none; } `, darkStyleVars: ` :root { --ngid-primary-background-color: #1D1F21; --ngid-secondary-background-color: rgb(40, 42, 46); --ngid-highlight-background-color: rgb(0, 0, 0); --ngid-highlight-color: rgb(178, 148, 187); --ngid-text-button-color: #81A2BE; --ngid-text-button-hover-color: #FFC685; --ngid-separator-color: gray; --ngid-primary-shadow-color: rgb(40, 42, 46); --ngid-warning-color: #D00; } `, getStyleVars: function () { const [themeCookie] = document.cookie.split(/;\s*/) .map(c => c.split(/=/,2)) .filter(([k, v]) => k == 'theme'); if ((themeCookie) && (themeCookie[1] == 'dark.css')) { return this.darkStyleVars; } else { return DEFAULT_STLYE_VARS; } }, stylePolyfill: false, nonStructuredLayout: false, }, '2cat': { hostStyle: ` .ngid-context-menu { background-color: #AAEEAA; } /* * Since we can't hide the text node, just leave them out. */ .ngid-ngpost .quote, .ngid-ngpost .title, .ngid-ngpost .name, .ngid-ngpost .warn_txt2, .threadpost.ngid-ngpost > div > a:not(:last-of-type), .reply.ngid-ngpost > div > a:not(:first-of-type) { display: none; } .threadpost.ngid-ngimage > div > a:not(:last-of-type), .reply.ngid-ngimage > div > a:not(:first-of-type) { display: none; } .ngid-ngpost > div > a.qlink, .ngid-ngimage > div > a.qlink { display: unset; } `, stylePolyfill: true, nonStructuredLayout: true, }, 'gzone-anime': { hostStyle: ` .ngid-ngpost .quote, .ngid-ngpost .title, .ngid-ngpost .name, .ngid-ngpost .warn_txt2, .threadpost.ngid-ngpost > a:not(:last-of-type), .reply.ngid-ngpost > div > a:not(:first-of-type) { display: none; } .threadpost.ngid-ngimage > a:not(:last-of-type), .reply.ngid-ngimage > div > a:not(:first-of-type) { display: none; } .ngid-ngpost a.qlink, .ngid-ngimage a.qlink { display: unset; } `, stylePolyfill: true, nonStructuredLayout: true, }, }; const hostId = Komica.hostMatcherOr(document.location, 'unknown'); console.debug(TAG, `We are at the board of host '${hostId}'.`); const queryer = Komica.postQueryer(hostId); const hostSettings = HOST_SETTINGS[hostId]; const settings = createSettings(await ngidSettingsInner()); async function ngidSettingsInner() { const tablePrefix = settingsTablePrefix(document.location); function getTableName(key) { return (key.startsWith('global')) ? key : `${tablePrefix}/${key}`; } const settingsInner = { ngIds: [], ngNos: [], ngWords: [], ngImages: [], options: {}, globalNgWords: [], }; for (const key of Object.keys(settingsInner)) { const tableName = getTableName(key); try { const value = JSON.parse(await GM.getValue(tableName, ''), settingsJsonReplacer); settingsInner[key] = value; } catch (e) { console.warn(TAG, `fail at read ${key}`); } if (Array.isArray(settingsInner[key])) { console.info(TAG, `${key} have ${settingsInner[key].length} items.`); } } settingsInner.saveNg = async function settingsSaveNg(key) { const tableName = getTableName(key); try { const jsonStr = JSON.stringify(settingsInner[key]); await GM.setValue(tableName, jsonStr); } catch (e) { console.error(TAG, e); } }; settingsInner.saveOptions = async function settingsSaveOptions() { const tableName = getTableName('options'); try { const jsonStr = JSON.stringify(settingsInner.options); await GM.setValue(tableName, jsonStr); } catch (e) { console.error(TAG, e); } }; return settingsInner; } function settingsTablePrefix(loc) { const boardName = loc.pathname.split(/\//).slice(0, -1).join('/'); return loc.host + boardName; } function settingsJsonReplacer(key, value) { if (key === 'creationTime') { return new Date(value); } else { return value; } } async function ngidAddStyle() { const styleVars = ((hostSettings) && (hostSettings.getStyleVars)) ? hostSettings.getStyleVars() : DEFAULT_STLYE_VARS; await GM.addStyle(styleVars); // Shared style. await GM.addStyle(GLOBAL_STYLE); await GM.addStyle(DIALOG_STYLE); // Host-dependent style. if (hostSettings) { if (hostSettings.stylePolyfill) { await GM.addStyle(POLYFILL_STYLE); } if (hostSettings.hostStyle) { await GM.addStyle(hostSettings.hostStyle); } } } function ngidStart() { insertSettingDialog(settings); // Init all posts' NG state. for (const post of queryer.queryPosts()) { initPostMeta(post); } updateNgState(); // Observing the thread expansion. // TODO: Move reusable code to a independent module. const threadObserver = new MutationObserver(function (records) { const postReplys = records.reduce((total, record) => { for (const node of record.addedNodes) { if (queryer.isReplyPost(node)) { total.push(node); } } return total; } , []); const replySize = postReplys.length; console.log(`Reply size change: ${replySize}`); postReplys.forEach(initPostMeta); updateNgState(); }); for (const thread of queryer.queryThreads()) { threadObserver.observe(thread, { childList: true }); } // Binding with the setting update. function onSettingChangeCb(key) { if ((key == 'ngWords') || (key == 'globalNgWords')) { updateNgWordState(); } updateNgState(); } settings.on('add', onSettingChangeCb); settings.on('remove', onSettingChangeCb); settings.on('clear', onSettingChangeCb); settings.on('swap', onSettingChangeCb); function onOptionChangeCb() { for (const ngPost of document.querySelectorAll('.ngid-ngpost')) { if (settings.options.transparentNg) { ngPost.classList.add('ngid-transparent-ng'); } else { ngPost.classList.remove('ngid-transparent-ng'); } } } settings.on('option', onOptionChangeCb); } const NGID_DESCIPTORS = [ { title: 'NGID', description: '指定したIDのスレ/レスを隠す', key: 'ngIds', prefix: 'ID:', lineEdit: true, replacer(value) { value = value.replace(/^ID:/, ''); return value; }, }, { title: 'NGNo', description: '指定したスレ/レスを隠す', key: 'ngNos', prefix: 'No.', lineEdit: false, replacer(value) { value = value.replace(/^No./, ''); if (value.match(/\D/)) { return ''; } return value; }, }, { title: 'NGWord', description: '指定した文字列を含むスレ/レスを隠す', key: 'ngWords', prefix: '', lineEdit: true, replacer(value) { return value; }, }, { title: 'NGImage', description: '指定したIDのイラストを隠す', key: 'ngImages', prefix: 'ID:', lineEdit: true, replacer(value) { value = value.replace(/^ID:/, ''); return value; }, }, ]; const NGID_OPTIONS = { 'transparentNg': { default: false, title: 'NG対象を透明化する' }, }; const GLOBAL_NGWORD_DESCRIPTOR = { title: 'NGWord', description: 'すべての板に適用するNGWord', key: 'globalNgWords', prefix: '', lineEdit: true, replacer(value) { return value; }, }; function createSettings(settingsInner) { const eventListener = { onadd: [], onremove: [], onclear: [], onswap: [], onoption: [], }; function addEventListener(name, cb) { if (!eventListener[name]) { // ignore unknown event return; } if (typeof cb === 'function') { eventListener[name].push(cb); } else { console.warn(TAG, 'event listener not a function'); } } function emitEvent(name, ...args) { try { eventListener[name].forEach(cb => cb(...args)); } catch (e) { console.error(TAG, e); } } function findNg(key, value) { if (!Array.isArray(settingsInner[key])) { throw new Error('Invalid key'); } return settingsInner[key].find(v => v.value === value); } async function addNg(key, value) { if (!Array.isArray(settingsInner[key])) { throw new Error('Invalid key'); } else if (settingsInner[key].some(v => value === v.value)) { return false; } settingsInner[key].push({ value: value, creationTime: new Date() }); await settingsInner.saveNg(key); emitEvent('onadd', key, value); return true; } async function removeNg(key, value) { if (!Array.isArray(settingsInner[key])) { throw new Error('Invalid key'); } settingsInner[key] = settingsInner[key].filter(v => v.value !== value); await settingsInner.saveNg(key); emitEvent('onremove', key, value); return true; } async function clearNg(key, predicate = null) { if (!Array.isArray(settingsInner[key])) { throw new Error('Invalid key'); } if (typeof predicate === 'function') { settingsInner[key] = settingsInner[key].filter(predicate) } else { settingsInner[key] = []; } await settingsInner.saveNg(key); emitEvent('onclear', key); } // unsafe async function swapNg(key, list) { if (!Array.isArray(settingsInner[key])) { throw new Error('Invalid key'); } const oldList = settingsInner[key]; settingsInner[key] = list; await settingsInner.saveNg(key); emitEvent('onswap', key); return oldList; } async function saveOptions() { await settingsInner.saveOptions(); emitEvent('onoption'); } return { get ngIds() { return settingsInner.ngIds.map(v => v.value); }, get ngNos() { return settingsInner.ngNos.map(v => v.value); }, get ngWords() { return settingsInner.ngWords.map(v => v.value); }, get ngImages() { return settingsInner.ngImages.map(v => v.value); }, get globalNgWords() { return settingsInner.globalNgWords.map(v => v.value); }, findNg, addNg, removeNg, clearNg, swapNg, get options() { return settingsInner.options; }, saveOptions, on(eventName, cb) { addEventListener(`on${eventName}`, cb); }, }; } function insertSettingDialog(settings) { const { tabBox, footer } = Komica.insertDialog('NGID', 'ngid-settings-dialog', 'ngid'); const localGroupTitle = 'この板'; NGID_DESCIPTORS.forEach(({ title }) => tabBox.addPage(title, localGroupTitle)); const optionsPageInfo = tabBox.addPage('⚙ 設定', localGroupTitle); const globalGroupTitle = '共通'; const globalNgWordPageInfo = tabBox.addPage('NGWord', globalGroupTitle); function switchTab(pageIdx, root) { switch (pageIdx) { case optionsPageInfo.index: renderOptions(root); break; case globalNgWordPageInfo.index: renderList(root, GLOBAL_NGWORD_DESCRIPTOR); break; default: if ((pageIdx >= 0) && (pageIdx < NGID_DESCIPTORS.length)) { renderList(root, NGID_DESCIPTORS[pageIdx]); } break; } } tabBox.on('switch', switchTab); function getCurrentPageDescriptor() { const currentSelected = tabBox.currentSelected; switch (currentSelected) { case globalNgWordPageInfo.index: return GLOBAL_NGWORD_DESCRIPTOR; default: if ((currentSelected >= 0) && (currentSelected < NGID_DESCIPTORS.length)) { return NGID_DESCIPTORS[currentSelected]; } else { return null; } } } function createListitem(value, prefix = '') { const view = document.createElement('div'); view.className = 'ngid-listitem'; const dataBlock = document.createElement('span'); dataBlock.innerHTML = `${prefix}${value}`; view.appendChild(dataBlock); const delButton = document.createElement('span'); delButton.className = 'ngid-text-button'; delButton.innerHTML = '削除'; delButton.dataset.value = value; delButton.addEventListener('click', removeItemCb, false); view.appendChild(delButton); return view; } async function removeItemCb(ev) { const pageDesciptor = getCurrentPageDescriptor(); if (pageDesciptor === null) { return; } const button = ev.target; await settings.removeNg(pageDesciptor.key, button.dataset.value); } function createInputField(placeholder, replacer) { const view = document.createElement('div'); view.className = 'ngid-inputfield'; const textField = document.createElement('input'); textField.placeholder = placeholder; view.appendChild(textField); const addButton = document.createElement('button'); addButton.innerHTML = '追加'; addButton.addEventListener('click', async ev => { const pageDesciptor = getCurrentPageDescriptor(); if (pageDesciptor === null) { return; } const value = replacer(textField.value).trim(); if (value !== '') { await settings.addNg(pageDesciptor.key, value); textField.value = ''; } textField.focus(); }, false); view.appendChild(addButton); return view; } function renderList(root, pageDesciptor) { root.innerHTML = ''; const { title, description, key, prefix, lineEdit, replacer } = pageDesciptor; const inputField = createInputField(description, replacer); root.appendChild(inputField); if (lineEdit) { const editButton = document.createElement('button'); editButton.classList.add('ngid-lineedit-button'); editButton.innerHTML = '編集'; editButton.addEventListener('click', () => renderLineEdit(root, pageDesciptor), false); inputField.appendChild(editButton); } // create items list const lists = settings[key]; const items = lists.map(data => createListitem(data, prefix)); items.reverse(); items.forEach(item => root.appendChild(item)); } function renderLineEdit(root, pageDesciptor) { root.innerHTML = ''; const { title, description, key, prefix, lineEdit, replacer } = pageDesciptor; const textView = document.createElement('textarea'); textView.classList.add('ngid-lineedit-textview'); textView.value = settings[key].join('\n'); const saveView = document.createElement('div'); saveView.classList.add('ngid-lineedit-saveview'); saveView.appendChild(document.createTextNode(description)); const saveButton = document.createElement('button'); saveButton.innerHTML = '保存'; saveButton.addEventListener('click', async ev => { const lists = textView.value.split(/\n/) .map(v => replacer(v).trim()) .filter(v => v.length > 0) .map(v => { return { value: v, creationTime: new Date() }; }); // swapNg will occur render and back to listview // unsafe await settings.swapNg(key, lists); }, false); saveView.appendChild(saveButton); // We need a block to fillup the page. const outerBlock = document.createElement('div'); outerBlock.style.cssText = 'display: flex; flex-direction: column; height: 100%; width: 100%'; outerBlock.appendChild(saveView); outerBlock.appendChild(textView); root.appendChild(outerBlock); } function createGap() { return document.createElement('hr'); } function createCheckbox(optionId, defaultValue, title) { const checked = (optionId in settings.options) ? settings.options[optionId] : defaultValue; const view = document.createElement('label'); view.for = `ngid-${optionId}`; view.className = 'ngid-listitem'; const titleBlock = document.createElement('span'); titleBlock.innerHTML = title; view.appendChild(titleBlock); const checkbox = document.createElement('input'); checkbox.type = 'checkbox'; checkbox.id = `ngid-${optionId}`; checkbox.className = 'ngid-text-button'; checkbox.checked = checked; checkbox.addEventListener('change', async () => { settings.options[optionId] = checkbox.checked; await settings.saveOptions(); }); view.appendChild(checkbox); return view; } function renderOptions(root) { root.innerHTML = ''; root.appendChild(createGap()); for (const [optionId, details] of Object.entries(NGID_OPTIONS)) { switch (typeof details.default) { case 'boolean': root.appendChild(createCheckbox(optionId, details.default, details.title)); break; } root.appendChild(createGap()); } } // rerender current list if it is openning function renderCurrentListCb(key) { const pageDesciptor = getCurrentPageDescriptor(); if ((pageDesciptor !== null) && (pageDesciptor.key === key)) { const root = tabBox.getCurrentPage(); renderList(root, pageDesciptor); } } settings.on('add', renderCurrentListCb); settings.on('remove', renderCurrentListCb); settings.on('clear', renderCurrentListCb); settings.on('swap', renderCurrentListCb); } // Mapping post no to meta data. // // String => PostMetaObject{ id: String, no: String, isThreadPost: bool, isContainsNgWord: bool, contextMenuRoot: HTMLElement } const postMetas = {}; // Init and store the meta data of the `post`. // // This function maybe called twice for a post due to the thread expanding, // but we don't mind and just reinit the post. function initPostMeta(post) { // Only when we know the post no. const postNo = queryer.queryNo(post); if (!postNo) { return; } post.dataset.ngidNo = postNo; // For convenience. const postMeta = { no: postNo, id: queryer.queryId(post), isThreadPost: queryer.isThreadPost(post), isContainsNgWord: isContainsNgWord(post), contextMenuRoot: null, }; postMetas[postNo] = postMeta; // Insert the context menu root and create the menu. const insertPoint = queryer.postNoEl(post); if (insertPoint) { const parent = insertPoint.parentElement; // WORKAROUND: GM4 double insert if (parent.querySelector('.ngid-context')) { return; } const contextMenuRoot = document.createElement('details'); contextMenuRoot.className = 'text-button ngid-context'; contextMenuRoot.addEventListener('mouseenter', autoToggleContextMenu); insertPoint.after(contextMenuRoot); postMeta.contextMenuRoot = contextMenuRoot; renderContextMenu(post, postMeta, ''); } } function isContainsNgWord(post) { const postBody = queryer.queryBody(post) || ''; const threadTitle = queryer.queryThreadTitle(post) || ''; const pred = word => ((postBody.includes(word)) || (threadTitle.includes(word))); return settings.ngWords.some(pred) || settings.globalNgWords.some(pred); } function autoToggleContextMenu() { this.open = true; } function renderContextMenu(post, postMeta, ngState) { const postId = postMeta.id; const postNo = postMeta.no; const isThreadPost = postMeta.isThreadPost; const root = postMeta.contextMenuRoot; // Remove the menu body. while (root.lastChild) { root.removeChild(root.lastChild); } const menu = document.createElement('div'); menu.className = 'ngid-context-menu'; root.appendChild(menu); const closeButton = document.createElement('button'); closeButton.type = 'button'; closeButton.classList.add('ngid-context-menu-close-button'); closeButton.innerHTML = 'メニューを閉じる'; closeButton.addEventListener('click', contentMenuCloseButtonCb); menu.appendChild(closeButton); const summary = document.createElement('summary'); summary.innerHTML = ' NG'; root.appendChild(summary); const postType = (isThreadPost) ? 'スレ' : 'レス'; if (ngState === 'ngword') { menu.appendChild(document.createTextNode( `この${postType}にはNGWordsが含まれている。`)); } else if (ngState === 'ngid') { menu.appendChild(document.createTextNode( `このIDはNGIDに指定されている。`)); } else { // Only show buttons of enabled function. if (postNo) { const ngNoButton = document.createElement('div'); ngNoButton.className = 'ngid-text-button'; ngNoButton.dataset.no = postNo; if (ngState == 'ngno') { ngNoButton.innerHTML = `この${postType}を現す`; } else { ngNoButton.innerHTML = `この${postType}を隠す`; } ngNoButton.addEventListener('click', addNgNoButtonCb, false); menu.appendChild(ngNoButton); } if (postId) { const ngIdButton = document.createElement('div'); ngIdButton.className = 'ngid-text-button'; ngIdButton.dataset.id = postId; ngIdButton.innerHTML = `ID:${postId}をNGIDに追加`; ngIdButton.addEventListener('click', addNgIdButtonCb, false); menu.appendChild(ngIdButton); const ngImageButton = document.createElement('div'); ngImageButton.className = 'ngid-text-button'; ngImageButton.dataset.id = postId; if (isNgImage(post)) { ngImageButton.innerHTML = `ID:${postId}のイラストを表す`; } else { ngImageButton.innerHTML = `ID:${postId}のイラストを隠す`; } ngImageButton.addEventListener('click', addNgImageButtonCb, false); menu.appendChild(ngImageButton); } } } function contentMenuCloseButtonCb() { this.parentElement.parentElement.open = false; } async function addNgIdButtonCb(ev) { const id = this.dataset.id; if (await settings.addNg('ngIds', id)) { console.log(`add NGID ${id}`); } } async function addNgNoButtonCb(ev) { const no = this.dataset.no; if (await settings.addNg('ngNos', no)) { console.log(`add NGNO ${no}`); } else { console.log(`remove NGNO ${no}`); await settings.removeNg('ngNos', no); } } async function addNgImageButtonCb(ev) { const id = this.dataset.id; if (await settings.addNg('ngImages', id)) { console.log(TAG, `add NGImage ${id}`); } else { console.log(`remove NGImage ${id}`); await settings.removeNg('ngImages', id); } } function isNgImage(post) { return post.classList.contains('ngid-ngimage'); } function updateNgWordState() { for (const post of queryer.queryPosts()) { const postMeta = postMetas[post.dataset.ngidNo]; if (postMeta) { postMeta.isContainsNgWord = isContainsNgWord(post); } } } function updateNgState() { for (const post of queryer.queryPosts()) { const postMeta = postMetas[post.dataset.ngidNo]; if (!postMeta) { continue; } const isNgPost = post.classList.contains('ngid-ngpost'); let ngState = ''; if (postMeta.isContainsNgWord) { ngState = 'ngword'; } else if (settings.ngIds.includes(postMeta.id)) { ngState = 'ngid'; } else if (settings.ngNos.includes(postMeta.no)) { ngState = 'ngno'; } const needNgImage = settings.ngImages.includes(postMeta.id); setNgState(post, ngState !== ''); setNgImage(post, needNgImage); // no touch if it isn't and wasn't a NGed post if ((isNgPost) || (ngState !== '') || (isNgImage(post) == needNgImage)) { const context = post.querySelector('.ngid-context'); renderContextMenu(post, postMeta, ngState); } } // A workaround for non-structured layout. if (hostSettings.nonStructuredLayout) { for (const post of queryer.queryThreads()) { const isNgThread = post.classList.contains('ngid-ngpost'); let el = post.nextSibling; while ((el) && (!(el instanceof HTMLHRElement))) { if (queryer.isReplyPost(el)) { if (isNgThread) { el.classList.add('ngid-destroy'); } else { el.classList.remove('ngid-destroy'); } } el = el.nextSibling; } } } } function setNgState(post, isNg) { if (isNg) { if (post.classList.contains('threadpost')) { post.parentElement.classList.add('ngid-ngthread'); } post.classList.add('ngid-ngpost'); if (settings.options.transparentNg) { post.classList.add('ngid-transparent-ng'); } } else { if (post.classList.contains('threadpost')) { post.parentElement.classList.remove('ngid-ngthread'); } post.classList.remove('ngid-ngpost'); if (settings.options.transparentNg) { post.classList.remove('ngid-transparent-ng'); } } } function setNgImage(post, isNg) { if (isNg) { post.classList.add('ngid-ngimage'); } else { post.classList.remove('ngid-ngimage'); } } await ngidAddStyle(); ngidStart(); })();