// ==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
// ==/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': '<br>',
'\\[b\\](.*?)\\[/b\\]': '<strong>$1</strong>',
'\\[i\\](.*?)\\[/i\\]': '<em>$1</em>',
'\\[u\\](.*?)\\[/u\\]': '<u>$1</u>',
'\\[s\\](.*?)\\[/s\\]': '<s>$1</s>',
'\\[code\\](.*?)\\[/code\\]': '<code>$1</code>',
'\\[h1\\](.*?)\\[/h1\\]': '<h1>$1</h1>',
'\\[h2\\](.*?)\\[/h2\\]': '<h2>$1</h2>',
'\\[h3\\](.*?)\\[/h3\\]': '<h3>$1</h3>',
'\\[h4\\](.*?)\\[/h4\\]': '<h4>$1</h4>',
'\\[sub\\](.*?)\\[/sub\\]': '<sub>$1</sub>',
'\\[sup\\](.*?)\\[/sup\\]': '<sup>$1</sup>',
'\\[quote\\](.*?)\\[/quote\\]': '<div style="width: 100%; display: inline-block; margin: 1em 0;" class="well well-sm">$1</div>',
'\\[spoiler\\](.*?)\\[/spoiler\\]': '<button type="button" class="btn btn-sm btn-warning btn-spoiler" onclick="$(this).next(`.spoiler`).toggle()">Spoiler</button><p class="spoiler display-none">$1</p>',
'\\[center\\](.*?)\\[/center\\]': '<p class="text-center">$1</p>',
'\\[left\\](.*?)\\[/left\\]': '<p class="text-left">$1</p>',
'\\[right\\](.*?)\\[/right\\]': '<p class="text-right">$1</p>',
'\\[img\\](.*?)\\[/img\\]': '<img src="$1">',
'\\[hr\\](.*?)\\[/hr\\]': '<hr>$1',
'\\[url\\](.*?)\\[/url\\]': '<a href="$1" target="_blank">$1</a>',
'\\[url=(.*?)\\](.*?)\\[/url\\]': '<a href="$1" target="_blank">$2</a>',
'\\[list\\](.*?)\\[/list\\]': '<ul>$1</ul>',
'\\[ol\\](.*?)\\[/ol\\]': '<ol>$1</ol>',
'\\[ul\\](.*?)\\[/ul\\]': '<ul>$1</ul>',
'\\[\\*\\](.*?)<br>': '<li>$1</li><br>'
});
// 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 += "<br>"
}
else if (e.type.match(/^(u|s|sub|sup|ol|code)$/)) {
accum += `<${e.type}>${pegAstToHtml(e.content)}</${e.type}>`
}
else if (e.type.match(/^(list|ul)$/)) {
accum += `<ul>${pegAstToHtml(e.content)}</ul>`
}
else if (e.type.match(/^h[123456]$/)) {
accum += `<${e.type}>${pegAstToHtml(e.content)}</${e.type}>`
}
else if (e.type.match(/^hr$/)) {
accum += `<${e.type}>${pegAstToHtml(e.content)}`
}
else if (e.type.match(/^b$/)) {
accum += `<strong>${pegAstToHtml(e.content)}</strong>`
}
else if (e.type.match(/^i$/)) {
accum += `<em>${pegAstToHtml(e.content)}</em>`
}
else if (e.type.match(/^url$/)) {
accum += `<a href="${e.data}" target="_blank">${pegAstToHtml(e.content)}</a>`
}
else if (e.type.match(/^img$/)) {
accum += `<img src="${pegAstToHtml(e.content)}"/>`
}
else if (e.type.match(/^quote$/)) {
accum += `<div style="width: 100%; display: inline-block; margin: 1em 0;" class="well well-sm">${pegAstToHtml(e.content)}</div>`
}
else if (e.type.match(/^spoiler$/)) {
accum += `<button type="button" class="btn btn-sm btn-warning btn-spoiler">Spoiler</button><p class="spoiler display-none">${pegAstToHtml(e.content)}</p>`
}
else if (e.type.match(/^(center|left|right)$/)) {
accum += `<p class="text-center">${pegAstToHtml(e.content)}</p>`
}
else if (e.type == "*") {
// must parse the inside for v2
//accum += `<li>${pegAstToHtml( bbcodePegParser.parse(e.unparse) )}</li>`
accum += `<li>${pegAstToHtml(e.content)}</li>`
}
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()