// ==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.1.0
// @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\\]': 'Spoiler $1
',
'\\[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]*
`)
// New version will enable:
// Partial rebuilds! only update what changed
// Autoscrolling Edit Preview! Ensure the line you are editing is visible as you change it.
let bbcodePegParser_v2 = 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.type
thisast.tag = res.content
thisast.content = []
thisast.location = location()
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.type
thisast.tag = res.content
thisast.content = []
thisast.location = location()
astcur.content.push(thisast)
astcur=thisast
stack.push(thisast)
}
else if (res.type == "opendata") {
thisast.type = res.type
thisast.tag = res.content
thisast.data = res.attr
thisast.content = []
thisast.location = location()
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
// NOTE should we set ast location end?
//stack[stack.length -1 -idx ].location.end = location().end
stack.splice(-idx,idx)
astcur=stack[stack.length -1]
}
else {
thisast.type="error"
thisast.content="[/" + res.content + "]"
thisast.location = location()
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 == "*") {
//astcur.location.end = location().end
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, location:location() } }
AttrTagProxy = "=" attr:Data? {return attr}
OpenDataTag = "[" tag:DataTagList attr:AttrTagProxy? "]" { return {type:"opendata", content:tag,attr:attr, location:location()} }
CloseTag = "[/" tag:(DataTagList / NormalTagList / PrefixTagList ) "]" { return {type:"close", content:tag, location:location()} }
Text
= text:(!(Tag / CloseTag / LineBreak). Text?) {
if(text[2] != null) {
return {type: "text", content:text[1] + text[2].content, location:{start: location().start, end:text[2].location.end} }
}
return {type: "text", content:text[1], location:location() }
}
LineBreak
= [\n] {
return {type: "linebreak", location:location() }
}
ErrorCatcher
= errTxt:. {return {type: "error", content: errTxt, location:location()} }
_ "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)}${e.type}>`
}
else if (e.type.match(/^(list|ul)$/)) {
accum += `${pegAstToHtml(e.content)} `
}
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 += `${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 += `Spoiler ${pegAstToHtml(e.content)}
`
}
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
}
// New steps:
// PegSimpleAST -> AST_WithHTML
// AST_WithHTML + cursor_location -> HtmlElement
// AST_WithHTML + text_change_location_and_range + all_text -> LocalAST_WithHTML_OfChange + local_ast_text_range -> LocalAST_WithHTML -> HtmlElement
function pegAstToHtml_v2(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 == "error") {
accum += e.content
}
// Everything after this must have a tag attribute!
// not nesting to avoid right shift
else if (!(e.type.match(/open|prefix|opendata/))) {
throw new Error({msg: `Unknown AST type "${e.type}" recieved!`, child_ast:e, container_ast:ast })
}
else if (e.tag.match(/^(u|s|sub|sup|ol|code)$/)) {
accum += `<${e.tag}>${pegAstToHtml(e.content)}${e.tag}>`
}
else if (e.tag.match(/^(list|ul)$/)) {
accum += `${pegAstToHtml(e.content)} `
}
else if (e.tag.match(/^h[123456]$/)) {
accum += `<${e.tag}>${pegAstToHtml(e.content)}${e.tag}>`
}
else if (e.tag.match(/^hr$/)) {
accum += `<${e.tag}>${pegAstToHtml(e.content)}`
}
else if (e.tag.match(/^b$/)) {
accum += `${pegAstToHtml(e.content)} `
}
else if (e.tag.match(/^i$/)) {
accum += `${pegAstToHtml(e.content)} `
}
else if (e.tag.match(/^url$/)) {
accum += `${pegAstToHtml(e.content)} `
}
else if (e.tag.match(/^img$/)) {
accum += ` `
}
else if (e.tag.match(/^quote$/)) {
accum += `${pegAstToHtml(e.content)}
`
}
else if (e.tag.match(/^spoiler$/)) {
accum += `Spoiler ${pegAstToHtml(e.content)}
`
}
else if (e.tag.match(/^(center|left|right)$/)) {
accum += `${pegAstToHtml(e.content)}
`
}
else if (e.tag == "*") {
// must parse the inside for v2
//accum += `${pegAstToHtml( bbcodePegParser.parse(e.unparse) )} `
accum += `${pegAstToHtml(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))
dbg(JSON.stringify(bbcodePegParser_v2.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, #start_thread_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()