Bitcointalk Editor + Image Uploader

Toggle SCEditor with native preview + upload images to hostmeme.com and keep [img width=.. height=..] for Bitcointalk

You will need to install an extension such as Tampermonkey, Greasemonkey or Violentmonkey to install this script.

You will need to install an extension such as Tampermonkey or Violentmonkey to install this script.

You will need to install an extension such as Tampermonkey or Violentmonkey to install this script.

You will need to install an extension such as Tampermonkey or Userscripts to install this script.

You will need to install an extension such as Tampermonkey to install this script.

You will need to install a user script manager extension to install this script.

(I already have a user script manager, let me install it!)

You will need to install an extension such as Stylus to install this style.

You will need to install an extension such as Stylus to install this style.

ستحتاج إلى تثبيت إضافة مثل Stylus لتثبيت هذا النمط.

ستحتاج إلى تثبيت إضافة لإدارة أنماط المستخدم لتتمكن من تثبيت هذا النمط.

ستحتاج إلى تثبيت إضافة لإدارة أنماط المستخدم لتثبيت هذا النمط.

ستحتاج إلى تثبيت إضافة لإدارة أنماط المستخدم لتثبيت هذا النمط.

(لدي بالفعل مثبت أنماط للمستخدم، دعني أقم بتثبيته!)

// ==UserScript==
// @name         Bitcointalk Editor + Image Uploader
// @namespace    Royal Cap
// @version      1.1
// @description  Toggle SCEditor with native preview + upload images to hostmeme.com and keep [img width=.. height=..] for Bitcointalk
// @match        https://bitcointalk.org/index.php?*
// @run-at       document-start
// @grant        GM_addStyle
// @grant        GM_getValue
// @grant        GM_setValue
// @require      https://cdn.jsdelivr.net/npm/sceditor@3/minified/sceditor.min.js
// @require      https://cdn.jsdelivr.net/npm/sceditor@3/minified/formats/bbcode.min.js
// @license MIT
// ==/UserScript==

