// ==UserScript== // @name Mangadex Preview Post // @description Preview new forum/comment posts and edits on MangaDex. Shows a formatted preview of your post/comment above the edit box. // @namespace https://github.com/Brandon-Beck // @version 0.0.9 // @grant unsafeWindow // @grant GM.getValue // @grant GM.setValue // @grant GM_getValue // @grant GM_setValue // @require https://gitcdn.xyz/repo/Brandon-Beck/Mangadex-Userscripts/a480c30b64fba63fad4e161cdae01e093bce1e4c/common.js // @require https://gitcdn.xyz/repo/Brandon-Beck/Mangadex-Userscripts/21ec54406809722c425c39a0f5b6aad59fb3d88d/uncommon.js // @require https://gitcdn.xyz/repo/Brandon-Beck/Mangadex-Userscripts/0d46bb0b3fa43f11ea904945e7baef7c6e2a6a5b/settings-ui.js // @require https://gitcdn.xyz/cdn/pegjs/pegjs/30f32600084d8da6a50b801edad49619e53e2a05/website/vendor/pegjs/peg.js // @match https://mangadex.org/* // @author Brandon Beck // @icon https://mangadex.org/images/misc/default_brand.png // @license MIT // @downloadURL none // ==/UserScript== class BBCode { /* Taken from https://github.com/DasRed/js-bbcode-parser * Distributed under MIT license */ /** * @param {Object} codes * @param {Object} [options] */ constructor(codes, options) { this.codes = []; options = options || {}; // copy options for (let optionName in options) { if (optionName === 'events') { continue; } this[optionName] = options[optionName]; } this.setCodes(codes); } /** * parse * * @param {String} text * @returns {String} */ parse(text) { return this.codes.reduce((text, code) => text.replace(code.regexp, code.replacement), text); } /** * add bb codes * * @param {String} regex * @param {String} replacement * @returns {BBCode} */ add(regex, replacement) { this.codes.push({ regexp: new RegExp(regex, 'igm'), replacement: replacement }); return this; } /** * set bb codes * * @param {Object} codes * @returns {BBCode} */ setCodes(codes) { this.codes = Object.keys(codes).map(function (regex) { const replacement = codes[regex]; return { regexp: new RegExp(regex, 'igm'), replacement: replacement }; }, this); return this; } } // create the Default const bbCodeParser = new BBCode({ '\n': '
', '\\[b\\](.*?)\\[/b\\]': '$1', '\\[i\\](.*?)\\[/i\\]': '$1', '\\[u\\](.*?)\\[/u\\]': '$1', '\\[s\\](.*?)\\[/s\\]': '$1', '\\[code\\](.*?)\\[/code\\]': '$1', '\\[h1\\](.*?)\\[/h1\\]': '

$1

', '\\[h2\\](.*?)\\[/h2\\]': '

$1

', '\\[h3\\](.*?)\\[/h3\\]': '

$1

', '\\[h4\\](.*?)\\[/h4\\]': '

$1

', '\\[sub\\](.*?)\\[/sub\\]': '$1', '\\[sup\\](.*?)\\[/sup\\]': '$1', '\\[quote\\](.*?)\\[/quote\\]': '
$1
', '\\[spoiler\\](.*?)\\[/spoiler\\]': '', '\\[center\\](.*?)\\[/center\\]': '

$1

', '\\[left\\](.*?)\\[/left\\]': '

$1

', '\\[right\\](.*?)\\[/right\\]': '

$1

', '\\[img\\](.*?)\\[/img\\]': '', '\\[hr\\](.*?)\\[/hr\\]': '
$1', '\\[url\\](.*?)\\[/url\\]': '$1', '\\[url=(.*?)\\](.*?)\\[/url\\]': '$2', '\\[list\\](.*?)\\[/list\\]': '', '\\[ol\\](.*?)\\[/ol\\]': '
    $1
