Last Epoch Tools KO Float (DB + Skills + Ailments + Minions)

LastEpochTools EN 페이지 우측에 KO 미러 패널을 띄웁니다. /db/*, /skills/*, /ailments/*, /minions/* 지원. (스킬은 Hover/Scroll Sync 옵션 유지) • 패널 투명도 조절.

// ==UserScript==
// @name         Last Epoch Tools KO Float (DB + Skills + Ailments + Minions)
// @namespace    https://github.com/McCommi/letools-ko-float
// @version      1.7.2
// @description  LastEpochTools EN 페이지 우측에 KO 미러 패널을 띄웁니다. /db/*, /skills/*, /ailments/*, /minions/* 지원. (스킬은 Hover/Scroll Sync 옵션 유지) • 패널 투명도 조절.
// @author       McCommi
// @license      MIT
// @match        https://www.lastepochtools.com/*
// @run-at       document-start
// @noframes
// @icon         https://www.lastepochtools.com/img/favicon-32x32.png
// ==/UserScript==

(function () {
  'use strict';
  if (window.top !== window) return;

  // ---------- helpers ----------
  const sleep = (ms) => new Promise(r => setTimeout(r, ms));
  const url  = () => new URL(location.href);
  const path = () => url().pathname;

  const starts = (seg) => path().startsWith(seg);
  const inKo   = (seg) => path().startsWith(seg + 'ko/');
  const isDbSection       = () => starts('/db/')       && !inKo('/db/');
  const isSkillsSection   = () => starts('/skills/')   && !inKo('/skills/');
  const isAilmentsSection = () => starts('/ailments/') && !inKo('/ailments/');
  const isMinionsSection  = () => starts('/minions/')  && !inKo('/minions/');

  function titleFor(seg, fallback='') {
    const parts = path().split('/').filter(Boolean);
    // parts[0] === seg without leading slash
    const i = (parts[1] === 'ko') ? 2 : 1;
    return parts[i] || fallback;
  }

  function buildKoUrl() {
    const U = url();
    if (isDbSection()) {
      const rest = U.pathname.slice('/db'.length);
      return `https://www.lastepochtools.com/db/ko${rest}${U.search}${U.hash}`;
    }
    if (isSkillsSection()) {
      const rest = U.pathname.slice('/skills'.length);
      return `https://www.lastepochtools.com/skills/ko${rest}${U.search}${U.hash}`;
    }
    if (isAilmentsSection()) {
      const rest = U.pathname.slice('/ailments'.length);
      return `https://www.lastepochtools.com/ailments/ko${rest}${U.search}${U.hash}`;
    }
    if (isMinionsSection()) {
      const rest = U.pathname.slice('/minions'.length);
      return `https://www.lastepochtools.com/minions/ko${rest}${U.search}${U.hash}`;
    }
    return null;
  }

  // SPA 네비 감지
  const notifyNav = () => window.dispatchEvent(new Event('letools:navigate'));
  (function hookHistory(){
    const push = history.pushState, replace = history.replaceState;
    history.pushState = function(){ const r = push.apply(this, arguments); notifyNav(); return r; };
    history.replaceState = function(){ const r = replace.apply(this, arguments); notifyNav(); return r; };
    window.addEventListener('popstate', notifyNav);
  })();

  // ---------- UI ----------
  let panel, iframe, css, opacityInput;
  const OP_KEY = 'letools-ko-opacity';

  function getOpacity() {
    const v = +localStorage.getItem(OP_KEY);
    return isFinite(v) && v >= 0.2 && v <= 1 ? v : 1;
  }
  function setOpacity(v) {
    v = Math.min(1, Math.max(0.2, +v || 1));
    localStorage.setItem(OP_KEY, String(v));
    if (panel) panel.style.opacity = String(v);
    if (opacityInput) opacityInput.value = String(v);
  }

  function appendOnce(node) {
    if (!node || node.isConnected) return;
    (document.body || document.documentElement).appendChild(node);
  }

  function ensurePanel() {
    if (!css) {
      css = document.createElement('style');
      css.textContent = `
        #letools-ko-panel{position:fixed; top:80px; right:24px; width:440px; height:72vh; z-index:999999;
          background:#0b0b0bcc; border:1px solid #333; border-radius:16px; overflow:hidden; color:#eee;
          box-shadow:0 10px 30px rgba(0,0,0,.45); font-family:ui-sans-serif,system-ui,Roboto,Noto Sans KR;}
        #letools-ko-panel .lekof-head{display:flex; align-items:center; justify-content:space-between;
          padding:10px 12px; background:linear-gradient(180deg,#1a1a1acc,#111111cc); cursor:move; user-select:none;}
        #letools-ko-panel .lekof-title{font-weight:700; max-width:50%; overflow:hidden; text-overflow:ellipsis; white-space:nowrap;}
        #letools-ko-panel .lekof-btns{display:flex; align-items:center; gap:8px;}
        #letools-ko-panel .lekof-btns button{background:#1f1f1f; border:1px solid #2e2e2e; color:#ddd; border-radius:8px; padding:4px 8px; cursor:pointer;}
        #letools-ko-panel .lekof-btns button:hover{background:#2a2a2a;}
        #letools-ko-panel .lekof-btns .active{outline:2px solid #5fb3ff;}
        #letools-ko-panel .lekof-op-slider{width:110px; height:20px; accent-color:#5fb3ff;}
        #letools-ko-panel .lekof-frame{width:100%; height:calc(100% - 46px); border:0; background:transparent;}
        #letools-ko-panel .lekof-resize{position:absolute; width:12px; height:12px; right:0; bottom:0; cursor:nwse-resize;
          background:linear-gradient(135deg, transparent 0 50%, #555 50% 100%);}
        #letools-ko-panel.pinned{ border-color:#5fb3ff; box-shadow:0 0 0 2px rgba(95,179,255,.25), 0 12px 32px rgba(0,0,0,.5); }
        @media (max-width:1200px){ #letools-ko-panel{ width:min(92vw, 480px); right:8px; height:60vh; } }
      `;
    }

    if (!panel) {
      panel = document.createElement('div');
      panel.id = 'letools-ko-panel';
      panel.innerHTML = `
        <div class="lekof-head">
          <div class="lekof-title">KO 뷰</div>
          <div class="lekof-btns">
            <span title="투명도(𝛂)"><small>𝛂</small></span>
            <input class="lekof-op-slider" type="range" min="0.2" max="1" step="0.05" value="1">
            <button class="lekof-sync" title="(스킬 전용) Hover/Scroll Sync 토글">🔄</button>
            <button class="lekof-refresh" title="새로고침">⟳</button>
            <button class="lekof-pin" title="고정">📌</button>
            <button class="lekof-close" title="닫기">✕</button>
          </div>
        </div>
        <iframe class="lekof-frame"
          sandbox="allow-scripts allow-forms allow-same-origin allow-popups allow-popups-to-escape-sandbox"
          referrerpolicy="no-referrer"></iframe>
        <div class="lekof-resize"></div>
      `;

      // 드래그
      (function drag(){
        let sx=0,sy=0,ox=0,oy=0,d=false;
        panel.addEventListener('mousedown',e=>{
          if (e.target && (e.target.classList?.contains('lekof-op-slider'))) return;
          if (!e.target.closest('.lekof-head')) return;
          d=true; sx=e.clientX; sy=e.clientY; const r=panel.getBoundingClientRect(); ox=r.left; oy=r.top; e.preventDefault();
        });
        window.addEventListener('mousemove',e=>{
          if(!d) return; const dx=e.clientX-sx, dy=e.clientY-sy;
          panel.style.left=`${Math.max(4,ox+dx)}px`; panel.style.top=`${Math.max(4,oy+dy)}px`; panel.style.right='auto';
        });
        window.addEventListener('mouseup',()=> d=false);
      })();

      // 리사이즈
      (function resize(){
        const handle = panel.querySelector('.lekof-resize');
        let sw=0,sh=0,sx=0,sy=0,r=false;
        handle.addEventListener('mousedown',e=>{
          r=true; sx=e.clientX; sy=e.clientY; const b=panel.getBoundingClientRect(); sw=b.width; sh=b.height; e.preventDefault();
        });
        window.addEventListener('mousemove',e=>{
          if(!r) return; const dx=e.clientX-sx, dy=e.clientY-sy;
          panel.style.width=`${Math.max(320,sw+dx)}px`; panel.style.height=`${Math.max(360,sh+dy)}px`;
        });
        window.addEventListener('mouseup',()=> r=false);
      })();

      // 버튼 & 슬라이더
      panel.querySelector('.lekof-close').addEventListener('click', ()=>{ panel.remove(); panel=null; iframe=null; });
      panel.querySelector('.lekof-pin').addEventListener('click', ()=> panel.classList.toggle('pinned'));
      panel.querySelector('.lekof-refresh').addEventListener('click', ()=> loadKo(true));
      panel.querySelector('.lekof-sync').addEventListener('click', ()=> toggleHoverSync());

      opacityInput = panel.querySelector('.lekof-op-slider');
      opacityInput.addEventListener('input', (e)=>{
        e.stopPropagation();
        setOpacity(e.target.value);
      });

      iframe = panel.querySelector('.lekof-frame');
      try { iframe.setAttribute('credentialless',''); } catch(e) {}
    }

    if (document.readyState === 'loading') {
      if (!css._appended) {
        document.addEventListener('DOMContentLoaded', () => { appendOnce(css); appendOnce(panel); applyOpacity(); });
        css._appended = true;
      }
    } else {
      appendOnce(css); appendOnce(panel); applyOpacity();
    }
    return panel;
  }

  function applyOpacity() { setOpacity(getOpacity()); }

  function setTitle() {
    if (!panel) return;
    const t = panel.querySelector('.lekof-title');
    if (!t) return;

    if (isDbSection())          t.textContent = `KO DB • ${titleFor('db','db')}`;
    else if (isSkillsSection()) t.textContent = `KO 스킬 • ${titleFor('skills','목록')}`;
    else if (isAilmentsSection()) t.textContent = `KO Ailments • ${titleFor('ailments','index')}`;
    else if (isMinionsSection())  t.textContent = `KO Minions • ${titleFor('minions','index')}`;
    else t.textContent = `KO 뷰`;
  }

  function shouldShow(){
    return isDbSection() || isSkillsSection() || isAilmentsSection() || isMinionsSection();
  }

  function loadKo(force=false){
    if (!shouldShow()) {
      if (panel && !panel.classList.contains('pinned')) { panel.remove(); panel=null; iframe=null; }
      return;
    }
    ensurePanel();
    setTitle();
    const target = buildKoUrl();
    if (!target) return;
    if (force || !iframe || iframe.src !== target) {
      iframe.src = target;
      if (isSkillsSection()) {
        iframe.addEventListener('load', setupIframeReceiverForSkills, { once:true });
      }
    }
  }

  async function maybeStart(){ await sleep(80); loadKo(); }
  window.addEventListener('letools:navigate', maybeStart);
  maybeStart();

  // -------- Hover/Scroll Sync (스킬 전용) --------
  let syncEnabled = true;
  function toggleHoverSync(){
    syncEnabled = !syncEnabled;
    panel?.querySelector('.lekof-sync')?.classList.toggle('active', syncEnabled);
  }

  function extractNodeKey(el){
    if (!el) return null;
    const node = el.closest?.('[class*="node"], [class*="Node"], [class*="skill-node"], [class*="SkillNode"]');
    if (!node) return null;
    const ds = node.dataset || {};
    const dataId = ds.nodeId || ds.id || ds.node || null;
    if (dataId) return { type:'data', value:String(dataId) };
    const a = node.querySelector('a[href*="node="]') || node.closest('a[href*="node="]');
    if (a) {
      try { const u = new URL(a.href, location.href); const q = u.searchParams.get('node'); if (q) return { type:'query', value:q }; } catch {}
    }
    if (node.id && /node[-_]/i.test(node.id)) return { type:'id', value:node.id.replace(/^.*?node[-_]/i,'') };
    const img = node.querySelector('img[src]');
    if (img) { try { return { type:'icon', value:new URL(img.src).pathname }; } catch {} }
    return null;
  }

  (function startSkillBroadcaster(){
    const sendScroll = ()=>{
      if (!syncEnabled || !isSkillsSection() || !iframe?.contentWindow) return;
      const doc = document.documentElement;
      const max = (doc.scrollHeight - doc.clientHeight) || 1;
      iframe.contentWindow.postMessage({ type:'LE_SYNC_DOC_SCROLL', pct: doc.scrollTop / max }, '*');
    };
    window.addEventListener('scroll', sendScroll, { passive:true });

    document.addEventListener('mouseover', (e)=>{
      if (!syncEnabled || !isSkillsSection() || !iframe?.contentWindow) return;
      if (panel && panel.contains(e.target)) return;
      const key = extractNodeKey(e.target);
      if (!key) return;
      iframe.contentWindow.postMessage({ type:'LE_SYNC_NODE_KEY', key }, '*');
    }, true);
  })();

  function setupIframeReceiverForSkills(){
    if (!iframe?.contentWindow) return;
    try {
      const w = iframe.contentWindow;
      const d = w.document;
      if (w.__le_id_sync_installed__) return;
      w.__le_id_sync_installed__ = true;

      function findByKey(key){
        if (!key) return null;
        const { type, value } = key;
        if (type === 'data') {
          const s = `[data-node-id="${CSS.escape(value)}"], [data-id="${CSS.escape(value)}"], [data-node="${CSS.escape(value)}"]`;
          return d.querySelector(s);
        }
        if (type === 'query') {
          const a = d.querySelector(`a[href*="node=${CSS.escape(value)}"]`);
          return a ? (a.closest('[class*="node"], [class*="Node"], [class*="skill-node"], [class*="SkillNode"]') || a) : null;
        }
        if (type === 'id') {
          return d.getElementById(`node-${value}`) || d.getElementById(`node_${value}`) || d.getElementById(value);
        }
        if (type === 'icon') {
          let imgs = Array.from(d.querySelectorAll(`img[src$="${CSS.escape(value)}"]`));
          if (!imgs.length) imgs = Array.from(d.querySelectorAll(`img[src*="${CSS.escape(value.split('/').pop()||'')}"]`));
          const nodes = imgs.map(img => img.closest('[class*="node"], [class*="Node"], [class*="skill-node"], [class*="SkillNode"]') || img);
          return nodes[0] || null;
        }
        return null;
      }

      w.addEventListener('message', (ev)=>{
        const msg = ev.data || {};
        if (msg.type === 'LE_SYNC_DOC_SCROLL') {
          const doc = d.documentElement;
          const max = (doc.scrollHeight - doc.clientHeight) || 1;
          doc.scrollTop = msg.pct * max;
          return;
        }
        if (msg.type === 'LE_SYNC_NODE_KEY') {
          const node = findByKey(msg.key);
          if (!node) return;
          node.scrollIntoView({ block:'nearest', inline:'nearest', behavior:'auto' });
          const r = node.getBoundingClientRect();
          const x = Math.round(r.left + r.width/2);
          const y = Math.round(r.top  + r.height/2);
          const move = new w.MouseEvent('mousemove', {bubbles:true, clientX:x, clientY:y});
          const over = new w.MouseEvent('mouseover', {bubbles:true, clientX:x, clientY:y});
          node.dispatchEvent(move); node.dispatchEvent(over);
        }
      });
    } catch {}
  }
})();