您需要先安装一个扩展,例如 篡改猴、Greasemonkey 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 Userscripts ,之后才能安装此脚本。
您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey,才能安装此脚本。
您需要先安装用户脚本管理器扩展后才能安装此脚本。
It adds various features to strangeworld@misao.
当前为
// ==UserScript== // @name tree view for qwerty // @name:ja くわツリービュー // @namespace strangeworld // @description It adds various features to strangeworld@misao. // @description:ja あやしいわーるど@みさおの投稿をツリーで表示できます。スタック表示の方にもいくつか機能を追加できます。 // @match *://misao.mixh.jp/cgi-bin/bbs.cgi* // @match *://misao.biz/cgi-bin/bbs.cgi* // @match *://usamin.elpod.org/cgi-bin/swlog.cgi?b=*&s=* // @grant GM_setValue // @grant GM.setValue // @grant GM_getValue // @grant GM.getValue // @grant GM_deleteValue // @grant GM.deleteValue // @grant GM_listValues // @grant GM.listValues // @grant GM_openInTab // @grant GM.openInTab // @grant window.close // @version 11.1 // @run-at document-start // @require https://unpkg.com/nano-jsx/bundles/nano.slim.min.js // @require https://cdn.jsdelivr.net/npm/zustand@4/umd/vanilla.development.js // ==/UserScript== (function (index_js, vanilla) { 'use strict'; let IS_GM = typeof GM_setValue === "function"; let IS_GM4 = typeof GM !== "undefined"; let IS_EXTENSION = !IS_GM && !IS_GM4; let IS_USAMIN = location.hostname === "usamin.elpod.org" || location.protocol === "file:" && /usamin/.test(location.pathname); const isGm = ()=>IS_GM; const isGm4 = ()=>IS_GM4; const isExtension = ()=>IS_EXTENSION; const isUsamin = ()=>IS_USAMIN; const nullAnchor = document.createElement("a"); Object.defineProperties(nullAnchor, { outerHTML: { value: "" }, search: { get () { return ""; }, set () {} } }); var nullAnchor$1 = nullAnchor; const a = document.createElement("a"); a.href = ">"; let gt = a.outerHTML === '<a href=">"></a>'; const replacer = (rel)=>(match)=>{ let href = match.replace(/"/g, """); if (gt) { href = href.replace(/>/g, ">").replace(/</g, "<"); } return `<a href="${href}" target="link" rel="${rel}">${match}</a>`; }; /** * @param {string} url */ function relinkify(url, rel = "noreferrer noopener") { return url.replace(/(?:https?|ftp|gopher|telnet|whois|news):\/\/[\x21-\x7e]+/gi, replacer(rel)); } class Post { /** * @param {Post} l * @param {Post} r */ static byID(l, r) { return +l.id - +r.id; } /** * @param {import("NG").default} ng * @param {Pick<Post, "text" | "name" | "title" | "isNG">} post * @returns */ static checkNG(ng, post) { post.isNG = ng.testWord(post.text) || ng.testHandle(post.name) || ng.testHandle(post.title); } /** * @param {import("NG").default} ng */ checkNG(ng) { Post.checkNG(ng, this); } /** * @param {Post} post * @returns {boolean} */ static wantsParent(post) { return !!post.parentId; } /** * @param {Post} post * @returns {boolean} */ static isOrphan(post) { return post.parent === null && !!post.parentId; } /** * @param {Post} post * @returns {boolean} */ static isRootCandidate(post) { return post.parent === null; } /** * @param {Post} post */ static mayHaveParent(post) { return post.mayHaveParent(); } /** * この投稿と二世代前まで個別非表示がない。 * @param {Post} post */ static isClean(post) { var _post_parent, _post_parent_parent, _post_parent1; return !(post.isVanished || ((_post_parent = post.parent) == null ? void 0 : _post_parent.isVanished) || ((_post_parent1 = post.parent) == null ? void 0 : (_post_parent_parent = _post_parent1.parent) == null ? void 0 : _post_parent_parent.isVanished)); } isOP() { return this.id === this.threadId; } getText() { if (this.hasDefaultReference()) { return this.text.slice(0, this.text.lastIndexOf("\n\n")) //参考と空行を除去 ; } return this.text; } hasDefaultReference() { const parent = this.parent; if (!parent) { return false; } if (parent.date === this.parentDate) { return true; } // usaminは、ヘッダの日時の表示の仕方が違う if (isUsamin()) { const [_, year, month, day, dow, hour, minute, second] = /^(\d+)\/(\d+)\/(\d+) \(([月火水木金土日])\) (\d+):(\d+):(\d+)$/.exec(parent.date) || []; return this.parentDate === `${year}/${month}/${day}(${dow})${hour}時${minute}分${second}秒`; } else { return false; } } computeQuotedText() { let lines = this.text.replace(/> >.*\n/g, "") //target属性がないのは参考リンクのみ .replace(/<a href="[^"]+">参考:.*<\/a>/i, "") // <A href=¥S+ target=¥"link¥">(¥S+)<¥/A> .replace(/<a href="[^"]+" target="link"(?: rel="([^"]*)")?>([^<]+)<\/a>/gi, this.relinkify).replace(/\n/g, "\n> "); lines = ("> " + lines + "\n").replace(/\n>[ \n\r\f\t]+\n/g, "\n").replace(/\n>[ \n\r\f\t]+\n$/, "\n"); return lines; } /** * @param {any} _ * @param {string} rel * @param {string} url */ relinkify(_, rel, url) { return relinkify(url, rel); } textCandidate() { const text = this.text.replace(/^> (.*\n?)|^.*\n?/gm, "$1").replace(/\n$/, "").replace(/^[ \n\r\f\t]*$/gm, "$&\n$&"); //TODO 引用と本文の間に一行開ける //text = text.replace(/((?:> .*\n)+)(.+)/, "$1\n$2"); //replace(/^(?!> )/m, "\n$&"); return text // + "\n\n"; ; } textCandidateLooksValid() { return this.getText().replace(/^> .*/gm, "").trim() !== ""; } dateCandidate() { return this.parentDate; } dateCandidateLooksValid(candidate) { return /^\d{4}\/\d{2}\/\d{2}\(.\)\d{2}時\d{2}分\d{2}秒$/.test(candidate); } hasQuote() { return /^> /m.test(this.text); } mayHaveParent() { return this.isRead && !this.isOP() && this.hasQuote(); } adoptAsEldestChild(childToBeAdopted) { const child = this.child; if (child) { childToBeAdopted.next = child; } this.child = childToBeAdopted; childToBeAdopted.parent = this; } getKeyForOwnParent() { return this.parentId; } /** * @returns {Post} */ makeParent() { throw new Error("Should not be called"); } /** * @param {string[]} vanishedMessageIDs */ setVanishedForRoot(vanishedMessageIDs) { this.setVanishedRecursive(vanishedMessageIDs); if (!this.isVanished) { this.setAscendantsVanished(vanishedMessageIDs); } } /** * @param {string[]} vanishedMessageIDs */ setVanished(vanishedMessageIDs) { this.isVanished = vanishedMessageIDs.includes(this.id); } /** * @param {string[]} vanishedMessageIDs */ setAscendantsVanished(vanishedMessageIDs) { this.setParentVanished(vanishedMessageIDs); if (!this.parent || this.parent.isVanished) { return; } this.parent.setParentVanished(vanishedMessageIDs); } /** * @param {string[]} vanishedMessageIDs */ setParentVanished(vanishedMessageIDs) { if (!this.parent) { const parentId = this.postParent.get(this.id); if (!parentId) { return; } this.parent = new Post(parentId, this.postParent); } this.parent.setVanished(vanishedMessageIDs); } /** * @param {string[]} vanishedMessageIDs */ setVanishedRecursive(vanishedMessageIDs) { var _this_child, _this_next; this.setVanished(vanishedMessageIDs); (_this_child = this.child) == null ? void 0 : _this_child.setVanishedRecursive(vanishedMessageIDs); (_this_next = this.next) == null ? void 0 : _this_next.setVanishedRecursive(vanishedMessageIDs); } /** * @param {Array<Post>} newRoots 新しい`root`ならこれに追加される * @param {boolean} [isRoot=true] このメソッドを呼ぶときに`root`でないなら`false` * @returns {?Post} */ drop(newRoots, isRoot = true) { if (this.child) { this.child = this.child.drop(newRoots, false); } if (this.next) { this.next = this.next.drop(newRoots, false); } if (Post.isClean(this)) { if (this.isRead && !this.child) { return this.next; } if (isRoot) { newRoots.push(this); } return this; } else { if (this.child && !this.child.isVanished) { newRoots.push(this.child); } return this.next; } } appendFfToButtons() { const [year, month, day] = this.date.match(/\d+/g) || []; const ff = `&ff=${year}${month}${day}.dat`; for (const target of [ "threadButton", "resButton", "posterButton" ]){ this[target].search = this[target].search + ff; } } /** * @return {[Post[], Post | null]} */ collectChildren() { let last = this.child; const children = []; if (last) { while(last.next){ children.push(last); last = last.next; } } return [ children, last ]; } getUniqueID() { var _this_child; return `${this.threadId}+${this.id}+${(_this_child = this.child) == null ? void 0 : _this_child.id}`; } /** * @param {string} id * @param {import("postParent/PostParent").default} postParent */ constructor(id, postParent){ this.id = id; this.postParent = postParent; /** @type {Post} */ this.parent = null; /** @type {Post} */ this.child = null; /** @type {Post} */ this.next = null; /** @type {boolean} */ this.isNG = null; } } Post.prototype.id = ""; Post.prototype.title = " "; Post.prototype.name = " "; Post.prototype.date = ""; Post.prototype.resButton = nullAnchor$1; Post.prototype.posterButton = nullAnchor$1; Post.prototype.threadButton = nullAnchor$1; Post.prototype.threadId = ""; /** うさみんの、どの掲示板からの投稿かを示すもの [misao] */ Post.prototype.site = ""; /** うさみん特有のボタン */ Post.prototype.usaminButtons = ""; /** * 親のid。string: 自然数の文字列。null: 親なし。undefined: 不明。 * @type {undefined|?string} */ Post.prototype.parentId = null; Post.prototype.parentDate = ""; Post.prototype.text = ""; Post.prototype.env = null; Post.prototype.isVanished = false; Post.prototype.isRead = false; Post.prototype.textBonus = 2; Post.prototype.dateBonus = 100; Post.prototype.parent = null; Post.prototype.child = null; Post.prototype.next = null; var Post$1 = Post; class ImaginaryPostPrototype extends Post$1 { /** * @param {Post} child */ setFields(child) { this.threadId = child.threadId; this.threadButton = child.threadButton; this.parentId = this.isOP() ? null : this.postParent.get(this.id); if (this.id) { this.setResButton(child); } } calculate(property) { const candidates = this.collectCandidates(property); const value = this.pickMostAppropriateCandidate(candidates); return Object.defineProperty(this, property, { value })[property]; } collectCandidates(property) { const getCandidate = `${property}Candidate`; const validates = `${getCandidate}LooksValid`; const bonus = this[`${property}Bonus`]; const ranks = Object.create(null); let child = this.child; while(child){ const candidate = child[getCandidate](); ranks[candidate] = (ranks[candidate] || 0) + 1; if (child[validates](candidate)) { ranks[candidate] += bonus; } child = child.next; } return ranks; } pickMostAppropriateCandidate(ranks) { let winner; let max = 0; for(const candidate in ranks){ const rank = ranks[candidate]; if (max < rank) { max = rank; winner = candidate; } } return winner; } getText() { return this.text; } setResButton(child) { const resButton = child.resButton.cloneNode(true); resButton.search = resButton.search.replace(/(?:&s=)\d+/, "&s=" + this.id); this.resButton = resButton; } getKeyForOwnParent() { return this.parentId ? this.parentId : "parent of " + this.id; } // @ts-ignore get text() { return this.calculate("text"); } /** * @param {Post} child * @param {import("postParent/PostParent").default} postParent */ constructor(child, postParent){ super(child.parentId, postParent); this.setFields(child); } } ImaginaryPostPrototype.prototype.isRead = true; var ImaginaryPostPrototype$1 = ImaginaryPostPrototype; function asyncGeneratorStep$3(gen, resolve, reject, _next, _throw, key, arg) { try { var info = gen[key](arg); var value = info.value; } catch (error) { reject(error); return; } if (info.done) { resolve(value); } else { Promise.resolve(value).then(_next, _throw); } } function _async_to_generator$3(fn) { return function() { var self = this, args = arguments; return new Promise(function(resolve, reject) { var gen = fn.apply(self, args); function _next(value) { asyncGeneratorStep$3(gen, resolve, reject, _next, _throw, "next", value); } function _throw(err) { asyncGeneratorStep$3(gen, resolve, reject, _next, _throw, "throw", err); } _next(undefined); }); }; } class GhostPost extends ImaginaryPostPrototype$1 { retrieveIdForcibly() { var _this = this; return _async_to_generator$3(function*() { return _this.postParent.findAsync(_this.child); })(); } } GhostPost.prototype.date = "?"; var GhostPost$1 = GhostPost; class MergedPost extends ImaginaryPostPrototype$1 { makeParent() { return new GhostPost$1(this, this.postParent); } // @ts-ignore get date() { return this.calculate("date"); } /** * @param {import("./Post").default} child * @param {import("./postParent/PostParent").default} postParent */ constructor(child, postParent){ super(child, postParent); if (child.title.startsWith(">")) { this.name = child.title.slice(1); } } } var MergedPost$1 = MergedPost; class ActualPost extends Post$1 { makeParent() { return new MergedPost$1(this, this.postParent); } } /** * @param {"nextSibling"|"nextElementSibling"} type - トラバースの仕方 * @returns {(nodeName: string) => (node: Node) => ?Node} */ const next = (type)=>(nodeName)=>(node)=>{ while(node = node[type]){ if (node.nodeName === nodeName) { return node; } } }; /** * @type {(nodeName: string) => (node: Node) => ?Node} * @param {string} nodeName * @param {Node} node - スタート地点 */ const nextSibling = next("nextSibling"); const nextElement = /** @type {function(string): function(HTMLElement): HTMLElement} */ next("nextElementSibling"); var nextElement$1 = nextElement; const nextFont = /** @type {function(Node): ?HTMLFontElement} */ nextElement("FONT"); const nextB = /** @type {function(Node): ?HTMLElement} */ nextElement("B"); const nextBlockquote = /** @type {function(Node): ?HTMLQuoteElement} */ nextElement("BLOCKQUOTE"); const nextComment = /** @type {function(Node): ?Comment} */ nextSibling("#comment"); /** @param {HTMLAnchorElement} anchor */ function collectEssentialElements(anchor) { const header = nextFont(anchor); const title = /** @type {HTMLElement} */ header.firstChild; const name = nextB(header); const info = nextFont(name); const date = /** @type {Text} */ info.firstChild; // レスボタン const resButton = /** @type {HTMLAnchorElement} */ info.firstElementChild; let posterButton, threadButton; let nextButton = /** @type {?HTMLAnchorElement} */ resButton.nextElementSibling; // 投稿者検索ボタン? if (nextButton && nextButton.search && nextButton.search.startsWith("?m=s")) { posterButton = /** @type {HTMLAnchorElement} */ nextButton; nextButton = /** @type {?HTMLAnchorElement} */ nextButton.nextElementSibling; } // スレッドボタン? if (nextButton) { threadButton = /** @type {HTMLAnchorElement} */ nextButton; } const blockquote = nextBlockquote(info); const pre = /** @type {HTMLPreElement} */ blockquote.firstElementChild; return { anchor, title, name, date, resButton, posterButton, threadButton, blockquote, pre }; } /** * 新しいのが先 * @param {ParentNode} context * @param {import("postParent/PostParent").default} postParent */ function makePosts(context, postParent) { const posts = isUsamin() ? makePostsUsamin(context, postParent) : makePostsKuzuha(context, postParent); sortByTime(posts); return posts; } /** * @param {ParentNode} context * @param {import("postParent/PostParent").default} postParent * @returns {ActualPost[]} */ const makePostsKuzuha = function(context, postParent) { /** @type {ActualPost[]} */ const posts = []; /** @type {NodeListOf<HTMLAnchorElement>} */ const as = context.querySelectorAll("a[name]"); for(let i = 0, len = as.length; i < len; i++){ const a = as[i]; const el = collectEssentialElements(a); const post = new ActualPost(a.name, postParent) // NOSONAR ; posts.push(post); post.title = el.title.innerHTML; post.name = el.name.innerHTML; post.date = el.date.nodeValue.trim().slice(4) //「投稿日:」削除 ; post.resButton = el.resButton; if (el.posterButton) { post.posterButton = el.posterButton; } if (el.threadButton) { post.threadButton = /** @type {HTMLAnchorElement} */ el.threadButton.cloneNode(true); post.threadId = /&s=([^&]+)/.exec(post.threadButton.search)[1]; } else { const id = post.id; const threadButton = /** @type {HTMLAnchorElement} */ el.resButton.cloneNode(true); threadButton.search = threadButton.search.replace(/^\?m=f/, "?m=t").replace(/&[udp]=[^&]*/g, "").replace(/(&s=)\d+/, `$1${id}`); threadButton.text = "◆"; post.threadButton = threadButton; post.threadId = id; } const env = nextFont(el.pre); if (env) { post.env = /** @type {HTMLElement} */ env.firstChild.innerHTML // font > i > env ; } const { text, parentId, parentDate } = breakdownPre(el.pre.innerHTML, post.id); post.text = text; if (parentId) { post.parentId = parentId; post.parentDate = parentDate; } } return posts; }; /** * @param {ParentNode} context * @param {import("postParent/PostParent").default} postParent * @returns {ActualPost[]} */ const makePostsUsamin = function(context, postParent) { const as = context.querySelectorAll("a[id]"); const nextPre = nextElement$1("PRE"); const nextFontOrB = (node)=>{ while(node = node.nextElementSibling){ const name = node.nodeName; if (name === "FONT" || name === "B") { return node; } } }; return Array.prototype.map.call(as, (a)=>{ const post = new ActualPost(a.id, postParent); let header = nextFontOrB(a); if (header.size === "+1") { post.title = header.firstChild.innerHTML; header = nextFontOrB(header); } if (header.tagName === "B") { post.name = header.innerHTML; header = nextFontOrB(header); } const info = header; post.date = info.firstChild.nodeValue.trim(); post.threadButton = info.firstElementChild; post.usaminButtons = info.innerHTML.replace(/^[^<]+/, "").replace(/[^>]*$/, "").replace(/\s+/g, " "); post.site = info.lastChild.textContent; const pre = nextPre(info); const { text, parentId, parentDate } = breakdownPre(pre.innerHTML, post.id); post.text = text; if (parentId) { post.parentId = parentId; post.parentDate = parentDate; } return post; }); }; /** * * @param {string} html * @param {string} id * @returns */ const breakdownPre = function(html, id) { let parentId; let parentDate; let text = html.replace(/<\/?font[^>]*>/gi, "").replace(/\r\n?/g, "\n").replace(/\n$/, ""); if (text.includes("<A")) { text = text.replace(//chrome " < > //firefox91 " < > //firefox56 " < > //古いfirefox %22 %3C %3E /<A href="<a href="(.*)(?:"|%22)"( target="link"(?: rel="noreferrer noopener")?)>\1"<\/a>\2><a href="\1(?:<\/A>|<\/A>|%3C\/A%3E)"\2>\1<\/A><\/a>/g, '<a href="$1" target="link">$1</a>'); } let candidate = text; const reference = /\n\n<a href="h[^"]+&s=([1-9]\d*)&r=[^"]+">参考:([^<]+)<\/a>$/.exec(text) || /\n\n<a href="#([1-9]\d*)">参考:([^<]+)<\/a>$/.exec(text); if (reference) { [, parentId, parentDate] = reference; if (+id <= +parentId) { parentId = null; } text = text.slice(0, reference.index); } // リンク欄を使ったリンクを落とす const url = /\n\n<[^<]+<\/a>$/.exec(text); if (url) { text = text.slice(0, url.index); } // 自動リンクがオフかつURLみたいのがあったら if (!text.includes("<") && text.includes(":")) { // 自動リンクする candidate = relinkify(text) + (url ? url[0] : "") + (reference ? reference[0] : ""); } candidate = candidate.replace(/target="link">/g, 'target="link" rel="noreferrer noopener">'); return { text: candidate, parentId, parentDate }; }; /** * 新しいのが先 * @param {ActualPost[]} posts */ const sortByTime = function(posts) { if (posts.length >= 2 && +posts[0].id < +posts[1].id) { posts.reverse(); } }; function originalRange(container, range = document.createRange()) { var firstAnchor = container.querySelector("a[name]"); if (!firstAnchor) { return range; } var end = kuzuhaEnd(container); if (!end) { return range; } var start = startNode(container, firstAnchor); range.setStartBefore(start); range.setEndAfter(end); return range; } function startNode(container, firstAnchor) { var h1 = container.querySelector("h1"); if (h1 && h1.compareDocumentPosition(firstAnchor) & Node.DOCUMENT_POSITION_FOLLOWING) { return h1; } else { return firstAnchor; } } function kuzuhaEnd(container) { var last = container.lastChild; while(last){ var type = last.nodeType; if (type === Node.COMMENT_NODE && last.nodeValue === " " || type === Node.ELEMENT_NODE && last.nodeName === "H3") { return last; } last = last.previousSibling; } return null; } class Context { /** * @param {ParentNode} fragment * @param {() => void} callback called before fetching posts from external resource */ makePosts(fragment, callback) { return this.collectPosts(fragment, callback); } /** * @param {ParentNode} fragment * @param {() => void} callback * @return {Promise<ActualPost[]>} */ collectPosts(fragment, callback) { return new Promise((resolve)=>{ const posts = makePosts(fragment, this.postParent); if (this.q.shouldFetch()) { callback(); const makePostsAndConcat = (/** @type {ActualPost[]} */ posts, { fragment })=>[ ...posts, ...makePosts(fragment, this.postParent) ]; return this.q.fetchOldLogs(fragment).then(({ afters, befores })=>[ ...afters.reduce(makePostsAndConcat, []), ...posts, ...befores.reduce(makePostsAndConcat, []) ]).then(resolve); } else { resolve(posts); } }).then((posts)=>{ this.postParent.insert(posts); this.makeButtonsPointToOldLog(posts); return posts; }); } /** * @param {ActualPost[]} posts */ makeButtonsPointToOldLog(posts) { if (this.q.isSearchingForThreadOrPoster()) { posts.forEach((post)=>{ post.appendFfToButtons(); }); } } /** * @param {ParentNode} fragment */ suggestLink(fragment) { return this.q.suggestLink(fragment); } getLogName() { return this.q.getLogName(); } /** * `config.deleteOriginal` = true の場合はない振りをする * @param {ParentNode} fragment */ extractOriginalPostsAreaFrom(fragment) { if (isUsamin()) { return document.createDocumentFragment(); } const range = originalRange(fragment); if (this.config.deleteOriginal) { range.deleteContents(); return document.createDocumentFragment(); } else { return range.extractContents(); } } /** * @param {import("./Config").default} config * @param {import("Query").default} q */ constructor(config, q, postParent){ this.config = config; this.q = q; this.postParent = postParent; } } /** * type のストレージが利用可能か * @param {"localStorage"|"sessionStorage"} type * @param {Window} win * @returns */ const storageIsAvailable = (type, win = window)=>{ // https://developer.mozilla.org/en-US/docs/Web/API/Web_Storage_API/Using_the_Web_Storage_API#Feature-detecting_localStorage try { var storage = win[type], x = "__storage_test__"; storage.setItem(x, x); storage.removeItem(x); return true; } catch (e) { return false; } }; var storageIsAvailable$1 = storageIsAvailable; class InMemoryStorage { setItem(k, v) { this.data[k] = v; } getItem(k) { const v = this.data[k]; return v != null ? v : null; } removeItem(k) { delete this.data[k]; } constructor(){ this.data = Object.create(null); } } /** * @param {import("Config").default} config * @returns {{setItem: (k: string, v: string) => void; getItem: (k: string) => ?string; removeItem: (k: string) => void}} */ const getStorage$1 = (config)=>{ if (isUsamin()) { return new InMemoryStorage(); } if (config.useVanishMessage && storageIsAvailable$1("localStorage")) { return localStorage; } if (storageIsAvailable$1("sessionStorage")) { return sessionStorage; } return new InMemoryStorage(); }; var getStorage$2 = getStorage$1; /** * @returns {Promise<string>} */ function ajax({ type = "GET", url = location.href, data = {} } = {}) { url = url.replace(/#.*$/, ""); for(const key in data){ url += "&" + encodeURIComponent(key) + "=" + encodeURIComponent(data[key]); } url = url.replace(/[&?]{1,2}/, "?"); return new Promise(function(resolve, reject) { var xhr = new XMLHttpRequest(); xhr.open(type, url); xhr.overrideMimeType("text/html; charset=windows-31j"); xhr.onload = function() { if (xhr.status === 200) { resolve(xhr.response); } else { reject(new Error(xhr.statusText)); } }; xhr.onerror = function() { reject(new Error("Network Error")); }; xhr.send(); }); } let _class$3 = class _class { /** * @param {Node} start * @param {Node} end */ extractContents(start, end) { this.range.setStartBefore(start); this.range.setEndAfter(end); return this.range.extractContents(); } /** * @param {Node} start * @param {Node} end */ deleteContents(start, end) { this.range.setStartBefore(start); this.range.setEndAfter(end); this.range.deleteContents(); } /** * @param {Node} wrapper * @param {Node} start * @param {Node} end */ surroundContents(wrapper, start, end) { this.range.setStartBefore(start); this.range.setEndAfter(end); this.range.surroundContents(wrapper); } /** * @param {Node} node */ selectNodeContents(node) { this.range.selectNodeContents(node); } /** * @param {string} html */ createContextualFragment(html) { return this.range.createContextualFragment(html); } constructor(range = document.createRange()){ this.range = range; } }; /** * @param {{type?: "GET"|"POST", url?: string, data?: {}}} options `data` is translated into search string */ var fetch = ((options)=>ajax(options).then(translateIntoDocumentFragment).catch(translateIntoDocumentFragment)); function translateIntoDocumentFragment(object) { const range = new _class$3(); const f = range.createContextualFragment(object); return f; } /** * @template T * @param {(arg: string) => T} fn * @returns {(arg: string) => T} */ function memoize(fn) { const cache = {}; return (arg)=>{ if (!Object.prototype.hasOwnProperty.call(cache, arg)) { cache[arg] = fn(arg); } return cache[arg]; }; } class PostParent { /** * @param {Post[]} posts */ insert(posts) { if (!posts.length) { return; } this.load(); for(let i = 0; i < posts.length; i++){ const { id, parentId } = posts[i]; if (Object.prototype.hasOwnProperty.call(this.data, id)) { continue; } this.data[id] = parentId; this.changed = true; } this.cleanUpAndSave(); } load() { if (!this.storage) { this.storage = getStorage$2(this.config); } if (!this.data) { this.data = JSON.parse(this.storage.getItem("postParent")) || {}; } } cleanUpAndSave() { if (!this.changed) { return; } let ids = Object.keys(this.data); const limits = this.getLimits(); if (ids.length <= limits.upper) { this.save(this.data); return; } ids = ids.map((id)=>+id).sort((l, r)=>r - l).map((id)=>"" + id); // @ts-ignore // migrationだった気がする if (this.data[ids[0]] === false) { ids.shift(); } /** @type {IdMap} */ const saveData = Object.create(null); let i = limits.lower; while(i--){ saveData[ids[i]] = this.data[ids[i]]; } this.save(saveData); } getLimits() { const config = this.config; if (!config.useVanishMessage) { // 最新の投稿の親の親がこれくらいに収まる return { upper: 500, lower: 300 }; } if (config.vanishMessageAggressive) { // 一日の投稿数の平均が3000件超えくらいだった return { upper: 3500, lower: 3300 }; } else { // 1000件目の親の親がこれくらいに収まる return { upper: 1500, lower: 1300 }; } // なぜ差が200なのかは覚えていない } /** * @param {IdMap} data */ save(data) { this.storage.setItem("postParent", JSON.stringify(data)); } /** * @public * @param {string} id * @returns {string=} */ get(id) { this.load(); return this.data[id]; } /** * 親のIDを返す * @public * @param {Object} data * @param {string} data.id 子のId * @param {string} data.threadId 探索するスレッドのId * @returns {Promise<string|undefined>} */ findAsync({ id, threadId }) { if (this.shouldFetch(id, threadId)) { return this.updateThread(threadId).then(()=>this.get(id)); } else { return Promise.resolve(this.get(id)); } } /** * @param {string} childId * @param {string} threadId */ areValidIds(childId, threadId) { return /^(?!0)\d+$/.test(threadId) && /^(?!0)\d+$/.test(childId) && +threadId <= +childId; } isPersistentStorage() { return !(this.storage instanceof InMemoryStorage); } /** * @param {string} childId * @param {string} threadId */ shouldFetch(childId, threadId) { return typeof this.data[childId] === "undefined" && this.isPersistentStorage() && this.areValidIds(childId, threadId); } /** * @param {string} threadId */ updateThread(threadId) { return fetch({ data: { m: "t", s: threadId } }).then((fragment)=>makePosts(fragment, this)).then(this.insert.bind(this)); } /** * @param {import("Config").default} config */ constructor(config){ this.config = config; this.storage = null; /** @type {IdMap} */ this.data = null; this.updateThread = memoize(this.updateThread.bind(this)); } } class StackPresenter { setView(view) { this.view = view; } clearVanishedThreadIDs() { return this.config.clearVanishedThreadIDs().then(()=>this.view.clearVanishedThreadIDs()); } removeVanishedThread(threadId) { return this.config.removeVanishedThread(threadId); } addVanishedThread(threadId) { return this.config.addVanishedThread(threadId); } /** * @param {ParentNode} fragment `fragment`の先頭は通常は空白。ログの一番先頭のみ\<A> */ render(fragment) { this.view.render(fragment); } /** * @param {ParentNode} fragment */ finish(fragment) { this.view.finishFooter(fragment); return new Promise((resolve)=>{ if (this.shouldFetch()) { this.complementThread().then(resolve); } else { resolve(); } }).then(()=>this.view.finish()); } shouldFetch() { return this.q.shouldFetch(); } complementThread() { this.view.showIsSearchingOldLogsExceptFor(this.q.getLogName()); return this.q.fetchOldLogs(this.view.el).then(({ befores, afters })=>{ this.view.setBeforesAndAfters(this.q.getLogName(), befores, afters); this.view.doneSearchingOldLogs(); }); } /** * @param {import("Config").default} config * @param {import("../Query").default} q */ constructor(config, q, range = new _class$3()){ this.config = config; this.q = q; this.range = range; /** @type {import("./StackView").default} */ this.view = null; } } class NG { mark(reg, value) { return value.replace(reg, "<mark class='NGWordHighlight'>$&</mark>"); } markWord(value) { return this.mark(this.wordg_, value); } markHandle(value) { return this.mark(this.handleg_, value); } testWord(value) { if (this.word_) { return this.word_.test(value); } } testHandle(value) { if (this.handle_) { return this.handle_.test(value); } } constructor(config){ const word = config.NGWord; const handle = config.NGHandle; let isInvalid = ""; if (config.useNG) { if (handle) { try { this.handle_ = new RegExp(handle); this.handleg_ = new RegExp(handle, "g"); } catch (e) { isInvalid += "NGワード(ハンドル)が不正です。"; } } if (word) { try { this.word_ = new RegExp(word); this.wordg_ = new RegExp(word, "g"); } catch (e) { isInvalid += "NGワード(本文)が不正です。"; } } } this.message = isInvalid ? `<span>${isInvalid}NGワードを適用しませんでした</span>` : ""; this.isEnabled = !!(this.word_ || this.handle_); } } /** * @param {Element} el * @param {string} event * @param {EventListenerOrEventListenerObject} listener */ var addEventListener = ((el, event, listener)=>{ el.addEventListener(event, listener, false); }); function Popup(config, image, body = document.body) { this.waitingMetadata = null; this.handleEvent = function(/** @type {Event} */ e) { if (isEscapePressed(e)) { // not ESC return; } if (mouseOutOfPopup(e)) { return; } this.doHandleEvent(); }; this.doHandleEvent = function() { var popup = document.getElementById("image-view"); if (popup) { popup.parentNode.removeChild(popup); } Array.prototype.slice.call(document.getElementsByClassName("popup")).forEach(function(el) { el.classList.remove("popup"); }); this.removeEventListeners(); if (this.waitingMetadata) { clearTimeout(this.waitingMetadata); } }; this.addEventListeners = function() { this.toggleEventListeners("add"); }; this.removeEventListeners = function() { this.toggleEventListeners("remove"); }; this.toggleEventListeners = function(toggle) { [ "click", "keydown", "mouseout" ].forEach(function(type) { body[toggle + "EventListener"](type, this); }, this); }; /** * @param {Event} e */ function mouseOutOfPopup(e) { return e instanceof MouseEvent && e.type === "mouseout" && /** @type {Element} */ e.relatedTarget.closest(".popup"); } /** * @param {Event} e */ function isEscapePressed(e) { return e instanceof KeyboardEvent && e.type === "keydown" && !/^Esc(?:ape)?$/.test(e.key); } function getRatio(natural, max) { if (/^\d+$/.test(max) && natural > max) { return +max / natural; } else { return 1; } } this.popup = function() { var isBestFit = config.popupBestFit; var viewport = document.compatMode === "BackCompat" ? document.body : document.documentElement; var windowHeight = viewport.clientHeight; var windowWidth = viewport.clientWidth; var imageView = document.createElement("figure"); imageView.id = "image-view"; imageView.classList.add("popup"); imageView.style.visibility = "hidden"; imageView.innerHTML = '<figcaption><span id="percentage"></span>%</figcaption>'; // bodyに追加することでimage-orientationが適用され // natural(Width|Height)以外の.*{[wW]idth|[hH]eight)が // EXIFのorientationが適用された値になる imageView.appendChild(image); body.appendChild(imageView); var width = image.offsetWidth; var height = image.offsetHeight; var marginHeight = Math.round(imageView.getBoundingClientRect().height) - height; var maxWidth = config.popupMaxWidth || (isBestFit ? windowWidth : width); var maxHeight = config.popupMaxHeight || (isBestFit ? windowHeight - marginHeight : height); var ratio = Math.min(getRatio(width, maxWidth), getRatio(height, maxHeight)); var percentage = Math.floor(ratio * 100); var bgcolor = ratio < 0.5 ? "red" : ratio < 0.9 ? "blue" : "green"; // 丸めないと画像が表示されないことがある var imageHeight = Math.floor(height * ratio) || 1; var imageWidth = Math.floor(width * ratio) || 1; imageView.style.display = "none"; image.height = imageHeight; image.width = imageWidth; imageView.querySelector("#percentage").textContent = "" + percentage; imageView.style.cssText = "background-color: " + bgcolor; }; /** @this Popup */ this.waitAndOpen = function() { if (!image.complete && image.naturalWidth === 0 && image.naturalHeight === 0) { this.waitingMetadata = setTimeout(this.waitAndOpen.bind(this), 50); } else { this.waitingMetadata = null; this.popup(); } }; } class Preload { /** * @param {string} url */ fetch(url) { if (this.isFetched(url)) { return; } const link = Object.assign(document.createElement("link"), { rel: "preload", as: "image", href: url }); this.head.appendChild(link); this.preloads.add(url); } /** * @param {string} url */ isFetched(url) { return this.preloads.has(url); } constructor(head = document.head){ /** @type {Set<string>} */ this.preloads = new Set(); this.head = head; } } /** * @template {Node} T */ class Builder { /** * @param {string | Node | Builder} something */ add(something) { this.children.push(something); return this; } /** * @param {string} html */ addHtml(html) { this.children.push(document.createRange().createContextualFragment(html)); return this; } appendChildren() { for (const something of this.children){ if (something instanceof Builder) { this.node.appendChild(something.build()); } else if (typeof something === "string") { this.node.appendChild(document.createTextNode(something)); } else if (something instanceof Node) { this.node.appendChild(something); } } this.node.normalize(); } build() { this.appendChildren(); return this.node; } /** * @param {T} node */ constructor(node){ /** @type {(string | Node | Builder)[]} */ this.children = []; this.node = node; } } class ElementBuilder extends Builder { /** * @param {string} className */ withClass(className) { this.className = className; return this; } /** * @param {string} attribute * @param {string} value */ with(attribute, value) { this.attributes[attribute] = value; return this; } /** * @param {string} key * @param {string} value */ withData(key, value) { this.data[key] = value; return this; } /** * @param {string} style */ withStyle(style) { this.style = style; return this; } /** @return {HTMLElementTagNameMap[T]} */ build() { if (this.className) { this.node.className = this.className; } if (this.style) { this.node.style.cssText = this.style; } Object.assign(this.node.dataset, this.data); Object.assign(this.node, this.attributes); return super.build(); } /** * @param {T} tagName */ constructor(tagName){ super(document.createElement(tagName)); this.className = ""; this.style = ""; /** @type {DOMStringMap} */ this.data = Object.create(null); /** @type {{[key: string]: string}} */ this.attributes = Object.create(null); } } function aDiv() { return new ElementBuilder("div"); } const mayHaveSmallerImage = /^https?:\/\/misao\.(?:mixh|on\.arena\.ne)\.jp\/c\/up\/misao\d+\.\w+$/; const misao = /^https?:\/\/misao\.(?:mixh|on\.arena\.ne)\.jp\/c\//; const imageReg = /\.(?:jpe?g|png|gif|bmp|webp)$/; const videoReg = /^[^?#]+\.(?:webm|avi|mov|mp[4g]|wmv)(?:[?#]|$)/i; const audioReg = /^[^?#]+\.(?:mp3|m4a|wma|au|mid|wav)(?:[?#]|$)/i; /** * @typedef {(href: string) => string} small サムネイル用の小さい画像のURL * @typedef {(href: string) => string} original - オリジナルサイズの画像のURL * @typedef {(href: string) => HTMLMediaElement} embed 埋め込まれるElement * @typedef {(href: string, ext: string) => string} replaceExtra 内部利用 * @typedef {{ * name: string; * prefix?: RegExp; * suffix?: RegExp; * small?: small; * strange?: boolean; * embed?: embed; * original?: original; * replaceExtra?: replaceExtra; * }} Site * @typedef {Site[]} Sites */ /** @type {Sites} */ const sites = [ { name: "misao", prefix: misao, suffix: imageReg, small: (href)=>mayHaveSmallerImage.test(href) ? href.replace(/up\//, "up/pixy_") : href, strange: true }, { name: "misaoAudio", prefix: misao, suffix: audioReg, strange: true, embed: (href)=>anAudio(href).build() }, { name: "misaoVideo", prefix: misao, suffix: videoReg, strange: true, embed: (href)=>aVideo(href).build() }, { name: "imgur", prefix: /^https?:\/\/(?:i\.)?imgur\.com\/[^/]+$/, small (href) { const original = href.replace(/^https?:\/\/(?:i\.)?/, "https:/i."); return original.replace(/\.\w+$/, "t$&"); } }, { name: "twimg", prefix: /^https?:\/\/pbs\.twimg\.com\/media\/[\w_-]+\.\w+/, suffix: /(?::(?:orig|large|medium|small|thumb))?$/, original (href) { return this.replaceExtra(href, ":orig"); }, small (href) { return this.replaceExtra(href, ":thumb"); }, replaceExtra (href, ext) { const [hrefWithoutTag] = this.prefix.exec(href) || []; return hrefWithoutTag ? hrefWithoutTag + ext : ""; } }, { name: "anyImage", suffix: /^[^?#]+\.(?:jpe?g|png|gif|bmp|webp)(?:[?#]|$)/i }, { name: "anyAudio", suffix: audioReg, embed: (href)=>anAudio(href).build() }, { name: "anyVideo", suffix: videoReg, embed: (href)=>aVideo(href).build() } ]; /** * @param {string} src */ function anAudio(src) { return new ElementBuilder("audio").with("controls", "on").with("preload", "on").with("src", src); } /** * @param {string} src */ function aVideo(src) { return new ElementBuilder("video").with("controls", "on").with("preload", "on").with("loop", "on").with("src", src); } var sites$1 = sites; /** * @param {Event} e */ function toggle(e) { e.preventDefault(); doToggle(/** @type {HTMLElement} */ e.target); } /** * @param {HTMLElement} el */ function doToggle(el) { const { name, href } = el.dataset; const site = sites$1.find((site)=>site.name === name); const a = nextElement$1("A")(el); if (el.classList.contains("embedded")) { a.parentNode.removeChild(a.nextElementSibling); } else { const media = site.embed(href); if (media instanceof HTMLVideoElement) { const metadata = el.nextElementSibling; if (!metadata.classList.contains("metadata")) { media.addEventListener("loadedmetadata", ()=>{ const { videoHeight, videoWidth } = media; metadata.insertAdjacentHTML("beforebegin", `<span class="metadata">[${videoWidth}x${videoHeight}]</span>`); }); } } const text = el.closest(".text_tree-mode-ascii"); const branch = text ? text.querySelector(".a-tree:not(.spacer)") : null; const div = aDiv().withStyle("white-space: initial").add(branch ? branch.cloneNode(true) : null).build(); div.appendChild(media); a.parentNode.insertBefore(div, a.nextSibling); } el.classList.toggle("embedded"); } /** * @param {import("../Config").default} config */ function Embedder(config, preload = new Preload()) { this.config = config; this.preload = preload; /** * @param {string} href */ this.thumbnailLink = function(href) { const site = pickAppropriateSite(href); if (!site) { return; } const original = site.original ? site.original(href) : href; let thumbnail = this.thumbnailHtml(original, href, site); if (config.shouki && !site.embed) { thumbnail += shouki(original); } return thumbnail; }; const pickAppropriateSite = (href)=>sites$1.find(({ prefix, suffix, strange })=>(strange ? true : config.popupAny) && test(href, prefix) && test(href, suffix)); const test = (href, test)=>!test || test.test(href); this.thumbnailHtml = function(original, href, site) { const small = this.small(href, site); if (small) { return a(original, thumbnailHtml(small)); } else if (site.embed) { return `[<a href = "javascript:;" class="embed" data-name="${site.name}" data-href="${href}">埋</a>]`; } else { return "[" + a(original, "■") + "]"; } }; this.small = function(href, site) { if (!site.small) { return; } const small = site.small(href); if (!small) { return; } if (href === small) { return href; } if (!config.thumbnailPopup) { return small; } this.preload.fetch(href); if (this.preload.isFetched(href)) { return small; } else { return href; } }; const a = (href, content)=>`<a href="${href}" target="link" class="thumbnail">${content}</a>`; const thumbnailHtml = (src)=>`<img referrerpolicy="no-referrer" class="thumbnail-img" src="${src}">`; const shouki = (href)=>`[<a href="https://lens.google.com/uploadbyurl?url=${href}" target="link">詳</a>]`; /** ポップアップを消した時、カーソルがサムネイルの上にあるか */ this.isClosedAboveThumbnail = function(e) { const relatedTarget = e.relatedTarget; //firefox: if (relatedTarget === null) { return true; } //opera12 if (relatedTarget instanceof HTMLBodyElement) { return true; } //chrome if (relatedTarget.closest("#image-view") && !document.getElementById("image-view")) { return true; } }; function setNote(a, text) { let note = a.nextElementSibling; // span.noteがない if (!note || !note.classList.contains("note")) { note = document.createElement("span"); note.className = "note"; a.parentNode.insertBefore(note, a.nextSibling); } note.textContent = text; } this.downloading = function(image, a) { let pending = true; const complete = function(success) { pending = false; if (success) { var _note; const note = a.nextElementSibling; if ((_note = note) == null ? void 0 : _note.classList.contains("note")) { note.parentNode.removeChild(note); } } else { setNote(a, "404?画像ではない?"); } }; image.addEventListener("load", complete.bind(null, true)); image.addEventListener("error", complete.bind(null, false)); setTimeout(function() { if (pending) { setNote(a, "ダウンロード中"); } }, 100); }; this.handleEvent = function(e) { if (this.isClosedAboveThumbnail(e)) { return; } const a = e.currentTarget; // ポップアップからサムネイルに帰ってきた if (a.classList.contains("popup")) { return; } const image = new Image(); image.referrerPolicy = "no-referrer"; this.downloading(image, a); image.classList.add("image-view-img"); image.src = a.href; a.classList.add("popup"); const popup = new Popup(config, image); popup.addEventListeners(); popup.waitAndOpen(); }; } /** @param {HTMLElement} container */ Embedder.prototype.register = function(container) { const as = container.getElementsByTagName("a"); let has = false; for(let i = as.length - 1; i >= 0; i--){ const a = as[i]; if (!a.hasAttribute("target")) { continue; } const thumbnail = this.thumbnailLink(a.href); if (thumbnail) { a.insertAdjacentHTML("beforebegin", thumbnail); has = true; } } if (has) { if (this.config.thumbnailPopup) { const thumbs = container.getElementsByClassName("thumbnail"); for(let i = thumbs.length - 1; i >= 0; i--){ addEventListener(thumbs[i], "mouseover", this); } } const embeds = container.getElementsByClassName("embed"); for(let i = embeds.length - 1; i >= 0; i--){ addEventListener(embeds[i], "click", toggle); } } }; /** * @param {HTMLAnchorElement} a */ function collectElements(a) { const el = collectEssentialElements(a); return { el, name: el.name.innerHTML, title: el.title.innerHTML, text: el.pre.innerHTML, isNG: false, threadId: el.threadButton ? /&s=([^&]+)/.exec(el.threadButton.search)[1] : el.anchor.name }; } let _class$2 = class _class { /** * @param {HTMLAnchorElement} a */ render(a) { const post = collectElements(a); const buttons = []; if (this.vanishThread(post, buttons)) { return; } if (this.vanishByNG(post, buttons)) { return; } this.buildMessage(post, buttons); this.registerThumbnail(post); } /** * @param {PseudoPost} post * @param {string[]} buttons */ vanishThread(post, buttons) { if (this.config.isVanishedThread(post.threadId)) { if (this.config.utterlyVanishNGThread) { this.hideMessage(post); return true; } else { buttons.push('<a href="javascript:;" class="showThread" title="このスレッドは非表示に設定されています">非表示解除</a>'); } } } /** * 投稿を隠す。消しはしない。消してしまうと存在したかを判定できない。 * @param {PseudoPost} post */ hideMessage(post) { const el = post.el; const end = nextComment(el.blockquote); const wrapper = document.createElement("div"); this.range.surroundContents(wrapper, el.anchor, end); wrapper.classList.add("hidden"); } /** * @param {PseudoPost} post * @param {string[]} buttons */ vanishByNG(post, buttons) { const ng = this.ng; if (ng.isEnabled) { Post$1.checkNG(ng, post); if (post.isNG) { if (this.config.utterlyVanishNGStack) { this.hideMessage(post); return true; } else if (this.config.NGCheckMode) { this.markNG(post); } else { buttons.push('<a href="javascript:;" class="showNG" title="この投稿にはNGワードが含まれるため、非表示になっています">NG</a>'); } } } } /** * @param {PseudoPost} post */ markNG(post) { const el = post.el; if (this.ng.testWord(post.text)) { el.pre.innerHTML = this.ng.markWord(post.text); } if (this.ng.testHandle(post.name)) { el.name.innerHTML = this.ng.markHandle(post.name); } if (this.ng.testHandle(post.title)) { el.title.innerHTML = this.ng.markHandle(post.title); } } /** * @param {PseudoPost} post * @param {string[]} buttons */ buildMessage(post, buttons) { if (this.needToWrap() || buttons.length) { const wrapper = this.wrapMessage(post); if (buttons.length) { // chromeのinsertAdjacentHTMLは // DocumentFragmentの中のNode(ここでいうwrapper)だとエラーになる時代があった // もう大丈夫そうなのでいつか直す const showOriginalButtons = document.createElement("span"); showOriginalButtons.className = "showOriginalButtons"; showOriginalButtons.innerHTML = buttons.join(" "); wrapper.parentNode.insertBefore(showOriginalButtons, wrapper); } } } needToWrap() { return this.config.useVanishThread || this.config.keyboardNavigation || window.Intl && Intl.v8BreakIterator // or blink ; } /** * @param {PseudoPost} post */ wrapMessage(post) { const el = post.el; const wrapper = /** @type {HTMLElement} */ this.original.cloneNode(false); this.range.surroundContents(wrapper, el.anchor, el.blockquote); if (this.config.useVanishThread) { wrapper.classList.add("useVanishThreadButton"); el.resButton.parentNode.insertBefore(this.vanishButton.cloneNode(true), el.threadButton); wrapper.dataset.threadId = post.threadId; } return wrapper; } /** * @param {PseudoPost} post */ registerThumbnail(post) { if (this.config.thumbnail) { this.embedder.register(post.el.pre); } } /** * @param {import("../Config").default} config */ constructor(config, range = new _class$3()){ this.config = config; this.range = range; this.ng = new NG(config); this.embedder = new Embedder(config); this.original = document.createElement("div"); this.original.className = "message original"; this.vanishButton = this.range.createContextualFragment('<a href="javascript:;" class="vanishThread" title="スレッドを非表示にします"></a> '); } }; /** * @param {Element} element * @returns () => void */ function savePosition(element) { const top = element.getBoundingClientRect().top; return function restorePosition() { window.scrollTo(window.scrollX, window.scrollY + element.getBoundingClientRect().top - top); }; } function locationReload() { window.location.reload(); } function midokureload() { /** @type {HTMLInputElement} */ const midoku = document.querySelector('#form input[name="midokureload"]'); if (midoku) { midoku.click(); } else { locationReload(); } } /** * @param {string} href */ function openInTab(href) { if (typeof GM_openInTab === "function") { GM_openInTab(href, false); // GM4Storage.openInTabがない場合があるからこうなっているらしい } else if (typeof GM === "object" && GM.openInTab) { GM.openInTab(href, false); } else { window.open(href); } } /** * @param {import("Config").default} config */ function KeyboardNavigation(config) { const messages = document.getElementsByClassName("message"); let focusedIndex = -1; let done = -1; this.enableToReload = function() { done = Date.now(); }; this.isValid = function(index) { return !!messages[index]; }; // jQuery 2系 jQuery.expr.filters.visibleより function isVisible(elem) { return elem.offsetWidth > 0 || elem.offsetHeight > 0 || elem.getClientRects().length > 0; } function isHidden(elem) { return !isVisible(elem); } this.indexOfNextVisible = function(index, dir) { const el = messages[index]; if (el && (isHidden(el) || el.classList.contains("invalid"))) { return this.indexOfNextVisible(index + dir, dir); } return index; }; let isUpdateScheduled = false; this.updateIfNeeded = function() { if (isUpdateScheduled) { return; } isUpdateScheduled = true; requestAnimationFrame(this.changeFocusedMessage); }; this.changeFocusedMessage = function() { const m = messages[focusedIndex]; const top = m.getBoundingClientRect().top; const x = window.scrollX; const y = window.scrollY; window.scrollTo(x, top + y - +config.keyboardNavigationOffsetTop); const focused = document.getElementsByClassName("focused")[0]; if (focused) { focused.classList.remove("focused"); } m.classList.add("focused"); isUpdateScheduled = false; }; this.focus = function(dir) { const index = this.indexOfNextVisible(focusedIndex + dir, dir); if (this.isValid(index)) { focusedIndex = index; this.updateIfNeeded(); } else if (dir === 1) { const now = Date.now(); if (done >= 0 && now - done >= 500) { done = now; midokureload(); } } }; this.res = function() { const focused = document.querySelector(".focused"); if (!focused) { return; } let selector; if (focused.querySelector(".res")) { selector = ".res"; } else { selector = "font > a:first-child"; } const res = /** @type {HTMLAnchorElement} */ focused.querySelector(selector); if (res) { openInTab(res.href); } }; } KeyboardNavigation.prototype.handleEvent = function(/** @type {KeyboardEvent} */ e) { const target = /** @type {HTMLElement} */ e.target; if (/^(?:INPUT|SELECT|TEXTAREA)$/.test(target.nodeName) || target.isContentEditable) { return; } switch(e.key){ case "j": this.focus(1); break; case "k": this.focus(-1); break; case "r": this.res(); break; } }; /** * @param {HTMLElement} el * @param {string} event * @param {string} selector * @param {EventListenerOrEventListenerObject} callback */ var on = ((el, event, selector, callback)=>{ el.addEventListener(event, (e)=>{ if (/** @type {HTMLElement} */ e.target.closest(selector)) { if ("handleEvent" in callback) { callback.handleEvent(e); } else { callback(e); } } }); }); function sendMessageToRuntime(message) { chrome.runtime.sendMessage(message); } function closeTab() { if (isExtension()) { sendMessageToRuntime({ type: "closeTab" }); } else { window.open("", "_parent"); window.close(); } } var html = "<style type=\"text/css\">\n\tli {\n\t\tlist-style-type: none;\n\t}\n\t#configInfo {\n\t\tfont-weight: bold;\n\t\tfont-style: italic;\n\t}\n\tlegend + ul {\n\t\tmargin: 0 0 0 0;\n\t}\n\t.errormessage {\n\t\tdisplay: none;\n\t}\n\t[aria-invalid=\"true\"] {\n\t\toutline: 2px solid red;\n\t}\n\t[aria-invalid=\"true\"] ~ .errormessage {\n\t\tdisplay: initial;\n\t}\n</style>\n<fieldset>\n\t<legend>設定</legend>\n\t<fieldset>\n\t\t<legend>表示</legend>\n\t\t<ul>\n\t\t\t<li>\n\t\t\t\t<label\n\t\t\t\t\t><input type=\"radio\" name=\"viewMode\" value=\"t\" />ツリー表示</label\n\t\t\t\t>\n\t\t\t</li>\n\t\t\t<li>\n\t\t\t\t<label\n\t\t\t\t\t><input type=\"radio\" name=\"viewMode\" value=\"s\" />スタック表示</label\n\t\t\t\t>\n\t\t\t</li>\n\t\t</ul>\n\t</fieldset>\n\t<fieldset>\n\t\t<legend>共通</legend>\n\t\t<ul>\n\t\t\t<li>\n\t\t\t\t<label\n\t\t\t\t\t><input\n\t\t\t\t\t\ttype=\"checkbox\"\n\t\t\t\t\t\tname=\"zero\"\n\t\t\t\t\t\taria-describedby=\"explain-zero\"\n\t\t\t\t\t/>常に0件リロード</label\n\t\t\t\t><em id=\"explain-zero\"\n\t\t\t\t\t>(チェックを外しても「表示件数」は0のままなので手動で直してね)</em\n\t\t\t\t>\n\t\t\t</li>\n\t\t\t<li>\n\t\t\t\t<label\n\t\t\t\t\t>未読リロードに使うアクセスキー<input\n\t\t\t\t\t\ttype=\"text\"\n\t\t\t\t\t\tname=\"accesskeyReload\"\n\t\t\t\t\t\tsize=\"1\"\n\t\t\t\t/></label>\n\t\t\t</li>\n\t\t\t<li>\n\t\t\t\t<label\n\t\t\t\t\t>内容欄へのアクセスキー<input type=\"text\" name=\"accesskeyV\" size=\"1\"\n\t\t\t\t/></label>\n\t\t\t</li>\n\t\t\t<li>\n\t\t\t\t<label\n\t\t\t\t\t><input\n\t\t\t\t\t\ttype=\"checkbox\"\n\t\t\t\t\t\tname=\"keyboardNavigation\"\n\t\t\t\t\t\taria-describedby=\"explain-keyboardNavigation\"\n\t\t\t\t\t/>jkで移動、rでレス窓開く</label\n\t\t\t\t><em id=\"explain-keyboardNavigation\"\n\t\t\t\t\t><a href=\"@GF@#keyboardNavigation\">chrome以外の人は説明を読む</a></em\n\t\t\t\t>\n\t\t\t</li>\n\t\t\t<ul>\n\t\t\t\t<li>\n\t\t\t\t\t<label\n\t\t\t\t\t\t>上から<input\n\t\t\t\t\t\t\ttype=\"text\"\n\t\t\t\t\t\t\tname=\"keyboardNavigationOffsetTop\"\n\t\t\t\t\t\t\tsize=\"4\"\n\t\t\t\t\t\t/>pxの位置に合わせる</label\n\t\t\t\t\t>\n\t\t\t\t</li>\n\t\t\t</ul>\n\t\t\t<li>\n\t\t\t\t<label\n\t\t\t\t\t><input\n\t\t\t\t\t\ttype=\"checkbox\"\n\t\t\t\t\t\tname=\"closeResWindow\"\n\t\t\t\t\t\taria-describedby=\"explain-clseResWindow\"\n\t\t\t\t\t/>書き込み完了した窓を閉じる</label\n\t\t\t\t>\n\t\t\t\t<em id=\"explain-clseResWindow\"\n\t\t\t\t\t><a href=\"@GF@#close-tab-in-greasemonkey\"\n\t\t\t\t\t\t>Greasemonkeyを使っている人は説明を読むこと</a\n\t\t\t\t\t></em\n\t\t\t\t>\n\t\t\t</li>\n\t\t\t<li></li>\n\t\t\t<li>\n\t\t\t\t<label\n\t\t\t\t\t><input\n\t\t\t\t\t\ttype=\"checkbox\"\n\t\t\t\t\t\tname=\"openLinkInNewTab\"\n\t\t\t\t\t/>target属性の付いたリンクを常に新しいタブで開く</label\n\t\t\t\t>\n\t\t\t</li>\n\t\t</ul>\n\t</fieldset>\n\t<fieldset>\n\t\t<legend>ツリーのみ</legend>\n\t\t<ul style=\"display: inline-block\">\n\t\t\t<li>\n\t\t\t\t<label\n\t\t\t\t\t><input\n\t\t\t\t\t\ttype=\"checkbox\"\n\t\t\t\t\t\tname=\"deleteOriginal\"\n\t\t\t\t\t/>元の投稿を非表示にする</label\n\t\t\t\t>(高速化)\n\t\t\t</li>\n\t\t\t<li>\n\t\t\t\tスレッドの表示順\n\t\t\t\t<ul>\n\t\t\t\t\t<li>\n\t\t\t\t\t\t<label\n\t\t\t\t\t\t\t><input\n\t\t\t\t\t\t\t\ttype=\"radio\"\n\t\t\t\t\t\t\t\tname=\"threadOrder\"\n\t\t\t\t\t\t\t\tvalue=\"ascending\"\n\t\t\t\t\t\t\t/>古→新</label\n\t\t\t\t\t\t>\n\t\t\t\t\t</li>\n\t\t\t\t\t<li>\n\t\t\t\t\t\t<label\n\t\t\t\t\t\t\t><input\n\t\t\t\t\t\t\t\ttype=\"radio\"\n\t\t\t\t\t\t\t\tname=\"threadOrder\"\n\t\t\t\t\t\t\t\tvalue=\"descending\"\n\t\t\t\t\t\t\t/>新→古</label\n\t\t\t\t\t\t>\n\t\t\t\t\t</li>\n\t\t\t\t</ul>\n\t\t\t</li>\n\t\t\t<li>\n\t\t\t\tツリーの表示に使うのは\n\t\t\t\t<ul>\n\t\t\t\t\t<li>\n\t\t\t\t\t\t<label\n\t\t\t\t\t\t\t><input\n\t\t\t\t\t\t\t\ttype=\"radio\"\n\t\t\t\t\t\t\t\tname=\"treeMode\"\n\t\t\t\t\t\t\t\tvalue=\"tree-mode-css\"\n\t\t\t\t\t\t\t/>CSS</label\n\t\t\t\t\t\t>\n\t\t\t\t\t</li>\n\t\t\t\t\t<li>\n\t\t\t\t\t\t<label\n\t\t\t\t\t\t\t><input\n\t\t\t\t\t\t\t\ttype=\"radio\"\n\t\t\t\t\t\t\t\tname=\"treeMode\"\n\t\t\t\t\t\t\t\tvalue=\"tree-mode-ascii\"\n\t\t\t\t\t\t\t/>文字</label\n\t\t\t\t\t\t>\n\t\t\t\t\t</li>\n\t\t\t\t</ul>\n\t\t\t</li>\n\t\t\t<li>\n\t\t\t\t<label\n\t\t\t\t\t><input\n\t\t\t\t\t\ttype=\"checkbox\"\n\t\t\t\t\t\tname=\"spacingBetweenMessages\"\n\t\t\t\t\t/>記事の間隔を開ける</label\n\t\t\t\t>\n\t\t\t</li>\n\t\t\t<li>\n\t\t\t\t<label\n\t\t\t\t\t><input type=\"text\" name=\"maxLine\" size=\"2\" />行以上は省略する</label\n\t\t\t\t>\n\t\t\t</li>\n\t\t\t<li>\n\t\t\t\t<label\n\t\t\t\t\t><input\n\t\t\t\t\t\ttype=\"checkbox\"\n\t\t\t\t\t\tname=\"characterEntity\"\n\t\t\t\t\t/>数値文字参照を展開</label\n\t\t\t\t>\n\t\t\t\t<em>(&#数字;が置き換わる)</em>\n\t\t\t</li>\n\t\t\t<li>\n\t\t\t\t<label\n\t\t\t\t\t><input\n\t\t\t\t\t\ttype=\"checkbox\"\n\t\t\t\t\t\tname=\"toggleTreeMode\"\n\t\t\t\t\t/>CSSツリー時にスレッド毎に一時的な文字/CSSの切り替えが出来るようにする</label\n\t\t\t\t>\n\t\t\t</li>\n\t\t</ul>\n\t\t<fieldset style=\"display: inline-block\">\n\t\t\t<legend>投稿非表示設定</legend>\n\t\t\t<ul>\n\t\t\t\t<li>\n\t\t\t\t\t<label\n\t\t\t\t\t\t><input\n\t\t\t\t\t\t\ttype=\"checkbox\"\n\t\t\t\t\t\t\tname=\"useVanishMessage\"\n\t\t\t\t\t\t\taria-describedby=\"explain-useVanishMessage\"\n\t\t\t\t\t\t/>投稿非表示機能を使う</label\n\t\t\t\t\t>\n\t\t\t\t\t<em id=\"explain-useVanishMessage\"\n\t\t\t\t\t\t>使う前に<a href=\"@GF@#vanishMessage\">投稿非表示機能の注意点</a\n\t\t\t\t\t\t>を読むこと。</em\n\t\t\t\t\t>\n\t\t\t\t</li>\n\t\t\t\t<li>\n\t\t\t\t\t<ul>\n\t\t\t\t\t\t<li>\n\t\t\t\t\t\t\t<span id=\"vanished-messages\"\n\t\t\t\t\t\t\t\t><span id=\"vanishedMessageIDs\"></span>個の投稿を非表示中</span\n\t\t\t\t\t\t\t><input\n\t\t\t\t\t\t\t\ttype=\"button\"\n\t\t\t\t\t\t\t\tvalue=\"クリア\"\n\t\t\t\t\t\t\t\tid=\"clearVanishMessage\"\n\t\t\t\t\t\t\t\taria-describedby=\"vanished-messages\"\n\t\t\t\t\t\t\t/>\n\t\t\t\t\t\t</li>\n\t\t\t\t\t\t<li>\n\t\t\t\t\t\t\t<label\n\t\t\t\t\t\t\t\t><input\n\t\t\t\t\t\t\t\t\ttype=\"checkbox\"\n\t\t\t\t\t\t\t\t\tname=\"utterlyVanishMessage\"\n\t\t\t\t\t\t\t\t/>完全に非表示</label\n\t\t\t\t\t\t\t>\n\t\t\t\t\t\t</li>\n\t\t\t\t\t\t<li>\n\t\t\t\t\t\t\t<label\n\t\t\t\t\t\t\t\t><input\n\t\t\t\t\t\t\t\t\ttype=\"checkbox\"\n\t\t\t\t\t\t\t\t\tname=\"vanishMessageAggressive\"\n\t\t\t\t\t\t\t\t/>パラノイア</label\n\t\t\t\t\t\t\t>\n\t\t\t\t\t\t</li>\n\t\t\t\t\t</ul>\n\t\t\t\t</li>\n\t\t\t</ul>\n\t\t</fieldset>\n\t</fieldset>\n\t<fieldset>\n\t\t<legend>スレッド非表示設定</legend>\n\t\t<ul>\n\t\t\t<li>\n\t\t\t\t<label\n\t\t\t\t\t><input\n\t\t\t\t\t\ttype=\"checkbox\"\n\t\t\t\t\t\tname=\"useVanishThread\"\n\t\t\t\t\t/>スレッド非表示機能を使う</label\n\t\t\t\t>\n\t\t\t</li>\n\t\t\t<li>\n\t\t\t\t<ul>\n\t\t\t\t\t<li>\n\t\t\t\t\t\t<span id=\"vanished-threads\"\n\t\t\t\t\t\t\t><span id=\"vanishedThreadIDs\"></span>個のスレッドを非表示中</span\n\t\t\t\t\t\t><input\n\t\t\t\t\t\t\ttype=\"button\"\n\t\t\t\t\t\t\tvalue=\"クリア\"\n\t\t\t\t\t\t\tid=\"clearVanishThread\"\n\t\t\t\t\t\t\taria-describedby=\"vanished-threads\"\n\t\t\t\t\t\t/>\n\t\t\t\t\t</li>\n\t\t\t\t\t<li>\n\t\t\t\t\t\t<label i\n\t\t\t\t\t\t\t><input\n\t\t\t\t\t\t\t\ttype=\"checkbox\"\n\t\t\t\t\t\t\t\tname=\"utterlyVanishNGThread\"\n\t\t\t\t\t\t\t/>完全に非表示</label\n\t\t\t\t\t\t>\n\t\t\t\t\t</li>\n\t\t\t\t\t<li>\n\t\t\t\t\t\t<label\n\t\t\t\t\t\t\t><input\n\t\t\t\t\t\t\t\ttype=\"checkbox\"\n\t\t\t\t\t\t\t\tname=\"autovanishThread\"\n\t\t\t\t\t\t\t/>NGワードを含む投稿があったら、そのスレッドを自動的に非表示に追加する(ツリーのみ)</label\n\t\t\t\t\t\t>\n\t\t\t\t\t</li>\n\t\t\t\t</ul>\n\t\t\t</li>\n\t\t</ul>\n\t</fieldset>\n\t<fieldset>\n\t\t<legend>画像</legend>\n\t\t<ul>\n\t\t\t<li>\n\t\t\t\t<label\n\t\t\t\t\t><input\n\t\t\t\t\t\ttype=\"checkbox\"\n\t\t\t\t\t\tname=\"thumbnail\"\n\t\t\t\t\t/>小町と退避の画像のサムネイルを表示</label\n\t\t\t\t>\n\t\t\t\t<ul>\n\t\t\t\t\t<li>\n\t\t\t\t\t\t<label\n\t\t\t\t\t\t\t><input\n\t\t\t\t\t\t\t\ttype=\"checkbox\"\n\t\t\t\t\t\t\t\tname=\"thumbnailPopup\"\n\t\t\t\t\t\t\t/>ポップアップ表示</label\n\t\t\t\t\t\t>\n\t\t\t\t\t\t<ul>\n\t\t\t\t\t\t\t<li>\n\t\t\t\t\t\t\t\t<label\n\t\t\t\t\t\t\t\t\t><input\n\t\t\t\t\t\t\t\t\t\ttype=\"checkbox\"\n\t\t\t\t\t\t\t\t\t\tname=\"popupBestFit\"\n\t\t\t\t\t\t\t\t\t/>画面サイズに合わせる</label\n\t\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t</li>\n\t\t\t\t\t\t\t<li>\n\t\t\t\t\t\t\t\t<label\n\t\t\t\t\t\t\t\t\t>最大幅:<input\n\t\t\t\t\t\t\t\t\t\ttype=\"text\"\n\t\t\t\t\t\t\t\t\t\tname=\"popupMaxWidth\"\n\t\t\t\t\t\t\t\t\t\tsize=\"5\"\n\t\t\t\t\t\t\t\t\t/>px </label\n\t\t\t\t\t\t\t\t><label\n\t\t\t\t\t\t\t\t\t>最大高:<input\n\t\t\t\t\t\t\t\t\t\ttype=\"text\"\n\t\t\t\t\t\t\t\t\t\tname=\"popupMaxHeight\"\n\t\t\t\t\t\t\t\t\t\tsize=\"5\"\n\t\t\t\t\t\t\t\t\t\taria-describedby=\"explain-popupMaxHeight\"\n\t\t\t\t\t\t\t\t\t/>px\n\t\t\t\t\t\t\t\t\t<em id=\"explain-popupMaxHeight\"\n\t\t\t\t\t\t\t\t\t\t>画面サイズに合わせない時の設定。空欄で原寸表示</em\n\t\t\t\t\t\t\t\t\t></label\n\t\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t</li>\n\t\t\t\t\t\t</ul>\n\t\t\t\t\t</li>\n\t\t\t\t\t<li>\n\t\t\t\t\t\t<label><input type=\"checkbox\" name=\"shouki\" />詳希(;゚Д゚)</label>\n\t\t\t\t\t</li>\n\t\t\t\t</ul>\n\t\t\t</li>\n\t\t\t<li>\n\t\t\t\t<label\n\t\t\t\t\t><input\n\t\t\t\t\t\ttype=\"checkbox\"\n\t\t\t\t\t\tname=\"popupAny\"\n\t\t\t\t\t/>小町と退避以外の画像も対象にする</label\n\t\t\t\t>\n\t\t\t</li>\n\t\t</ul>\n\t</fieldset>\n\t<fieldset>\n\t\t<legend>NGワード</legend>\n\t\t<ul>\n\t\t\t<li>\n\t\t\t\t<label\n\t\t\t\t\t><input\n\t\t\t\t\t\ttype=\"checkbox\"\n\t\t\t\t\t\tname=\"useNG\"\n\t\t\t\t\t\taria-describedby=\"explain-useNG\"\n\t\t\t\t\t/>NGワードを使う</label\n\t\t\t\t>\n\t\t\t\t<p id=\"explain-useNG\">\n\t\t\t\t\t指定には正規表現を使う。以下簡易説明。複数指定するには|(縦棒)で\"区切る\"(先頭や末尾につけてはいけない)。()?*+[]{}^$.の前には\\を付ける。\n\t\t\t\t</p>\n\t\t\t</li>\n\n\t\t\t<li>\n\t\t\t\t<table role=\"presentation\">\n\t\t\t\t\t<tr>\n\t\t\t\t\t\t<td><label for=\"NGHandle\">ハンドル</label></td>\n\t\t\t\t\t\t<td>\n\t\t\t\t\t\t\t<input\n\t\t\t\t\t\t\t\tid=\"NGHandle\"\n\t\t\t\t\t\t\t\ttype=\"text\"\n\t\t\t\t\t\t\t\tname=\"NGHandle\"\n\t\t\t\t\t\t\t\tsize=\"30\"\n\t\t\t\t\t\t\t\taria-errormessage=\"NGHandleNote\"\n\t\t\t\t\t\t\t\taria-describedby=\"explain-NGHandle\"\n\t\t\t\t\t\t\t/><em id=\"explain-NGHandle\">投稿者とメールと題名</em>\n\t\t\t\t\t\t\t<span id=\"NGHandleNote\" class=\"errormessage\"></span>\n\t\t\t\t\t\t</td>\n\t\t\t\t\t</tr>\n\t\t\t\t\t<tr>\n\t\t\t\t\t\t<td><label for=\"NGWord\">本文</label></td>\n\t\t\t\t\t\t<td>\n\t\t\t\t\t\t\t<input\n\t\t\t\t\t\t\t\tid=\"NGWord\"\n\t\t\t\t\t\t\t\ttype=\"text\"\n\t\t\t\t\t\t\t\tname=\"NGWord\"\n\t\t\t\t\t\t\t\tsize=\"30\"\n\t\t\t\t\t\t\t\taria-errormessage=\"NGWordNote\"\n\t\t\t\t\t\t\t/>\n\t\t\t\t\t\t\t<span id=\"NGWordNote\" class=\"errormessage\"></span>\n\t\t\t\t\t\t</td>\n\t\t\t\t\t</tr>\n\t\t\t\t\t<tr>\n\t\t\t\t\t\t<td></td>\n\t\t\t\t\t\t<td>\n\t\t\t\t\t\t\t<input\n\t\t\t\t\t\t\t\tid=\"quote-input\"\n\t\t\t\t\t\t\t\ttype=\"text\"\n\t\t\t\t\t\t\t\tsize=\"15\"\n\t\t\t\t\t\t\t\tvalue=\"\"\n\t\t\t\t\t\t\t\taria-describedby=\"explain-quote-input\"\n\t\t\t\t\t\t\t/>\n\t\t\t\t\t\t\t<span id=\"explain-quote-input\"\n\t\t\t\t\t\t\t\t>よく分からん人はここにNGワードを一つづつ入力して追加ボタンだ</span\n\t\t\t\t\t\t\t>\n\t\t\t\t\t\t</td>\n\t\t\t\t\t</tr>\n\t\t\t\t\t<tr>\n\t\t\t\t\t\t<td></td>\n\t\t\t\t\t\t<td>\n\t\t\t\t\t\t\t<input id=\"quote-output\" type=\"text\" size=\"15\" readonly /><input\n\t\t\t\t\t\t\t\ttype=\"button\"\n\t\t\t\t\t\t\t\tid=\"addToNGWord\"\n\t\t\t\t\t\t\t\tvalue=\"本文に追加\"\n\t\t\t\t\t\t\t/>\n\t\t\t\t\t\t</td>\n\t\t\t\t\t</tr>\n\t\t\t\t</table>\n\t\t\t</li>\n\n\t\t\t<li>\n\t\t\t\t<label\n\t\t\t\t\t><input\n\t\t\t\t\t\ttype=\"checkbox\"\n\t\t\t\t\t\tname=\"NGCheckMode\"\n\t\t\t\t\t/>NGワードを含む投稿を畳まず、NGワードをハイライトする</label\n\t\t\t\t>\n\t\t\t</li>\n\t\t\t<li>\n\t\t\t\t<label\n\t\t\t\t\t><input\n\t\t\t\t\t\ttype=\"checkbox\"\n\t\t\t\t\t\tname=\"utterlyVanishNGStack\"\n\t\t\t\t\t/>完全非表示</label\n\t\t\t\t>\n\t\t\t</li>\n\t\t</ul>\n\t</fieldset>\n\t<p>\n\t\t<label for=\"css\">追加CSS</label><br />\n\t\t<textarea id=\"css\" name=\"css\" cols=\"70\" rows=\"5\"></textarea>\n\t</p>\n\t<fieldset>\n\t\t<legend>エクスポート/インポート</legend>\n\t\t<button type=\"button\" id=\"showExportArea\">エクスポート</button>\n\t\t<input type=\"button\" id=\"showImportArea\" value=\"インポート\" />\n\t\t<div\n\t\t\tclass=\"exportArea\"\n\t\t\tstyle=\"display: none\"\n\t\t\taria-labelledby=\"showExportArea\"\n\t\t>\n\t\t\t<textarea rows=\"5\" cols=\"50\" aria-label=\"Export area\"></textarea>\n\t\t</div>\n\t\t<div\n\t\t\tclass=\"importArea\"\n\t\t\tstyle=\"display: none\"\n\t\t\taria-labelledby=\"showImportArea\"\n\t\t>\n\t\t\t<textarea\n\t\t\t\trows=\"5\"\n\t\t\t\tcols=\"50\"\n\t\t\t\taria-label=\"Import area\"\n\t\t\t\taria-errormessage=\"importNote\"\n\t\t\t></textarea>\n\t\t\t<span id=\"importNote\" class=\"errormessage\">\n\t\t\t\tデータが不正のため、インポート出来ませんでした。\n\t\t\t</span>\n\t\t\t<div><input type=\"button\" id=\"import\" value=\"インポートする\" /></div>\n\t\t</div>\n\t</fieldset>\n\t<p style=\"display: flex; justify-content: space-between\">\n\t\t<span>\n\t\t\t<input\n\t\t\t\ttype=\"submit\"\n\t\t\t\tid=\"save\"\n\t\t\t\taccesskey=\"s\"\n\t\t\t\ttitle=\"くわツリービューの設定を保存する\"\n\t\t\t\tvalue=\"保存[s]\"\n\t\t\t/>\n\t\t\t<span id=\"explainWhyNotSave\" class=\"errormessage\"\n\t\t\t\t>NGワードの正規表現が不正なので保存しませんでした</span\n\t\t\t>\n\t\t\t<input\n\t\t\t\ttype=\"button\"\n\t\t\t\tid=\"close\"\n\t\t\t\taccesskey=\"c\"\n\t\t\t\ttitle=\"くわツリービューの設定を閉じる\"\n\t\t\t\tvalue=\"閉じる[c]\"\n\t\t\t/>\n\t\t\t<span id=\"configInfo\"></span>\n\t\t</span>\n\t\t<span>\n\t\t\t<input type=\"button\" id=\"clear\" value=\"デフォルトに戻す\" />\n\t\t</span>\n\t</p>\n</fieldset>\n"; /** * @constructor * @param {import("Config").default} item */ function ConfigController(item) { this.item = item; const el = document.createElement("form"); el.id = "config"; this.el = el; this.clearTimeoutId = null; const events = [ "save", "clear", "close", "showExportArea", "showImportArea", "import", "clearVanishThread", "clearVanishMessage", "addToNGWord" ]; for(let i = events.length - 1; i >= 0; i--){ const event = events[i]; on(el, "click", "#" + event, this[event].bind(this)); } on(el, "keyup", "#quote-input", this.quotemeta.bind(this)); this.render(); [ "#NGWord", "#NGHandle" ].forEach((target)=>{ on(el, "input", target, this.validateRegExp.bind(this, target)); }); } ConfigController.prototype = { /** * querySelector * @param {string} selector * @returns {HTMLInputElement} */ $: function(selector) { return this.el.querySelector(selector); }, /** * querySelectorAll * @param {string} selector * @returns {HTMLInputElement[]} */ $$: function(selector) { return Array.prototype.slice.call(this.el.querySelectorAll(selector)); }, render: function() { this.el.innerHTML = this.template(); this.restore(); }, template: function() { return html.replace(/@GF@/g, "https://greasyfork.org/scripts/1971-tree-view-for-qwerty"); }, scrollIntoView () { this.el.scrollIntoView(); }, showExportArea () { this.toggleExportImportArea(".exportArea", this.item.toMinimalJson()); }, showImportArea () { this.toggleExportImportArea(".importArea", ""); }, /** * * @param {string} targetClassToShow * @param {string} text - 表示するテキスト */ toggleExportImportArea (targetClassToShow, text) { this.$(`${targetClassToShow} textarea`).value = text; this.$$(".importArea, .exportArea").forEach((el)=>{ el.style.display = "none"; }); this.$(targetClassToShow).style.display = "flex"; }, import () { this.$(".importArea textarea").setAttribute("aria-invalid", "false"); const text = this.$(".importArea textarea").value.replace(/(?:^\s+)|(?:\s+$)/g, ""); if (text === "") { return; } try { const json = JSON.parse(text); return this.info("インポートしました。", ()=>this.item.update(json)); } catch (e) { this.$(".importArea textarea").setAttribute("aria-invalid", "true"); } }, quotemeta: function() { const output = this.$("#quote-output"); const input = this.$("#quote-input"); output.value = quotemeta(input.value); }, addToNGWord: function() { let output = this.$("#quote-output").value; if (!output.length) { return; } const word = this.$("#NGWord").value; if (word.length) { output = word + "|" + output; } this.$("#NGWord").value = output; this.$$("#quote-output, #quote-input").forEach(function(el) { el.value = ""; }); }, validateRegExp: function(target) { const regexp = this.$(target).value; const note = this.$(`${target}Note`); try { //chrome70くらい: regを使わないと、最適化(?)でnew RegExp(regexp)が削除されてしまい //文法にミスがあってもエラーが発生しない const reg = new RegExp(regexp); this.$(target).setAttribute("aria-invalid", String(!reg)); } catch (e) { this.$(target).setAttribute("aria-invalid", "true"); note.textContent = e.message; } }, save: function(e) { e.preventDefault(); this.removeExplainWhyNotSave(); if (this.areNGRegExpsInvalid()) { this.explainWhyNotSave(); return; } const items = this.parse(); if (items) { return this.info("保存しました。", ()=>this.item.update(items)); } }, parse: function() { const items = {}; this.$$("input, select, textarea").forEach((el)=>{ const k = el.name; let v = null; if (!k) { return; } switch(el.type){ case "radio": if (el.checked) { v = el.value; } break; case "text": case "textarea": v = el.value; break; case "checkbox": v = el.checked; break; } if (v !== null) { items[k] = v; } }); return items; }, areNGRegExpsInvalid: function() { return !!this.$('[aria-invalid="true"]'); }, explainWhyNotSave: function() { this.$("#save").setAttribute("aria-invalid", "true"); }, removeExplainWhyNotSave: function() { this.$("#save").setAttribute("aria-invalid", "false"); }, /** * @param {string} text * @param {() => Promise<void>} fun */ info: function(text, fun) { const info = this.$("#configInfo"); clearTimeout(this.clearTimeoutId); info.textContent = "保存中"; return new Promise((resolve, reject)=>{ fun().then(()=>{ info.textContent = text; this.clearTimeoutId = setTimeout(()=>{ info.innerHTML = ""; resolve(); }, 5000); }, reject); }); }, clear: function() { return this.info("デフォルトに戻しました。", ()=>this.item.clear().then(()=>this.restore())); }, close: function() { if (isExtension()) { closeTab(); } else { this.el.parentNode.removeChild(this.el); window.scrollTo(0, 0); } }, clearVanishThread: function() { return this.info("非表示に設定されていたスレッドを解除しました。", ()=>this.item.clearVanishedThreadIDs().then(()=>{ this.$("#vanishedThreadIDs").textContent = "0"; })); }, clearVanishMessage: function() { return this.info("非表示に設定されていた投稿を解除しました。", ()=>this.item.clearVanishedMessageIDs().then(()=>{ this.$("#vanishedMessageIDs").textContent = "0"; })); }, restore: function restore() { const config = this.item; this.$("#vanishedThreadIDs").textContent = "" + config.vanishedThreadIDs.length; this.$("#vanishedMessageIDs").textContent = "" + config.vanishedMessageIDs.length; this.$$("input, select, textarea").forEach(function(el) { const name = el.name; if (!name) { return; } const value = config[name]; switch(el.type){ case "radio": el.checked = value === el.value; break; case "text": case "textarea": el.value = value; break; case "checkbox": el.checked = value; break; } }); [ "#NGWord", "#NGHandle" ].forEach((target)=>{ this.validateRegExp(target); }); } }; /** * * @param {string} str * @returns {string} */ const quotemeta = function(str) { return (str + "").replace(/([()[\]{}|*+.^$?\\])/g, "\\$1"); }; function getBody() { return document.body; } var css = ".text {\n\twhite-space: pre-wrap;\n}\n.text,\n.extra {\n\tmin-width: 20rem;\n}\n.text_tree-mode-css,\n.extra_tree-mode-css {\n\tmargin-left: 1rem;\n}\n.env {\n\tfont-family: initial;\n\tfont-size: smaller;\n}\n\n.thread + .thread {\n\tmargin-top: 0.8rem;\n}\n\n.thread-header {\n\tbackground: #447733 none repeat scroll 0 0;\n\tborder-color: #669955 #225533 #225533 #669955;\n\tborder-style: solid;\n\tborder-width: 1px 2px 2px 1px;\n\tfont-size: 0.8rem;\n\tfont-family: normal;\n\tfont-weight: normal;\n\tmargin-top: 0.8rem;\n\tmargin-bottom: 0;\n\tpadding: 0;\n\tpadding-inline-start: 0.1rem;\n\twidth: 100%;\n}\n\n.message-header {\n\twhite-space: nowrap;\n}\n.message-header_tree-mode-css {\n\tfont-size: 0.85rem;\n\tfont-family: normal;\n}\n.message-info {\n\tfont-family: monospace;\n\tcolor: #87ce99;\n}\n\n.read .text,\n.quote {\n\tcolor: #ccb;\n}\nheader,\nfooter {\n\tdisplay: flex;\n\tfont-size: 0.9rem;\n\tjustify-content: space-between;\n}\n\n.modified {\n\tcolor: #fbb;\n}\n\n.note,\n.toggleCharacterEntity.on,\n.env {\n\tfont-style: italic;\n}\n\n.chainingHidden::after {\n\tcontent: \"この投稿も非表示になります\";\n\tfont-weight: bold;\n\tfont-style: italic;\n\tcolor: red;\n}\n.a-tree {\n\tfont-style: initial;\n\tvertical-align: top;\n}\n\n.border {\n\tdisplay: block;\n\tposition: absolute;\n\ttop: 1em;\n\ttop: 1lh;\n\twidth: 1px;\n\theight: 100%;\n\tbackground-color: #adb;\n\tz-index: -1;\n}\n\n.messageAndChildrenButLast {\n\tposition: relative;\n}\n\n.thumbnail-img {\n\twidth: 80px;\n\tmax-height: 400px;\n\timage-orientation: from-image;\n}\n#image-view {\n\tposition: fixed;\n\ttop: 50%;\n\tleft: 50%;\n\ttransform: translate(-50%, -50%);\n\tbackground: #004040;\n\tcolor: white;\n\tfont-weight: bold;\n\tfont-style: italic;\n\tmargin: 0;\n\timage-orientation: from-image;\n}\n.image-view-img {\n\tbackground-color: white;\n}\n\n.focused {\n\toutline: 2px solid yellow;\n}\n\n.truncation,\n.hidden {\n\tdisplay: none;\n}\n.spacing {\n\tpadding-bottom: 1rem;\n}\n.spacer:first-child {\n\tdisplay: none;\n}\n\n.toggleMessage::after {\n\tcontent: \"消\";\n}\n\n.toggleMessage.revert::after {\n\tcontent: \"戻\";\n}\n\n.showOriginalButtons + .message {\n\tdisplay: none;\n}\n\n.showMessage:not(.on),\n.showMessage.on ~ * {\n\tdisplay: none;\n}\n\n.qtv-error {\n\tfont-family: initial;\n\tborder: red solid;\n}\n"; class Qtv { initializeComponent() { this.applyCss(); this.zero(); this.addEventListeners(); this.setAccesskeyToV(); this.setIdsToFormAndLinks(); this.registerKeyboardNavigation(); } applyCss() { document.head.insertAdjacentHTML("beforeend", `<style>${css + this.config.css}</style>`); } zero() { if (this.config.zero) { const d = this.getD(); this.setZeroToD(d); } } /** @returns {HTMLInputElement} */ getD() { return /** @type {HTMLInputElement} */ document.getElementsByName("d")[0]; } /** * @param {?HTMLInputElement} d */ setZeroToD(d) { if (d && d.value !== "0") { d.value = "0"; } } addEventListeners() { const body = getBody(); on(body, "click", "#openConfig", (e)=>{ e.preventDefault(); this.openConfig(); }); const delegateTweakLink = (/** @type {Event} */ e)=>{ this.tweakLink(/** @type {HTMLAnchorElement} */ e.target); }; on(body, "mousedown", "a", delegateTweakLink); } openConfig() { if (isExtension()) { this.openConfigOnExtension(); } else if (this.thereIsNoConfigPageOpen()) { this.openConfigOnGreaseMonkey(); } } openConfigOnExtension() { sendMessageToRuntime({ type: "openConfig" }); } thereIsNoConfigPageOpen() { return !document.getElementById("config"); } openConfigOnGreaseMonkey() { this.openConfigAtTopOfPage(); } openConfigAtTopOfPage() { const controller = new ConfigController(this.config); this.prepend(controller.el); controller.scrollIntoView(); } /** * @param {HTMLAnchorElement} a */ tweakLink(a) { this.changeTargetToBlank(a); this.appendNoreferrerAndNoopenerToPreventFromModifyingURL(a); } /** * @param {HTMLAnchorElement} a */ changeTargetToBlank(a) { if (this.config.openLinkInNewTab && a.target === "link") { a.target = "_blank"; } } appendNoreferrerAndNoopenerToPreventFromModifyingURL(a) { if (a.target) { a.rel += " noreferrer noopener"; } } setAccesskeyToV() { const accessKey = this.config.accesskeyV; if (accessKey.length === 1) { const v = document.getElementsByName("v")[0]; if (v) { v.accessKey = accessKey; v.title = "内容"; } } } setIdsToFormAndLinks() { const form = document.forms[0]; if (form) { this.setIdToForm(form); this.setIdToLinks(form); } } /** * @param {HTMLFormElement} form */ setIdToForm(form) { form.id = "form"; } /** * @param {HTMLFormElement} form */ setIdToLinks(form) { const fonts = form.getElementsByTagName("font"); // これ以外に指定のしようがない const link = fonts[fonts.length - 3]; if (link) { link.id = "link"; } } registerKeyboardNavigation() { if (this.config.keyboardNavigation) { this.keyboardNavigation = new KeyboardNavigation(this.config); document.addEventListener("keydown", this.keyboardNavigation, false); } } /** * @param {ParentNode} _fragment */ render(_fragment) { //empty } finish(_fragment) { if (this.keyboardNavigation) { this.keyboardNavigation.enableToReload(); } } /** * 本来投稿が来るところの先頭に挿入 * @param {Node} node */ insert(node) { const hr = document.body.querySelector("body > hr"); if (hr) { hr.parentNode.insertBefore(node, hr.nextSibling); } } /** * 一番下に追加 * @param {Node} node */ append(node) { document.body.appendChild(node); } /** * 一番上に追加 * @param {Node} node */ prepend(node) { document.body.insertBefore(node, document.body.firstChild); } /** * @param {Node} node */ remove(node) { node.parentNode.removeChild(node); } /** * @param {import("Config").default} config */ constructor(config){ this.config = config; } } /** * @param {any} howManyPosts 何件表示されている振りをする?表示がある振りをする? * @param {ParentNode} container Modify. \<P>\<I>\</I>\</P>から\<HR>が含まれている */ function tweakFooter(howManyPosts, container) { const i = container.querySelector("p i"); if (!i) { return container; } /* <P><I><FONT size="-1">ここまでは、現在登録されている新着順1番目から1番目までの記事っぽい!</FONT></I></P> <TABLE>次のページ、リロードボタン</TABLE> <HR> `<TABLE>`は、このページに投稿がない、次のページに表示すべき投稿がない、のいずれかの場合は含まれない */ const p = /** @type {HTMLElement} */ i.parentNode // === <P> ; const table = nextElement$1("TABLE")(p); let end; if (table && howManyPosts) { // 消すのはpだけ end = p; } else { // tableはないか、あるが0件の振りをするためtableは飛ばす const hr = nextElement$1("HR")(p); end = hr; } new _class$3().deleteContents(p, end); return container; } let _class$1 = class _class { /** * @param {MouseEvent} e */ /* * <span class="showOriginalButtons"><a>NG</a><a>非表示解除</a>e.targetは左のaのどちらか</span> * <div class="original message"></div> */ handleEvent(e) { e.preventDefault(); const button = /** @type {Element} */ e.target; const buttons = /** @type {Element} */ button.parentNode; if (button.matches(".showNG")) { this.showNG(buttons); } else if (button.matches(".showThread")) { this.showThread(buttons); } } /** * @param {Element} buttons */ showNG(buttons) { this.removeButtons(buttons); } /** * @param {Element} buttons */ showThread(buttons) { const thisMessage = /** @type {HTMLElement} */ buttons.nextElementSibling; const threadId = thisMessage.dataset.threadId; const restore = savePosition(buttons); this.presenter.removeVanishedThread(threadId); for (const message of document.querySelectorAll(`.message[data-thread-id="${threadId}"]`)){ if (message === thisMessage) { restore(); } const buttons = message.previousElementSibling; if (buttons.matches(".showOriginalButtons")) { this.removeButtons(buttons); } } } /** * @param {Element} buttons */ removeButtons(buttons) { buttons.parentNode.removeChild(buttons); } /** * @param {import("./StackPresenter").default} presenter */ constructor(presenter){ this.presenter = presenter; } }; class StackView extends Qtv { setPresenter(presenter) { this.presenter = presenter; } initializeComponent() { this.setupMiniInfo(); this.accesskey(); super.initializeComponent(); this.insert(this.el); } setupMiniInfo() { const setup = document.body.querySelector('input[name="setup"]'); if (!setup) { return; } const miniinfo = this.miniinfo = this.createMiniInfo(setup); this.showOptionButton(miniinfo); this.showClearVanishedThreadsButton(miniinfo); this.showNGMessage(miniinfo); this.showMiniInfo(setup, miniinfo); } showMiniInfo(setup, miniinfo) { setup.parentNode.insertBefore(miniinfo, setup.nextSibling); } showNGMessage(miniinfo) { if (this.ng.message) { miniinfo.insertAdjacentHTML("beforeend", " " + this.ng.message); } } showClearVanishedThreadsButton(miniinfo) { const numVanishedThreads = this.config.vanishedThreadIDs.length; if (numVanishedThreads) { miniinfo.insertAdjacentHTML("beforeend", ' 非表示解除(<a class="clearVanishedThreadIDs" href="javascript:;"><span class="count">' + numVanishedThreads + "</span>スレッド</a>)"); } } showOptionButton(miniinfo) { miniinfo.insertAdjacentHTML("beforeend", ' <a href="javascript:;" id="openConfig">★くわツリービューの設定★</a>'); } createMiniInfo(setup) { const miniinfo = document.createElement("span"); miniinfo.id = "qtv-miniinfo"; setup.parentNode.insertBefore(miniinfo, setup.nextSibling); return miniinfo; } accesskey() { const midoku = /** @type {HTMLElement} */ document.body.querySelector('input[name="midokureload"]'); if (midoku) { midoku.accessKey = this.config.accesskeyReload; midoku.title = "ヽ(´ー`)ノロード"; } } /** @override */ addEventListeners() { super.addEventListeners(); const el = this.el; const showHiddenMessage = new _class$1(this.presenter); on(el, "click", ".showNG", showHiddenMessage); on(el, "click", ".showThread", showHiddenMessage); on(el, "click", ".vanishThread", (/** @type {MouseEvent} */ e)=>{ this.handleVanishThread(e); }); on(document.body, "click", ".clearVanishedThreadIDs", (e)=>{ e.preventDefault(); this.presenter.clearVanishedThreadIDs(); }); } /** * @param {MouseEvent} e */ handleVanishThread(e) { e.preventDefault(); const thisVanishButton = /** @type {Element} */ e.target; const revert = thisVanishButton.classList.contains("revert"); const message = /** @type {HTMLElement} */ thisVanishButton.closest(".message"); const threadId = message.dataset.threadId; const restore = savePosition(message); if (revert) { this.presenter.removeVanishedThread(threadId); } else { this.presenter.addVanishedThread(threadId); } for (const message of document.querySelectorAll(`.message[data-thread-id="${threadId}"]`)){ // 投稿が有効か無効かのトグル message.classList.toggle("invalid"); // 削除なら本文を消す。復帰なら表示する。 // スレッドが表示・非表示が切り替わったことを分かりやすく示す。 message.querySelector("blockquote").classList.toggle("hidden"); // ボタンを削除ボタンを復帰ボタンに、またはその逆 const vanishButton = message.querySelector(".vanishThread"); vanishButton.classList.toggle("revert"); } restore(); } clearVanishedThreadIDs() { const count = document.body.querySelector(".clearVanishedThreadIDs .count"); if (count) { count.innerHTML = "0"; } } /** * @param {ParentNode} fragment `fragment`の先頭は通常は空白。ログの一番先頭のみ\<A> * @param {ParentNode} container */ render(fragment, container = this.el) { const { range } = this; let comment; while(comment = this.firstComment(fragment)){ const first = /** @type {Text|HTMLAnchorElement} */ fragment.firstChild; const one = range.extractContents(first, comment); // 以下のように一つずつやるとO(n) // 一気に全部やるとO(n^2) // chrome57の時点で一気にやってもO(n)になってる const a = /** @type {HTMLAnchorElement} */ one.querySelector("a[name]"); try { if (a) { this.renderer.render(a); } container.appendChild(one); } catch (e) { container.appendChild(one); this.skipThisPost(a, e); } } } /** * @param {ParentNode} fragment */ firstComment(fragment) { let first = fragment.firstChild; while(first){ if (first.nodeType === Node.COMMENT_NODE && first.nodeValue === " ") { return first; } first = first.nextSibling; } return null; } /** * @param {HTMLAnchorElement} a * @param {Error} e */ skipThisPost(a, e) { a.insertAdjacentHTML("beforebegin", `<div class="qtv-error">問題が発生したため、この投稿の処理を中断しました。<pre class="qtv-error">${e.message}</pre></div>`); } /** * @param {ParentNode} fragment */ finishFooter(fragment) { fragment = this.tweakFooter(fragment); return this.append(fragment); } /** * @param {ParentNode} fragment */ tweakFooter(fragment) { if (this.needsToTweakFooter()) { return tweakFooter(this.countMessages(), fragment); } return fragment; } needsToTweakFooter() { const config = this.config; return this.ng.isEnabled && config.utterlyVanishNGStack || config.useVanishThread && config.utterlyVanishNGThread; } countMessages() { return this.el.querySelectorAll(".message").length; } showIsSearchingOldLogsExceptFor(ff) { const info = document.createElement("div"); info.id = "qtv-info"; info.innerHTML = `<strong>${ff}以外の過去ログを検索中...</strong>`; this.prepend(info); } doneSearchingOldLogs() { this.remove(document.querySelector("#qtv-info")); } /** * @param {string} ff 日付.dat * @param {import("Query").FetchResult[]} befores * @param {import("Query").FetchResult[]} afters */ setBeforesAndAfters(ff, befores, afters) { if (!document.body.querySelector("h1")) { document.body.insertAdjacentHTML("afterbegin", `<h1>${ff}</h1>`); } const h1 = document.querySelector("h1"); befores.reverse().forEach((before)=>{ const container = this.createPseudoPage(before); h1.parentNode.insertBefore(container, h1); }, this); afters.reverse().forEach((after)=>{ const container = this.createPseudoPage(after); this.el.appendChild(container); }, this); } /** * * @param {Object} data * @param {DocumentFragment} data.fragment * @param {import("Query").ff} data.ff */ createPseudoPage({ fragment, ff }) { for (const script of fragment.querySelectorAll("script")){ fragment.removeChild(script); } let container; if (fragment.querySelector("h1")) { container = document.createDocumentFragment(); } else { container = this.range.createContextualFragment(`<h1>${ff}</h1>`); } this.render(fragment, container); // 何か余り物があるかもしれないのでそれも追加 container.appendChild(fragment); const numPosts = container.querySelectorAll(".message").length; container.appendChild(this.range.createContextualFragment(numPosts ? `<h3>${numPosts}件見つかりました。</h3>` : "<h3>指定されたスレッドは見つかりませんでした。</h3>")); return container; } /** * @param {import("Config").default} config */ constructor(config, range = new _class$3(), renderer = new _class$2(config, range)){ super(config); this.range = range; this.renderer = renderer; this.ng = new NG(config); this.el = document.createElement("main"); this.el.id = "qtv-main"; this.miniinfo = null; /** @type {import("./StackPresenter").default} */ this.presenter = null; } } /** * Configが読み込まれるまで、送られてきたHTMLはここに溜め込み、表示されないようにする。 */ class Buffer { /** * @param {{onProgress: (fragment: DocumentFragment) => void, onLoaded: (fragment: DocumentFragment) => void}} listener */ setListener(listener) { this.listener = listener; } /** * @param {HTMLHRElement} hr `BODY`直下の一番目の`HR`。投稿はこの下から始まる。 */ onHr(hr) { hr.parentNode.insertBefore(this.marker, hr.nextSibling); this.range.setStartAfter(this.marker); } /** * @param {Node} lastChild 読み込まれた一番最後のノード */ onProgress(lastChild) { if (lastChild !== this.marker) { this.range.setEndAfter(lastChild); this.fragment.appendChild(this.range.extractContents()); } // lastChild === markerの場合、つまりbufferに変化がなくてもlistener.renderを呼んでいる。 // 無駄に見える。何か理由があってこうした気がするけど覚えていない。 this.listener.onProgress(this.fragment); } onLoaded() { this.listener.onLoaded(this.fragment); } constructor(range = document.createRange()){ this.range = range; this.fragment = document.createDocumentFragment(); /** これを基準にする。これの次が新しいデータ。`hr`の次に要素を挿入しても新しいデータだと勘違いしない */ this.marker = document.createComment("qtv-main-started"); this.listener = null; } } const delayPromise = (ms)=>new Promise((resolve)=>setTimeout(resolve, ms)); class DelayNotice { onHr() { return delayPromise(this.timeout_ms).then(this.popup.bind(this)); } popup() { if (this.configLoaded) { return; } this.notice = document.createElement("aside"); this.notice.id = "qtv-status"; this.notice.style.cssText = "position:fixed;top:0px;left:0px;background-color:black;color:white;z-index:1"; this.notice.textContent = "設定読込待ち"; const body = getBody(); body.insertBefore(this.notice, body.firstChild); const removeNotice = ()=>body.removeChild(this.notice); this.gotConfig.then(removeNotice, removeNotice); } constructor(gotConfig, timeout_ms = 700){ this.gotConfig = gotConfig; this.timeout_ms = timeout_ms; this.configLoaded = false; this.notice = null; this.gotConfig.then(()=>{ this.configLoaded = true; }); } } /** * @returns {Promise<void>} */ function ready$1({ doc = document, capture = false } = {}) { return new Promise(function(resolve) { const readyState = doc.readyState; if (readyState === "complete" || readyState !== "loading" && !doc.documentElement.doScroll) { resolve(); } else { doc.addEventListener("DOMContentLoaded", ()=>resolve(), { capture, once: true }); } }); } class LoadedObserver { /** * @param {import("./LoadingObserver").Listener} listener */ addListener(listener) { this.listeners.push(listener); } observe() { ready$1().then(()=>{ const hr = document.body.querySelector("body > hr"); if (hr) { this.notify("onHr", hr); this.notify("onProgress", document.body.lastChild); } this.notify("onLoaded"); }); } notify(event, arg) { this.listeners.forEach((listener)=>{ if (listener[event]) { listener[event](arg); } }); } constructor(){ /** * @type {import("./LoadingObserver").Listener[]} */ this.listeners = []; } } const find = Array.prototype.find; const isHR = (node)=>node.nodeName === "HR"; var findHr = ((mutations)=>{ for(let i = 0; i < mutations.length; i++){ const mutation = mutations[i]; if (mutation.target.nodeName === "BODY") { var element = find.call(mutation.addedNodes, isHR); if (element) { return element; } } } }); var waitForDomContentLoaded = (()=>ready$1({ capture: true })); function doNothing() {} var getInfo = (()=>isGm() ? getGMInfo(GM_info) : isGm4() ? getGMInfo(GM.info) : { platform: "chrome", version: chrome.runtime.getManifest().version }); const getGMInfo = (/** @type {Tampermonkey.ScriptInfo} */ info)=>({ platform: info.scriptHandler + info.version, version: info.script.version }); /** @type {Error} */ let e; /** * @param {Error} error */ function handleError(error) { if (e) { return; } e = error; return ready$1().then(getBody).then(doHandle); } /** * @param {HTMLBodyElement} body */ function doHandle(body) { const pre = document.createElement("pre"); pre.className = "qtv-error"; pre.innerHTML = 'くわツリービューの処理を中断しました。表示出来なかった投稿があります。<a href="javascript:;">スタックトレースを表示する</a>'; const dStackTrace = document.createElement("pre"); dStackTrace.style.display = "none"; const info = getInfo(); dStackTrace.textContent = `qtvStacktrace/${info.platform}+${info.version} ${e.name}: ${e.stack || ""}`; pre.appendChild(dStackTrace); pre.addEventListener("click", showStackTrace); body.insertBefore(pre, body.firstChild); console.error(e); throw e; } /** * @param {Event} e */ function showStackTrace(e) { const el = /** @type {Element} */ e.target; el.parentNode.querySelector("pre").style.display = null; } class LoadingObserver { makeMutationObserver(callback) { return new MutationObserver(callback); } /** * @param {MutationRecord[]} mutations * @param {MutationObserver} observer */ processRecords(mutations, observer) { observer.disconnect(); this.inspect(mutations); this.observe(); } /** * @param {MutationRecord[]} mutations */ inspect(mutations) { if (!this.hr) { this.hr = findHr(mutations); if (this.hr) { this.notify("onHr", this.hr); } } if (this.hr) { this.notify("onProgress", this.doc.body.lastChild); } } /** * @param {string} event * @param {ChildNode} [arg] */ notify(event, arg) { for(let i = 0; i < this.listeners.length; i++){ const listener = this.listeners[i]; if (!listener[event]) { continue; } try { const ret = listener[event](arg); // エラーの処理はここでやるべきではないと思う if (ret && ret.catch) { ret.catch(this.cleanupAfterError); } } catch (e) { this.cleanupAfterError(e); } } } cleanupAfterError(e) { this.observer.disconnect(); this.observer.observe = doNothing; handleError(e); } observe() { if (this.doc.body) { if (this.isFirstCall) { this.first(); } this.observer.observe(this.doc.body, { childList: true }); } else { this.observer.observe(this.doc.documentElement, { childList: true, subtree: true }); } this.isFirstCall = false; } first() { this.hr = this.doc.body.querySelector("body > hr"); if (this.hr) { this.notify("onHr", this.hr); this.notify("onProgress", this.doc.body.lastChild); } } /** * @param {Listener} listener */ addListener(listener) { this.listeners.push(listener); } constructor(loaded = waitForDomContentLoaded(), doc = document){ /** * @type {Listener[]} */ this.listeners = []; this.doc = doc; this.hr = null; this.observer = this.makeMutationObserver(this.processRecords.bind(this)); this.isFirstCall = true; loaded.then(()=>{ const records = this.observer.takeRecords(); this.observer.disconnect(); if (records.length) { this.inspect(records); } this.notify("onLoaded"); }).catch(()=>{}); } } class Stash { stash(buffer) { this.area.appendChild(buffer); } restore() { this.area.parentNode.removeChild(this.area); var range = document.createRange(); range.selectNodeContents(this.area); return range.extractContents(); } appendTo(node) { node.appendChild(this.area); } constructor(){ var area = this.area = document.createElement("div"); area.id = "qtv-stash-area"; area.hidden = true; } } const Fragment = (props) => { return props.children; }; const isSSR = () => typeof _nano !== 'undefined' && _nano.isSSR === true; /** Creates a new Microtask using Promise() */ const tick = Promise.prototype.then.bind(Promise.resolve()); // https://stackoverflow.com/a/7616484/12656855 const strToHash = (s) => { let hash = 0; for (let i = 0; i < s.length; i++) { const chr = s.charCodeAt(i); hash = (hash << 5) - hash + chr; hash |= 0; // Convert to 32bit integer } return Math.abs(hash).toString(32); }; const appendChildren = (element, children, escape = true) => { // if the child is an html element if (!Array.isArray(children)) { appendChildren(element, [children], escape); return; } // htmlCollection to array if (typeof children === 'object') children = Array.prototype.slice.call(children); children.forEach(child => { // if child is an array of children, append them instead if (Array.isArray(child)) appendChildren(element, child, escape); else { // render the component const c = _render(child); if (typeof c !== 'undefined') { // if c is an array of children, append them instead if (Array.isArray(c)) appendChildren(element, c, escape); // apply the component to parent element else { if (isSSR() && !escape) element.appendChild(c.nodeType == null ? c.toString() : c); else element.appendChild(c.nodeType == null ? document.createTextNode(c.toString()) : c); } } } }); }; /** * A simple component for rendering SVGs */ const SVG = (props) => { const child = props.children[0]; const attrs = child.attributes; if (isSSR()) return child; const svg = hNS('svg'); for (let i = attrs.length - 1; i >= 0; i--) { svg.setAttribute(attrs[i].name, attrs[i].value); } svg.innerHTML = child.innerHTML; return svg; }; const _render = (comp) => { // null, false, undefined if (comp === null || comp === false || typeof comp === 'undefined') return []; // string, number if (typeof comp === 'string' || typeof comp === 'number') return comp.toString(); // SVGElement if (comp.tagName && comp.tagName.toLowerCase() === 'svg') return SVG({ children: [comp] }); // HTMLElement if (comp.tagName) return comp; // TEXTNode (Node.TEXT_NODE === 3) if (comp && comp.nodeType === 3) return comp; // Class Component if (comp && comp.component && comp.component.isClass) return renderClassComponent(comp); // Class Component (Uninitialized) if (comp.isClass) return renderClassComponent({ component: comp, props: {} }); // Functional Component if (comp.component && typeof comp.component === 'function') return renderFunctionalComponent(comp); // Array (render each child and return the array) (is probably a fragment) if (Array.isArray(comp)) return comp.map(c => _render(c)).flat(); // function if (typeof comp === 'function' && !comp.isClass) return _render(comp()); // if component is a HTMLElement (rare case) if (comp.component && comp.component.tagName && typeof comp.component.tagName === 'string') return _render(comp.component); // (rare case) if (Array.isArray(comp.component)) return _render(comp.component); // (rare case) if (comp.component) return _render(comp.component); // object if (typeof comp === 'object') return []; console.warn('Something unexpected happened with:', comp); }; const renderFunctionalComponent = (fncComp) => { const { component, props } = fncComp; return _render(component(props)); }; const renderClassComponent = (classComp) => { const { component, props } = classComp; // calc hash const hash = strToHash(component.toString()); // make hash accessible in constructor, without passing it to it component.prototype._getHash = () => hash; const Component = new component(props); if (!isSSR()) Component.willMount(); let el = Component.render(); el = _render(el); Component.elements = el; // pass the component instance as ref if (props && props.ref) props.ref(Component); if (!isSSR()) tick(() => { Component._didMount(); }); return el; }; const hNS = (tag) => document.createElementNS('http://www.w3.org/2000/svg', tag); // https://stackoverflow.com/a/42405694/12656855 const h = (tagNameOrComponent, props = {}, ...children) => { // if children is passed as props, merge with ...children if (props && props.children) { if (Array.isArray(children)) { if (Array.isArray(props.children)) children = [...props.children, ...children]; else children.push(props.children); } else { if (Array.isArray(props.children)) children = props.children; else children = [props.children]; } } // render WebComponent in SSR if (isSSR() && _nano.ssrTricks.isWebComponent(tagNameOrComponent)) { const element = _nano.ssrTricks.renderWebComponent(tagNameOrComponent, props, children, _render); if (element === null) return `ERROR: "<${tagNameOrComponent} />"`; else return element; } // if tagNameOrComponent is a component if (typeof tagNameOrComponent !== 'string') return { component: tagNameOrComponent, props: Object.assign(Object.assign({}, props), { children: children }) }; // custom message if document is not defined in SSR try { if (isSSR() && typeof tagNameOrComponent === 'string' && !document) throw new Error('document is not defined'); } catch (err) { console.log('ERROR:', err.message, '\n > Please read: https://github.com/nanojsx/nano/issues/106'); } let ref; const element = tagNameOrComponent === 'svg' ? hNS('svg') : document.createElement(tagNameOrComponent); // check if the element includes the event (for example 'oninput') const isEvent = (el, p) => { // check if the event begins with 'on' if (0 !== p.indexOf('on')) return false; // we return true if SSR, since otherwise it will get rendered if (el._ssr) return true; // check if the event is present in the element as object (null) or as function return typeof el[p] === 'object' || typeof el[p] === 'function'; }; for (const p in props) { // https://stackoverflow.com/a/45205645/12656855 // style object to style string if (p === 'style' && typeof props[p] === 'object') { const styles = Object.keys(props[p]) .map(k => `${k}:${props[p][k]}`) .join(';') .replace(/[A-Z]/g, match => `-${match.toLowerCase()}`); props[p] = `${styles};`; } // handel ref if (p === 'ref') ref = props[p]; // handle events else if (isEvent(element, p.toLowerCase())) element.addEventListener(p.toLowerCase().substring(2), (e) => props[p](e)); // dangerouslySetInnerHTML else if (p === 'dangerouslySetInnerHTML' && props[p].__html) { if (!isSSR()) { const fragment = document.createElement('fragment'); fragment.innerHTML = props[p].__html; element.appendChild(fragment); } else { element.innerHTML = props[p].__html; } } // modern dangerouslySetInnerHTML else if (p === 'innerHTML' && props[p].__dangerousHtml) { if (!isSSR()) { const fragment = document.createElement('fragment'); fragment.innerHTML = props[p].__dangerousHtml; element.appendChild(fragment); } else { element.innerHTML = props[p].__dangerousHtml; } } // className else if (/^className$/i.test(p)) element.setAttribute('class', props[p]); // setAttribute else if (typeof props[p] !== 'undefined') element.setAttribute(p, props[p]); } // these tags should not be escaped by default (in ssr) const escape = !['noscript', 'script', 'style'].includes(tagNameOrComponent); appendChildren(element, children, escape); if (ref) ref(element); return element; }; var __rest = (window && window.__rest) || function (s, e) { var t = {}; for (var p in s) if (Object.prototype.hasOwnProperty.call(s, p) && e.indexOf(p) < 0) t[p] = s[p]; if (s != null && typeof Object.getOwnPropertySymbols === "function") for (var i = 0, p = Object.getOwnPropertySymbols(s); i < p.length; i++) { if (e.indexOf(p[i]) < 0 && Object.prototype.propertyIsEnumerable.call(s, p[i])) t[p[i]] = s[p[i]]; } return t; }; const createNode = function (type, props) { let { children = [] } = props, _props = __rest(props, ["children"]); if (!Array.isArray(children)) children = [children]; return h(type, _props, ...children); }; class Thread { /** * @param {Post} post */ addPost(post) { this.posts.push(post); this.allPosts[post.id] = post; } computeRoots() { if (this.roots) { return this.roots; } this.setNGtoPosts(); if (this.shouldExcludeNGPosts()) { this.excludeNGPosts(); } this.makeFamilyTree(); this.makeMissingParent(); this.makeMissingGrandParent(); if (this.shouldSetRejectLevel()) { this.setRejectLevel(); } if (this.shouldRejectPosts()) { this.dropRejectedPosts(); } return this.roots; } /** * 今実際にある`Post`を繋いで親子関係を作る */ makeFamilyTree() { this.posts.filter(Post$1.wantsParent).forEach(this.adopt, this); } /** * @param {Post} post */ adopt(post) { const parent = this.allPosts[post.getKeyForOwnParent()]; if (!parent) { return; } parent.adoptAsEldestChild(post); } /** * 仮想の親(`MergedPost`)を作り親子関係を作る */ makeMissingParent() { const orphans = this.posts.filter(Post$1.isOrphan); this.connect(orphans); this.roots = this.getRootCandidates(); } connect(orphans) { orphans.forEach(this.makeParent, this); orphans.forEach(this.adopt, this); } getRootCandidates() { return Object.values(this.allPosts).filter(Post$1.isRootCandidate).sort(this.byID); } /** * 想像上の親(`GhostPost`)を作り親子関係を作る */ makeMissingGrandParent() { const orphans = this.roots.filter(Post$1.mayHaveParent); this.connect(orphans); this.roots = this.getRootCandidates(); } /** * @param {Post} orphan */ makeParent(orphan) { const key = orphan.getKeyForOwnParent(); this.allPosts[key] = this.allPosts[key] || orphan.makeParent(); } /** * @param {Post} l * @param {Post} r */ byID(l, r) { const lid = l.id ? l.id : l.child.id; const rid = r.id ? r.id : r.child.id; return +lid - +rid; } shouldSetRejectLevel() { return +this.getSmallestMessageID() <= this.getThreshold(); } getThreshold() { return +this.config.vanishedMessageIDs[0]; } getSmallestMessageID(keys = Object.keys) { return keys(this.allPosts).sort(this.byNumericalOrder)[0]; } /** * @param {string} l * @param {string} r */ byNumericalOrder(l, r) { return +l - +r; } setNGtoPosts() { const ng = new NG(this.config); this.posts.forEach((post)=>{ post.checkNG(ng); this.isNG = this.isNG || post.isNG; }); } shouldExcludeNGPosts() { return this.config.utterlyVanishNGStack; } excludeNGPosts() { this.posts.filter((post)=>post.isNG).forEach((post)=>{ delete this.allPosts[post.id]; }); this.posts = this.posts.filter((post)=>!post.isNG); } shouldRejectPosts() { return this.config.utterlyVanishMessage; } setRejectLevel() { const vanishedMessageIDs = this.config.vanishedMessageIDs; // なぜ逆順なのかは覚えていない for(let i = this.roots.length - 1; i >= 0; i--){ this.roots[i].setVanishedForRoot(vanishedMessageIDs); } } dropRejectedPosts() { /** @type {Post[]} */ const newRoots = []; for(let i = this.roots.length - 1; i >= 0; i--){ this.roots[i].drop(newRoots); } newRoots.sort(Post$1.byID); this.roots = newRoots; } getDate() { return this.posts[0].date; } getNumber() { if (this.shouldRejectPosts()) { return this.posts.filter(Post$1.isClean).length; } else { return this.posts.length; } } getID() { return this.posts[0].threadId; } getThreadButton() { return this.posts[0].threadButton; } getSite() { return this.posts[0].site; } /** * live */ isVanished() { return this.config.isVanishedThread(this.getID()); } /** * @param {import("Config").default} config */ constructor(config){ this.config = config; /** @type {Array<Post>} */ this.posts = []; /** @type {Array<Post>} */ this.roots = null; this.isNG = false; /** @type {{[id: string]: Post}} */ this.allPosts = Object.create(null); } } class ThreadMaker { /** * @param {ActualPost[]} posts */ make(posts) { const allThreads = Object.create(null); posts.forEach((post)=>{ const id = post.threadId; let thread = allThreads[id]; if (!thread) { thread = allThreads[id] = new Thread(this.config); this.threads.push(thread); } thread.addPost(post); }); this.sortThreads(); this.computeRoots(); return this.threads; } sortThreads() { if (this.config.threadOrder === "ascending") { this.threads.reverse(); } } computeRoots() { this.threads.forEach((thread)=>thread.computeRoots()); } /** * @param {import("Config").default} config */ constructor(config){ this.config = config; /** @type {Thread[]} */ this.threads = []; } } function _extends$2() { _extends$2 = Object.assign || function(target) { for(var i = 1; i < arguments.length; i++){ var source = arguments[i]; for(var key in source){ if (Object.prototype.hasOwnProperty.call(source, key)) { target[key] = source[key]; } } } return target; }; return _extends$2.apply(this, arguments); } function AButton(props) { return /*#__PURE__*/ createNode("a", _extends$2({ href: "javascript:;", role: "button" }, props)); } /** * @typedef {object} Props * @prop {import("Config").default} config * @prop {import("Thread").default} thread * @prop {NG} [ng] * @prop {(props: any) => any} renderer * * @param {Props} props */ function F2({ config, thread, ng = new NG(config), renderer }) { const MessageAndChildren = renderer; return /*#__PURE__*/ createNode("div", { className: "messages", children: thread.computeRoots().map((post)=>/*#__PURE__*/ createNode(MessageAndChildren, { post: post, config: config, ng: ng })) }); } /** * @typedef {import("Post").default} Post */ /** * @param {any} jsx * @param {Post} post */ function tryRender(jsx, post) { try { return index_js.render(jsx); } catch (e) { console.error(e); return ErrorMessageWithPlainMessage({ error: e, post }); } } /** * @param {{error: Error, post: Post}} props */ function ErrorMessageWithPlainMessage({ error, post }) { return /*#__PURE__*/ createNode("div", { className: "qtv-error", children: [ /*#__PURE__*/ createNode("p", { children: [ "エラーが発生したため、この投稿をスキップしました: ", error.message ] }), /*#__PURE__*/ createNode("p", { children: /*#__PURE__*/ createNode("button", { type: "button", onClick: (e)=>{ e.target.parentNode.innerHTML = error.stack; }, children: "スタックトレース" }) }), /*#__PURE__*/ createNode("b", { children: post.title }), " 投稿者:", /*#__PURE__*/ createNode("b", { children: post.name }), " 投稿日:", post.date, " ", post.resButton.cloneNode(true), " ", post.posterButton.cloneNode(true), " ", post.threadButton.cloneNode(true), /*#__PURE__*/ createNode("blockquote", { children: /*#__PURE__*/ createNode("pre", { dangerouslySetInnerHTML: { __html: post.text } }) }) ] }); } class Text extends index_js.Component { render() { const { mayHaveThumbnails } = this.props; const el = this.renderText(); if (mayHaveThumbnails) { this.putThumbnails(el); } return el; } renderText() { const { config, className, showAsIs, innerHTML } = this.props; let __html = innerHTML; let truncationNote; if (config.maxLine && !showAsIs) { const maxLine = +config.maxLine; const lines = innerHTML.split("\n"); const 省略可能な行数 = Math.max(lines.length - maxLine, 0); if (省略可能な行数) { __html = this.state.truncation ? lines.slice(0, maxLine).join("\n") : __html; truncationNote = /*#__PURE__*/ createNode("div", { children: this.renderTruncationNoteContent(省略可能な行数) }); } } return /*#__PURE__*/ createNode("div", { className: className, dangerouslySetInnerHTML: { __html }, children: truncationNote }); } /** * @param {number} 省略可能な行数 */ renderTruncationNoteContent(省略可能な行数) { /** * @param {MouseEvent} e */ const handleToggleTruncation = (e)=>{ e.stopPropagation(); this.setState({ truncation: !this.state.truncation }, true); }; return /*#__PURE__*/ createNode(Fragment, { children: [ "(", /*#__PURE__*/ createNode(AButton, { class: "toggleTruncation note", onclick: handleToggleTruncation, children: this.state.truncation ? `以下${省略可能な行数}行省略` : "省略する" }), ")" ] }); } /** * @param {HTMLElement} el */ putThumbnails(el) { if (!this.props.config.thumbnail) { return; } new Embedder(this.props.config).register(el); } /** * @param {Props} props */ constructor(props){ super(props); this.id += props.post.getUniqueID(); this.initState = { truncation: true }; } } class TextTransformer { transform() { this.makeText(); this.checkThumbnails(); this.checkCharacterEntity(); this.characterEntity(); return { html: this.text, hasCharacterEntity: this.hasCharacterEntity, mayHaveThumbnails: this.mayHaveThumbnails }; } makeText() { const post = this.post; if (!this.showAsIs && !post.isNG) { this.snipOutQuotedArea(); } this.formatText(); this.trimText(); this.specialTextForEmptyText(); } formatText() { if (this.post.isNG) { this.text = this.markMatches(this.text, this.ng.word_); this.parent = this.markMatches(this.parent, this.ng.word_); } if (this.parent.length === 0) { return; } const textLines = this.text.split("\n"); const parentTextLines = this.parent.split("\n"); for(let i = 0; i < textLines.length; i++){ let line = textLines[i]; const parentLine = parentTextLines[i]; let quoteClass = "quote"; if (parentLine !== undefined) { if (line !== parentLine) { quoteClass += " modified"; } textLines[i] = `<span class="${quoteClass}">${line}</span>`; } } this.text = textLines.join("\n"); } /** * @param {string} str * @param {RegExp} regex */ markMatches(str, regex) { let result = ""; let match; while((match = regex.exec(str)) !== null){ result += str.slice(0, match.index); const matchStr = match[0]; const lines = matchStr.split("\n"); for(let i = 0; i < lines.length; i++){ result += `<mark class='NGWordHighlight'>${lines[i]}</mark>`; if (i < lines.length - 1) { result += "\n"; } } str = str.slice(match.index + match[0].length); } result += str; return result; } snipOutQuotedArea() { if (this.text.startsWith(this.parent)) { this.text = this.text.slice(this.parent.length); this.parent = ""; return; } //右端に空白があるかもしれないので消してからチェック this.parent = this.trimEnds(this.parent); this.text = this.trimEnds(this.text); if (this.text.startsWith(this.parent)) { this.text = this.text.slice(this.parent.length); this.parent = ""; return; } /* 終わりの空行引用は消してレスする人がいる > PARENT > TEXT 上のようになるところを下のようにレスする > PARENT TEXT */ const a = this.parent.replace(/\n(?:> *\n)+\n$/, "\n\n"); if (this.text.startsWith(a)) { this.text = this.text.slice(a.length); this.parent = ""; return; } //親の親の消す深海式レスのチェック const grandparentDeleted = this.parent.replace(/^> > .*\n/gm, ""); if (this.text.startsWith(grandparentDeleted)) { this.text = this.text.slice(grandparentDeleted.length); this.parent = ""; return; //NOSONAR } //諦める } /** * @param {string} string */ trimEnds(string) { return string.replace(/^.+$/gm, this.trimEnd); } /** * @param {string} string */ trimEnd(string) { return string.trimEnd(); } trimText() { //空白のみの投稿が空投稿になってしまうが、分かりやすくなっていいだろう this.text = this.text.replace(/^\s*\n/, "").trimEnd(); } specialTextForEmptyText() { if (this.text.length === 0) { this.text = '<span class="note">(空投稿)</span>'; } } checkCharacterEntity() { this.hasCharacterEntity = /&#(?:\d+|x[\da-fA-F]+);/.test(this.text); } characterEntity() { if (this.hasCharacterEntity && this.expandCharacterEntity) { this.text = this.text.replace(/&(#(?:\d+|x[0-9a-fA-F]+);)/g, "&$1"); } } checkThumbnails() { this.mayHaveThumbnails = this.text.includes("<a"); } /** * @param {object} arg * @param {Post} arg.post * @param {import("Config").default} arg.config * @param {import("NG").default} arg.ng * @param {boolean} arg.showAsIs * @param {boolean} arg.expandCharacterEntity */ constructor({ post, config, ng, showAsIs, expandCharacterEntity }){ var _post_parent; this.post = post; this.config = config; this.ng = ng; this.showAsIs = showAsIs; this.text = post.getText(); var _post_parent_computeQuotedText; this.parent = (_post_parent_computeQuotedText = (_post_parent = post.parent) == null ? void 0 : _post_parent.computeQuotedText()) != null ? _post_parent_computeQuotedText : ""; this.hasCharacterEntity = false; this.expandCharacterEntity = expandCharacterEntity; this.mayHaveThumbnails = false; } } function TitleAndName({ title, name }) { if (fromKuuhakuToKuuhaku(title, name)) { return null; } return /*#__PURE__*/ createNode(Fragment, { children: [ /*#__PURE__*/ createNode("strong", { children: title }), " : ", /*#__PURE__*/ createNode("strong", { dangerouslySetInnerHTML: { __html: name } }), " #" ] }); } /** * @param {string} title * @param {string} name */ function fromKuuhakuToKuuhaku(title, name) { return (title === "> " || title === " ") && name === " "; } function asyncGeneratorStep$2(gen, resolve, reject, _next, _throw, key, arg) { try { var info = gen[key](arg); var value = info.value; } catch (error) { reject(error); return; } if (info.done) { resolve(value); } else { Promise.resolve(value).then(_next, _throw); } } function _async_to_generator$2(fn) { return function() { var self = this, args = arguments; return new Promise(function(resolve, reject) { var gen = fn.apply(self, args); function _next(value) { asyncGeneratorStep$2(gen, resolve, reject, _next, _throw, "next", value); } function _throw(err) { asyncGeneratorStep$2(gen, resolve, reject, _next, _throw, "throw", err); } _next(undefined); }); }; } /** * @typedef {object} VanishedState * @prop {Set<Post>} vanishedMessages * @prop {(post: Post) => boolean} isVanished * @prop {(post: Post) => void} add * @prop {(post: Post) => void} remove * @prop {(post: Post) => boolean} isVanishedInTwoAncestors * @prop {() => void} _reset * * @typedef {import("zustand").StoreApi<VanishedState>} VanishedStore */ /** * @type {VanishedStore} */ const vanishedMessagesStore = vanilla.createStore((set, get)=>({ vanishedMessages: new Set(), isVanished (post) { return this.vanishedMessages.has(post); }, isVanishedInTwoAncestors (post) { var _post_parent; return this.isVanished(post.parent) || this.isVanished((_post_parent = post.parent) == null ? void 0 : _post_parent.parent); }, add: (post)=>set((state)=>({ vanishedMessages: new Set(state.vanishedMessages).add(post) })), remove: (post)=>set((state)=>{ const vanishedMessages = new Set(state.vanishedMessages); vanishedMessages.delete(post); return { vanishedMessages }; }), _reset: ()=>get().vanishedMessages.clear() })); class Message extends index_js.Component { didMount() { this.unsubscribe = vanishedMessagesStore.subscribe((state, prevState)=>{ const { post } = this.props; const wasChain = prevState.isVanishedInTwoAncestors(post); const isChain = state.isVanishedInTwoAncestors(post); if (wasChain !== isChain) { this.update(); } }); } didUnmount() { this.unsubscribe(); } /** * @param {string} className */ classNames(className) { return `${className} ${className}_${this.mode()}`; } /** * @returns {string} */ mode() { throw new Error("Should be implemented in a subclass"); } render() { const handleMouseDown = (/** @type {MouseEvent} */ e)=>{ e.stopPropagation(); const el = e.target; const id = setTimeout(()=>{ this.setState({ showAsIs: !this.state.showAsIs }, true); }, 500); const cancel = function() { clearTimeout(id); el.removeEventListener("mouseup", cancel); el.removeEventListener("mousemove", cancel); }; el.addEventListener("mouseup", cancel); el.addEventListener("mousemove", cancel); }; let className = this.classNames("message"); if (this.props.post.isRead) { className += " read"; } const transformer = new this.TextTransformer({ post: this.props.post, config: this.props.config, ng: this.props.ng, showAsIs: this.state.showAsIs, expandCharacterEntity: this.state.expandCharacterEntity }); const { html: innerHTML, hasCharacterEntity, mayHaveThumbnails } = transformer.transform(); const textShouldBeHidden = vanishedMessagesStore.getState().isVanished(this.props.post); const Text = this.Text; var _this_props_post_id; return /*#__PURE__*/ createNode("article", { className: className, id: (_this_props_post_id = this.props.post.id) != null ? _this_props_post_id : "undefined", onmousedown: handleMouseDown, children: [ this.headerContent(hasCharacterEntity), textShouldBeHidden || /*#__PURE__*/ createNode(Text, { className: this.classNames("text"), config: this.props.config, innerHTML: innerHTML, mayHaveThumbnails: mayHaveThumbnails, showAsIs: this.state.showAsIs, post: this.props.post }), this.props.post.env && /*#__PURE__*/ createNode("div", { className: this.classNames("extra"), children: this.envContent() }) ] }); } /** * @param {boolean} hasCharacterEntity */ headerContent(hasCharacterEntity) { return /*#__PURE__*/ createNode(Fragment, { children: [ this.shouldBeHidden() && this.unfoldButton(), this.renderHeader({ hasCharacterEntity }) ] }); } /** * NGか個別非表示になっている */ shouldBeHidden() { const notCheckMode = !this.props.config.NGCheckMode; const { post } = this.props; const rejectionLevel = !!this.getRejectionLevel(); return post.isNG && notCheckMode || rejectionLevel; } unfoldButton() { const reasons = []; const rejectionLevel = this.getRejectionLevel(); if (rejectionLevel > 0) { reasons.push([ null, "孫", "子", "個" ][rejectionLevel]); } if (this.props.post.isNG) { reasons.push("NG"); } return /*#__PURE__*/ createNode(AButton, { className: "showMessageButton showMessage on", onclick: (/** @type {MouseEvent} */ e)=>{ e.stopPropagation(); const el = /** @type {Element} */ e.target; el.classList.toggle("on"); }, children: reasons.join(",") }); } getRejectionLevel() { var _post_parent, _post_parent_parent, _post_parent1; const { post } = this.props; return Math.max(post.isVanished ? 3 : 0, ((_post_parent = post.parent) == null ? void 0 : _post_parent.isVanished) ? 2 : 0, ((_post_parent1 = post.parent) == null ? void 0 : (_post_parent_parent = _post_parent1.parent) == null ? void 0 : _post_parent_parent.isVanished) ? 1 : 0); } /** * @param {{hasCharacterEntity: boolean}} param */ renderHeader({ hasCharacterEntity }) { const { post, ng } = this.props; let title = post.title; let name = post.name; if (post.isNG) { title = ng.markHandle(title); name = ng.markHandle(name); } const resButton = this.props.post.resButton; let headerClassName = this.classNames("message-header"); if (vanishedMessagesStore.getState().isVanishedInTwoAncestors(post)) { headerClassName += " chainingHidden"; } return /*#__PURE__*/ createNode("span", { className: headerClassName, children: [ this.resButton(resButton), /*#__PURE__*/ createNode("span", { className: "message-info", children: [ /*#__PURE__*/ createNode(TitleAndName, { title: title, name: name }), post.date ] }), " ", isUsamin() ? /*#__PURE__*/ createNode("span", { dangerouslySetInnerHTML: { __html: post.usaminButtons } }) : resButton.cloneNode(true), " ", this.vanishButton(), " ", this.foldButton(), " ", post.posterButton.cloneNode(true), " ", hasCharacterEntity && this.characterEntityButton(), " ", post.threadButton.cloneNode(true) ] }); } /** * @param {HTMLAnchorElement} button */ resButton(button) { const b = /** @type {HTMLAnchorElement} */ button.cloneNode(true); b.classList.add("res"); b.target = "link"; b.textContent = "■"; return b; } vanishButton() { if (this.state.retrieveError) { return this.state.retrieveError.message; } const { post } = this.props; if (post.isVanished) { return /*#__PURE__*/ createNode(AButton, { className: "cancelVanishedMessage", onclick: (/** @type {MouseEvent} */ e)=>{ e.stopPropagation(); this.props.post.isVanished = false; vanishedMessagesStore.getState().remove(post); this.props.config.removeVanishedMessage(post.id); this.update(); }, children: "非表示を解除" }); } if (this.props.config.useVanishMessage) { const messageIsVanished = vanishedMessagesStore.getState().isVanished(post); return messageIsVanished ? this.revertVanishMessage() : this.vanishMessage(); } } vanishMessage() { var _this = this; return /*#__PURE__*/ createNode(AButton, { className: "vanishMessage", onclick: /*#__PURE__*/ _async_to_generator$2(function*() { try { const { config, post } = _this.props; var _post_id; const id = (_post_id = post.id) != null ? _post_id : yield /** @type {GhostPost} */ post.retrieveIdForcibly(); if (!id) { throw new Error("最新1000件以内に存在しないため投稿番号が取得できませんでした。過去ログからなら消せるかもしれません"); } if (id.length > 100) { throw new Error("この投稿は実在しないようです"); } vanishedMessagesStore.getState().add(post); config.addVanishedMessage(post.id); } catch (error) { _this.setState({ retrieveError: error }); } _this.update(); }), children: "消" }); } revertVanishMessage() { return /*#__PURE__*/ createNode(AButton, { className: "revertVanishMessage", onclick: ()=>{ const { config, post } = this.props; vanishedMessagesStore.getState().remove(post); config.removeVanishedMessage(post.id); this.update(); }, children: "戻" }); } foldButton() { const { post } = this.props; const rejectionLevel = !!this.getRejectionLevel(); const on = vanishedMessagesStore.getState().isVanished(post) ? "on" : ""; return rejectionLevel && /*#__PURE__*/ createNode(AButton, { className: `fold ${on}`, onclick: ()=>this.update(), children: "畳む" }); } characterEntityButton() { return /*#__PURE__*/ createNode(AButton, { className: `toggleCharacterEntity ${this.state.expandCharacterEntity ? "on" : ""}`, onclick: (/** @type {MouseEvent} */ e)=>{ e.stopPropagation(); this.setState({ expandCharacterEntity: !this.state.expandCharacterEntity }); this.update(); }, children: "文字参照" }); } envContent() { return /*#__PURE__*/ createNode("span", { className: "env", children: [ "(", this.props.post.env.replace(/<br>/, "/"), ")" ] }); } /** @param {T} props */ constructor(props){ super(props); const { post } = props; this.id += post.getUniqueID(); this.initState = { expandCharacterEntity: props.config.characterEntity, showAsIs: false, retrieveError: null }; this.unsubscribe = null; } } Message.prototype.TextTransformer = TextTransformer; Message.prototype.Text = Text; function _extends$1() { _extends$1 = Object.assign || function(target) { for(var i = 1; i < arguments.length; i++){ var source = arguments[i]; for(var key in source){ if (Object.prototype.hasOwnProperty.call(source, key)) { target[key] = source[key]; } } } return target; }; return _extends$1.apply(this, arguments); } /** * @typedef {import("Post").default} Post * @typedef {import("./Message").Props} Props */ /** * @param {Props & {init?: string}} props */ function MessageAndChildrenAscii(props) { const { post } = props; var _props_init; const init = (_props_init = props.init) != null ? _props_init : ""; const [childrenButLast, last] = post.collectChildren(); const hasNext = !!post.next; const header = post.isOP() ? " " : init + (hasNext ? "├" : "└"); const text = init + (hasNext ? "|" : " ") + (post.child ? "|" : " "); return /*#__PURE__*/ createNode(Fragment, { children: [ tryRender(/*#__PURE__*/ createNode(MessageAscii, _extends$1({}, props, { header: header, text: text })), post), last && [ ...childrenButLast, last ].map((child)=>/*#__PURE__*/ createNode(MessageAndChildrenAscii, _extends$1({}, props, { post: child, init: init + (hasNext ? "|" : " ") }))) ] }); } /** * @augments Message<Props & {text: string, header: string}> */ class MessageAscii extends Message { /** @override */ render() { const text = this.props.text; const textTree = `<span class="a-tree">${text}</span>`; const spacer = this.props.config.spacingBetweenMessages ? `<div class="a-tree spacer">${text}</div>` : ""; this.TextTransformer = class extends TextTransformer { transform() { const ret = super.transform(); ret.html = spacer + ret.html.replace(/^/gm, textTree); return ret; } }; this.Text = class extends Text { /** * @override * @param {number} 省略可能な行数 */ renderTruncationNoteContent(省略可能な行数) { return /*#__PURE__*/ createNode(Fragment, { children: [ /*#__PURE__*/ createNode("span", { class: "a-tree", children: text }), super.renderTruncationNoteContent(省略可能な行数) ] }); } }; return /*#__PURE__*/ createNode(Fragment, { children: [ super.render(), this.props.config.spacingBetweenMessages && /*#__PURE__*/ createNode("div", { className: "a-tree spacer", children: this.props.text }) ] }); } /** @override */ mode() { return "tree-mode-ascii"; } /** * @override * @param {boolean} hasCharacterEntity */ headerContent(hasCharacterEntity) { return /*#__PURE__*/ createNode(Fragment, { children: [ /*#__PURE__*/ createNode("span", { class: "a-tree", children: this.props.header }), super.headerContent(hasCharacterEntity) ] }); } /** * @override */ envContent() { return /*#__PURE__*/ createNode(Fragment, { children: [ /*#__PURE__*/ createNode("span", { className: "a-tree", children: this.props.text }), super.envContent() ] }); } } function _extends() { _extends = Object.assign || function(target) { for(var i = 1; i < arguments.length; i++){ var source = arguments[i]; for(var key in source){ if (Object.prototype.hasOwnProperty.call(source, key)) { target[key] = source[key]; } } } return target; }; return _extends.apply(this, arguments); } /** * @typedef {import("Post").default} Post * @typedef {import("./Message").Props} Props */ /** * @param {Props & { depth?: number; post: Post; }} props */ function MessageAndChildrenCss(props) { const { post } = props; var _props_depth; const depth = (_props_depth = props.depth) != null ? _props_depth : 1; const [children, last] = post.collectChildren(); return /*#__PURE__*/ createNode(Fragment, { children: [ children.length ? /*#__PURE__*/ createNode(MessageAndChildrenButLast, { depth: depth, children: [ tryRender(/*#__PURE__*/ createNode(MessageCss, _extends({}, props, { depth: depth })), post), children.map((/** @type {any} */ child)=>/*#__PURE__*/ createNode(MessageAndChildrenCss, _extends({}, props, { post: child, depth: depth + 1 }))) ] }) : tryRender(/*#__PURE__*/ createNode(MessageCss, _extends({}, props, { depth: depth })), post), last && /*#__PURE__*/ createNode(MessageAndChildrenCss, _extends({}, props, { post: last, depth: depth + 1 })) ] }); } /** * @param {{ children: any[]; depth: number; }} props */ function MessageAndChildrenButLast({ children, depth }) { return /*#__PURE__*/ createNode("div", { className: "messageAndChildrenButLast", children: [ children, /*#__PURE__*/ createNode("div", { className: "border", style: `left:${depth + 0.5}rem` }) ] }); } /** * @augments Message<Props & {depth: number}> */ class MessageCss extends Message { /** @override */ mode() { return "tree-mode-css"; } render() { const el = super.render(); el.style.marginLeft = this.props.depth + "rem"; if (this.props.config.spacingBetweenMessages) { el.classList.add("spacing"); } return el; } } /** * @param {import("Config").default["treeMode"]} treeMode */ function getThreadContent(treeMode) { return ({ "tree-mode-css": MessageAndChildrenCss, "tree-mode-ascii": MessageAndChildrenAscii })[treeMode]; } function asyncGeneratorStep$1(gen, resolve, reject, _next, _throw, key, arg) { try { var info = gen[key](arg); var value = info.value; } catch (error) { reject(error); return; } if (info.done) { resolve(value); } else { Promise.resolve(value).then(_next, _throw); } } function _async_to_generator$1(fn) { return function() { var self = this, args = arguments; return new Promise(function(resolve, reject) { var gen = fn.apply(self, args); function _next(value) { asyncGeneratorStep$1(gen, resolve, reject, _next, _throw, "next", value); } function _throw(err) { asyncGeneratorStep$1(gen, resolve, reject, _next, _throw, "throw", err); } _next(undefined); }); }; } class ThreadRenderer extends index_js.Component { render() { const { config, thread } = this.props; const number = thread.getNumber(); if (!number) { return; } const treeMode = this.state.treeMode; const threadButton = thread.getThreadButton(); const MessageAndChildren = getThreadContent(treeMode); const isVanished = thread.isVanished(); let className = `thread ${treeMode}`; if (isVanished) { className += " NGThread"; } const useToggleTreeModeButton = config.toggleTreeMode && config.treeMode === "tree-mode-css"; const ng = new NG(config); const handleToggleTreeMode = ()=>{ this.setState({ treeMode: this.state.treeMode === "tree-mode-css" ? "tree-mode-ascii" : "tree-mode-css" }, true); }; var _this = this; /** @type {(e: Event) => Promise<void>} */ const handleToggleVanishThread = /*#__PURE__*/ _async_to_generator$1(function*(e) { const { config, thread } = _this.props; const id = thread.getID(); const el = /** @type {Element} */ e.target; const wantsToRevert = el.textContent === "戻"; if (wantsToRevert) { yield config.removeVanishedThread(id); } else { yield config.addVanishedThread(id); } _this.update(); }); return /*#__PURE__*/ createNode("pre", { className: className, role: "group", children: [ /*#__PURE__*/ createNode("h2", { className: "thread-header", children: [ threadButton.cloneNode(true), " 更新日:", thread.getDate(), " 記事数:", thread.getNumber(), useToggleTreeModeButton && /*#__PURE__*/ createNode(Fragment, { children: [ " ", /*#__PURE__*/ createNode(AButton, { className: "toggleTreeMode", onclick: handleToggleTreeMode, children: "●" }) ] }), config.useVanishThread && /*#__PURE__*/ createNode(Fragment, { children: [ " ", /*#__PURE__*/ createNode(AButton, { className: "vanishThread", onclick: handleToggleVanishThread, children: isVanished ? "戻" : "消" }) ] }), " ", threadButton.cloneNode(true), thread.getSite() ] }), !isVanished && /*#__PURE__*/ createNode(F2, { thread: thread, config: config, ng: ng, renderer: MessageAndChildren }) ] }); } /** @param {Props} props */ constructor(props){ super(props); this.id += props.thread.getID(); this.initState = { treeMode: props.config.treeMode }; } } function asyncGeneratorStep(gen, resolve, reject, _next, _throw, key, arg) { try { var info = gen[key](arg); var value = info.value; } catch (error) { reject(error); return; } if (info.done) { resolve(value); } else { Promise.resolve(value).then(_next, _throw); } } function _async_to_generator(fn) { return function() { var self = this, args = arguments; return new Promise(function(resolve, reject) { var gen = fn.apply(self, args); function _next(value) { asyncGeneratorStep(gen, resolve, reject, _next, _throw, "next", value); } function _throw(err) { asyncGeneratorStep(gen, resolve, reject, _next, _throw, "throw", err); } _next(undefined); }); }; } class TreePresenter { /** * @param {import("./TreeView").default} view */ setView(view) { this.view = view; } render() { // empty. ツリーでは逐一処理はしない。 } /** * @param {ParentNode} fragment */ finish(fragment) { this.fragment = fragment; return this.ctxt.makePosts(fragment, ()=>this.searchOldLogs()).then((posts)=>{ this.threads = new ThreadMaker(this.config).make(posts); this.autovanishThreads(); this.excludeVanishedThreads(); this.showPostCount(); const threadsWereShown = this.renderThreads(); this.suggestLinkToOldLog(); this.prepareToggleOriginal(threadsWereShown); return threadsWereShown; }).then(()=>{ this.view.finish(fragment); }); } showPostCount() { const numPosts = this.threads.reduce((total, thread)=>total + thread.getNumber(), 0); this.view.showPostCount(numPosts); } searchOldLogs() { this.view.setInfo(`<strong>${this.ctxt.getLogName()}以外の過去ログを検索中...</strong>`); } excludeVanishedThreads() { if (this.config.utterlyVanishNGThread) { this.threads = this.threads.filter((thread)=>!thread.isVanished()); } } autovanishThreads() { if (!this.config.autovanishThread) { return; } const ids = this.threads.filter((thread)=>thread.isNG).map((thread)=>thread.getID()); if (!ids.length) { return; } const done = this.config.addVanishedThread(ids); if (this.config.utterlyVanishNGThread) { this.view.savingAutovanishedThreads(); return done.then(()=>this.view.doneSavingAutovanishedThreads(this.config.vanishedThreadIDs.length)); } } renderThreads() { var _this = this; return _async_to_generator(function*() { _this.view.setInfo(" - スレッド構築中"); let i = 0; const length = _this.threads.length; let deadline = performance.now() + 50; while(i < length){ var _navigator_scheduling; if (((_navigator_scheduling = navigator.scheduling) == null ? void 0 : _navigator_scheduling.isInputPending()) || performance.now() >= deadline) { yield _this.yieldToMain(); deadline = performance.now() + 50; continue; } _this.showThread(_this.threads[i]); i++; } _this.view.clearInfo(); })(); } yieldToMain() { return new Promise((resolve)=>{ setTimeout(resolve, 0); }); } /** * @param {Thread} thread */ showThread(thread) { if (!thread.getNumber()) { return; } let jsx; try { jsx = index_js.render(/*#__PURE__*/ createNode(ThreadRenderer, { config: this.config, thread: thread })); } catch (e) { console.error(e); jsx = index_js.render(this.ErrorMessage({ error: e, thread })); } this.view.renderThread(jsx); } /** * @param {{error: Error, thread: Thread}} props */ ErrorMessage({ error, thread }) { return /*#__PURE__*/ createNode("div", { className: "qtv-error", children: [ /*#__PURE__*/ createNode("p", { children: [ "エラーが発生したため、このスレッドをスキップしました: ", error.message ] }), /*#__PURE__*/ createNode("p", { children: /*#__PURE__*/ createNode("button", { type: "button", onClick: (e)=>{ e.target.parentNode.innerHTML = error.stack; }, children: "スタックトレース" }) }), /*#__PURE__*/ createNode("p", { children: thread.getThreadButton() }) ] }); } /** * @param {Promise} threadsWereShown */ prepareToggleOriginal(threadsWereShown) { const postsArea = this.ctxt.extractOriginalPostsAreaFrom(this.fragment); return threadsWereShown.then(()=>this.appendToggleOriginal(postsArea)); } /** * @param {ParentNode} original 元の投稿表示部分 */ appendToggleOriginal(original) { if (original.querySelector("a[name]")) { this.view.appendToggleOriginal(original); } } /** * `bbs.log`内をスレッド検索したが、スレッドの先頭が存在しない。 */ suggestLinkToOldLog() { const link = this.ctxt.suggestLink(this.fragment); if (link) { this.view.suggestLinkToOldLog(this.ctxt.suggestLink(this.fragment)); } } /** * @param {import("Context").default} ctxt * @param {import("Config").default} config */ constructor(ctxt, config){ this.ctxt = ctxt; this.config = config; /** @type {Thread[]} */ this.threads = null; /** @type {ParentNode} */ this.fragment = null; } } function clearVanishedIds(config, method, button) { return config[method]().then(()=>{ button.firstElementChild.innerHTML = "0"; }); } function clickQtvReload(form) { form.querySelector("#qtv-reload").click(); } function reload() { var form = document.getElementById("form"); if (!form) { locationReload(); return; } var reload = document.getElementById("qtv-reload"); if (!reload) { form.insertAdjacentHTML("beforeend", '<input type="submit" id="qtv-reload" name="reload" value="1" style="display:none;">'); } clickQtvReload(form); } var createReload = ((config)=>{ var reload = '<input type="button" value="リロード" class="mattari">'; if (!config.zero) { reload = reload.replace("mattari", "reload"); reload += '<input type="button" value="未読" class="mattari">'; } return reload; }); function getAccesskey(config) { var accesskey = config.accesskeyReload; return /^\w$/.test(accesskey) ? accesskey : "R"; } function getCounterAndViewing(body) { var hr = body.getElementsByTagName("hr")[0]; if (hr) { var font = hr.previousElementSibling; if (font && font.tagName === "FONT") { // eslint-disable-next-line // 2005/03/01 から counter(こわれにくさレベル4) 現在の参加者 : viewing名 (300秒以内) const [, , , counter, , viewing] = font.textContent.match(/[\d,]+/g) || []; return `${counter} / ${viewing} 名`; } } return ""; } class _class { /** * @param {string} html */ setInfo(html) { this.info.innerHTML = html; } clearInfo() { this.info.innerHTML = ""; } /** * @param {string} html */ setExtraInfo(html) { this.el.querySelector("#extraInfo").innerHTML = html; } /** * @param {number} numPosts */ setPostCount(numPosts) { if (numPosts) { this.postCount.textContent = `${numPosts}件取得`; } else { this.postCount.textContent = "未読メッセージはありません。"; } } getContent() { return this.content; } addEventListeners() { const { el } = this; const click = on.bind(null, el, "click"); click(".reload", reload); click(".mattari", midokureload); click(".goToForm", this.focusV); [ "Message", "Thread" ].forEach((type)=>{ const id = "clearVanished" + type + "IDs"; click("#" + id, (/** @type {MouseEvent} */ e)=>{ e.preventDefault(); clearVanishedIds(this.config, id, e.target); }); }); } focusV() { setTimeout(function() { document.getElementsByName("v")[0].focus(); }, 50); } savingAutovanishedThreads() { const buttons = this.footer.querySelector(".clearVanishedButtons"); buttons.insertAdjacentHTML("beforebegin", '<span class="savingVanishedThreadIDs">非表示スレッド保存中</span>'); } /** * @param {number} numVanishedThreads */ doneSavingAutovanishedThreads(numVanishedThreads) { const saving = this.footer.querySelector(".savingVanishedThreadIDs"); if (!saving) { return; } saving.parentNode.removeChild(saving); if (numVanishedThreads) { const buttons = this.footer.querySelector(".clearVanishedButtons"); buttons.querySelector("#clearVanishedThreadIDs .count").textContent = String(numVanishedThreads); buttons.classList.remove("hidden"); } } createTreeGuiContainer(body = document.body) { const el = document.createElement("div"); el.id = "container"; el.innerHTML = this.headerTemplate(body) + '<div id="content"></div><hr>' + this.footerTemplate(); const ng = new NG(this.config); if (ng.message) { el.querySelector("#header").lastElementChild.insertAdjacentHTML("beforebegin", ng.message); } const header = el.firstElementChild; const info = header.querySelector("#info"); const postCount = info.previousElementSibling; this.el = el; this.info = info; this.postCount = postCount; this.content = /** @type {HTMLElement} */ header.nextSibling; this.footer = /** @type {HTMLElement}*/ el.lastChild; } /** * @param {HTMLElement} body */ headerTemplate(body) { const reload = createReload(this.config); const accesskey = getAccesskey(this.config); const counterAndViewing = getCounterAndViewing(body); return ` <header id="header"> <span> ${reload.replace('class="mattari"', `$& title="ヽ(´ー`)ノロード" accesskey="${accesskey}"`)} ${counterAndViewing} <span id="postCount"></span> <span id="info">ダウンロード中...</span> <span id="extraInfo"></span> </span> <span> <a href="javascript:;" id="openConfig">設定</a> <a href="#link">link</a> <a href="#form" class="goToForm">投稿フォーム</a> ${reload} </span> </header>`; } footerTemplate() { const reload = createReload(this.config); const length = { Thread: this.config.vanishedThreadIDs.length, Message: this.config.vanishedMessageIDs.length }; const hidden = length.Thread || length.Message ? "" : "hidden"; const count = (/** @type {string} */ type, /** @type {string} */ text)=>`<a role="button" id="clearVanished${type}IDs" href="javascript:;"><span class="count">${length[type]}</span>${text}</a>`; return ` <footer id="footer"> <span> ${reload} </span> <span> <span class="clearVanishedButtons ${hidden}"> 非表示解除(${count("Thread", "スレッド")}/${count("Message", "投稿")}) </span> ${reload} </span> </footer>`; } hasMessage() { return !!this.content.querySelector(".message:not(.read)"); } /** * @param {Config} config */ constructor(config){ this.config = config; this.createTreeGuiContainer(); this.addEventListeners(); } } /** * @param {{original: ParentNode}} props */ function ToggleOriginal({ original }) { let qtvStack = null; const toggleOriginal = (e)=>{ qtvStack.hidden = !qtvStack.hidden; e.target.scrollIntoView(); }; const el = /*#__PURE__*/ createNode("div", { children: [ /*#__PURE__*/ createNode("div", { style: "text-align:center", children: /*#__PURE__*/ createNode(AButton, { class: "toggleOriginal", onclick: toggleOriginal, children: "元の投稿の表示する(時間がかかることがあります)" }) }), /*#__PURE__*/ createNode("hr", {}), /*#__PURE__*/ createNode("div", { id: "qtv-stack", hidden: true, ref: (ref)=>qtvStack = ref }) ] }); // @ts-ignore qtvStack.appendChild(original); return el; } class TreeView extends Qtv { setPresenter(presenter) { this.presenter = presenter; } /** * @override */ initializeComponent() { super.initializeComponent(); this.prepend(this.gui.el); } /** * @param {ParentNode} original 元の投稿表示部分 */ appendToggleOriginal(original) { this.insert(index_js.render(/*#__PURE__*/ createNode(ToggleOriginal, { original: original }))); } showPostCount(numPosts) { this.gui.setPostCount(numPosts); } setInfo(html) { this.gui.setInfo(html); } clearInfo() { this.gui.clearInfo(); } savingAutovanishedThreads() { this.gui.savingAutovanishedThreads(); } /** * @param {number} [numVanishedThreads] */ doneSavingAutovanishedThreads(numVanishedThreads) { this.gui.doneSavingAutovanishedThreads(numVanishedThreads); } /** * @param {string} href */ suggestLinkToOldLog(href) { this.gui.setExtraInfo(`<a id="hint" href="${href}">過去ログを検索する</a>`); } /** * @param {ParentNode} fragment */ appendLeftovers(fragment) { this.append(fragment); } /** * @param {ParentNode} fragment * @override */ finish(fragment) { tweakFooter(this.gui.hasMessage(), fragment); this.appendLeftovers(fragment); return super.finish(); } renderThread(el) { this.gui.getContent().appendChild(el); } /** * @param {import("Config").default} config */ constructor(config){ super(config); this.gui = new _class(this.config); /** @type {import("./TreePresenter").default} */ this.presenter = null; this.ng = new NG(config); } } function getTitle() { return document.title; } class CloseResWindow { onLoaded() { return this.closeIfNeeded(); } closeIfNeeded() { return this.gotConfig.then((config)=>{ if (this.shouldClose(config)) { this.close(); } }); } /** * @param {import("Config").default} config * @private */ shouldClose(config) { return config.closeResWindow && getTitle().endsWith(" 書き込み完了"); } /** @private */ close() { closeTab(); } /** * @param {Promise<import("Config").default>} gotConfig */ constructor(gotConfig){ this.gotConfig = gotConfig; } } var CloseResWindow$1 = CloseResWindow; /** * @typedef {import("Config").default} Config */ class State { /** * @param {Prestage} _p * @param {Config} _config */ configLoaded(_p, _config) { throw new Error("Undefined"); } /** * @param {Prestage} _p * @param {ParentNode} _fragment */ onProgress(_p, _fragment) { throw new Error("Undefined"); } /** * @param {Prestage} _p * @param {ParentNode} _fragment */ onLoaded(_p, _fragment) { throw new Error("Undefined"); } /** * @param {Prestage} _p */ proceed(_p) { throw new Error("Undefined"); } /** * @param {Prestage} _p */ abort(_p) { throw new Error("Undefined"); } } class Init extends State { /** * @override * @param {Prestage} p * @param {Config} config */ configLoaded(p, config) { p.setReady(); p.setConfig(config); } /** * @override * @param {Prestage} p * @param {ParentNode} fragment */ onProgress(p, fragment) { p.setBuffering(); p.onProgress(fragment); } /** * @override * @param {Prestage} p */ onLoaded(p) { p.setDead(); } } class Buffering extends State { /** * @override * @param {Prestage} p * @param {Config} config */ configLoaded(p, config) { p.setConfig(config); p.setRendering(); p.prepareRendering(); } /** * @override * @param {Prestage} _p * @param {ParentNode} _fragment */ onProgress(_p, _fragment) { // Bufferがバッファリング中 } /** * @override * @param {Prestage} p * @param {ParentNode} fragment */ onLoaded(p, fragment) { p.setWaitingForConfig(); p.stash(fragment); } } class WaitingForConfig extends State { /** * @override * @param {Prestage} p * @param {Config} config */ configLoaded(p, config) { p.setConfig(config); p.checkToProceed(); } /** * @override * @param {Prestage} p */ proceed(p) { p.prepareRendering(); p.rewindAndFinish(); } /** * @override * @param {Prestage} p */ abort(p) { p.restore(); p.setDead(); } } /** * `Config`ロード済み。まだ投稿を受信していない。 */ class Ready extends State { /** * @override * @param {Prestage} p * @param {ParentNode} _fragment */ onProgress(p, _fragment) { p.setCheckingToProceed(); p.checkToProceed(); } /** * @override * @param {Prestage} p */ onLoaded(p) { p.setDead(); } } class CheckingToProceed extends State { /** * @override * @param {Prestage} p */ proceed(p) { p.setRendering(); p.prepareRendering(); } /** * @override * @param {Prestage} p */ abort(p) { p.setDead(); } } class Rendering extends State { /** * @override * @param {Prestage} p * @param {ParentNode} fragment */ onProgress(p, fragment) { p.render(fragment); } /** * @override * @param {Prestage} p * @param {ParentNode} fragment */ onLoaded(p, fragment) { p.finish(fragment); p.setDead(); } } class Dead extends State { /** @override */ configLoaded() {} /** * @override * @param {Prestage} p * @param {ParentNode} fragment */ onProgress(p, fragment) { p.passThrough(fragment); } /** * @override * @param {Prestage} p * @param {ParentNode} fragment */ onLoaded(p, fragment) { p.passThrough(fragment); } /** @override */ proceed() {} /** @override */ abort() {} } const init = new Init(); const buffering = new Buffering(); const ready = new Ready(); const waitingForConfig = new WaitingForConfig(); const checkingToProceed = new CheckingToProceed(); const rendering = new Rendering(); const dead = new Dead(); class Prestage { prepareRendering() { this.controller.prepareRendering(); } /** * @param {Config} config */ setConfig(config) { this.controller.setConfig(config); } /** * @param {ParentNode} fragment */ render(fragment) { this.controller.render(fragment); } /** * @param {ParentNode} fragment */ finish(fragment) { this.controller.finish(fragment); } /** * @param {ParentNode} fragment */ stash(fragment) { this.controller.stash(fragment); } rewindAndFinish() { this.controller.rewindAndFinish(); } restore() { this.controller.restore(); } checkToProceed() { this.controller.checkToProceed(this); } /** * @param {ParentNode} fragment */ passThrough(fragment) { this.controller.passThrough(fragment); } /** * @param {Config} config */ configLoaded(config) { this.state.configLoaded(this, config); } /** * @param {ParentNode} fragment */ onProgress(fragment) { this.state.onProgress(this, fragment); } /** * @param {ParentNode} fragment */ onLoaded(fragment) { this.state.onLoaded(this, fragment); } proceed() { this.state.proceed(this); } abort() { this.state.abort(this); } setBuffering() { this.state = buffering; } setWaitingForConfig() { this.state = waitingForConfig; } setReady() { this.state = ready; } setRendering() { this.state = rendering; } setCheckingToProceed() { this.state = checkingToProceed; } setDead() { this.state = dead; } /** * @param {import("./PrestageController").default} controller */ constructor(controller){ /** @type {State} */ this.state = init; this.controller = controller; } } /** * @param {import("Config").default} config */ function shouldQuitHere(config, title = getTitle()) { return isUsamin() && config.viewMode === "s" || title.endsWith(" 個人用環境設定") || title.startsWith("くずはすくりぷと ") || title === "パスワード"; } class PrestageController { /** * @param {Config} config */ setConfig(config) { this.config = config; } prepareRendering() { if (this.config.isTreeView()) { this.qtv = this.factory.treeView(this.config); } else { this.qtv = this.factory.stackView(this.config); } } /** * @param {ParentNode} fragment */ render(fragment) { this.qtv.render(fragment); } /** * @param {ParentNode} fragment */ finish(fragment) { this.render(fragment); return this.qtv.finish(fragment).catch(handleError); } /** * @param {ParentNode} fragment */ stash(fragment) { this.stasher.stash(fragment); this.stasher.appendTo(document.body); } rewindAndFinish() { const fragment = this.stasher.restore(); return this.finish(fragment); } restore() { const fragment = this.stasher.restore(); document.body.appendChild(fragment); } /** * @param {import("./Prestage").default} p */ checkToProceed(p) { if (shouldQuitHere(this.config)) { p.abort(); } else { p.proceed(); } } /** * @param {ParentNode} fragment */ passThrough(fragment) { document.body.appendChild(fragment); } /** * @param {import("stream/Stash").default} stasher * @param {import("./Factory").default} factory */ constructor(stasher, factory){ this.stasher = stasher; this.factory = factory; /** @type {Config} */ this.config = null; this.qtv = null; } } class Factory { /** * @param {Promise<Config>} gotConfig */ prestage(gotConfig) { const observer = window.MutationObserver ? new LoadingObserver() : new LoadedObserver(); const buffer = new Buffer(); const closeResWindow = new CloseResWindow$1(gotConfig); const notice = new DelayNotice(gotConfig); const stasher = new Stash(); const controller = new PrestageController(stasher, this); const prestage = new Prestage(controller); observer.addListener(notice); observer.addListener(buffer); observer.addListener(closeResWindow); buffer.setListener(prestage); gotConfig.then((config)=>prestage.configLoaded(config)); observer.observe(); return prestage; } /** * @param {Config} config */ treeView(config) { const postParent = new PostParent(config); const ctxt = new Context(config, this.q, postParent); const presenter = new TreePresenter(ctxt, config); const view = new TreeView(config); view.setPresenter(presenter); view.initializeComponent(); presenter.setView(view); return presenter; } /** * @param {Config} config */ stackView(config) { const view = new StackView(config); const presenter = new StackPresenter(config, this.q); view.setPresenter(presenter); view.initializeComponent(); presenter.setView(view); return presenter; } /** * @param {import("../Query").default} q */ constructor(q){ this.q = q; } } /** @typedef {import("Config").ConfigOptions} ConfigOptions */ var ChromeStorage = { /** * @returns {Promise<Partial<ConfigOptions>>} */ load: function() { return new Promise((resolve)=>{ this.storage().get(null, resolve); }); }, /** * @param {string|string[]} keyOrKeys - 削除したいキー * @returns {Promise<void>} */ remove: function(keyOrKeys) { return new Promise((resolve)=>this.storage().remove(keyOrKeys, resolve)); }, /** * @template {keyof ConfigOptions} T * @param {T} key * @param {ConfigOptions[T]} value * @returns {Promise<void>} */ set: function(key, value) { return new Promise((resolve)=>this.storage().set({ [key]: value }, ()=>resolve())); }, /** * @param {Partial<ConfigOptions>} items * @returns {Promise<void>} */ setAll: function(items) { return new Promise((resolve)=>this.storage().set(items, resolve)); }, /** * @returns {Promise<void>} */ clear: function() { return new Promise((resolve)=>{ this.storage().clear(resolve); }); }, /** * @param {string} key */ get: function(key) { return new Promise((resolve)=>this.storage().get(key, (item)=>resolve(item[key]))); }, storage: function() { return chrome.storage.local; } }; /** @typedef {import("Config").ConfigOptions} ConfigOptions */ var GM4Storage = { /** * @returns {Promise<Partial<ConfigOptions>>} */ load: function() { return this.storage().listValues().then((keys)=>Promise.all(keys.map((key)=>this.storage().getValue(key))).then((values)=>values.reduce((config, value, i)=>{ if (value != null) { config[keys[i]] = JSON.parse(value); } else { this.remove(keys[i]).catch(()=>{ // give up. nothing to do }); } return config; }, Object.create(null)))); }, /** * @param {string|string[]} keyOrKeys - 削除したいキー * @returns {Promise<any>} */ remove: function(keyOrKeys) { const keys = Array.isArray(keyOrKeys) ? keyOrKeys : [ keyOrKeys ]; return Promise.all(keys.map((key)=>this.storage().deleteValue(key))); }, /** * @template {keyof ConfigOptions} T * @param {T} key * @param {ConfigOptions[T]} value * @returns {Promise<void>} */ set: function(key, value) { return this.storage().setValue(key, JSON.stringify(value)); }, /** * @param {Partial<ConfigOptions>} items * @returns {Promise<void>} */ setAll: function(items) { const keys = /** @type {(keyof Partial<ConfigOptions>)[]} */ Object.keys(items); return Promise.all(keys.map((key)=>this.set(key, items[key]))).then(); }, /** * @returns {Promise<void>} */ clear: function() { const storage = this.storage(); return storage.listValues().then((keys)=>keys.forEach(storage.deleteValue)); }, /** * @param {string} key * @returns {Promise<any>} */ get: function(key) { return this.storage().getValue(key, "null").then(JSON.parse); }, storage: function() { return GM; } }; /** @typedef {import("Config").ConfigOptions} ConfigOptions */ var GMStorage = { /** * @returns {Promise<Partial<ConfigOptions>>} */ load: function() { return new Promise((resolve)=>{ const config = Object.create(null); const keys = GM_listValues(); let i = keys.length; while(i--){ const key = keys[i]; const value = GM_getValue(key); if (value != null) { config[key] = JSON.parse(value); } else { GM_deleteValue(key); } } resolve(config); }); }, /** * @param {string|string[]} keyOrKeys - 削除したいキー * @returns {Promise<void>} */ remove: function(keyOrKeys) { return new Promise((resolve)=>{ const keys = Array.isArray(keyOrKeys) ? keyOrKeys : [ keyOrKeys ]; keys.forEach((key)=>GM_deleteValue(key)); resolve(); }); }, /** * @template {keyof ConfigOptions} T * @param {T} key * @param {ConfigOptions[T]} value * @returns {Promise<void>} */ set: function(key, value) { return new Promise((resolve)=>{ GM_setValue(key, JSON.stringify(value)); resolve(); }); }, /** * @param {Partial<ConfigOptions>} items * @returns {Promise<void>} */ setAll: function(items) { return new Promise((resolve)=>{ for (const key of Object.keys(items)){ this.set(key, items[key]).catch(()=>{ // give up }); } resolve(); }); }, /** * @returns {Promise<void>} */ clear: function() { return new Promise((resolve)=>{ GM_listValues().forEach(GM_deleteValue); resolve(); }); }, /** * @template {keyof ConfigOptions} T * @param {T} key * @returns {Promise<?ConfigOptions[T]>} */ get: function(key) { return Promise.resolve(JSON.parse(GM_getValue(key, "null"))); } }; /** @returns {import("./ConfigStorage").default} */ function getStorage() { return isGm4() ? GM4Storage : isGm() ? GMStorage : ChromeStorage; } /** * @typedef ConfigOptions * @prop {"tree-mode-ascii" | "tree-mode-css"} treeMode * @prop {boolean} toggleTreeMode * @prop {boolean} thumbnail * @prop {boolean} thumbnailPopup * @prop {boolean} popupAny * @prop {string} popupMaxWidth * @prop {string} popupMaxHeight * @prop {boolean} popupBestFit * @prop {"ascending" | "descending"} threadOrder スレッド内の最新投稿を基準に、古いものを上に、新しいものを下に。 * @prop {string} NGHandle * @prop {string} NGWord * @prop {boolean} useNG * @prop {boolean} NGCheckMode * @prop {boolean} spacingBetweenMessages * @prop {boolean} useVanishThread * @prop {string[]} vanishedThreadIDs * @prop {boolean} autovanishThread * @prop {boolean} utterlyVanishNGThread * @prop {boolean} useVanishMessage * @prop {string[]} vanishedMessageIDs * @prop {boolean} vanishMessageAggressive * @prop {boolean} utterlyVanishMessage * @prop {boolean} utterlyVanishNGStack NGにヒットした投稿を完全に非表示にするか。Stackという名前に反して、ツリーモードでもスタックモードでも使うので注意。 * @prop {boolean} deleteOriginal * @prop {boolean} zero * @prop {string} accesskeyReload * @prop {string} accesskeyV * @prop {boolean} keyboardNavigation * @prop {string} keyboardNavigationOffsetTop * @prop {"s"|"t"} viewMode * @prop {string} css * @prop {boolean} shouki * @prop {boolean} closeResWindow * @prop {string} maxLine * @prop {boolean} openLinkInNewTab * @prop {boolean} characterEntity * * @typedef {keyof Pick<ConfigOptions, "vanishedMessageIDs"|"vanishedThreadIDs">} propertyOfVanishedIDs */ /** @implements {ConfigOptions} */ class Config { static load() { const storage = getStorage(); return storage.load().then((options)=>{ if (isUsamin()) { options.deleteOriginal = false; options.useVanishMessage = false; options.useVanishThread = false; options.autovanishThread = false; } return new Config(options, storage); }); } /** * @param {propertyOfVanishedIDs} target * @param {string|string[]} id_or_ids * @returns {Promise<void>} */ addID(target, id_or_ids) { var ids = Array.isArray(id_or_ids) ? id_or_ids : [ id_or_ids ]; this[target] = ids.concat(this[target]); return this._storage.get(target).then((IDs)=>{ IDs = Array.isArray(IDs) ? IDs : []; ids = ids.filter((id)=>IDs.indexOf(id) === -1); IDs = IDs.concat(ids).sort((l, r)=>+r - +l); this[target] = IDs; return this._storage.set(target, IDs).then(); }); } /** * @param {propertyOfVanishedIDs} target * @param {string} id */ removeID(target, id) { return this._storage.get(target).then((ids)=>{ ids = Array.isArray(ids) ? ids : []; const index = ids.indexOf(id); if (index !== -1) { ids.splice(index, 1); this[target] = ids; return (ids.length ? this._storage.set(target, ids) : this._storage.remove(target)).then(); } else { this[target] = ids; } }); } /** * @param {propertyOfVanishedIDs} target */ clearIDs(target) { return this._storage.remove(target).then(()=>{ this[target] = []; }); } /** @param {string|string[]} id_or_ids */ addVanishedMessage(id_or_ids) { return this.addID("vanishedMessageIDs", id_or_ids); } /** @param {string} id */ removeVanishedMessage(id) { return this.removeID("vanishedMessageIDs", id); } clearVanishedMessageIDs() { return this.clearIDs("vanishedMessageIDs"); } /** @param {string|string[]} id_or_ids */ addVanishedThread(id_or_ids) { return this.addID("vanishedThreadIDs", id_or_ids); } /** @param {string} id */ removeVanishedThread(id) { return this.removeID("vanishedThreadIDs", id); } clearVanishedThreadIDs() { return this.clearIDs("vanishedThreadIDs"); } clear() { return this._storage.clear().then(()=>{ Object.assign(this, Config.prototype); }); } /** * @param {Partial<ConfigOptions>} items */ update(items) { // Config.prototypeとは違うキー const keysToSet = Object.keys(items).filter((key)=>{ const value = Config.prototype[key]; const type = typeof value; return type !== "undefined" && type !== "function" && items[key] !== value; }); const newConfig = Object.assign(Object.create(null), ...keysToSet.map((key)=>({ [key]: items[key] }))); // Config.prototypeと同じキー const keysToRemove = Object.keys(items).filter((key)=>items[key] === Config.prototype[key]); return Promise.all([ this._storage.setAll(newConfig), this._storage.remove(keysToRemove) ]).then(()=>{ Object.assign(this, newConfig); }); } toMinimalJson() { const config = this; return JSON.stringify(Object.keys(Config.prototype).filter((key)=>config[key] !== Config.prototype[key]).reduce((newConfig, key)=>Object.assign(newConfig, { [key]: config[key] }), Object.create(null))); } /** * @param {string} id */ isVanishedThread(id) { return this.useVanishThread && this.vanishedThreadIDs.indexOf(id) > -1; } isTreeView() { return this.viewMode === "t"; } /** * @param {Partial<ConfigOptions>} options * @param {import('config/ConfigStorage').default} storage */ constructor(options, storage){ Object.assign(this, options); /** @type {import('config/ConfigStorage').default} */ this._storage = storage; } } Config.prototype.treeMode = /** @type {"tree-mode-ascii" | "tree-mode-css"} */ "tree-mode-ascii"; Config.prototype.toggleTreeMode = false; Config.prototype.thumbnail = true; Config.prototype.thumbnailPopup = true; Config.prototype.popupAny = false; Config.prototype.popupMaxWidth = ""; Config.prototype.popupMaxHeight = ""; Config.prototype.popupBestFit = true; Config.prototype.threadOrder = /** @type {"ascending" | "descending"} */ "ascending"; Config.prototype.NGHandle = ""; Config.prototype.NGWord = ""; Config.prototype.useNG = true; Config.prototype.NGCheckMode = false; Config.prototype.spacingBetweenMessages = false; Config.prototype.useVanishThread = true; Config.prototype.vanishedThreadIDs = /** @type {String[]} */ []; Config.prototype.autovanishThread = false; Config.prototype.utterlyVanishNGThread = false; Config.prototype.useVanishMessage = false; Config.prototype.vanishedMessageIDs = /** @type {String[]} */ []; Config.prototype.vanishMessageAggressive = false; Config.prototype.utterlyVanishMessage = false; Config.prototype.utterlyVanishNGStack = false; Config.prototype.deleteOriginal = true; Config.prototype.zero = true; Config.prototype.accesskeyReload = "R"; Config.prototype.accesskeyV = ""; Config.prototype.keyboardNavigation = false; Config.prototype.keyboardNavigationOffsetTop = "200"; Config.prototype.viewMode = /** @type {"s"|"t"} */ "t"; Config.prototype.css = ""; Config.prototype.shouki = true; Config.prototype.closeResWindow = false; Config.prototype.maxLine = ""; Config.prototype.openLinkInNewTab = false; Config.prototype.characterEntity = true; var Config$1 = Config; /** * @typedef {string} ff 日付.dat */ class ConcurrentFetcherPolicy { /** * @param {ff[]} ffs */ fetch(ffs) { return Promise.all(ffs.map((ff)=>this.fetcher.fetch(ff))); } /** * @param {{fetch(ff: string): Promise<import("Query").FetchResult>}} fetcher */ constructor(fetcher){ this.fetcher = fetcher; } } const fill = (n)=>n < 10 ? "0" + n : String(n); /** * @param {Date} date */ var breakDate = ((date)=>({ year: fill(date.getFullYear()), month: fill(date.getMonth() + 1), date: fill(date.getDate()) })); class RecentOldLogNames { /** * @param {string} pivot - ff 最近7つのログからこれを除くログ名を返す */ generate(pivot) { const dates = this.getThese7LogNames(pivot); const afters = []; const befores = []; for (const date of dates){ const diff = this.asInt(date) - this.asInt(pivot); if (diff < 0) { befores.push(date); } else if (diff > 0) { afters.push(date); } } return { afters, befores }; } // くずはすくりぷとの過去ログ保存日数はデフォルトで5日間だし、 // 任意の日数を設定できるので7日間/7ヶ月間の決め打ちは良くない。 // 保存方法を月毎にしている場合はログの自動消去が起こらないため無限に溜まる。 getThese7LogNames(pivot) { const dates = []; const back = new Date(this.now); if (this.logsAreSavedDaily(pivot)) { for(let i = 0; i < 7; i++){ const { year, month, date } = breakDate(back); dates.push(`${year}${month}${date}.dat`); back.setDate(back.getDate() - 1); } } else { for(let i = 0; i < 7; i++){ const { year, month } = breakDate(back); dates.push(`${year}${month}.dat`); back.setMonth(back.getMonth() - 1); } } return dates; } /** * @param {string} pivot */ logsAreSavedDaily(pivot) { return pivot.length === 12; } /** * @param {string} string */ asInt(string) { return Number.parseInt(string, 10); } constructor(now = new Date()){ this.now = now; } } /** * @typedef {{fragment: DocumentFragment, ff: ff}} FetchResult * @typedef {string} ff 日付.dat */ class StoppableSequentialFetcherPolicy { /** * @param {ff[]} ffs * @param {ParentNode} container */ fetch(ffs, container) { return ffs.reduce((sequence, ff)=>sequence.then((fetchResults)=>{ if (this.shouldStop(container)) { return fetchResults; } return this.fetcher.fetch(ff).then((fetchResult)=>{ container = fetchResult.fragment; return [ ...fetchResults, fetchResult ]; }); }), Promise.resolve(/** @type {FetchResult[]} */ [])); } /** * @param {ParentNode} container */ shouldStop(container) { return this.fetcher.shouldStop(container); } /** * @param {{fetch (ff: ff): Promise<FetchResult>, shouldStop(container: ParentNode): boolean}} fetcher */ constructor(fetcher){ this.fetcher = fetcher; } } class Query { /** * @param {string} search */ static parse(search) { if (typeof search === "object") { return search; } const obj = Object.create(null); const kvs = search.substring(1).split("&"); kvs.forEach(function(kv) { obj[kv.split("=")[0]] = kv.split("=")[1]; }); return obj; } /** * @param {string} key */ get(key) { return this.q[key]; } /** * @param {string} key * @param {string} value */ set(key, value) { this.q[key] = value; } /** * 過去ログでスレッドボタンがあるか? * @returns {boolean} */ shouldHaveValidPosts() { if (this.q.m !== "g") { return false; } // html形式にはボタンが付かない if (/^\d+\.html?$/.test(this.q.e)) { return false; } // 検索ボタンを押した && `引用機能`にチェックが入っている return !!(this.q.sv && this.q.btn); } isNormalMode() { return !this.q.m; } /** * @param {ParentNode} fragment */ suggestLink(fragment) { if (this.searchedBbsLogButOpNotFound(fragment)) { const { year, month, date } = breakDate(new Date()); return `${this.href}&ff=${year}${month}${date}.dat`; } } /** * `bbs.log`内をスレッド検索し、スレッドの先頭が存在ない。 * @param {ParentNode} fragment */ searchedBbsLogButOpNotFound(fragment) { return this.isSearchingBbsLogForThread() && !this.hasOP(fragment); } /** * 通常モードからスレッドボタンを押した場合 * @private */ isSearchingBbsLogForThread() { return this.q.m === "t" && !this.q.ff && /^[1-9]\d*$/.test(this.q.s); } isSearchingForThreadOrPoster() { return this.q.m === "t" || this.q.m === "s"; } /** ログ補完するべきか */ shouldFetch() { return this.shouldSearchLog() || this.isFromKomachi(); } /** * 過去ログ検索して、スレッドボタンを押した。くずはすくりぷとはスレッドの投稿を日付を跨いで探してくれない。 * @returns {boolean} */ shouldSearchLog() { return this.q.m === "t" && /^\d+\.dat$/.test(this.q.ff) && /^[1-9]\d*$/.test(this.q.s); } /** * 小町のlogボタンから来た? */ isFromKomachi() { // referrerを切ってる人もいるだろうし、referrerのチェックはない方がいい // かもしれないが、わざわざ検索窓で一日だけにチェックを入れて小町のファ // イルを検索する人は、複数日検索されると困ったりするんだろうか。 return /^https?:\/\/misao\.(?:mixh|on\.arena\.ne)\.jp\/c\/upload\.cgi/.test(this.referrer) && /^\?chk\d+\.dat=checked&kwd=https?:\/\/misao\.(?:mixh|on\.arena\.ne)\.jp\/c\/up\/misao\d+\.\w+&s1=0&e1=0&s2=24&e2=0&ao=a&tt=a&alp=checked&btn=checked(?:&g=checked)?&m=g&k=%82%A0&sv=on$/.test(this.search); } /** * @param {ParentNode} fragment */ fetchOldLogs(fragment) { return this.run(fragment); } /** * @param {ParentNode} container - この中にOPがあれば遡らない */ run(container) { const { afters, befores } = new RecentOldLogNames().generate(this.getCurrentBbsLogName()); if (this.isFromKomachi()) { befores.length = 0; } const after = this.concurrent(afters); const before = this.sequence(befores, container); return Promise.all([ after, before ]).then(([afters, befores])=>({ afters, befores })); } /** * @param {ff[]} afters */ concurrent(afters) { return new ConcurrentFetcherPolicy(this).fetch(afters); } /** * @param {ff[]} befores * @param {ParentNode} container */ sequence(befores, container) { return new StoppableSequentialFetcherPolicy(this).fetch(befores, container); } /** * @param {ParentNode} container */ shouldStop(container) { return this.hasOP(container); } /** * スレッド検索中で`container`にスレッドの先頭が含まれているなら`true`、それ以外は`false` * @param {ParentNode} container */ hasOP(container) { return this.q.m === "t" && !!container.querySelector(this.selectorForOP()); } selectorForOP() { if (this.q.m === "t") { return 'a[name="' + this.q.s + '"]'; } } /** * @param {ff} ff * @returns {Promise<FetchResult>} */ fetch(ff) { return fetch({ url: "bbs.cgi", data: this.queryFor(ff) }).then((fragment)=>{ return { fragment, ff }; }); } /** * @param {ff} ff */ queryFor(ff) { return Object.assign(Object.create(null), this.q, // m=tのときの検索対象は、ffに指定されたファイル this.isFromKomachi() ? { e: ff } : { ff }); } getCurrentBbsLogName() { if (this.q.m === "g") { if (this.q.e) { return this.q.e; } else { return Object.keys(this.q).find((key)=>/^chk\d+\.dat$/.test(key)).replace(/^chk/, ""); } } else if (this.q.m === "t") { return this.q.ff; } } /** @public */ getLogName() { return this.getCurrentBbsLogName(); } constructor(location = window.location, referrer = document.referrer){ /** * @type {{ * m: "g", * sv?: "on", * btn?: "checked", * e?: string, * ff?: string, * kwd?: string, * } | { * m: "s" | "t", * ff?: string, * s: string, * } | { * m?: "f" | "o" | "op" | "p" | "c" | "l" | "n" | "ad", * }} * @param m `g`: 過去ログ * `s`: 投稿者検索 * `t`: スレッド検索 * `f`: レス画面 * `l`: トピック一覧 * `o`|`op`: ログ読み専用 * `ad`: 管理者モード(くずはすくりぷと内では何の判定にも使われていない) * @param sv 検索窓で検索ボタンを押した。検索するログは`chk日付.dat=checked`で指定される(複数可)。 * @param btn 検索窓で`引用機能`にチェックを入れた。レスボタン、投稿者検索ボタン、スレッドボタンが表示される。 * これがあるなら、過去ログの保存形式はバイナリ(.dat)で、過去ログからのフォロー投稿・投稿者検索が可になっている。 * @param e 検索窓で日付のリンクを押した。それに対応するファイル名。`日付.html?`か`日付.dat`。 * くずはすくりぷとの処理上はsvと組み合わせてもいいようだ。 * @param ff `m=s`,`m=t`のときに開くファイル名。`日付.html?`か`日付.dat`。空なら`bbs.log` * @param s `m=s`なら*投稿者名*, `m=t`なら*スレッドID*。 * @param ad 管理者モードは`POST`が使われるので`location.search`からでは読めない。 * @param {"1"} ac `m=f`とともに使う。レス窓の投稿のように、書き込み後「書き込み完了」と表示され、掲示板に戻らない。 */ this.q = Query.parse(location.search); this.search = location.search; this.href = location.href; this.referrer = referrer; } } /** * 内容欄にフォーカスして表示 * @param {HTMLBodyElement} body */ function tweak(body) { const v = body.querySelector("textarea"); if (v) { v.focus() // Firefox needs focus before setSelectionRange. ; v.scrollIntoView(); // 内容を下までスクロール firefox v.setSelectionRange(v.textLength, v.textLength); // 内容を下までスクロール chrome v.scrollTop = v.scrollHeight; } } var tweakResWindow = (()=>ready$1().then(getBody).then(tweak)); class Main { static main(q = new Query()) { switch(q.get("m")){ case "f": tweakResWindow(); return; case "l": case "c": return; case "g": if (!q.shouldHaveValidPosts()) { return; } } const factory = new Factory(q); factory.prestage(Config$1.load()); } } Main.main(); })(nanoJSX, zustandVanilla);