', '\\[ul\\](.*?)\\[/ul\\]': '', '\\[\\*\\](.*?)
': '
  • $1

  • ' }); // define configuration function for default bbCodeParser.create = BBCode /* PEG grammer */ let bbcodePegParser = peg.generate(String.raw` start = res:Expressions? {return res} Expressions = reses:Expression+ { let astroot = [{type:"root",content:[]}] let stack = [astroot[0]] let astcur = astroot[0] reses.forEach((res) => { let thisast = {} if (res.type == "open") { thisast.type = res.content thisast.content = [] astcur.content.push(thisast) astcur=thisast stack.push(thisast) } else if (res.type == "prefix") { // cannot directly nest bullet in bullet (must have a non-prexix container class) if (astcur.type == "*") { stack.pop() astcur=stack[stack.length -1] } thisast.type = res.content thisast.content = [] astcur.content.push(thisast) astcur=thisast stack.push(thisast) } else if (res.type == "opendata") { thisast.type = res.content thisast.data = res.attr thisast.content = [] astcur.content.push(thisast) astcur=thisast stack.push(thisast) } else if (res.type == "close") { let idx = Object.values(stack).reverse().findIndex((e)=>e.type == res.content) if (idx != -1 ) { idx=idx+1 stack.splice(-idx,idx) astcur=stack[stack.length -1] } else { thisast.type="error" thisast.content="[/" + res.content + "]" astcur.content.push(thisast) } } else if (res.type == "linebreak" ) { // TODO should check if prefix instead if prefix is to be expanded appon if (astcur.type == "*") { stack.pop() astcur=stack[stack.length -1] } // Linebreaks are only added when we are not exiting a prefix else { astcur.content.push(res) } } else { astcur.content.push(res) } }) return astroot[0].content } Expression = res:(OpenTag / OpenDataTag / CloseTag / PrefixTag / LineBreak / Text ) /*head:Term tail:(_ ("+" / "-") _ Term)* { return tail.reduce(function(result, element) { if (element[1] === "+") { return result + element[3]; } if (element[1] === "-") { return result - element[3]; } }, head); } */ Tag = tag:(OpenCloseTag / PrefixTag) {return tag} OpenCloseTag = open:(OpenTag / OpenDataTag) content:Expression? close:CloseTag? &{ let hasClose = close != null if (false && hasClose && open.tag != close.tag) { throw new Error( "Expected [/" + open.tag + "] but [/" + close.tag + "] found." ); } return true } { return {type:open.tag, data:open.attr, content} } PrefixTag = "[" tag:PrefixTagList "]" { return {type:"prefix", content:tag} } // PrefixTag = "[" tag:PrefixTagList "]" content:(!("[/" ListTags "]" / LineBreak ) .)* { return {type:tag,unparsed:content.join('')} } ListTags = "list" / "ul" / "ol" / "li" NormalTagList = "list" / "spoiler" / "center" / "code" / "quote" / "img" / "sub" / "sup" / "left" / "right" / "ol" / "ul" / "h1" / "h2" / "h3" / "h4" / "hr" / "b" / "s" / "i" / "u" DataTagList = "url" PrefixTagList = "*" Data = text:(!"]". Data?) { /*if(text[2] != null) { return {type: "data", content:text[1] + text[2].content } } return {type: "data", content:text[1] } */ if(text[2] != null) { return text[1] + text[2] } return text[1] } OpenTag = "[" tag:NormalTagList "]" { return {type:"open", content:tag} } AttrTagProxy = "=" attr:Data? {return attr} OpenDataTag = "[" tag:DataTagList attr:AttrTagProxy? "]" { return {type:"opendata", content:tag,attr:attr} } CloseTag = "[/" tag:(DataTagList / NormalTagList / PrefixTagList ) "]" { return {type:"close", content:tag} } Text = text:(!(Tag / CloseTag / LineBreak). Text?) { if(text[2] != null) { return {type: "text", content:text[1] + text[2].content } } return {type: "text", content:text[1] } } LineBreak = [\n] { return {type: "linebreak" } } ErrorCatcher = errTxt:. {return {type: "error", content: errTxt} } _ "whitespace" = [ \t\n\r]* `) /* main code */ function pegAstToHtml(ast) { if (ast == null) { return "" } if (typeof(ast) !== "object") { return ast } //dbg(ast) //Object.values(ast) let res = ast.reduce((accum, e) => { if (e.type == "text") { accum += e.content } else if (e.type == "linebreak") { accum += "
    " } else if (e.type.match(/^(u|s|sub|sup|ol|code)$/)) { accum += `<${e.type}>${pegAstToHtml(e.content)}` } else if (e.type.match(/^(list|ul)$/)) { accum += `` } else if (e.type.match(/^h[123456]$/)) { accum += `<${e.type}>${pegAstToHtml(e.content)}` } else if (e.type.match(/^hr$/)) { accum += `<${e.type}>${pegAstToHtml(e.content)}` } else if (e.type.match(/^b$/)) { accum += `${pegAstToHtml(e.content)}` } else if (e.type.match(/^i$/)) { accum += `${pegAstToHtml(e.content)}` } else if (e.type.match(/^url$/)) { accum += `${pegAstToHtml(e.content)}` } else if (e.type.match(/^img$/)) { accum += `` } else if (e.type.match(/^quote$/)) { accum += `
    ${pegAstToHtml(e.content)}
    ` } else if (e.type.match(/^spoiler$/)) { accum += `` } else if (e.type.match(/^(center|left|right)$/)) { accum += `

    ${pegAstToHtml(e.content)}

    ` } else if (e.type == "*") { // must parse the inside for v2 //accum += `
  • ${pegAstToHtml( bbcodePegParser.parse(e.unparse) )}
  • ` accum += `
  • ${pegAstToHtml(e.content)}
  • ` } else if (e.type == "error") { accum += e.content } else if (e.content != null ){ accum += pegAstToHtml(e.content) } else { accum += e } return accum },"") return res } function makePreview(txt) { //dbg(pegAstToHtml(bbcodePegParser.parse(txt))) // Faster, but less dynamic //let html = bbCodeParser.parse(txt) // Slower, but more dynamic let html = pegAstToHtml(bbcodePegParser.parse(txt)) let tmpl = document.createElement("div") tmpl.innerHTML = html return tmpl } function fastPreview(txt) { // Faster, but less dynamic // (not much faster either) let html = bbCodeParser.parse(txt) let tmpl = document.createElement("div") tmpl.innerHTML = html return tmpl } let previewDivTempl = document.createElement("div") function createPreviewCallbacks() { let forms = Object.values(document.querySelectorAll(".post_edit_form")) forms = forms.concat( Object.values(document.querySelectorAll("#post_reply_form"))) forms = forms.concat( Object.values(document.querySelectorAll("#change_profile_form"))) forms.forEach((forum)=>{ // Try to make it side by side //e.parentElement.parentElement.insertBefore(previewDiv,e.parentElement) //e.parentElement.classList.add("sticky-top", "pt-5", "col-6") let textarea = forum.querySelector("textarea") let previewDiv = makePreview(textarea.value) forum.parentElement.insertBefore(previewDiv,forum) let curDisplayedVersion = 0 let nextVersion = 0 let updateTimeout let updateTimeoutDelay = 50 let maxAcceptableDelay = 500 let useFallbackPreview = false function UpdatePreview() { // Measure load speed. Used for setting update delay dynamicly. let startTime = Date.now() // Create a preview buffer let thisVersion = nextVersion++ let newPreview if (useFallbackPreview) { newPreview = fastPreview(textarea.value) } else { newPreview = makePreview(textarea.value) } let imgLoadPromises = [] Object.values(newPreview.querySelectorAll("img")).forEach((img) => { imgLoadPromises.push(new Promise(resolve => { img.addEventListener('load', resolve) // Errors dont really matter to us img.addEventListener('error', resolve) // Esure we are not already done if (img.complete) { resolve() } })) }) // Wait for all images to load or error (size calculations needed) before we swap and rescroll // This is the part that actualy updates the preview Promise.all(imgLoadPromises).then(()=>{ let endTime = Date.now() let updateLoadDelay = endTime - startTime if (!useFallbackPreview && updateLoadDelay > maxAcceptableDelay) { useFallbackPreview = true dbg(`It took ${updateLoadDelay} milli to update. Max acceptable delay was ${maxAcceptableDelay}! Switching to fallback preview!`) // We intentionally do not update the timout delay when we swap to fallback preview } else { // average out the times updateTimeoutDelay = (updateTimeoutDelay + updateLoadDelay) / 2 dbg(`It took ${updateLoadDelay} milli to update. Changing delay to ${updateTimeoutDelay} `) } // Return if we are older than cur preview if (thisVersion < curDisplayedVersion) { newPreview.remove() return } curDisplayedVersion = thisVersion // Remember scroll position let old_height = $(document).height(); //store document height before modifications let old_scroll = $(window).scrollTop(); //remember the scroll position // Replace the Preview with the buffered content previewDiv.parentElement.insertBefore(newPreview,previewDiv) previewDiv.remove() previewDiv=newPreview // Scroll back to position $(document).scrollTop(old_scroll + $(document).height() - old_height); }) } function UpdatePreviewProxy() { dbg(`Reseting timeout with delay ${updateTimeoutDelay} `) clearTimeout(updateTimeout) updateTimeout = setTimeout(UpdatePreview,updateTimeoutDelay) } let buttons = Object.values(forum.querySelectorAll("button")) buttons.forEach((btn)=>{ btn.addEventListener('click', UpdatePreviewProxy) }) textarea.oninput = UpdatePreviewProxy }) } createPreviewCallbacks()