(function () {
  'use strict';

  const THEME_UI = 'https://cdn.jsdelivr.net/npm/sceditor@3/minified/themes/default.min.css';
  const THEME_CONTENT = 'https://cdn.jsdelivr.net/npm/sceditor@3/minified/themes/content/default.min.css';
  const STORAGE_KEY = 'btc_sceditor_enabled';

  GM_addStyle(`
    .tm-sce-toolbar{display:flex;gap:.5rem;align-items:center;margin:8px 0 6px 0;}
    .tm-sce-btn{cursor:pointer;padding:.35rem .6rem;border:1px solid #aaa;border-radius:8px;background:#f4f4f4;font:inherit}
    .tm-sce-btn:hover{background:#e9e9e9}
    .tm-sce-badge{font-size:.85em;color:#444}
  `);

  addStylesheetOnce(THEME_UI);

  // --- Core: wait for the forum textarea and boot
  waitForTextarea().then(init).catch(() => {});

  // --- Normalize any [img=WxH] to [img width=W height=H]
  function normalizeImgSizeAttrs(bbcode) {
    return bbcode.replace(/\[img=(\d+)x(\d+)\]([^\[]+?)\[\/img\]/gi, '[img width=$1 height=$2]$3[/img]');
  }

  function init(textarea) {
    if (textarea.dataset.tmSceReady) return;
    textarea.dataset.tmSceReady = '1';

    const ui = buildUI();
    textarea.parentElement.insertBefore(ui.toolbar, textarea);

    const lastEnabled = tryGetBool(STORAGE_KEY, false);

    ui.toggleBtn.addEventListener('click', () => {
      if (textarea.dataset.tmSceEnabled === '1') {
        destroyEditor(textarea, ui);
        GM_setValue(STORAGE_KEY, false);
      } else {
        createEditor(textarea, ui);
        GM_setValue(STORAGE_KEY, true);
      }
    });

    const form = textarea.closest('form');
    if (form) {
      form.addEventListener('submit', () => {
        const inst = getInstance(textarea);
        if (inst) {
          try { inst.updateOriginal(); } catch (e) {}
        }
        // Final safety pass on submit
        textarea.value = normalizeImgSizeAttrs(textarea.value);
      });
    }

    if (lastEnabled) createEditor(textarea, ui);

    // Add Image Upload Button beside "Post" button
    addUploadButton();
  }

  function buildUI() {
    const toolbar = document.createElement('div');
    toolbar.className = 'tm-sce-toolbar';

    const toggleBtn = document.createElement('button');
    toggleBtn.type = 'button';
    toggleBtn.className = 'tm-sce-btn';
    toggleBtn.textContent = 'Enable Editor';

    const badge = document.createElement('span');
    badge.className = 'tm-sce-badge';
    badge.textContent = '(native textarea)';

    toolbar.append(toggleBtn, badge);

    return { toolbar, toggleBtn, badge };
  }

  // --- The important part: override SCEditor’s img BBCode to force width/height attributes
  function overrideImgBBCode() {
    if (!window.sceditor || !sceditor.formats || !sceditor.formats.bbcode) return;

    // Define an img tag that always outputs [img width=.. height=..] and understands [img=WxH] on input
    sceditor.formats.bbcode.set('img', {
      // Match any <img> and capture width/height where present
      tags: {
        img: { width: null, height: null, src: null }
      },
      isInline: true,
      allowsEmpty: false,

      // HTML -> BBCode
      format: function (element/* HTMLElement */, content/* string */) {
        if (!element || !element.getAttribute) return content;
        const src = element.getAttribute('src') || '';
        const w = element.getAttribute('width');
        const h = element.getAttribute('height');
        // Always prefer attribute form for Bitcointalk
        const parts = [];
        if (w) parts.push('width=' + w);
        if (h) parts.push('height=' + h);
        const attr = parts.length ? ' ' + parts.join(' ') : '';
        return `[img${attr}]${src}[/img]`;
      },

      // BBCode -> HTML
      html: function (token, attrs, content) {
        // Accept either [img width=.. height=..] or [img=WxH]
        let w = attrs.width || null;
        let h = attrs.height || null;

        // If writer used [img=WxH], parse it and convert to attributes
        const def = attrs.defaultattr || attrs.defaultAttr || null;
        if ((!w || !h) && def && /^(\d+)x(\d+)$/i.test(def)) {
          const m = String(def).match(/^(\d+)x(\d+)$/i);
          if (m) { w = w || m[1]; h = h || m[2]; }
        }

        const wAttr = w ? ` width="${w}"` : '';
        const hAttr = h ? ` height="${h}"` : '';
        const safeSrc = String(content || '').trim();

        // Self-closing img is fine for the WYSIWYG surface
        return `<img src="${safeSrc}"${wAttr}${hAttr} />`;
      },

      // Never quote numeric attrs in the output BBCode
      quoteType: sceditor.BBCodeParser.QuoteType.never
    });
  }

  function createEditor(textarea, ui) {
    try {
      // Important: override must happen before create()
      overrideImgBBCode();

      sceditor.create(textarea, {
        format: 'bbcode',
        style: THEME_CONTENT,
        autoExpand: true,
        autofocus: true,
        enablePasteFiltering: true,
        autoUpdate: true
      });

      const inst = getInstance(textarea);

      // Keep preview in sync and normalize any legacy [img=WxH]
      inst.bind('valuechanged', () => {
        try {
          // First sync to the original
          inst.updateOriginal();
          // Then normalize original BBCode to attribute style
          textarea.value = normalizeImgSizeAttrs(textarea.value);

          // Update Bitcointalk’s native preview if present
          const previewEl = document.querySelector('#preview_body');
          if (previewEl) {
            previewEl.innerHTML = inst.fromBBCode(textarea.value, true);
          }
        } catch (e) {}
      });

      textarea.dataset.tmSceEnabled = '1';
      ui.toggleBtn.textContent = 'Disable Editor';
      ui.badge.textContent = '(Editor active)';
    } catch (err) {
      console.error('[SCEditor Toggle] Failed:', err);
      alert('Could not initialize SCEditor.');
    }
  }

  function destroyEditor(textarea, ui) {
    const inst = getInstance(textarea);
    if (inst) {
      try { inst.updateOriginal(); } catch (e) {}
      try { inst.unbind('valuechanged'); inst.destroy(); } catch (e) {}
    }
    textarea.dataset.tmSceEnabled = '0';
    ui.toggleBtn.textContent = 'Enable Editor';
    ui.badge.textContent = '(native textarea)';
  }

  function getInstance(textarea) {
    try { return sceditor.instance(textarea); } catch { return null; }
  }

  function addStylesheetOnce(href) {
    if (document.querySelector(`link[rel="stylesheet"][href="${href}"]`)) return;
    const link = document.createElement('link');
    link.rel = 'stylesheet';
    link.href = href;
    document.documentElement.appendChild(link);
  }

  function waitForTextarea() {
    return new Promise((resolve, reject) => {
      const direct = findTextarea();
      if (direct) return resolve(direct);

      const obs = new MutationObserver(() => {
        const ta = findTextarea();
        if (ta) {
          obs.disconnect();
          resolve(ta);
        }
      });
      obs.observe(document.documentElement, { childList: true, subtree: true });
      setTimeout(() => { obs.disconnect(); reject(new Error('No textarea found')); }, 8000);
    });
  }

  function findTextarea() {
    return document.querySelector('textarea[name="message"]');
  }

  function tryGetBool(key, defVal) {
    try { return !!GM_getValue(key, defVal); } catch { return defVal; }
  }

  // =========================
  // IMAGE UPLOADER SECTION
  // =========================
  function addUploadButton() {
    const postBtn = document.querySelector("input[name='post']");
    if (!postBtn || document.getElementById("uploadImageBtn")) return;

    const uploadBtn = document.createElement("button");
    uploadBtn.id = "uploadImageBtn";
    uploadBtn.innerText = "Upload Image";
    uploadBtn.type = "button";
    uploadBtn.style.marginLeft = "10px";
    uploadBtn.style.padding = "5px 10px";

    postBtn.parentNode.insertBefore(uploadBtn, postBtn.nextSibling);

    uploadBtn.addEventListener("click", () => {
      const input = document.createElement("input");
      input.type = "file";
      input.accept = "image/*";

      input.onchange = async () => {
        const file = input.files[0];
        if (!file) return;

        const formData = new FormData();
        formData.append("image", file);

        uploadBtn.innerText = "Uploading...";

        try {
          const response = await fetch("https://hostmeme.com/bitcointalk.php", {
            method: "POST",
            body: formData,
          });

          const data = await response.json();
          if (data.success && data.url && data.width && data.height) {
            // Note: width first, then height to match Bitcointalk usage
            const bbcode = `[img width=${data.width} height=${data.height}]${data.url}[/img]`;

            // If editor is active, insert through SCEditor to keep preview synced
            const textarea = document.querySelector("textarea[name='message']");
            const inst = textarea ? getInstance(textarea) : null;
            if (inst) {
              inst.insert(bbcode);
              try { inst.updateOriginal(); } catch (e) {}
              textarea.value = normalizeImgSizeAttrs(textarea.value);
            } else if (textarea) {
              textarea.value += `\n${bbcode}\n`;
            }
          } else {
            alert("Upload failed: " + (data.error || "Unknown error"));
          }
        } catch (err) {
          alert("Upload error: " + err.message);
        } finally {
          uploadBtn.innerText = "Upload Image";
        }
      };

      input.click();
    });
  }

  // Observer in case form loads dynamically
  const observer = new MutationObserver(addUploadButton);
  observer.observe(document.body, { childList: true, subtree: true });
})();