// ==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 // @author Brandon Beck // @license MIT // @icon https://mangadex.org/favicon-96x96.png // @version 0.3.8 // @grant GM_xmlhttpRequest // @require https://gitcdn.xyz/cdn/pegjs/pegjs/30f32600084d8da6a50b801edad49619e53e2a05/website/vendor/pegjs/peg.js // @match https://mangadex.org/* // @downloadURL none // ==/UserScript== 'use strict' const isUserscript = window.GM_xmlhttpRequest !== undefined // This is used when run in Browser Console / Bookmarklet mode // Loads the same scripts used in UserScript. // Does not run at all in userscript mode. function loadScript(url) { // Adding the script tag to the head as suggested before const { head } = document const script = document.createElement('script') script.type = 'text/javascript' script.src = url // Then bind the event to the callback function. // There are several events for cross browser compatibility. return new Promise((resolve ,reject) => { // script.onreadystatechange = resolve script.onload = resolve script.onerror = reject // Fire the loading head.appendChild(script) }) } // Ensure Console/Bookmarklet is not run on other sites. if (!isUserscript && !window.location.href.startsWith('https://mangadex.org')) { alert('Mangadex Post Preview script only works on https://mangadex.org') throw Error('Mangadex Post Preview script only works on https://mangadex.org') } const ERROR_IMG = 'https://i.pinimg.com/originals/e3/04/73/e3047319a8ae7192cb462141c30953a8.gif' const LOADING_IMG = 'https://i.redd.it/ounq1mw5kdxy.gif' const imageBlobs = {} function getImageBlob(url) { if (!imageBlobs[url]) { imageBlobs[url] = new Promise((ret ,err) => { GM_xmlhttpRequest({ method: 'GET' ,url ,responseType: 'blob' ,onerror: err ,ontimeout: err ,onload: (response) => { if (((response.status >= 200 && response.status <= 299) || response.status === 304) && response.response) { imageBlobs[url] = Promise.resolve(response.response) return ret(imageBlobs[url]) } return err(response) } }) }) } return imageBlobs[url] } function getImageObjectURL(url) { return getImageBlob(url).then(b => URL.createObjectURL(b)) } const imgCache = {} // Clones are made because the same image may be used more than once in a post. function cloneImageCacheEntry(source) { const element = source.element.cloneNode() // Take for granted that we are loaded if our source was loaded. // Not necessarily true, but things should already be set as if it were // since we cloned the values. const { loadPromise } = source return { element ,loadPromise } } function getImgForURL(url) { if (isUserscript) { return getImgForURLViaFetch(url) } return getImgForURLViaImg(url) } function getImgForURLViaImg(url) { if (imgCache[url] !== undefined) { return cloneImageCacheEntry(imgCache[url]) } // TODO add images loaded in thread to cache. const element = document.createElement('img') // element.element.src=LOADING_IMG const loadPromise = new Promise((ret ,err) => { element.onload = () => ret(element) element.onerror = e => err(new Error(e.toString())) element.src = url }) imgCache[url] = { element ,loadPromise } // First use. Clone not needed since gaurenteed to be unused return imgCache[url] } function getImgForURLNoCache(url) { const element = document.createElement('img') // element.element.src=LOADING_IMG const loadPromise = new Promise((ret ,err) => { element.onload = () => ret(element) element.onerror = e => err(new Error(e.toString())) element.src = url }) // First use. Clone not needed since gaurenteed to be unused return { element ,loadPromise } } function getImgForURLViaFetch(url) { const promise = getImageObjectURL(url) const element = document.createElement('img') // element.element.src=LOADING_IMG const loadPromise = promise.then(e => new Promise((resolve ,reject) => { element.onload = () => { URL.revokeObjectURL(e) resolve(element) } element.onerror = (err) => { URL.revokeObjectURL(e) reject(new Error(err.toString())) } element.src = e })) // Clone not needed since a new img is generated every time. return { element ,loadPromise } } function getImgForURLViaFetchClone(url) { if (imgCache[url] !== undefined) { return cloneImageCacheEntry(imgCache[url]) } const promise = getImageObjectURL(url) const element = document.createElement('img') // element.element.src=LOADING_IMG // NOTE: Must not revoke object url if cloning is to be done const loadPromise = promise.then(e => new Promise((resolve ,reject) => { element.onload = () => { // URL.revokeObjectURL(e) resolve(element) } element.onerror = (err) => { // URL.revokeObjectURL(e) reject(new Error(err.toString())) } element.src = e })) imgCache[url] = { element ,loadPromise } return imgCache[url] } /* 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 generatedBBCodePegParser function bbcodePegParser() { if (generatedBBCodePegParser) return generatedBBCodePegParser generatedBBCodePegParser = peg.generate(String.raw` start = res:Expressions? {return res} Expressions = reses:Expression+ { let astroot = [{type:"root",content:[],location:[0,0]}] 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.location[1] = res.location[1] 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.location[1] = res.location[1] 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.location[1] = res.location[1] 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.location[1] = res.location[1] astcur=stack[stack.length -1] } else { thisast.type="error" thisast.content="[/" + res.content + "]" thisast.location = res.location astcur.location[1] = res.location[1] 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 // Are Linebreaks added when we are exiting a prefix? Seems like it! // Not sure why though... astcur.content.push(res) stack.pop() astcur=stack[stack.length -1] } else { astcur.location[1] = res.location[1] astcur.content.push(res) } } else { astcur.location[1] = res.location[1] astcur.content.push(res) } }) // Close all tags (location). Remember we start at 1 bc root is just a container for (let i = 1; i < stack.length; i++) { stack[i].location[1] = astcur.location[1] } //stack.splice(start, end) not needed 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" / "h" / "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]* `) return generatedBBCodePegParser } // 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 }) } const 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 === 'h') { const element = { element: document.createElement('mark') ,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') { // 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 } } const imageCacheEntry = getImgForURL(url) const element = { element: imageCacheEntry.element ,location: e.location ,type: 'image' ,imagePromise: imageCacheEntry.loadPromise.then(() => url) } element.element.style.maxWidth = '100%' element.element.classList.add('align-bottom') // element.element.src=LOADING_IMG accum.push(element) } else if (e.tag === 'quote') { const element = { element: document.createElement('div') ,location: e.location ,type: 'container' ,contains: [] } accum.push(element) element.element.style.width = '100%' element.element.style.display = 'inline-block' element.element.style.margin = '1em 0' element.element.classList.add('well' ,'well-sm') element.contains = pegAstToHtml_v2(e.content) element.contains.forEach((child_ast_element) => { element.element.appendChild(child_ast_element.element) }) } else if (e.tag === 'spoiler') { 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('div') ,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) }) // NOTE: The world was fixed and mended together! This might be equivilent now /* 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('div') ,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 === '*') { const element = { element: document.createElement('li') ,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.content != null) { // FIXME? Is this possible? Root? pegAstToHtml_v2(e.content).forEach((e) => { accum.push(e) }) } else { // FIXME: Does this even happen? throw Error(`Recieved unknown and unhandeled ast entry '${JSON.stringify(e)}'`) /* accum.push({ type: 'text' ,element: document.createTextNode(e.content) ,location: e.location }) */ } return accum } ,[]) /* TODO: Implement bi-directional scrolling. scroll textarea to current visible content res.filter(e => e.element.nodeName.toLowerCase() !== 'button') .forEach((e) => { e.element.addEventListener('click' ,() => { selectTextAreaPosition(e.location[0]) }) }) */ return res } function makePreview(txt) { const astHtml = pegAstToHtml_v2(bbcodePegParser().parse(txt)) const previewDiv = document.createElement('div') previewDiv.style.flexGrow = '1' astHtml.forEach(e => previewDiv.appendChild(e.element)) // Conform to MD style previewDiv.classList.add('postbody' ,'mb-3' ,'mt-4') // FIXME: Ensure this is equivilent // Threads get wordWrap from tr.post // Profile gets it from card // Not sure why word break is needed, since I don't see it in md's css previewDiv.style.wordWrap = 'break-word' // previewDiv.style.overflowWrap = 'break-word' previewDiv.style.wordBreak = 'break-word' return [previewDiv ,astHtml] } function createPreviewCallbacks() { const nav = document.querySelector('nav.navbar.fixed-top') // @ts-ignore let navY if (nav === undefined) { navY = 0 } else if (nav.getBoxQuads !== undefined) { navY = nav.getBoxQuads()[0].p3.y } else { navY = nav.getBoundingClientRect().height } const navHeight = navY // let image_buffers: Map