fapnip / OpenEOS Editor Extensions

// ==UserScript==
// @name        OpenEOS Editor Extensions
// @namespace   OpenEOS editor extensions
// @match       https://milovana.com/eos/editor/*
// @grant       GM_addStyle
// @grant       GM_xmlhttpRequest
// @license     MIT
// @version     1.8.5
// @author      fapnip
// @description Adds additional fields to the EOS editor to support OpenEOS features: https://milovana.com/forum/viewtopic.php?f=26&t=23558
// ==/UserScript==

let lastPageText = "";

const oeosUrlBase = "https://oeos.art/";

onRequestAnimationFrame();

const oeosUrlMatcher = /^file:.+(\+\(\|(oeos|oeos-video):(.*)\))$/
const allowOeosUrlMatcher = /^file:/
const allowedUrlMatcher = /(^(https:\/\/thumbs[0-9]*\.*gfycat\.com\/.+|https:\/\/thumbs[0-9]*\.*redgifs\.com\/.+|https:\/\/w*[0-9]*\.*mboxdrive\.com\/.+|https:\/\/media[0-9]*\.vocaroo\.com\/.+|https:\/\/iili\.io\/.+|https:\/\/i\.ibb\.co\/.+|https:\/\/media\.milovana\.com\/.+)|^(file:|gallery:).*\+\(\|(oeos|oeos-video):(.+)\)$)/
const gifUrlStart = '+(|oeos:'
const vidUrlStart = '+(|oeos-video:'
const gifUrlEnd = ')'
const redGifsWatchMatch = /^(https:\/\/|.*)(www\.|.*)(redgifs|gfycat)\.com\/(watch\/|)([a-zA-Z]+)/
const vids = []

const loadScriptQuery = `
  query LoadScript($teaseId: ID!) {
    tease(id: $teaseId) {
      id
      title
      privateShareUrl
      ... on EosTease {
        script
      }
    }
  }
`

let lastTeaseId = null
let lastTeaseKey = null
let lastTeaseScript = null

// TODO:  Figure out some mutation observers to use instead of requestAnimationFrame loop
function onRequestAnimationFrame() {
  requestAnimationFrame(onRequestAnimationFrame);
  
  const loader = document.getElementsByClassName("MuiCircularProgress-root")[0];
  if (loader !== undefined) {
    return;
  }
  
  const page = document.querySelector("#root > div > main > header > div > h6");
  if (page && page.textContent !== lastPageText) {
    lastPageText = page.textContent;
    onPageEnter(page.textContent, page);
  }
}

function getReactInternalInstance(node) {
  for (const key in node) {
    if (key.startsWith('__reactInternalInstance$')) {
      return node[key]
    }
  }
  return null
}

