// ==UserScript==
// @name Mangadex Preview Post
// @description Preview new forum/comment posts and edits on MangaDex. Shows a formatted preview of your post/comment beside the edit box.
// @namespace https://github.com/Brandon-Beck
// @version 0.2.0
// @grant unsafeWindow
// @grant GM.getValue
// @grant GM.setValue
// @grant GM_getValue
// @grant GM_setValue
// @grant GM_xmlhttpRequest
// @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==
//@ts-ignore
const ERROR_IMG = 'https://i.pinimg.com/originals/e3/04/73/e3047319a8ae7192cb462141c30953a8.gif';
//@ts-ignore
const LOADING_IMG = 'https://i.redd.it/ounq1mw5kdxy.gif';
const imageBlobs = {};
//@ts-ignore
function getImageBlob(url) {
if (!imageBlobs[url]) {
imageBlobs[url] = new Promise((ret, err) => {
GM_xmlhttpRequest({
method: "GET",
url: url,
responseType: "blob",
onerror: err,
ontimeout: err,
onload: (response) => {
if ((response.status == 200 || response.status == 304) && response.response) {
imageBlobs[url] = Promise.resolve(response.response);
return ret(imageBlobs[url]);
}
return err(response);
}
});
});
}
return imageBlobs[url];
/*return fetch(url).then(d=>{
if (d.ok) {
imageBlobs[url] = d.blob()
return imageBlobs[url]
}
return Promise.reject(d.statusText)
})*/
}
function getImageObjectURL(url) {
return getImageBlob(url).then(b => {
/* For converting them into data-uris. Not too useful.
const a = new FileReader()
a.onload = (e) => {
console.log(a.result)
}
a.readAsDataURL(b)
*/
return URL.createObjectURL(b);
});
}
/* PEG grammer */
// 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.
// FIXME Img is text only. not recursive
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 = []
// Must update end location when tag closes
thisast.location = res.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 == "*") {
// FIXME are we supposed to subtract 1 here?
astcur.location = res.location[0] - 1
stack.pop()
astcur=stack[stack.length -1]
}
thisast.type = res.type
thisast.tag = res.content
thisast.content = []
thisast.location = res.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 = res.location
astcur.content.push(thisast)
astcur=thisast
stack.push(thisast)
}
else if (res.type == "close") {
let idx = Object.values(stack).reverse().findIndex((e)=>e.tag == res.content)
if (idx != -1 ) {
idx=idx+1
// NOTE should we set ast location end? Yes!
for (let i = stack.length -idx; i < stack.length; i++) {
stack[i].location[1] = res.location[1]
}
stack.splice(-idx,idx)
astcur=stack[stack.length -1]
}
else {
thisast.type="error"
thisast.content="[/" + res.content + "]"
thisast.location = res.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.tag == "*") {
// FIXME are we supposed to subtract 1 here?
astcur.location[1] = res.location[0] - 1
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, location:[location().start.offset,location().end.offset]} }
// 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().start.offset,location().end.offset] } }
AttrTagProxy = "=" attr:Data? {return attr}
OpenDataTag = "[" tag:DataTagList attr:AttrTagProxy? "]" { return {type:"opendata", content:tag,attr:attr, location:[location().start.offset,location().end.offset]} }
CloseTag = "[/" tag:(DataTagList / NormalTagList / PrefixTagList ) "]" { return {type:"close", content:tag, location:[location().start.offset,location().end.offset]} }
Text
= text:(!(Tag / CloseTag / LineBreak). Text?) {
if(text[2] != null) {
return {type: "text", content:text[1] + text[2].content, location:[location().start.offset,text[2].location[1]] }
}
return {type: "text", content:text[1], location:[location().start.offset,location().end.offset] }
}
ContiguousText
= text:(!(Tag / CloseTag / LineBreak / _ ). ContiguousText?) {
if(text[2] != null) {
return {type: "text", content:text[1] + text[2].content, location:[location().start.offset,text[2].location[1]] }
}
return {type: "text", content:text[1], location:[location().start.offset,location().end.offset] }
}
LineBreak
= [\n] {
return {type: "linebreak", location:[location().start.offset,location().end.offset] }
}
ErrorCatcher
= errTxt:. {return {type: "error", content: errTxt, location:[location().start.offset,location().end.offset]} }
_ "whitespace"
= [ \t\n\r]*
`);
// 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") {
// This should never happen
return [];
}
function pushIt(a, ast, element) {
a.push({
type: 'text',
element,
location: ast.location
});
}
let res = ast.reduce((accum, e) => {
if (e.type == "text") {
pushIt(accum, e, document.createTextNode(e.content));
}
else if (e.type == "linebreak") {
//pushIt(accum, e, document.createElement('br'), 'container')
const element = {
element: document.createElement('br'),
location: e.location,
type: 'container',
contains: []
};
accum.push(element);
}
else if (e.type == "error") {
pushIt(accum, e, document.createTextNode(e.content));
}
// Everything after this must have a tag attribute!
// not nesting to avoid right shift
else if (!(e.type == 'open' || e.type == 'prefix' || e.type == 'opendata')) {
// @ts-ignore: Not a string, but doesn't need to be. Make or edit type
throw new Error({ msg: `Unknown AST type "${e.type}" recieved!`, child_ast: e, container_ast: ast });
}
else if (e.tag === 'u' || e.tag == 's' || e.tag == 'sub'
|| e.tag == 'sup' || e.tag == 'ol' || e.tag == 'code'
|| e.tag == 'h1' || e.tag == 'h2' || e.tag == 'h3'
|| e.tag == 'h4' || e.tag == 'h5' || e.tag == 'h6') {
const element = {
element: document.createElement(e.tag),
location: e.location,
type: 'container',
contains: []
};
accum.push(element);
element.contains = pegAstToHtml_v2(e.content);
element.contains.forEach(child_ast_element => {
element.element.appendChild(child_ast_element.element);
});
}
else if (e.tag === 'list' || e.tag === 'ul') {
const element = {
element: document.createElement('ul'),
location: e.location,
type: 'container',
contains: []
};
accum.push(element);
element.contains = pegAstToHtml_v2(e.content);
element.contains.forEach(child_ast_element => {
element.element.appendChild(child_ast_element.element);
});
}
else if (e.tag === 'hr') {
const element = {
element: document.createElement(e.tag),
location: e.location,
type: 'container',
contains: []
};
accum.push(element);
// FIXME Contain children, in a non nested fashion
//element.contains=pegAstToHtml_v2(e.content)
pegAstToHtml_v2(e.content).forEach(e => {
accum.push(e);
});
}
else if (e.tag === 'b') {
const element = {
element: document.createElement('strong'),
location: e.location,
type: 'container',
contains: []
};
accum.push(element);
element.contains = pegAstToHtml_v2(e.content);
element.contains.forEach(child_ast_element => {
element.element.appendChild(child_ast_element.element);
});
}
else if (e.tag === 'i') {
const element = {
element: document.createElement('em'),
location: e.location,
type: 'container',
contains: []
};
accum.push(element);
element.contains = pegAstToHtml_v2(e.content);
element.contains.forEach(child_ast_element => {
element.element.appendChild(child_ast_element.element);
});
}
else if (e.tag === 'url') {
//accum += `${pegAstToHtml(e.content)}`
const element = {
element: document.createElement('a'),
location: e.location,
type: 'container',
contains: []
};
accum.push(element);
if (e.data) {
;
element.element.href = e.data;
}
element.contains = pegAstToHtml_v2(e.content);
element.contains.forEach(child_ast_element => {
element.element.appendChild(child_ast_element.element);
});
}
else if (e.tag === 'img') {
//accum += ``
let promise = Promise.reject();
// FIXME should Only pass url via image when parsing
let url;
if (e.content) {
//@ts-ignore
const urltest = e.content[0];
if (urltest && urltest.type === 'text') {
url = urltest.content;
promise = getImageObjectURL(url);
}
}
const element = {
element: document.createElement(e.tag),
location: e.location,
type: 'image',
imagePromise: promise
};
//element.element.src=LOADING_IMG
promise.then(e => {
element.element.onload = () => {
URL.revokeObjectURL(e);
};
element.element.onerror = () => {
URL.revokeObjectURL(e);
};
element.element.src = e;
}).catch(b => {
console.log(`Url '${url}' failed to load with error!`);
console.log(b);
element.element.src = ERROR_IMG;
});
accum.push(element);
}
else if (e.tag === 'quote') {
//accum += `
${pegAstToHtml(e.content)}
` const button = { element: document.createElement('button'), location: e.location, type: 'container', contains: [] }; button.element.textContent = 'Spoiler'; button.element.classList.add('btn', 'btn-sm', 'btn-warning', 'btn-spoiler'); accum.push(button); const element = { element: document.createElement('p'), location: e.location, type: 'container', contains: [] }; accum.push(element); element.element.classList.add('spoiler', 'display-none'); element.contains = pegAstToHtml_v2(e.content); element.contains.forEach(child_ast_element => { element.element.appendChild(child_ast_element.element); }); /* In a perfect world. it would work like this... but md is a bit broken ;(button.element as HTMLButtonElement).addEventListener('click',()=>{ ;(element.element as HTMLDivElement).classList.toggle('display-none') }) Code to do this is afer makepreview, to ensure buggieness is preserved*/ } else if (e.tag === 'center' || e.tag === 'left' || e.tag === 'right') { //accum += `${pegAstToHtml(e.content)}
` const element = { element: document.createElement('p'), location: e.location, type: 'container', contains: [] }; accum.push(element); element.element.classList.add(`text-${e.tag}`); element.contains = pegAstToHtml_v2(e.content); element.contains.forEach(child_ast_element => { element.element.appendChild(child_ast_element.element); }); } else if (e.tag == "*") { // must parse the inside for v2 //accum += `