// ==UserScript==
// @name Komica Keyman
// @description Easy manage hide.keywords on Komica
// @namespace https://greasyfork.org/en/users/112088
// @match https://gita.komica1.org/00b/*
// @version 0.1a
// @grant GM.addStyle
// ==/UserScript==
const Komica = {};
(function komicaDialog(exports) {
'use strict'
const TAG = '[Komica_Dialog]';
function insertDialog(name, id, namespace, options = {}) {
// WORKAROUND: GM4 double insert
if (document.querySelector(`#${id}`)) {
return;
}
const tabBox = createTabBox(namespace);
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);
function toggleDialog() {
dialog.classList.toggle(`${namespace}-dialog-show`);
if (dialog.classList.contains(`${namespace}-dialog-show`)) {
if (options.onopen) {
options.onopen();
}
tabBox.currentSelected = 0;
} else if (options.onclose) {
options.onclose();
}
}
return { tabBox, footer, toggleDialog };
}
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);
(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 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,
};
function postQueryer(host) {
const ret = (MAPPER[host]) ? MAPPER[host] : NULL_QUERYER;
return Object.assign({}, ret);
}
exports.postQueryer = postQueryer;
})(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_Keywords_Manager]';
const DEFAULT_STLYE_VARS = `
:root {
--keyman-primary-background-color: #FFFFEE;
--keyman-secondary-background-color: #F0E0D6;
--keyman-highlight-background-color: #EEAA88;
--keyman-primary-color: #800000;
--keyman-highlight-color: #800000;
--keyman-text-button-color: #00E;
--keyman-text-button-hover-color: #D00;
--keyman-separator-color: #000;
--keyman-primary-shadow-color: #5f5059;
}
`;
const DIALOG_STYLE = `
.keyman-textarea-page {
display: flex;
}
.keyman-textarea-page textarea {
width: 100%;
}
.keyman-keywords-page {
display: grid;
grid-template-columns: [i-start] auto max-content [i-end];
grid-auto-rows: min-content;
}
.keyman-keywords-page button {
place-self: center;
}
.keyman-keywords-page span {
margin: 0 6px;
place-self: center start;
}
.keyman-keywords-page canvas {
margin: 3px 0;
}
.keyman-separtor {
grid-column: i;
}
.keyman-dialog {
visibility: hidden;
position: fixed;
top: -10px;
z-index: 1;
opacity: 0;
display: grid;
grid-template: "h h" min-content "c c" auto "f b" min-content / max-content 1fr;
width: 40%;
height: 50%;
margin: 0 30%;
overflow: hidden;
border-radius: 5px;
box-shadow: 0 0 15px 5px var(--keyman-primary-shadow-color);
background-color: var(--keyman-primary-background-color);
transition: top 100ms, visibility 100ms, opacity 100ms;
}
.keyman-dialog-show {
visibility: visible;
opacity: 1;
top: 30px;
}
.keyman-dialog-footer {
grid-area: f;
align-self: center;
margin: 10px 20px;
}
.keyman-dialog-close-button {
place-self: center end;
margin: 10px 20px;
}
.keyman-tabbox-header {
grid-area: h;
display: flex;
justify-content: start;
background-color: var(--keyman-secondary-background-color);
}
.keyman-tabbox-tab {
cursor: pointer;
flex: 1;
padding: 3px 12px;
font-weight: bold;
text-align: center;
}
.keyman-tabbox-tab:hover {
background-color: var(--keyman-highlight-background-color);
color: var(--keyman-highlight-color);
}
.keyman-tabbox-tab.keyman-tabbox-selected {
background-color: var(--keyman-highlight-background-color);
color: var(--keyman-highlight-color);
}
.keyman-tabbox-container {
grid-area: c;
display: flex;
overflow-y: auto;
}
.keyman-tabbox-page {
width: 0;
opacity: 0;
overflow-y: scroll;
overflow-x: hidden;
transition: opacity 200ms;
}
.keyman-tabbox-page.keyman-tabbox-selected {
width: 100%;
opacity: 1;
padding: 10px;
}
@media screen and (max-device-width: 600px) {
.keyman-dialog {
width: calc(100vw - 20px);
margin: 0 10px;
}
.keyman-tabbox-container {
width: calc(100vw - 20px);
}
}
`;
const CONTEXT_STYLE = `
.keyman-text-button {
cursor: pointer;
color: var(--keyman-text-button-color);
}
.keyman-text-button:hover {
color: var(--keyman-text-button-hover-color);
}
.keyman-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;
color: var(--keyman-primary-color);
background-color: var(--keyman-highlight-background-color);
}
.keyman-context:hover .keyman-context-menu {
visibility: visible;
margin-top: -1.7em;;
}
.popup_area .keyman-context {
display: none;
}
.keyman-context {
cursor: pointer;
display: inline-block;
}
.keyman-context summary {
list-style: none;
}
.keyman-context summary::-webkit-details-marker {
display: none;
}
.keyman-context-menu-close-button {
text-align: center;
display: none;
}
@media screen and (max-device-width: 600px) {
.keyman-context-menu {
visibility: visible;
margin: -1.7em 6px 0 6px;
width: calc(100% - 32px);
left: 0;
}
.keyman-context-menu-close-button {
display: unset;
align-self: center;
}
}
`;
const HOST_SETTINGS = {
'komica': {
darkStyleVars: `
:root {
--keyman-primary-background-color: #1D1F21;
--keyman-secondary-background-color: rgb(40, 42, 46);
--keyman-highlight-background-color: rgb(0, 0, 0);
--keyman-primary-color: #C5C8C6;
--keyman-highlight-color: rgb(178, 148, 187);
--keyman-text-button-color: #81A2BE;
--keyman-text-button-hover-color: #FFC685;
--keyman-separator-color: gray;
--keyman-primary-shadow-color: rgb(40, 42, 46);
}
`,
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;
}
},
},
};
const hostId = 'komica';
const queryer = Komica.postQueryer(hostId);
const hostSettings = HOST_SETTINGS[hostId];
function readSettings() {
function* commaSplited(input) {
for (const word of input.split(/,/)) {
if (word != '' ) {
yield word;
}
}
}
return {
keywords: '',
keywordList: [],
_changed: function () {
this.keywords = this.keywordList.join(',');
if (this.onchange) {
this.onchange();
}
},
addKeyword: function (input) {
let changed = 0;
for (const word of commaSplited(input)) {
if (!this.keywordList.includes(word)) {
this.keywordList.push(word);
changed++;
}
}
if (changed > 0) {
this._changed();
}
},
removeKeyword: function (input) {
const set = new Set(commaSplited(input));
const newList = [];
let changed = 0;
for (const word of this.keywordList) {
if (set.has(word)) {
changed++;
} else {
newList.push(word);
}
}
if (changed > 0) {
this.keywordList = newList;
this._changed();
}
},
clearKeyword: function () {
this.keywordList.length = 0;
this.keywords = '';
if (this.onchange) {
this.onchange();
}
},
swapKeywords: function (input) {
const list = [];
const set = new Set();
for (const word of commaSplited(input)) {
if (!set.has(word)) {
list.push(word);
set.add(word);
}
}
this.keywordList = list;
this._changed();
},
read: function () {
const input = document.querySelector('#keywordInput');
this.keywordList = [...commaSplited(input.value)];
this.keywords = this.keywordList.join(',');
},
update: function () {
const input = document.querySelector('#keywordInput');
input.value = this.keywords;
const button = document.querySelector('#keywordUpdateBtn');
button.click();
},
};
}
function insertManagerButton({ toggleDialog }) {
const toggleButton = document.createElement('button');
toggleButton.textContent = '編輯列表';
toggleButton.addEventListener('click', toggleDialog, false);
const anchor = document.querySelector('#keywordInput');
anchor.after(toggleButton);
}
async function addStyle() {
const styleVars = ((hostSettings) && (hostSettings.getStyleVars))
? hostSettings.getStyleVars() : DEFAULT_STLYE_VARS;
await GM.addStyle(styleVars);
await GM.addStyle(DIALOG_STYLE);
await GM.addStyle(CONTEXT_STYLE);
}
function onLoad(settings) {
const dialog = insertSettingDialog(settings);
insertManagerButton(dialog);
for (const post of queryer.queryPosts()) {
initPostMeta(post);
}
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;
} , []);
postReplys.forEach(initPostMeta);
});
for (const thread of queryer.queryThreads()) {
threadObserver.observe(thread, { childList: true });
}
}
function insertSettingDialog(settings) {
const options = {
onopen: () => {
settings.read();
},
onclose: () => {
settings.update();
},
};
const { tabBox, footer, toggleDialog } = Komica.insertDialog('編輯列表', 'keyman-settings-dialog', 'keyman', options);
footer.textContent = '※更改需要關閉列表才套用';
const keywordsPageInfo = tabBox.addPage('關鍵字列表');
const textareaPageInfo = tabBox.addPage('編輯');
keywordsPageInfo.page.classList.add('keyman-keywords-page');
textareaPageInfo.page.classList.add('keyman-textarea-page');
function switchTab(pageIdx, root) {
switch (pageIdx) {
case keywordsPageInfo.index:
renderBlacklist(root);
break;
default:
renderTextarea(root);
break;
}
}
tabBox.on('switch', switchTab);
settings.onchange = () => {
const pageIdx = tabBox.currentSelected;
const root = tabBox.getCurrentPage();
if (root) {
switchTab(pageIdx, root);
}
};
function renderBlacklist(root) {
root.innerHTML = '';
root.append(...createInputField('隱藏包含關鍵字貼文'));
root.append(createGridSepartor());
for (const value of settings.keywordList) {
root.append(...createListitem(value), createGridSepartor());
}
const clearButton = document.createElement('button');
clearButton.textContent = '清空';
clearButton.addEventListener('click', async () => {
await settings.clearKeyword();
});
}
function createInputField(placeholder) {
const inputView = document.createElement('input');
inputView.placeholder = placeholder;
const addButton = document.createElement('button');
addButton.innerHTML = '加入';
addButton.addEventListener('click', async ev => {
await settings.addKeyword(inputView.value);
inputView.value = '';
inputView.focus();
});
return [inputView, addButton];
}
function createListitem(value) {
const valueView = document.createElement('span');
valueView.textContent = value;
const delButton = document.createElement('span');
delButton.className = 'keyman-text-button';
delButton.textContent = '移除';
delButton.dataset.value = value;
delButton.addEventListener('click', async ev => {
const button = ev.target;
await settings.removeKeyword(button.dataset.value);
})
return [valueView, delButton];
}
function renderTextarea(root) {
root.innerHTML = '';
const textarea = document.createElement('textarea');
textarea.value = settings.keywords;
textarea.addEventListener('change', async ev => {
const textarea = ev.target;
await settings.swapKeywords(textarea.value);
})
root.append(textarea);
const button = document.createElement('button');
button.textContent = '空行換成逗號';
button.addEventListener('click', async ev => {
textarea.value = textarea.value.replaceAll(/\n/g, ',');
await settings.swapKeywords(textarea.value);
})
root.append(button);
}
function createGridSepartor() {
const view = document.createElement('div');
view.className = 'keyman-separtor';
view.append(document.createElement('hr'));
return view;
}
return { toggleDialog };
}
const postMetas = {};
function initPostMeta(post) {
const postNo = queryer.queryNo(post);
if (!postNo) {
return;
}
const postMeta = {
no: postNo,
id: queryer.queryId(post),
name: queryer.queryName(post),
isThreadPost: queryer.isThreadPost(post),
contextMenuRoot: null,
};
postMetas[postNo] = postMeta;
const insertPoint = queryer.postNoEl(post);
if (insertPoint) {
const parent = insertPoint.parentElement;
// WORKAROUND: GM4 double insert
if (parent.querySelector('.keyman-context')) {
return;
}
const contextMenuRoot = document.createElement('details');
contextMenuRoot.className = 'text-button keyman-context';
contextMenuRoot.addEventListener('mouseenter', autoToggleContextMenu);
insertPoint.after(contextMenuRoot);
postMeta.contextMenuRoot = contextMenuRoot;
renderContextMenu(post, postMeta, '');
}
}
function autoToggleContextMenu() {
this.open = true;
}
function renderContextMenu(post, postMeta) {
const postId = postMeta.id;
const postNo = postMeta.no;
const postName = postMeta.name;
const isThreadPost = postMeta.isThreadPost;
const root = postMeta.contextMenuRoot;
const menu = document.createElement('div');
menu.className = 'keyman-context-menu';
const summary = document.createElement('summary');
summary.innerHTML = ' HIDE';
const closeButton = document.createElement('button');
closeButton.type = 'button';
closeButton.classList.add('keyman-context-menu-close-button');
closeButton.textContent = '關閉';
closeButton.addEventListener('click', contextMenuCloseButtonCb);
menu.appendChild(closeButton);
const message = document.createElement('div');
message.textContent = '加入隱藏關鍵字列表';
menu.appendChild(message);
for (const word of [postNo, postId, postName]) {
const button = document.createElement('div');
button.className = 'keyman-text-button';
button.textContent = word;
button.addEventListener('click', contextMenuCb);
menu.appendChild(button);
}
root.replaceChildren(menu, summary);
}
function contextMenuCloseButtonCb() {
this.parentElement.parentElement.open = false;
}
async function contextMenuCb() {
const word = this.textContent;
settings.read();
await settings.addKeyword(word);
settings.update();
}
const settings = readSettings();
await addStyle();
onLoad(settings);
})();