NOTICE: By continued use of this site you understand and agree to the binding Terms of Service and Privacy Policy.
// ==UserScript== // @name Mangadex Preview Post // @description WhatYouSeeIsWhatYouGet preview generator for MangaDex comments/posts/profile. Shows a formatted preview next to the edit box. // @namespace https://github.com/Brandon-Beck // @author Brandon Beck // @license MIT // @icon https://mangadex.org/favicon-96x96.png // @version 0.3.15 // @grant GM_xmlhttpRequest // @require https://gitcdn.xyz/cdn/pegjs/pegjs/0b102d29a86254a50275b900706098aeca349740/website/vendor/pegjs/peg.js // @match https://mangadex.org/* // ==/UserScript== /* global $ */ 'use strict' // FIXME: The entire MediaTag code is an ugly mess const isUserscript = window.GM_xmlhttpRequest !== undefined // Ensure Console/Bookmarklet is not run on other sites. if (!isUserscript && !window.location.href.startsWith('https://mangadex.org')) { /* eslint-disable-next-line no-alert */ alert('Mangadex Post Preview script only works on https://mangadex.org') throw Error('Mangadex Post Preview script only works on https://mangadex.org') } // 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) }) } 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 } } // Firefox Speed Test: Fastest to Slowest // getImgForURLViaFetch: Caches blobs // -- No noticable lag or problems with several small images on page. // -- Very usable with Hell's test, though there is a small bit of lag // -- (Rebuilds hell in under 1 second. // -- Does better than getImgForURL does with a normal post with small images) // getImgForURLViaImg: Caches imgs, clones on reuse // -- Holding down a key causes noticable shakyness. No real script lag, // -- but the images width/height seem to start off at 0 and then // -- suddenly grow. Verry offsetting to look at // -- Survives Hell's test almost just as well. Very minor additional lag. // -- As such, I believe this is quite scalable. // getImgForURLNoCache: Creates new img and sets src like normal // -- Noticable lag. Preview will not update while a key is being spammed. // -- Slightly jumpy like above, but not as noticable since the lag // -- spreads it out. // -- Survives Hell's test just as well as getImgForURLViaImg. // -- Whatever benifit we get from cloning may not apply here. // -- Perhaps due to the fact we are looking up only 1 image several hundrad times. // BROKEN getImgForURLViaFetchClone: Caches Img of blobs. // -- Does not work when image is used multiple times, for some reason. // -- Should be comparable to getImgForURLViaFetch, if it worked. // -- Failed to render any images for hell's test. 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 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 getImgForURL(url) { if (isUserscript) { return getImgForURLViaFetch(url) } return getImgForURLViaImg(url) } /* PEG grammer */ // TODO: // Partial rebuilds! only update what changed // FIXME: // Img is text only. not recursive let generatedBBCodePegParser function tokensToSimpleAST(tokens) { // FIXME Figure out Why pegjs returns null. is it an error, does empty // do an early escape? does having none of a token:expresion+ // return null instead of [] (tested return token? token: [], didn't help) if (tokens == null) { return [] } // Why did I make a root again? const astroot = [ { type: 'root' ,content: [] ,location: [0 ,0] } ] const stack = [astroot[0]] let astcur = astroot[0] /* eslint-disable prefer-destructuring */ let mediaStateOpened let mediaErrorState = false tokens.forEach((token) => { if (mediaStateOpened && token.type !== 'linebreak') { const openMedia = astcur.content[astcur.content.length - 1] if (token.type === 'close' && token.tag === 'img') { if (!mediaErrorState) { mediaStateOpened.explicitlyClosed = true mediaStateOpened.location[1] = token.location[1] } else { if (openMedia.type === 'openmedia') { const errorAst = { type: 'error' ,content: `[${mediaStateOpened.tag}]${mediaStateOpened.content}` ,location: mediaStateOpened.location } astcur.content.pop() astcur.content.push(errorAst) } const errorAst = { type: 'error' ,content: `[/${token.tag}]` ,location: openMedia.location } astcur.content.push(errorAst) } mediaErrorState = false mediaStateOpened = undefined astcur.location[1] = token.location[1] return undefined } if (openMedia.type === 'openmedia') { if (openMedia.content === '' && (token.type === 'link' || token.type === 'text') && token.content.match(/^[^ \t\n\r:[\]]+:\/\/[^ \t\n\r[\]]+$/)) { openMedia.content = token.content astcur.location[1] = token.location[1] mediaErrorState = false return undefined } const errorAst = { type: 'error' ,content: `[${openMedia.tag}]${openMedia.content}` ,location: openMedia.location } astcur.content.pop() astcur.content.push(errorAst) } mediaErrorState = true if (token.type === 'open' || token.type === 'prefix' || token.type === 'opendata' || token.type === 'openmedia') { const errorAst = { type: 'error' ,content: `[${token.tag}]` ,location: token.location } astcur.content.push(errorAst) } else if (token.type === 'close') { const errorAst = { type: 'error' ,content: `[/${token.tag}]` ,location: token.location } astcur.content.push(errorAst) } else if (token.type === 'link') { const errorAst = { type: 'error' ,content: `${token.content}` ,location: token.location } astcur.content.push(errorAst) } else if (token.type === 'error' || token.type === 'text') { const errorAst = { type: 'error' ,content: `${token.content}` ,location: token.location } astcur.content.push(errorAst) } astcur.location[1] = token.location[1] return undefined } if (token.type === 'close') { let idx = Object.values(stack).reverse().findIndex(e => (e.type === 'open' || e.type === 'opendata' || e.type === 'prefix') && e.tag === token.tag) if (idx !== -1) { idx += 1 // NOTE should we set ast location end? Yes! for (let i = stack.length - idx; i < stack.length; i++) { stack[i].location[1] = token.location[1] } stack.splice(-idx ,idx) astcur.location[1] = token.location[1] astcur = stack[stack.length - 1] } else { const thisast = { type: 'error' ,content: `[/${token.tag}]` ,location: token.location } astcur.location[1] = token.location[1] astcur.content.push(thisast) } } else if (token.type === 'open') { const thisast = { type: token.type ,tag: token.tag ,content: [] ,location: token.location } // Must update end location when tag closes astcur.content.push(thisast) astcur.location[1] = token.location[1] // ;({ location: [,astcur.location[1]] } = token) astcur = thisast stack.push(thisast) } else if (token.type === 'prefix') { const thisast = { type: token.type ,tag: token.tag ,content: [] ,location: token.location } // cannot directly nest bullet in bullet (must have a non-prexix container class) if (astcur.type === 'prefix') { // FIXME are we supposed to subtract 1 here? astcur.location[1] = token.location[0] // - 1 stack.pop() astcur = stack[stack.length - 1] } astcur.content.push(thisast) astcur.location[1] = token.location[1] astcur = thisast stack.push(thisast) } else if (token.type === 'opendata') { const thisast = { type: token.type ,tag: token.tag ,content: [] ,location: token.location } thisast.data = token.attr astcur.content.push(thisast) astcur.location[1] = token.location[1] astcur = thisast stack.push(thisast) } else if (token.type === 'openmedia') { const thisast = { type: token.type ,tag: token.tag ,content: '' ,location: token.location ,explicitlyClosed: false } astcur.content.push(thisast) astcur.location[1] = token.location[1] mediaStateOpened = thisast mediaErrorState = true // astcur = thisast // stack.push(thisast) } else if (token.type === 'linebreak') { // TODO should check if prefix instead if prefix is to be expanded appon // if (astcur.type === 'prefix') { // FIXME are we supposed to subtract 1 here? // astcur.location[1] = token.location[0] // - 1 // Are Linebreaks added when we are exiting a prefix? Seems like it! // Not sure why though... // astcur.content.push(token) // stack.pop() // astcur = stack[stack.length - 1] // } // else { ({ location: [,astcur.location[1]] } = token) astcur.content.push(token) // } } else if (token.type === 'link') { astcur.location[1] = token.location[1] const previousSiblingAst = astcur.content[astcur.content.length - 1] if ((astcur.type === 'root' && !previousSiblingAst) || (previousSiblingAst && (previousSiblingAst.type === 'linebreak' || (previousSiblingAst.type === 'text' && previousSiblingAst.content.endsWith(' '))))) { astcur.content.push(token) } else { astcur.content.push({ type: 'error' ,location: token.location ,content: token.content }) } } else { astcur.location[1] = token.location[1] astcur.content.push(token) } return undefined }) // 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] } if (mediaStateOpened) { // FIXME make sure this makes sense const openMedia = astcur.content[astcur.content.length - 1] if (openMedia.type === 'openmedia') { const errorAst = { type: 'error' ,content: `[${mediaStateOpened.tag}]${mediaStateOpened.content}` ,location: mediaStateOpened.location } astcur.content.pop() astcur.content.push(errorAst) } const errorMediaCloseAst = { type: 'error' ,content: `[/${mediaStateOpened.tag}]` // FIXME should I use token or doErrornousMediaClose location> ,location: mediaStateOpened.location } // astcur.location[1] = token.location[1] astcur.content.push(errorMediaCloseAst) mediaStateOpened = undefined } // stack.splice(start, end) not needed return astroot[0].content /* eslint-enable prefer-destructuring */ } function simpleAstTrim(ast) { let contentStartIndex = ast.findIndex(e => !(e.type === 'linebreak' || ((e.type === 'text' || e.type === 'error') && e.content.match(/^ +$/)))) if (contentStartIndex === -1) contentStartIndex = 0 let contentEndIndex = ast.slice().reverse().findIndex(e => !(e.type === 'linebreak' || ((e.type === 'text' || e.type === 'error') && e.content.match(/^ +$/)))) if (contentEndIndex === -1) contentEndIndex = 0 else contentEndIndex = ast.length - contentEndIndex return ast.slice(contentStartIndex ,contentEndIndex) } function bbcodeTokenizer() { if (generatedBBCodePegParser) return generatedBBCodePegParser generatedBBCodePegParser = peg.generate(String.raw` start = tokens:Expressions? {return tokens} Expressions = tokens:Expression+ { return tokens } Expression = res:(OpenTag / OpenMediaTag / OpenDataTag / CloseTag / PrefixTag / LineBreak / ImplicitLinkLoose / 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:(OpenCloseNormalTag / OpenCloseMediaTag) { return {type:open.tag, data:open.attr, content} } OpenCloseMediaTag = open:OpenMediaTag 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, content: open.content} } OpenCloseNormalTag = 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", tag: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" / "sub" / "sup" / "left" / "right" / "ol" / "ul" / "h1" / "h2" / "h3" / "h4" / "hr" / "h" / "b" / "s" / "i" / "u" MediaTagList = "img" 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", tag:tag, location:[location().start.offset,location().end.offset] } } // content:ExplicitLinkLoose OpenMediaTag = "[" tag:MediaTagList "]" { return {type:"openmedia", tag:tag, location:[location().start.offset,location().end.offset] } } AttrTagProxy = "=" attr:ExplicitLinkLoose {return attr.content} OpenDataTag = "[" tag:DataTagList attr:AttrTagProxy "]" { return {type:"opendata", tag:tag,attr:attr, location:[location().start.offset,location().end.offset]} } CloseTag = "[/" tag:(DataTagList / MediaTagList / NormalTagList / PrefixTagList ) "]" { return {type:"close", tag:tag, location:[location().start.offset,location().end.offset]} } // FIXME find actual values // Explicit URL Links. Regex is something like [a-zA-Z0-9<LOTS OF SPECIAL CHARS>]://[a-zA-Z0-9] ExplicitLinkAddressStrict = text:(!([ \t\n\r\[\]]). ExplicitLinkAddressStrict?) { return text.join('') } ExplicitLinkProtoStrict = text:([a-zA-Z0-9]+) { return text.join('') } ExplicitLinkStrict = text:(ExplicitLinkProtoStrict "://" ExplicitLinkAddressStrict) !([ \t\n\r]) { return {type: "link", content:text.join(''), location:[location().start.offset,location().end.offset] } } ExplicitLinkAddressLoose = text:(!([ \t\n\r\[\]]). ExplicitLinkAddressLoose?) { return text.join('') } ExplicitLinkProtoLoose = text:(!([ \t\n\r\[\]\:\/]). ExplicitLinkProtoLoose?) { return text.join('') } ExplicitLinkLoose = text:(ExplicitLinkProtoLoose "://" ExplicitLinkAddressLoose) !([ \t\n\r]) { return {type: "link", content:text.join(''), location:[location().start.offset,location().end.offset] } } // Implicit URL links. At least these are valid. (http|ftp)s?://[a-zA-Z0-9./\-%"':@+]+ ImplicitLinkAddressStrict = text:[a-zA-Z0-9./\-%"':@+]+ { return text.join('') } ImplicitLinkStrict = text:( ("http" / "ftp") "s"? "://" ImplicitLinkAddressStrict) !([^ \t\n\r]) { return {type: "link", content:text.join(''), location:[location().start.offset,location().end.offset] } } ImplicitLinkAddressLoose = text:(!([ \t\n\r\[\]]). ImplicitLinkAddressLoose?) { return text.join('') } ImplicitLinkLoose = text:( ("http" / "ftp") "s"? "://" ImplicitLinkAddressLoose) !([^ \t\n\r]) { return {type: "link", content:text.join(''), location:[location().start.offset,location().end.offset] } } Text = text:(!(Tag / CloseTag / LineBreak / ImplicitLinkLoose). 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] } } Word = text:(!(Tag / CloseTag / LineBreak / " "). Word?) { if(text[2] != null) { return {type: "word", content:text[1] + text[2].content, location:[location().start.offset,text[2].location[1]] } } return {type: "word", content:text[1], location:[location().start.offset,location().end.offset] } } Space = text:(" "+) { return {type: "space", content:text.join(''), 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 astToHtmlAst(ast) { if (ast == null) { return [] } if (typeof (ast) !== 'object') { // This should never happen return [] } function appendText(accum ,htmlAst ,otext) { // MD Single spacing // FIXME do this in parser // let text = otext.replace(/^\n +/ ,'\n') // text = otext.replace(/^ +/ ,'') if (accum[accum.length - 1] && accum[accum.length - 1].element.nodeType === document.TEXT_NODE) { /* eslint-disable-next-line no-param-reassign */ let text = accum[accum.length - 1].element.nodeValue + otext text = text.replace(/^\n[ \t]+/ ,'\n') text = text.replace(/[ \t]+/g ,' ') accum[accum.length - 1].element.nodeValue = text return undefined } const text = otext.replace(/[ \t]+/g ,' ') accum.push({ type: 'text' ,element: document.createTextNode(text) ,location: htmlAst.location }) return undefined } const res = ast.reduce((accum ,e) => { if (e.type === 'text') { appendText(accum ,e ,e.content) } else if (e.type === 'linebreak') { const brAst = { element: document.createElement('br') ,location: e.location ,type: 'container' ,contains: [] } accum.push(brAst) // NOTE: Why? No clue what the goal was with this, but it is how md does it // FIXME prefer br element for scroll const newlineTextNode = { element: document.createTextNode('\n') ,location: e.location ,type: 'container' ,contains: [] } accum.push(newlineTextNode) } else if (e.type === 'error') { appendText(accum ,e ,e.content) } else if (e.type === 'link') { // accum += `<a href="${e.data}" target="_blank">${pegAstToHtml(e.content)}</a>` const linkAst = { element: document.createElement('a') ,location: e.location ,type: 'container' ,contains: [] } const linkTextAst = { element: document.createTextNode(e.content) ,location: e.location ,type: 'container' ,contains: [] } accum.push(linkAst) linkAst.element.target = '_blank' linkAst.element.rel = 'nofollow' if (e.content) { linkAst.element.href = e.content } linkAst.contains.push(linkTextAst) linkAst.contains.forEach((childAstElement) => { linkAst.element.appendChild(childAstElement.element) }) } else if (e.type === 'openmedia') { // FIXME should Only pass url via image when parsing const imageCacheEntry = getImgForURL(e.content) const element = { element: imageCacheEntry.element ,location: e.location ,type: 'image' ,imagePromise: imageCacheEntry.loadPromise.then(() => e.content) } element.element.classList.add('align-bottom') element.element.style.maxWidth = '100%' // FIXME Do not do this. Move away from isEqualNode which cares about this space instead // Why does .style sometimes add the space on its own? are you screwing with me? const styleAttr = element.element.attributes.getNamedItem('style') if (styleAttr) styleAttr.value = `${styleAttr.value.trim()} ` // element.element.src=LOADING_IMG accum.push(element) } // 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({ message: 'Unknown AST 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 = astToHtmlAst(e.content) element.contains.forEach((childAstElement) => { element.element.appendChild(childAstElement.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 = astToHtmlAst(e.content) element.contains.forEach((childAstElement) => { element.element.appendChild(childAstElement.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=astToHtmlAst(e.content) astToHtmlAst(e.content).forEach((childAstElement) => { accum.push(childAstElement) }) } else if (e.tag === 'b') { const element = { element: document.createElement('strong') ,location: e.location ,type: 'container' ,contains: [] } accum.push(element) element.contains = astToHtmlAst(e.content) element.contains.forEach((childAstElement) => { element.element.appendChild(childAstElement.element) }) } else if (e.tag === 'i') { const element = { element: document.createElement('em') ,location: e.location ,type: 'container' ,contains: [] } accum.push(element) element.contains = astToHtmlAst(e.content) element.contains.forEach((childAstElement) => { element.element.appendChild(childAstElement.element) }) } else if (e.tag === 'h') { const element = { element: document.createElement('mark') ,location: e.location ,type: 'container' ,contains: [] } accum.push(element) element.contains = astToHtmlAst(e.content) element.contains.forEach((childAstElement) => { element.element.appendChild(childAstElement.element) }) } else if (e.tag === 'url') { // accum += `<a href="${e.data}" target="_blank">${pegAstToHtml(e.content)}</a>` const element = { element: document.createElement('a') ,location: e.location ,type: 'container' ,contains: [] } accum.push(element) element.element.target = '_blank' if (e.data) { element.element.href = e.data } element.contains = astToHtmlAst(e.content) element.contains.forEach((childAstElement) => { element.element.appendChild(childAstElement.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' // FIXME dont use isEqualNode. Fix this compare (style uses 0px automaticly on ff) const styleAttr = element.element.attributes.getNamedItem('style') if (styleAttr) styleAttr.value += ' margin: 1em 0;' else element.element.style.margin = '1em 0' element.element.classList.add('well' ,'well-sm') element.contains = astToHtmlAst(e.content) element.contains.forEach((childAstElement) => { element.element.appendChild(childAstElement.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') button.element.type = 'button' 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 = astToHtmlAst(e.content) // FIXME: [spoiler] and [/spoiler] should scroll to button. set inner location. // didnt work though... as if btn location wasnt set exits // if (element.contains[0]) { // element.location[0] = element.contains[0].location[0] // element.location[1] = element.contains[element.contains.length - 1].location[1] // } element.contains.forEach((childAstElement) => { element.element.appendChild(childAstElement.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 += `<p class="text-center">${pegAstToHtml(e.content)}</p>` const element = { element: document.createElement('div') ,location: e.location ,type: 'container' ,contains: [] } accum.push(element) element.element.classList.add(`text-${e.tag}`) element.contains = astToHtmlAst(e.content) element.contains.forEach((childAstElement) => { element.element.appendChild(childAstElement.element) }) } else if (e.tag === '*') { const element = { element: document.createElement('li') ,location: e.location ,type: 'container' ,contains: [] } accum.push(element) element.contains = astToHtmlAst(e.content) element.contains.forEach((childAstElement) => { element.element.appendChild(childAstElement.element) }) } else if (e.content != null) { // FIXME? Is this possible? Root? astToHtmlAst(e.content).forEach((childAstElement) => { accum.push(childAstElement) }) } 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 } /* ********************************************* * Validate Result ********************************************* */ function comparePreviewToPost(previewAst ,post) { // FIXME work with image blob src if (previewAst.length !== post.childNodes.length) { console.warn(`Preview children count ${previewAst.length} does not match Post children count ${post.childNodes.length} for post #${post.parentElement.parentElement.id}`) console.warn(previewAst) return false } const invalidAstKey = previewAst.findIndex((childAst ,key) => { if (!post.childNodes[key].isEqualNode(childAst.element)) { return true } return false }) if (invalidAstKey !== -1) { console.warn(`Preview did NOT match post #${post.parentElement.parentElement.id}!`) console.warn('Ast Elm') console.warn(previewAst[invalidAstKey].element) console.warn('Post Elm') console.warn(post.childNodes[invalidAstKey]) return false } return true } /* ********************************************* * Build Interface ********************************************* */ function makePreview(txt) { // TODO compare bbcode to old BBCode // generate tokens and only for changed region // replace changed region html const astHtml = astToHtmlAst(simpleAstTrim(tokensToSimpleAST(bbcodeTokenizer().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 === null) { 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<string, Blob> 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") const textarea = (forum.querySelector('textarea')) if (!textarea) { // FIXME throw errors. Kind of want to short circit this one though return Error('Failed to find text area for forum') } // Setup variables let curDisplayedVersion = 0 let nextVersion = 1 let updateTimeout let updateTimeoutDelay = 50 const maxAcceptableDelay = 10000 const useFallbackPreview = false // Prepare form if (!forum.parentElement) { return undefined } // Setup our custom styles /* eslint-disable no-param-reassign */ forum.parentElement.style.alignItems = 'flex-start' forum.parentElement.classList.add('d-flex') forum.parentElement.style.flexDirection = 'row-reverse' forum.style.position = 'sticky' forum.style.top = '0px' // Causes buttons to wrap on resize forum.style.width = 'min-content' // Padding keeps us from hitting the navbar. Margin lines us back up with the preview forum.style.paddingTop = `${navHeight}px` forum.style.marginTop = `-${navHeight}px` /* eslint-enable no-param-reassign */ textarea.style.resize = 'both' // FIXME set textarea maxheight. form should be 100vh max. textarea.style.minWidth = '120px' textarea.style.width = '25vw' textarea.style.paddingLeft = '0' textarea.style.paddingRight = '0' // Make Initial Preview // FIXME use Update preview for initial preview as well let [previewDiv ,astHtml] = makePreview(textarea.value) forum.parentElement.insertBefore(previewDiv ,forum) // Run sanity check if in console mode if (!isUserscript && forum.classList.contains('post_edit_form')) { const post = document.querySelector(`#post_${forum.id} .postbody`) if (post) comparePreviewToPost(astHtml ,post) } // Move editor to left column if in a thread. const tableLeft = forum.parentElement.parentElement.firstElementChild if (tableLeft !== forum.parentElement) { if (tableLeft.firstChild.nodeName.toLowerCase() === 'img') { // We are a thread post! Lets integrate into the thread tableLeft.firstChild.remove() tableLeft.appendChild(forum) // Conform to MD thread post style forum.parentElement.classList.remove('p-3') forum.parentElement.classList.add('pb-3') forum.parentElement.parentElement.classList.add('post') // FIXME: Profile page also needs formating. // md's wordWrap is break-word, but it seems to // be acting like wordwrap: anywhere for some reason. } else { // Add padding to new posts and profile, so the preview doesn't touch // textarea the border forum.classList.add('pr-3') // Fixes profile interface overlap problem if (forum.id === 'change_profile_form') { textarea.parentElement.style.flexBasis = '100%' textarea.parentElement.style.maxWidth = '100%' } // FIXME: d-flex is causing preview to affect settings tabs // other than the profile tab. Making the entire preview // an invisible block that fills the page // invisible links can be accidently clicked on as well } } let currentSpoiler function searchAst(ast ,cpos) { // slice bc reverse is in place const a = ast.slice().reverse().find(e => e.location[0] <= cpos && cpos <= e.location[1]) if (a) { if (a.type === 'container') { // unhide spoilers // Ensure we are not a Text node and that we are a spoiler if (!currentSpoiler && a.element.nodeType !== 3 && a.element.classList.contains('spoiler') && a.element.style.display !== 'block') { currentSpoiler = a.element currentSpoiler.style.display = 'block' } const b = searchAst(a.contains ,cpos) if (b) { return b } } return a.element } return undefined } // Auto scroll into view function scrollToPos(pos = textarea.selectionStart) { // Hide previous spoiler if (currentSpoiler) { currentSpoiler.style.display = 'none' currentSpoiler = undefined } // Get element from ast that starts closest to pos const elm = searchAst(astHtml ,pos) if (elm) { // FIXME Scroll pos is a bit hard to find. // getBoxQuads, getClientRect, getBoundingClientRect all give the offset from the viewport // Height of child elements not calculated in... // SAFE for (text)nodes?, not safe for elements with nested content if (elm.nodeType === 3) { // @ts-ignore let y if (elm.getBoxQuads !== undefined) { y = elm.getBoxQuads()[0].p1.y } else { // if we do not have getBoxQuads, we will have to test from the // container element instead of the text node; y = elm.parentElement.getBoundingClientRect().top } // FIXME. Must be a better way to scroll (especialy in case of nested scroll frames) // Scroll to top document.scrollingElement.scrollBy(0 ,y) } else { // FIXME. Must be a better way to scroll (especialy in case of nested scroll frames) // Scroll to ~ center directly // const y: number = (elm as HTMLElement).offsetTop // document.scrollingElement!.scrollTo({top:y}) // Scroll to top elm.scrollIntoView() } // Scroll out of nav document.scrollingElement.scrollBy(0 ,-navHeight) // Add this line to scroll to center // document.scrollingElement!.scrollBy(0,-(window.innerHeight-navHeight)/2) // Finally, ensure we keep the textarea in view const bound = forum.getBoundingClientRect() // document.scrollingElement!.scrollBy(0,bound.bottom - bound.height) document.scrollingElement.scrollBy(0 ,bound.top) } } textarea.addEventListener('selectionchange' ,() => { // Only autoscroll if our ast is in sync with the preview. if (curDisplayedVersion === nextVersion - 1 && astHtml[astHtml.length - 1] != null && astHtml[astHtml.length - 1].location[1] === textarea.value.length) { scrollToPos() } }) function UpdatePreview() { // Measure load speed. Used for setting update delay dynamicly. const startTime = Date.now() // Create a preview buffer const thisVersion = nextVersion++ const [newPreview ,newAstHtml] = makePreview(textarea.value) // Setup spoilers the same way md does $(newPreview).find('.btn-spoiler').click(function spoilerButton() { // @ts-ignore $(this).next('.spoiler').toggle() }) // previewDiv, astHtml const 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(() => { const endTime = Date.now() const updateLoadDelay = endTime - startTime if (!useFallbackPreview && updateLoadDelay > maxAcceptableDelay) { // NOTE: Fallback preview removed. Focusing on speed improvments of normal preview // 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 // Replace the Preview with the buffered content previewDiv.parentElement.insertBefore(newPreview ,previewDiv) previewDiv.remove() previewDiv = newPreview astHtml = newAstHtml // Scroll to position scrollToPos() }) } function UpdatePreviewProxy() { // dbg(`Reseting timeout with delay ${updateTimeoutDelay} `) clearTimeout(updateTimeout) updateTimeout = setTimeout(UpdatePreview ,updateTimeoutDelay) } const buttons = Object.values(forum.querySelectorAll('button')) buttons.forEach((btn) => { btn.addEventListener('click' ,UpdatePreviewProxy) }) textarea.oninput = UpdatePreviewProxy return undefined }) } /* ************************************* * Run It! ************************************* */ if (isUserscript) createPreviewCallbacks() else { // Import and wait for PegJS // then createPreviewCallbacks() loadScript('https://gitcdn.xyz/cdn/pegjs/pegjs/0b102d29a86254a50275b900706098aeca349740/website/vendor/pegjs/peg.js') .then(() => { createPreviewCallbacks() }) }