Script Forge

Floating companion panel for Script Forge. Bypass links in one click.

이 스크립트를 설치하려면 Tampermonkey, Greasemonkey 또는 Violentmonkey와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 Tampermonkey와 같은 확장 프로그램을 설치해야 합니다.

이 스크립트를 설치하려면 Tampermonkey 또는 Violentmonkey와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 Tampermonkey 또는 Userscripts와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 Tampermonkey와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 유저 스크립트 관리자 확장 프로그램이 필요합니다.

(이미 유저 스크립트 관리자가 설치되어 있습니다. 설치를 진행합니다!)

이 스타일을 설치하려면 Stylus와 같은 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 Stylus와 같은 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 Stylus와 같은 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 유저 스타일 관리자 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 유저 스타일 관리자 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 유저 스타일 관리자 확장 프로그램이 필요합니다.

(이미 유저 스타일 관리자가 설치되어 있습니다. 설치를 진행합니다!)

// ==UserScript==
// @name         Script Forge
// @namespace    https://script-forge.xyz
// @version      1.0.0
// @description  Floating companion panel for Script Forge. Bypass links in one click.
// @author       Script Forge
// @license      All rights reserved
// @match        *://*/*
// @grant        GM_getValue
// @grant        GM_setValue
// @grant        GM_openInTab
// @run-at       document-end
// @noframes
// ==/UserScript==

