Talkomatic Binary & Morse — per-key multiline

Per-key converter that turns your typed chars into binary OR morse. Preserves newlines, paste, backspace/delete. SPA-safe. Toggle modes in UI. 🔢 ·-

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.

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

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

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

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

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

// ==UserScript==
// @name         Talkomatic Binary & Morse — per-key multiline
// @namespace    http://tampermonkey.net/
// @version      1.6
// @description  Per-key converter that turns your typed chars into binary OR morse. Preserves newlines, paste, backspace/delete. SPA-safe. Toggle modes in UI. 🔢 ·-
// @author       DeepFriedChip
// @match        https://classic.talkomatic.co/*
// @grant        none
// @run-at       document-idle     
// ==/UserScript==

(() => {
  "use strict";
  const VER = "1.6 (binary + morse)";

  console.log("Per-key Binary & Morse loaded", VER);

  // ---------- UI ----------
  const css = `
    #bin-ui { position: fixed; right: 12px; bottom: 12px; z-index:2147483647; font-family: monospace; user-select:none;}
    #bin-ui .card { background:#111; color:#fff; padding:8px; border-radius:8px; border:1px solid #333; }
    #bin-ui button{ margin:4px 6px 4px 0; padding:6px 8px; border-radius:6px; background:#222; color:#fff; cursor:pointer; border:1px solid #333;}
    .bspan { white-space: pre; display:inline-block; }
    .bspan[title] { cursor: default; }
    .chat-input br { display:block; content:""; line-height: 1em; }
    #bin-ui .small { font-size:12px; color:#aaa; margin-top:6px; }
    #bin-ui select, #bin-ui input { background:#111; color:#fff; border:1px solid #333; padding:4px; border-radius:6px; margin-left:6px; }
  `;
  const s = document.createElement("style"); s.textContent = css; document.head.appendChild(s);

  const ui = document.createElement("div");
  ui.id = "bin-ui";
  ui.innerHTML = `
    <div class="card">
      <div style="font-weight:700">Per-key Converter • ${VER}</div>
      <div style="margin-top:6px;">
        <button id="bin-start">🟢 Start</button>
        <button id="bin-stop">⛔ Stop</button>
        <button id="bin-restore">⤴ Restore</button>
        <button id="bin-copy">📋 Copy</button>
      </div>
      <div style="margin-top:8px;">
        mode:
        <select id="bin-mode">
          <option value="binary">Binary</option>
          <option value="morse">Morse</option>
        </select>
        bits: <select id="bin-bits"><option>8</option><option>16</option><option>32</option></select>
        sep: <select id="bin-sep"><option value=" ">space</option><option value="">none</option><option value="/">slash</option></select>
      </div>
      <div class="small">
        Ctrl+Enter to send original if "send original on Enter" checked.
        <div><label><input id="bin-sendorig" type="checkbox"> send original on Enter</label></div>
      </div>
    </div>
  `;
  document.body.appendChild(ui);

  const btnStart = ui.querySelector("#bin-start");
  const btnStop = ui.querySelector("#bin-stop");
  const btnRestore = ui.querySelector("#bin-restore");
  const btnCopy = ui.querySelector("#bin-copy");
  const selMode = ui.querySelector("#bin-mode");
  const selBits = ui.querySelector("#bin-bits");
  const selSep = ui.querySelector("#bin-sep");
  const chkSendOrig = ui.querySelector("#bin-sendorig");

  // ---------- Morse map ----------
  const MORSE_MAP = {
    "A": ".-",    "B": "-...",  "C": "-.-.",  "D": "-..",
    "E": ".",     "F": "..-.",  "G": "--.",   "H": "....",
    "I": "..",    "J": ".---",  "K": "-.-",   "L": ".-..",
    "M": "--",    "N": "-.",    "O": "---",   "P": ".--.",
    "Q": "--.-",  "R": ".-.",   "S": "...",   "T": "-",
    "U": "..-",   "V": "...-",  "W": ".--",   "X": "-..-",
    "Y": "-.--",  "Z": "--..",
    "0": "-----", "1": ".----", "2": "..---", "3": "...--",
    "4": "....-", "5": ".....", "6": "-....", "7": "--...",
    "8": "---..", "9": "----.",
    ".": ".-.-.-", ",": "--..--", "?": "..--..", "'": ".----.",
    "!": "-.-.--", "/": "-..-.",  "(": "-.--.",  ")": "-.--.-",
    "&": ".-...",  ":": "---...", ";": "-.-.-.", "=": "-...-",
    "+": ".-.-.",  "-": "-....-", "_": "..--.-", "\"": ".-..-.",
    "$": "...-..-", "@": ".--.-."
  };

  // ---------- helpers ----------
  const DOT_MAP = [1, 8, 2, 16, 4, 32, 64, 128];
  const bitsToBin = (codePoint, bits) => {
    let b = codePoint.toString(2);
    if (b.length > bits) b = b.slice(-bits);
    else if (b.length < bits) b = "0".repeat(bits - b.length) + b;
    return b;
  };
  function getMorseForChar(ch) {
    // uppercase letter mapping, fallback to '?' for unknown or use space for ' '
    if (ch === " ") return "/";
    const up = ch.toUpperCase();
    if (MORSE_MAP[up]) return MORSE_MAP[up];
    // if char is newline, handle elsewhere
    return "?";
  }

  function charToBinaryOrMorseNode(ch, mode, bits, sep) {
    if (ch === "\n") {
      const br = document.createElement("br");
      br.dataset.orig = "\n";
      return br;
    }

    const span = document.createElement("span");
    span.className = "bspan";
    span.dataset.orig = ch;
    span.title = ch;

    if (mode === "binary") {
      const cp = ch.codePointAt(0);
      const bin = bitsToBin(cp, bits);
      span.textContent = sep ? bin + sep : bin;
    } else { // morse
      const morse = getMorseForChar(ch);
      // represent space between letters with a single space by default (user sep can modify)
      const out = (morse === "/") ? "/" : morse;
      // choose dot/dash characters: use '.' and '-' for compatibility
      span.textContent = sep ? out + sep : out;
    }
    return span;
  }

  function getChatInputEl() {
    return document.querySelector(".chat-input[contenteditable='true']");
  }

  function focusAndPlaceCaretAfter(node) {
    const range = document.createRange();
    const sel = window.getSelection();
    try {
      if (!node) {
        const el = getChatInputEl();
        if (!el) return;
        range.selectNodeContents(el);
        range.collapse(false);
      } else {
        range.setStartAfter(node);
        range.collapse(true);
      }
      sel.removeAllRanges();
      sel.addRange(range);
      const parent = getChatInputEl();
      if (parent) parent.focus();
    } catch (e) {}
  }

  // compute caret location relative to childNodes
  function computeCaretLocation(el) {
    const sel = window.getSelection();
    if (!sel || sel.rangeCount === 0) return { spanIndex: el.childNodes.length, insideSpan: false, offset: 0, node: null };
    const range = sel.getRangeAt(0);
    let node = range.startContainer;
    let offset = range.startOffset;

    if (node === el) {
      return { spanIndex: offset, insideSpan: false, offset, node };
    }

    let child = node;
    while (child && child.parentNode !== el) {
      child = child.parentNode;
      if (!child) break;
    }
    if (!child || child.parentNode !== el) {
      return { spanIndex: el.childNodes.length, insideSpan: false, offset: 0, node: null };
    }

    let idx = 0;
    for (let i = 0; i < el.childNodes.length; i++) {
      if (el.childNodes[i] === child) { idx = i; break; }
    }

    let inside = false, txtOff = 0;
    if (range.startContainer.nodeType === 3) {
      inside = true;
      txtOff = range.startOffset;
    }

    return { spanIndex: idx, insideSpan: inside, textOffsetInSpan: txtOff, node: child };
  }

  function reconstructOriginalFromSpans(el) {
    if (!el) return "";
    const arr = [];
    for (const node of el.childNodes) {
      if (node.nodeType === 1 && node.classList && node.classList.contains("bspan")) {
        arr.push(node.dataset.orig || "");
      } else if (node.nodeType === 1 && node.tagName === "BR") {
        arr.push("\n");
      } else if (node.nodeType === 3) {
        arr.push(node.nodeValue || "");
      } else {
        arr.push(node.textContent || "");
      }
    }
    return arr.join("");
  }

  function renderFromOriginalString(el, originalStr) {
    if (!el) return;
    while (el.firstChild) el.removeChild(el.firstChild);
    const mode = selMode.value;
    const bits = parseInt(selBits.value, 10);
    const sep = selSep.value;

    for (const ch of Array.from(originalStr)) {
      const node = charToBinaryOrMorseNode(ch, mode, bits, sep);
      el.appendChild(node);
    }
  }

  // ---------- event handlers ----------
  let enabled = false;
  let observer = null;
  let lastKnownOriginal = ""; // includes \n

  function onKeyDown(e) {
    const el = getChatInputEl();
    if (!el) return;

    // handle ctrl/meta combos
    if (e.ctrlKey || e.metaKey || e.altKey) {
      if ((e.key === "Enter" || e.key === "Return") && chkSendOrig.checked) {
        e.preventDefault();
        const orig = lastKnownOriginal;
        el.textContent = orig;
        el.dispatchEvent(new InputEvent("input", { bubbles: true, cancelable: true, composed: true }));
        const ev = new KeyboardEvent("keydown", { key: "Enter", code: "Enter", bubbles: true, cancelable: true });
        el.dispatchEvent(ev);
        setTimeout(() => {
          renderFromOriginalString(el, orig);
          focusAndPlaceCaretAfter(el.lastChild || el);
        }, 40);
        return;
      }
      return;
    }

    // printable char
    if (e.key && e.key.length === 1 && !e.isComposing) {
      e.preventDefault();
      const ch = e.key;
      const mode = selMode.value;
      const bits = parseInt(selBits.value, 10);
      const sep = selSep.value;
      const loc = computeCaretLocation(el);
      const insertIndex = loc.spanIndex;

      const node = charToBinaryOrMorseNode(ch, mode, bits, sep);

      if (loc.insideSpan && loc.node) {
        const parent = loc.node.parentNode;
        if (loc.textOffsetInSpan === 0) parent.insertBefore(node, loc.node);
        else parent.insertBefore(node, loc.node.nextSibling);
      } else {
        const ref = el.childNodes[insertIndex] || null;
        el.insertBefore(node, ref);
      }

      // update original
      let origIndex = 0;
      for (let i = 0; i < el.childNodes.length; i++) {
        const n = el.childNodes[i];
        if (n === node) break;
        if (n.nodeType === 1 && n.classList && n.classList.contains("bspan")) origIndex++;
        else if (n.nodeType === 1 && n.tagName === "BR") origIndex++;
        else if (n.nodeType === 3) origIndex += (n.nodeValue || "").length;
      }
      const before = lastKnownOriginal.slice(0, origIndex);
      const after = lastKnownOriginal.slice(origIndex);
      lastKnownOriginal = before + ch + after;

      focusAndPlaceCaretAfter(node);
      el.dispatchEvent(new InputEvent("input", { bubbles: true, cancelable: true, composed: true }));
      return;
    }

    // Enter -> newline
    if (e.key === "Enter" && !e.ctrlKey && !e.metaKey && !e.altKey) {
      e.preventDefault();
      const el = getChatInputEl();
      if (!el) return;
      const loc = computeCaretLocation(el);
      const insertIndex = loc.spanIndex;
      const br = document.createElement("br");
      br.dataset.orig = "\n";
      const ref = el.childNodes[insertIndex] || null;
      el.insertBefore(br, ref);

      let origIndex = 0;
      for (let i = 0; i < insertIndex; i++) {
        const n = el.childNodes[i];
        if (n.nodeType === 1 && n.classList && n.classList.contains("bspan")) origIndex++;
        else if (n.nodeType === 1 && n.tagName === "BR") origIndex++;
        else if (n.nodeType === 3) origIndex += (n.nodeValue || "").length;
      }
      const before = lastKnownOriginal.slice(0, origIndex);
      const after = lastKnownOriginal.slice(origIndex);
      lastKnownOriginal = before + "\n" + after;

      focusAndPlaceCaretAfter(br);
      el.dispatchEvent(new InputEvent("input", { bubbles: true, cancelable: true, composed: true }));
      return;
    }

    // Backspace/Delete
    if (e.key === "Backspace" || e.key === "Delete") {
      e.preventDefault();
      const el = getChatInputEl();
      if (!el) return;
      const loc = computeCaretLocation(el);

      if (e.key === "Backspace") {
        let idx = loc.spanIndex;
        const toRemoveIndex = Math.max(0, idx - 1);
        const nodeToRemove = el.childNodes[toRemoveIndex] || null;
        if (!nodeToRemove) return;

        if (nodeToRemove.nodeType === 1 && nodeToRemove.classList && nodeToRemove.classList.contains("bspan")) {
          let origIndex = 0;
          for (let i = 0; i < toRemoveIndex; i++) {
            const n = el.childNodes[i];
            if (n.nodeType === 1 && n.classList && n.classList.contains("bspan")) origIndex++;
            else if (n.nodeType === 1 && n.tagName === "BR") origIndex++;
            else if (n.nodeType === 3) origIndex += (n.nodeValue || "").length;
          }
          lastKnownOriginal = lastKnownOriginal.slice(0, origIndex) + lastKnownOriginal.slice(origIndex + 1);
          nodeToRemove.remove();
        } else if (nodeToRemove.nodeType === 1 && nodeToRemove.tagName === "BR") {
          let origIndex = 0;
          for (let i = 0; i < toRemoveIndex; i++) {
            const n = el.childNodes[i];
            if (n.nodeType === 1 && n.classList && n.classList.contains("bspan")) origIndex++;
            else if (n.nodeType === 1 && n.tagName === "BR") origIndex++;
            else if (n.nodeType === 3) origIndex += (n.nodeValue || "").length;
          }
          lastKnownOriginal = lastKnownOriginal.slice(0, origIndex) + lastKnownOriginal.slice(origIndex + 1);
          nodeToRemove.remove();
        } else if (nodeToRemove.nodeType === 3) {
          const txt = nodeToRemove.nodeValue || "";
          if (txt.length > 0) {
            nodeToRemove.nodeValue = txt.slice(0, -1);
            lastKnownOriginal = lastKnownOriginal.slice(0, -1);
          } else {
            nodeToRemove.remove();
            lastKnownOriginal = lastKnownOriginal.slice(0, -1);
          }
        } else {
          nodeToRemove.remove();
        }

        const ref = el.childNodes[toRemoveIndex] || el;
        const range = document.createRange();
        const sel = window.getSelection();
        if (ref === el) {
          range.selectNodeContents(el);
          range.collapse(false);
        } else {
          range.setStartBefore(ref);
          range.collapse(true);
        }
        sel.removeAllRanges();
        sel.addRange(range);
        el.dispatchEvent(new InputEvent("input", { bubbles: true, cancelable: true, composed: true }));
      } else {
        // Delete key
        const idx = loc.spanIndex;
        const nodeToRemove = el.childNodes[idx] || null;
        if (!nodeToRemove) return;
        if (nodeToRemove.nodeType === 1 && nodeToRemove.classList && nodeToRemove.classList.contains("bspan")) {
          let origIndex = 0;
          for (let i = 0; i < idx; i++) {
            const n = el.childNodes[i];
            if (n.nodeType === 1 && n.classList && n.classList.contains("bspan")) origIndex++;
            else if (n.nodeType === 1 && n.tagName === "BR") origIndex++;
            else if (n.nodeType === 3) origIndex += (n.nodeValue || "").length;
          }
          lastKnownOriginal = lastKnownOriginal.slice(0, origIndex) + lastKnownOriginal.slice(origIndex + 1);
          nodeToRemove.remove();
        } else if (nodeToRemove.nodeType === 1 && nodeToRemove.tagName === "BR") {
          let origIndex = 0;
          for (let i = 0; i < idx; i++) {
            const n = el.childNodes[i];
            if (n.nodeType === 1 && n.classList && n.classList.contains("bspan")) origIndex++;
            else if (n.nodeType === 1 && n.tagName === "BR") origIndex++;
            else if (n.nodeType === 3) origIndex += (n.nodeValue || "").length;
          }
          lastKnownOriginal = lastKnownOriginal.slice(0, origIndex) + lastKnownOriginal.slice(origIndex + 1);
          nodeToRemove.remove();
        } else if (nodeToRemove.nodeType === 3) {
          const txt = nodeToRemove.nodeValue || "";
          if (txt.length > 1) nodeToRemove.nodeValue = txt.slice(1);
          else nodeToRemove.remove();
          lastKnownOriginal = lastKnownOriginal.slice(0, -1);
        } else {
          nodeToRemove.remove();
        }
        el.dispatchEvent(new InputEvent("input", { bubbles: true, cancelable: true, composed: true }));
      }
      return;
    }

    // let arrow keys, tab, etc behave normally
  }

  function onPaste(e) {
    const el = getChatInputEl();
    if (!el) return;
    e.preventDefault();
    const txt = (e.clipboardData || window.clipboardData).getData("text") || "";
    if (!txt) return;
    const loc = computeCaretLocation(el);
    const insertIndex = loc.spanIndex;
    const mode = selMode.value;
    const bits = parseInt(selBits.value, 10);
    const sep = selSep.value;

    let ref = el.childNodes[insertIndex] || null;
    for (const ch of Array.from(txt)) {
      if (ch === "\n") {
        const br = document.createElement("br");
        br.dataset.orig = "\n";
        el.insertBefore(br, ref);
      } else {
        const node = charToBinaryOrMorseNode(ch, mode, bits, sep);
        el.insertBefore(node, ref);
      }
    }

    let origIndex = 0;
    for (let i = 0; i < insertIndex; i++) {
      const n = el.childNodes[i];
      if (n.nodeType === 1 && n.classList && n.classList.contains("bspan")) origIndex++;
      else if (n.nodeType === 1 && n.tagName === "BR") origIndex++;
      else if (n.nodeType === 3) origIndex += (n.nodeValue || "").length;
    }
    const before = lastKnownOriginal.slice(0, origIndex);
    const after = lastKnownOriginal.slice(origIndex);
    lastKnownOriginal = before + txt + after;

    const afterNode = ref ? ref.previousSibling : el.lastChild;
    if (afterNode) focusAndPlaceCaretAfter(afterNode);
    el.dispatchEvent(new InputEvent("input", { bubbles: true, cancelable: true, composed: true }));
  }

  function onKeyUp(e) {
    const el = getChatInputEl();
    if (!el) return;
    if (e.key === "Enter" && chkSendOrig.checked && !e.ctrlKey && !e.metaKey) {
      e.preventDefault();
      const orig = lastKnownOriginal;
      el.textContent = orig;
      el.dispatchEvent(new InputEvent("input", { bubbles: true, cancelable: true, composed: true }));
      const ev = new KeyboardEvent("keydown", { key: "Enter", code: "Enter", bubbles: true, cancelable: true });
      el.dispatchEvent(ev);
      setTimeout(() => {
        renderFromOriginalString(el, orig);
        focusAndPlaceCaretAfter(el.lastChild || el);
      }, 40);
    }
  }

  // ---------- lifecycle ----------
  function enable() {
    if (enabled) return;
    const chat = getChatInputEl();
    if (!chat) return alert("No chat input found — open a room page first.");

    if (chat.querySelector && chat.querySelector(".bspan")) {
      lastKnownOriginal = reconstructOriginalFromSpans(chat);
    } else {
      lastKnownOriginal = chat.textContent || "";
      renderFromOriginalString(chat, lastKnownOriginal);
    }

    chat.addEventListener("keydown", onKeyDown, true);
    chat.addEventListener("keyup", onKeyUp, true);
    chat.addEventListener("paste", onPaste, true);

    observer = new MutationObserver(() => {
      const newChat = getChatInputEl();
      if (newChat && newChat !== chat) {
        try { chat.removeEventListener("keydown", onKeyDown, true); } catch (e) {}
        try { chat.removeEventListener("keyup", onKeyUp, true); } catch (e) {}
        try { chat.removeEventListener("paste", onPaste, true); } catch (e) {}
        const curOrig = lastKnownOriginal;
        renderFromOriginalString(newChat, curOrig);
        newChat.addEventListener("keydown", onKeyDown, true);
        newChat.addEventListener("keyup", onKeyUp, true);
        newChat.addEventListener("paste", onPaste, true);
      }
    });
    observer.observe(document.documentElement || document.body, { childList: true, subtree: true });

    enabled = true;
    btnStart.disabled = true;
    btnStop.disabled = false;
    console.log("Converter enabled");
  }

  function disable() {
    if (!enabled) return;
    const chat = getChatInputEl();
    if (chat) {
      try { chat.removeEventListener("keydown", onKeyDown, true); } catch (e) {}
      try { chat.removeEventListener("keyup", onKeyUp, true); } catch (e) {}
      try { chat.removeEventListener("paste", onPaste, true); } catch (e) {}
    }
    if (observer) { observer.disconnect(); observer = null; }
    enabled = false;
    btnStart.disabled = false;
    btnStop.disabled = true;
    console.log("Converter disabled");
  }

  function restoreOriginal() {
    const el = getChatInputEl();
    if (!el) return alert("chat input not found");
    const orig = lastKnownOriginal || reconstructOriginalFromSpans(el);
    el.textContent = "";
    const parts = orig.split("\n");
    for (let i = 0; i < parts.length; i++) {
      el.appendChild(document.createTextNode(parts[i]));
      if (i < parts.length - 1) {
        const br = document.createElement("br");
        br.dataset.orig = "\n";
        el.appendChild(br);
      }
    }
    el.dispatchEvent(new InputEvent("input", { bubbles: true, cancelable: true, composed: true }));
    lastKnownOriginal = orig;
    alert("Restored original text (with newlines) into textbox.");
  }

  async function copyConverted() {
    const el = getChatInputEl();
    if (!el) return alert("chat input not found");
    let out = "";
    for (const node of el.childNodes) {
      if (node.nodeType === 1 && node.classList && node.classList.contains("bspan")) out += node.textContent || "";
      else if (node.nodeType === 1 && node.tagName === "BR") out += "\n";
      else if (node.nodeType === 3) out += node.nodeValue || "";
      else out += node.textContent || "";
    }
    try {
      await navigator.clipboard.writeText(out);
      alert("Converted text copied to clipboard.");
    } catch (e) {
      prompt("Converted text (copy):", out);
    }
  }

  // when mode changes, re-render current content from lastKnownOriginal
  selMode.addEventListener("change", () => {
    const el = getChatInputEl();
    if (!el) return;
    renderFromOriginalString(el, lastKnownOriginal || reconstructOriginalFromSpans(el));
    // toggle UI bits/sep availability: bits only for binary
    if (selMode.value === "morse") {
      selBits.disabled = true;
      // default morse separator to single space for readability
      if (!selSep.value) selSep.value = " ";
    } else {
      selBits.disabled = false;
    }
  });

  // UI hookups
  btnStart.addEventListener("click", () => enable());
  btnStop.addEventListener("click", () => disable());
  btnRestore.addEventListener("click", () => restoreOriginal());
  btnCopy.addEventListener("click", () => copyConverted());

  btnStop.disabled = true;

  // keep UI alive on SPA
  const keepUiObs = new MutationObserver(() => {
    if (!document.body.contains(ui)) document.body.appendChild(ui);
  });
  keepUiObs.observe(document.documentElement || document.body, { childList: true, subtree: true });

  // export helper
  window.__PerKeyConverter = { enable, disable, restoreOriginal, copyConverted, version: VER };

  console.log("Per-key Binary & Morse ready. Click Start.");
})();