function getCurrentTeaseId() {
  const matches = window.location.href.match(/\/eos\/editor\/([0-9]+)\//)
  if (!matches) {
    return null
  }
  return matches[1]
}

function getKeyFromTeaseUrl(url) {
  const matches = url.match(/&key=([a-z0-9]+)/)
  if (matches) {
    return matches[1]
  }
  return null
}

function getCurrentTeaseKey(onContinue, onError) {
  const currentTeaseId = getCurrentTeaseId()
  if (!currentTeaseId) {
    onError && onError()
    return
  }
  if (currentTeaseId !== lastTeaseId) lastTeaseKey = null
  lastTeaseId = currentTeaseId
  if (!lastTeaseKey) {
    
    const query = {
       operationName: 'LoadScript',
       variables: {
          teaseId: currentTeaseId
       },
       query: loadScriptQuery
    }
    
    GM_xmlhttpRequest({
      method: 'POST',
      url: '/graphql/',
      data: JSON.stringify(query),
      headers: {
        'Content-Type': 'application/json'
      },
      onload: response => {
        try {
          const result = JSON.parse(response.responseText)
          lastTeaseScript = result
          lastTeaseKey = getKeyFromTeaseUrl(result.data.tease.privateShareUrl)
          onContinue && onContinue(lastTeaseKey, lastTeaseScript)
        } catch (e) {
          console.error(e)
          onError && onError()
        }
      }
    })
  } else {
    onContinue && onContinue(lastTeaseKey, lastTeaseScript)
  }
}

const loadPreviewInTab = () => {
  getCurrentTeaseKey(teaseKey => {
    const teasUrl = oeosUrlBase +  `?id=${getCurrentTeaseId()}&key=${teaseKey}&preview=1`
    window.open(teasUrl, 'oeos_preview_' + getCurrentTeaseId());
  })
}

GM_addStyle(`
#root > div > main > div > div > div.MuiGrid-root.MuiGrid-item.MuiGrid-grid-xs-12.MuiGrid-grid-md-7.MuiGrid-grid-lg-6 > div > div.MuiCardActions-spacing + div {
  padding-top: 0 !important;
  height: 60% !important;
}
#root > div > main > div > div > div.MuiGrid-root.MuiGrid-item.MuiGrid-grid-xs-12.MuiGrid-grid-md-7.MuiGrid-grid-lg-6 > div > div > img {
  display: block;
  top: 50% !important;
  left: 50% !important;
  max-width: 100% !important;
  width: auto !important;
  transform: translate(-50%, -50%) !important;
  object-fit: contain;
}
.oeos-hide {
  display:none !important;
}
.oeos-preview-switch {
  margin-left: 10px;
}
#root.oeos-preview-enabled .oeos-preview-switch span.oeos-show-oeos {
  display: none;
}
#root:not(.oeos-preview-enabled) .oeos-preview-switch span.oeos-show-eos {
  display: none;
}
#root:not(.oeos-preview-enabled) iframe#oeos-preview {
  display: none;
}
#root.oeos-preview-enabled iframe:not(#oeos-preview) {
  display: none;
}
`)

function onPageEnter(pageTitle, pageHeaderEl) {
  const actionTitle = document.querySelector("#root > div > main > div > div > div.MuiGrid-root.MuiGrid-item.MuiGrid-grid-xs-12.MuiGrid-grid-md-7.MuiGrid-grid-lg-6 > div > div.MuiCardHeader-root > div.MuiCardHeader-content > span");
  let previewModeEl = document.querySelector("#oeosPreviewMode");
  if (previewModeEl) {
    previewModeEl.classList.add('oeos-hide')
  }
  
  if (actionTitle !== null && actionTitle.textContent && actionTitle.textContent.match(/^(Image$|Audio)/)) {
    const isVideo = !!actionTitle.textContent.match(/^(Audio)/);
    const form = document.querySelector("#root > div > main > div > div > div.MuiGrid-root.MuiGrid-item.MuiGrid-grid-xs-12.MuiGrid-grid-md-7.MuiGrid-grid-lg-6 > div > div.MuiCardContent-root > div");
    const formInput = form.querySelector("input");
    const buttonContainer = document.querySelector("#root > div > main > div > div > div.MuiGrid-root.MuiGrid-item.MuiGrid-grid-xs-12.MuiGrid-grid-md-7.MuiGrid-grid-lg-6 > div > div.MuiCardActions-root.MuiCardActions-spacing");
    const formInputReact = getReactInternalInstance(formInput);
    const clickPosition = document.createElement('div');
    const vidContainer = document.createElement('div');
    vidContainer.classList.add('oeos-hide');
    vidContainer.innerHTML = `<video class="MuiCardMedia-media" controls="" loop="" style="
        position: relative;
        transform: translate(-50%, 0) !important;
        top: 0 !important;
        height: 100%;
        display: block;
        left: 50% !important;
        max-width: 100% !important;
        width: auto !important;
        object-fit: contain;">
      <source src="" type="video/mp4">
      Your browser does not support the video tag.
    </video>`;
    const vidElement = vidContainer.getElementsByTagName('video')[0]
    const vidSource = vidContainer.getElementsByTagName('source')[0]
    const gifLabelText = isVideo ? 'OpenEOS video URL' : 'OpenEOS Gif/Webp URL'
    
    vids.forEach(v => {
      const vs = v && v.getElementsByTagName('source')
      if (vs && vs.length) {
        vs[0].src = ''
        vs[0].parentNode.load()
        v.remove()
      }
    })
    vids.length = 0
    
    if (isVideo) {
      buttonContainer.parentNode.insertBefore(vidContainer, buttonContainer.nextSibling);
      vids.push(vidContainer)
    }
    
    // if (!buttonContainer) {
    //   // console.warn('buttonContainer', buttonContainer)
    //   requestAnimationFrame(() => {onPageEnter(pageTitle, pageHeaderEl)})
    //   return
    // }
    
    buttonContainer.insertAdjacentElement('afterbegin', clickPosition)
    let img;
    const getImg = () => {
      if (!img) {
        img = document.querySelector("#root > div > main > div > div > div.MuiGrid-root.MuiGrid-item.MuiGrid-grid-xs-12.MuiGrid-grid-md-7.MuiGrid-grid-lg-6 > div > div > img");
        img.addEventListener("click", (e) => {
          const rect = e.target.getBoundingClientRect()
          const x = e.clientX - rect.left //x position within the element.
          const y = e.clientY - rect.top //y position within the element.
          const relX = x / e.target.clientWidth // between 0 and 1, where clicked
          const relY = y / e.target.clientHeight // between 0 and 1, where clicked
          console.log(e.clientX, e.clientY, rect)
          clickPosition.innerHTML = `X:${relX.toFixed(2)} (${Math.floor(relX * 100)}%), Y:${relY.toFixed(2)} (${Math.floor(relY * 100)}%)`
        });
      }
    }
    
    
    const formGif = form.cloneNode(true);
    const formGifInput = formGif.querySelector("input");
    const formGifInputRoot = formGif.querySelector(".MuiInput-root");
    const formGifLabel = formGif.querySelector("label");
    formGifLabel.classList.add('MuiInputLabel-shrink')
    formGifLabel.classList.add('MuiFormLabel-filled')
    
    const triggerReactUpdate = () => {
         // Pretend we did a change in react
        const evt = document.createEvent('HTMLEvents')
        evt.initEvent("change", false, true)
        formInput.dispatchEvent(evt)
        formInputReact.memoizedProps.onChange(evt)
    }
    
    const resoveOeosUrl = oeosRawUrl => {
       if (oeosRawUrl.startsWith('[')) {
        // Is json array
        let arr
        try {
          arr = JSON.parse(oeosRawUrl)
        } catch (e) {
          throw new Error('Invalid JSON array: ' + oeosRawUrl)
        }
        if (!arr || !arr.length) {
          throw new Error('Empty or Invalid JSON array: ' + oeosRawUrl)
        }
        return resoveOeosUrl(arr[Math.floor(Math.random() * arr.length)])
      } else {
        if (!oeosRawUrl || !oeosRawUrl.match(allowedUrlMatcher)) {
          throw new Error('Invalid or Disallowed OEOS Image URL')
        }
        return oeosRawUrl
      }
    }
    
    const setImage = () => {
      if (!isVideo) {
        getImg()
      }
      const im = isVideo ? vidSource : img
      if (im) {
        if (!im._origSrc) im._origSrc = im.src
        let oeosRawUrl = formGifInput.value
        let error = ''
        if (oeosRawUrl) {
          try {
            oeosRawUrl = resoveOeosUrl(oeosRawUrl)
          } catch (e) {
            error = e.toString()
          }
          if (im.src !== oeosRawUrl) {
            clickPosition.innerHTML = ''
            im.src = oeosRawUrl
            im._lastOeos = oeosRawUrl
          }
        } else {
          if (im.src && (im.src.match(/^https:\/\/media\.milovana\.com\/timg\/tb_/) || (isVideo && im.src.match(/^https:\/\/media\.milovana\.com\//))) && im.src !== im._lastOeos) {
            im._origSrc = im.src
          }
          im._lastOeos = null
          if (im.src !== im._origSrc) {
            clickPosition.innerHTML = ''
            im.src = im._origSrc
            triggerReactUpdate() // Make sure it's right
          }
        }
        if (error) {
          formGifLabel.textContent = error;
          console.error(error)
          formGifInputRoot.classList.add('Mui-error')
          formGifLabel.classList.add('Mui-error')
        } else {
          formGifLabel.textContent = gifLabelText;
          formGifInputRoot.classList.remove('Mui-error')
          formGifLabel.classList.remove('Mui-error')
        }
        if (isVideo) {
          if (!oeosRawUrl || error) {
            if (im) {
              im.src = ''
            }
            vidContainer.classList.add('oeos-hide')
            const rgwm = oeosRawUrl && oeosRawUrl.match(redGifsWatchMatch)
            // console.log('rgwm', rgwm, oeosRawUrl)
            if (rgwm) {
              const rgwmType = rgwm[3]
              const rgwmId = rgwm[5]
              let rgurl = '';
              if (rgwmType === 'gfycat') {
                rgurl = 'https://gfycat.com/' + rgwmId
              } else {
                rgurl = 'https://www.redgifs.com/watch/' + rgwmId
              }
              
                  // console.log('Doing RGWM', rgurl)
              formGifLabel.textContent = 'Looking up mp4 URL from ' + rgwmType + ' watch URL...';
              GM_xmlhttpRequest({
                method: 'GET',
                url: rgurl,
                // headers: {
                //   'Content-Type': 'application/json'
                // },
                onload: response => {
                  // console.log('Got RGWM Response', response)
                  let error = ''
                  try {
                    let vmatR = ''
                    if (rgwmType === 'gfycat') {
                      vmatR = new RegExp('https://thumbs[0-9]*\\.gfycat\\.com/' + rgwmId + '-mobile\\.mp4', 'i')
                    } else {
                      vmatR = new RegExp('https://thumbs[0-9]*\\.redgifs\\.com/' + rgwmId + '-mobile\\.mp4', 'i')
                    }
                    const vmat = response.responseText.match(vmatR)
                    // console.log('vmatR', vmatR, vmat, response.responseText)
                    if (vmat) {
                      formGifInput.value = vmat[0]
                      updateEosUrl()
                    } else {
                      error = 'Unable to get video URL from given ' + rgwmType + ' watch link'
                    }
                  } catch (e) {
                    console.error(e)
                      error = 'Error trying to get video URL from given ' + rgwmType + ' watch link'
                  }
                  if (error) {
                    formGifLabel.textContent = error;
                    console.error(error)
                    formGifInputRoot.classList.add('Mui-error')
                    formGifLabel.classList.add('Mui-error')
                  }
                }
              })
            }
          } else {
            vidContainer.classList.remove('oeos-hide')
          }
          vidElement.load()
        } else {
          if (oeosRawUrl && error) {
            const rgimgbb = oeosRawUrl && oeosRawUrl.match(/^(https:\/\/|.*)(www\.|)ibb\.co\/([a-zA-Z0-9]+)\s*$/)
            // console.log('rgwm', rgwm, oeosRawUrl)
            if (rgimgbb) {
              const imgbbId = rgimgbb[3]
              const imgbbUrl = oeosRawUrl
              
                  // console.log('Doing RGWM', rgurl)
              formGifLabel.textContent = 'Looking up image URL from ImgBB URL...';
              GM_xmlhttpRequest({
                method: 'GET',
                url: imgbbUrl,
                // headers: {
                //   'Content-Type': 'application/json'
                // },
                onload: response => {
                  // console.log('Got IMGBB Response', response)
                  let error = ''
                  try {
                    const imatR = /<link rel="image_src" href="(https:\/\/[^"]+?)">/
                    const imat = response.responseText.match(imatR)
                    // console.log('vmatR', vmatR, vmat, response.responseText)
                    if (imat) {
                      formGifInput.value = imat[1]
                      updateEosUrl()
                    } else {
                      error = 'Unable to get image URL from given link'
                    }
                  } catch (e) {
                    console.error(e)
                      error = 'Error trying to get image URL from given link'
                  }
                  if (error) {
                    formGifLabel.textContent = error;
                    console.error(error)
                    formGifInputRoot.classList.add('Mui-error')
                    formGifLabel.classList.add('Mui-error')
                  }
                }
              })
            }
          }
        }
      }
    }
    
    const updateOeosUrl = () => {
      const matches = formInput.value.match(oeosUrlMatcher)
      if (matches) {
        const oeosUrl = decodeURIComponent(matches[3])
        if (oeosUrl !== formGifInput.value) {
          formGifInput.value = oeosUrl
        }
      } else {
        formGifInput.value = ''
      }
      if (formInput.value.match(allowOeosUrlMatcher)) {
        formGif.style.display = 'inline-flex'
      } else {
        formGif.style.display = 'none'
      }
      setImage()
    }
    
    const updateEosUrl = () => {
      const matches = formInput.value.match(oeosUrlMatcher)
      const oeosRawUrl = formGifInput.value
      const oeosUrl = encodeURIComponent(oeosRawUrl)
      const fullOeosUrl = (isVideo ? vidUrlStart : gifUrlStart) + oeosUrl + gifUrlEnd
      let didChange = false
      if (!oeosUrl) {
        if (matches) {
          formInput.value = formInput.value.replace(matches[1], '')
          didChange = true
        }
      } else {
        if (matches) {
          if (matches[1] !== fullOeosUrl) {
            formInput.value = formInput.value.replace(matches[1], fullOeosUrl)
            didChange = true
          }
        } else {
          formInput.value = formInput.value + fullOeosUrl
          didChange = true
        }
      }
      if (didChange) {
        triggerReactUpdate()
      }
      if (formInput.value.match(allowOeosUrlMatcher)) {
        formGif.style.display = 'inline-flex'
      } else {
        formGif.style.display = 'none'
        isVideo && vidContainer.classList.add('oeos-hide')
      }
      setImage()
    }
    
    form.parentNode.appendChild(formGif);
    
    formGifLabel.textContent = gifLabelText;
    formGifInput.value = "";
    
    const updateEosForm = () => {
      updateEosUrl();
    }
    
    formGifInput.addEventListener("input", updateEosForm);
    
    // Monitor change of EOS url
    const observer = new MutationObserver(m => {
      updateOeosUrl()
    })
    observer.observe(formInput,  {attributes: true})
    updateOeosUrl() // Force update of OEOS url
    updateEosUrl()
    
  }
  
  if (pageTitle === "Share") {
    
    const form = document.querySelector("#root > div > main > div > div > div > div");
    
    if (form === null) {
      return;
    }
    
    const linkLabel = 'OpenEOS Link';
    
    const formOpenEOS = form.cloneNode(true);
    const formOpenEOSInput = formOpenEOS.querySelector("input");
    const formOpenEOSLabel = formOpenEOS.querySelector("label");
    const formOpenEOSLegend = formOpenEOS.querySelector("legend span");
    let link = formOpenEOS.value;
    formOpenEOSInput.value = formOpenEOSInput.value.replace("https://milovana.com/webteases/showtease.php", oeosUrlBase);
    
    formOpenEOSLabel.innerHTML = linkLabel;
    formOpenEOSLegend.innerHTML = linkLabel;
    
    form.parentNode.appendChild(formOpenEOS);
    
    formOpenEOS.onclick = () => {
      formOpenEOSInput.select();
      document.execCommand("copy");
    }
  }
  
  //  Current host for OpenEOS can't be configured to allow OpenEOS to run in iframe.  We'll just provide a button to open in a new tab, for now.
  if (pageTitle === "Preview") {
    
    if (!previewModeEl) {
      previewModeEl = document.createElement('div')
      previewModeEl.id = 'oeosPreviewMode'
      previewModeEl.innerHTML = `<button class="oeos-preview-switch" tabindex="0" type="button">
                                    <span class="MuiButton-label"><span class="oeos-show-oeos">Preview in OpenEOS</span></span><span class="MuiTouchRipple-root"></span>
                                 </button>`
      const previewSwitchButton = previewModeEl.querySelector('.oeos-preview-switch')
      previewSwitchButton.addEventListener('click', () => {
        loadPreviewInTab()
      })
    }
    pageHeaderEl.parentNode.insertBefore(previewModeEl, pageHeaderEl.nextSibling)
    previewModeEl.classList.remove('oeos-hide')
    
  }
  
//  Current host for OpenEOS can't be configured to allow OpenEOS to run in iframe.  We'll disable OpenEOS ifrmae embedding, for now
//   if (pageTitle === "Preview") {
//     const rootEl = document.querySelector('#root')
//     const eosIframe = rootEl.querySelector('iframe:not(#oeos-preview)')
//     let oeosIframe = rootEl.querySelector('iframe#oeos-preview')
//     const loadPreview = () => {
//       if (!oeosIframe) {
//         getCurrentTeaseKey(teaseKey => {
//           const teasUrl = oeosUrlBase +  `?id=${getCurrentTeaseId()}&key=${teaseKey}`
//           oeosIframe = document.createElement('iframe')
//           oeosIframe.classList.add(...eosIframe.classList)
//           oeosIframe.id = 'oeos-preview'
//           oeosIframe.src = teasUrl
//           eosIframe.parentNode.insertBefore(oeosIframe, eosIframe.nextSibling)
//         })
//       }
//     }
    
//     if (!previewModeEl) {
//       previewModeEl = document.createElement('div')
//       previewModeEl.id = 'oeosPreviewMode'
//       previewModeEl.innerHTML = `<button class="oeos-preview-switch" tabindex="0" type="button">
//                                     <span class="MuiButton-label"><span class="oeos-show-oeos">Switch to OpenEOS</span><span class="oeos-show-eos">Switch to Standard EOS</span></span><span class="MuiTouchRipple-root"></span>
//                                  </button>`
//       const previewSwitchButton = previewModeEl.querySelector('.oeos-preview-switch')
//       previewSwitchButton.addEventListener('click', () => {
//         if (rootEl.classList.contains('oeos-preview-enabled')) {
//           rootEl.classList.remove('oeos-preview-enabled')
//         } else {
//           rootEl.classList.add('oeos-preview-enabled')
//         }
//         if (rootEl.classList.contains('oeos-preview-enabled')) loadPreview()
//         getCurrentTeaseKey()
//       })
//     }
//     pageHeaderEl.parentNode.insertBefore(previewModeEl, pageHeaderEl.nextSibling)
//     previewModeEl.classList.remove('oeos-hide')
//     if (rootEl.classList.contains('oeos-preview-enabled')) loadPreview()
    
//   }
}