(function () {
  'use strict';

  const SITE = 'https://script-forge.xyz';
  const VERSION = '1.0.0';

  if (/^(www\.)?script-forge\.xyz$/.test(location.hostname)) return;

  const KEYS = { x: 'sf_x', y: 'sf_y', collapsed: 'sf_col' };

  const Store = {
    get(k, fb) {
      try { const v = GM_getValue(k, null); return v !== null ? v : fb; } catch { return fb; }
    },
    set(k, v) { try { GM_setValue(k, v); } catch { } }
  };

  const Bus = (() => {
    const m = {};
    return {
      on(e, fn) { (m[e] ??= []).push(fn); },
      off(e, fn) { m[e] = (m[e] || []).filter(h => h !== fn); },
      emit(e, d) { (m[e] || []).forEach(fn => fn(d)); }
    };
  })();

  const Registry = (() => {
    const mods = new Map();
    return {
      add(id, mod) { mods.set(id, mod); },
      all() { return [...mods.values()]; }
    };
  })();

  function makeDraggable(wrap, handle, onEnd, threshold = 4) {
    let ox, oy, sx, sy, moved = false, live = false, raf;

    function clampedPos(nx, ny) {
      return {
        x: Math.max(0, Math.min(nx, window.innerWidth - wrap.offsetWidth)),
        y: Math.max(0, Math.min(ny, window.innerHeight - wrap.offsetHeight))
      };
    }

    function applyPos(x, y) {
      wrap.style.left = x + 'px';
      wrap.style.top = y + 'px';
      wrap.style.right = 'auto';
      wrap.style.bottom = 'auto';
    }

    function onDown(e) {
      if (e.button !== 0) return;
      if (e.target.closest('[data-action]')) return;
      live = true;
      moved = false;
      const r = wrap.getBoundingClientRect();
      ox = r.left; oy = r.top;
      sx = e.clientX; sy = e.clientY;
      handle.setPointerCapture(e.pointerId);
      e.preventDefault();
    }

    function onMove(e) {
      if (!live) return;
      const dx = e.clientX - sx, dy = e.clientY - sy;
      if (!moved && dx * dx + dy * dy < threshold * threshold) return;
      moved = true;
      cancelAnimationFrame(raf);
      raf = requestAnimationFrame(() => {
        const p = clampedPos(ox + dx, oy + dy);
        applyPos(p.x, p.y);
      });
    }

    function onUp() {
      if (!live) return;
      live = false;
      cancelAnimationFrame(raf);
      if (moved && onEnd) {
        const r = wrap.getBoundingClientRect();
        onEnd(r.left, r.top);
      }
    }

    handle.addEventListener('pointerdown', onDown);
    handle.addEventListener('pointermove', onMove);
    handle.addEventListener('pointerup', onUp);
    handle.addEventListener('pointercancel', onUp);

    return {
      didMove() { return moved; },
      destroy() {
        handle.removeEventListener('pointerdown', onDown);
        handle.removeEventListener('pointermove', onMove);
        handle.removeEventListener('pointerup', onUp);
        handle.removeEventListener('pointercancel', onUp);
      }
    };
  }

  const CSS = `
    *, *::before, *::after {
      box-sizing: border-box;
      margin: 0;
      padding: 0;
    }

    #w {
      position: fixed;
      z-index: 2147483647;
    }

    #panel {
      width: 272px;
      background: #111111;
      border: 1px solid #1e1e1e;
      border-radius: 6px;
      box-shadow:
        0 16px 48px rgba(0,0,0,.7),
        0 1px 0   rgba(255,255,255,.03) inset;
      font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', system-ui, sans-serif;
      overflow: hidden;
      display: none;
    }

    #panel.on {
      display: block;
      animation: p-in .14s ease;
    }

    @keyframes p-in {
      from { opacity: 0; transform: translateY(5px) scale(.985); }
      to   { opacity: 1; transform: translateY(0)   scale(1);    }
    }

    .hd {
      display: flex;
      align-items: center;
      height: 40px;
      padding: 0 8px 0 14px;
      background: #0d0d0d;
      border-bottom: 1px solid #191919;
      cursor: grab;
      user-select: none;
    }

    .hd:active { cursor: grabbing; }

    .brand {
      flex: 1;
      font-size: 12px;
      font-weight: 600;
      letter-spacing: .01em;
      color: #888888;
    }

    .brand b {
      font-weight: 600;
      color: #e05a28;
    }

    .ctrls { display: flex; }

    .ib {
      all: unset;
      box-sizing: border-box;
      width: 28px;
      height: 28px;
      display: flex;
      align-items: center;
      justify-content: center;
      border-radius: 4px;
      cursor: pointer;
      color: #3c3c3c;
      transition: color .1s, background .1s;
    }

    .ib:hover  { color: #888; background: rgba(255,255,255,.05); }
    .ib:active { color: #aaa; }

    .ib svg { display: block; }

    .bd { padding: 14px; }

    .lbl {
      font-size: 9px;
      font-weight: 700;
      letter-spacing: .1em;
      text-transform: uppercase;
      color: #2e2e2e;
      margin-bottom: 6px;
    }

    .url-wrap {
      padding: 8px 10px;
      background: #0b0b0b;
      border: 1px solid #1a1a1a;
      border-radius: 4px;
      font-family: 'SF Mono', 'JetBrains Mono', 'Fira Code', Consolas, monospace;
      font-size: 10.5px;
      color: #3e3e3e;
      word-break: break-all;
      line-height: 1.6;
      margin-bottom: 10px;
      min-height: 38px;
    }

    .url-wrap.has { color: #747474; }

    .go {
      all: unset;
      box-sizing: border-box;
      display: block;
      width: 100%;
      padding: 9px 14px;
      background: #e05a28;
      border-radius: 4px;
      font-size: 12px;
      font-weight: 600;
      color: #fff;
      text-align: center;
      cursor: pointer;
      letter-spacing: .005em;
      transition: background .1s;
    }

    .go:hover  { background: #c94d21; }
    .go:active { background: #b3451e; }

    .ft {
      display: flex;
      align-items: center;
      justify-content: space-between;
      padding: 7px 14px;
      border-top: 1px solid #171717;
    }

    .ft-a { font-size: 10px; color: #272727; }
    .ft-b { font-size: 9px;  color: #232323; font-variant-numeric: tabular-nums; }

    #tab {
      display: none;
      align-items: center;
      gap: 8px;
      height: 34px;
      padding: 0 13px;
      background: #111111;
      border: 1px solid #1e1e1e;
      border-radius: 5px;
      box-shadow: 0 6px 22px rgba(0,0,0,.6);
      cursor: grab;
      user-select: none;
      font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', system-ui, sans-serif;
    }

    #tab.on    { display: flex; }
    #tab:active { cursor: grabbing; }

    .t-dot {
      width: 5px;
      height: 5px;
      border-radius: 50%;
      background: #e05a28;
      flex-shrink: 0;
    }

    .t-lbl {
      font-size: 11.5px;
      font-weight: 600;
      color: #555555;
      letter-spacing: .01em;
      white-space: nowrap;
    }
  `;

  function bypassMod() {
    let urlEl = null;

    function getUrl() { return location.href; }
    function getDisp(url) { return url.length > 60 ? url.slice(0, 58) + '\u2026' : url; }

    function refreshUrl() {
      if (!urlEl) return;
      const url = getUrl();
      const disp = getDisp(url);
      urlEl.textContent = disp || '\u2014';
      urlEl.className = 'url-wrap' + (url ? ' has' : '');
    }

    return {
      id: 'bypass',

      render() {
        const url = getUrl();
        const disp = getDisp(url);
        const el = document.createElement('div');
        el.className = 'bd';
        el.innerHTML =
          '<div class="lbl">Current Page</div>' +
          '<div class="url-wrap' + (url ? ' has' : '') + '">' + (disp || '\u2014') + '</div>' +
          '<button class="go" data-action="bypass">Bypass with Script Forge</button>';
        urlEl = el.querySelector('.url-wrap');
        return el;
      },

      mount(root) {
        root.querySelector('[data-action="bypass"]').addEventListener('click', () => {
          GM_openInTab(SITE + '/?url=' + encodeURIComponent(getUrl()), { active: true, insert: true });
        });

        // Keep display in sync with SPA / client-side navigation
        let _lastUrl = location.href;
        const _nav = setInterval(() => {
          if (location.href !== _lastUrl) {
            _lastUrl = location.href;
            refreshUrl();
          }
        }, 500);

        // Also listen for popstate / hashchange
        window.addEventListener('popstate', refreshUrl, { passive: true });
        window.addEventListener('hashchange', refreshUrl, { passive: true });
      }
    };
  }

  function buildWidget() {
    const collapsed = Store.get(KEYS.collapsed, false);
    const px = Store.get(KEYS.x, null);
    const py = Store.get(KEYS.y, null);

    const host = document.createElement('div');
    const root = host.attachShadow({ mode: 'open' });
    document.documentElement.appendChild(host);

    const style = document.createElement('style');
    style.textContent = CSS;

    const wrap = document.createElement('div');
    wrap.id = 'w';

    if (px !== null && py !== null) {
      wrap.style.left = px + 'px';
      wrap.style.top = py + 'px';
    } else {
      wrap.style.bottom = '20px';
      wrap.style.right = '20px';
    }

    const panel = document.createElement('div');
    panel.id = 'panel';

    const hd = document.createElement('div');
    hd.className = 'hd';
    hd.innerHTML =
      '<div class="brand"><b>Script</b> Forge</div>' +
      '<div class="ctrls">' +
      '<button class="ib" data-action="collapse" title="Minimise">' +
      '<svg width="10" height="1.5" viewBox="0 0 10 1.5">' +
      '<rect width="10" height="1.5" rx=".75" fill="currentColor"/>' +
      '</svg>' +
      '</button>' +
      '</div>';

    panel.appendChild(hd);

    const mods = Registry.all();
    mods.forEach(m => panel.appendChild(m.render()));

    const ft = document.createElement('div');
    ft.className = 'ft';
    ft.innerHTML =
      '<span class="ft-a">script-forge.xyz</span>' +
      '<span class="ft-b">v' + VERSION + '</span>';
    panel.appendChild(ft);

    const tab = document.createElement('div');
    tab.id = 'tab';
    tab.innerHTML =
      '<div class="t-lbl">Script Forge</div>';

    wrap.appendChild(panel);
    wrap.appendChild(tab);
    root.appendChild(style);
    root.appendChild(wrap);

    function savePos() {
      const r = wrap.getBoundingClientRect();
      Store.set(KEYS.x, r.left);
      Store.set(KEYS.y, r.top);
    }

    function clamp() {
      const r = wrap.getBoundingClientRect();
      const cx = Math.max(0, Math.min(r.left, window.innerWidth - wrap.offsetWidth));
      const cy = Math.max(0, Math.min(r.top, window.innerHeight - wrap.offsetHeight));
      wrap.style.left = cx + 'px';
      wrap.style.top = cy + 'px';
      wrap.style.right = 'auto';
      wrap.style.bottom = 'auto';
    }

    function openPanel() {
      tab.classList.remove('on');
      panel.classList.add('on');
      Store.set(KEYS.collapsed, false);
      requestAnimationFrame(clamp);
    }

    function openTab() {
      panel.classList.remove('on');
      tab.classList.add('on');
      Store.set(KEYS.collapsed, true);
      requestAnimationFrame(clamp);
    }

    if (collapsed) openTab();
    else openPanel();

    requestAnimationFrame(() => {
      if (px === null || py === null) {
        const r = wrap.getBoundingClientRect();
        wrap.style.left = r.left + 'px';
        wrap.style.top = r.top + 'px';
        wrap.style.right = 'auto';
        wrap.style.bottom = 'auto';
      }
      clamp();
    });

    makeDraggable(wrap, hd, savePos);
    const tabDrag = makeDraggable(wrap, tab, savePos);

    root.querySelector('[data-action="collapse"]').addEventListener('click', e => {
      e.stopPropagation();
      openTab();
    });

    tab.addEventListener('click', () => {
      if (!tabDrag.didMove()) openPanel();
    });

    mods.forEach(m => { if (m.mount) m.mount(root); });

    window.addEventListener('resize', clamp, { passive: true });
  }

  Registry.add('bypass', bypassMod());
  buildWidget();

})();