您需要先安装一个扩展,例如 篡改猴、Greasemonkey 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 Userscripts ,之后才能安装此脚本。
您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey,才能安装此脚本。
您需要先安装用户脚本管理器扩展后才能安装此脚本。
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 {} } })();