ProtectedText — Vertical Tabs

Replace original horizontal tabs with vertical tabs in sidebar.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

// ==UserScript==
// @name         ProtectedText — Vertical Tabs
// @namespace    ProtectedText-Vertical-Tabs
// @version      1.8
// @description  Replace original horizontal tabs with vertical tabs in sidebar.
// @author       SirGryphin
// @match        https://www.protectedtext.com/*
// @grant        none
// @run-at       document-idle
// @license      MIT
// ==/UserScript==

(function () {
  'use strict';

  if (window.location.pathname.length <= 1) return;

  // Wait for jQuery UI to finish its own layout before we measure anything
  const INIT_DELAY = 1600;
  const SIDEBAR_W  = 175;

  setTimeout(init, INIT_DELAY);

  window.ptSidebarDebug = function () {
    const tabs = document.getElementById('tabs');
    if (!tabs) { console.log('No #tabs'); return; }
    const ul = tabs.querySelector('ul');
    console.log('=== tabs ===');
    (ul ? ul.querySelectorAll('li') : []).forEach((li, i) => {
      const a = li.querySelector('a');
      console.log(`[${i}] class="${li.className}" a.text="${a ? a.textContent.trim() : ''}" li.text="${li.textContent.trim()}"`);
    });
    const panel = getActivePanel();
    const ta = panel && panel.querySelector('textarea');
    if (ta) {
      const r = ta.getBoundingClientRect();
      console.log(`textarea rect: top=${Math.round(r.top)} left=${Math.round(r.left)} w=${Math.round(r.width)} h=${Math.round(r.height)}`);
    }
    console.log('=== add candidates ===');
    document.querySelectorAll('button,a,span,li,div').forEach(el => {
      if (el.childElementCount === 0 && el.textContent.trim() === '+')
        console.log(`+ : tag=${el.tagName} id="${el.id}" class="${el.className}"`);
    });
  };

  function getActivePanel() {
    return Array.from(document.querySelectorAll('[id^="tabs-"]'))
      .find(p => p.style.display !== 'none' && p.offsetParent !== null)
      || document.querySelector('[id^="tabs-"]');
  }

  function init() {
    const tabsEl = document.getElementById('tabs');
    if (!tabsEl) { setTimeout(init, 1000); return; }
    const ul = tabsEl.querySelector('ul');
    if (!ul)    { setTimeout(init, 800);  return; }

    // ── Measure the textarea position BEFORE we change anything ──────────────
    // We use this to know where to place the sidebar and what indent to give panels
    const panel0 = getActivePanel();
    const ta0    = panel0 && panel0.querySelector('textarea');
    if (!ta0) { setTimeout(init, 600); return; }

    const taRect   = ta0.getBoundingClientRect();
    const scrollTop = window.scrollY || document.documentElement.scrollTop;

    // How much space is to the left of the textarea?
    // The sidebar will sit in that gap. If there isn't enough room we cap at what's available.
    const availableLeft = taRect.left;  // pixels from viewport left edge to textarea left edge
    const sidebarWidth  = Math.min(SIDEBAR_W, availableLeft - 10);

    if (sidebarWidth < 60) {
      // Not enough margin — do nothing rather than overlap
      console.warn('PT Sidebar: not enough left margin to show sidebar without overlap');
      return;
    }

    // Left position of sidebar = textarea left - sidebar width - 6px gap
    const sidebarLeft = taRect.left - sidebarWidth - 6;

    // ── Add a top gap where the old tab bar was ───────────────────────────────
    // We don't change #tabs layout, just add a small top padding
    tabsEl.style.paddingTop = '8px';

    // ── Styles ────────────────────────────────────────────────────────────────
    const style = document.createElement('style');
    style.textContent = `
      /* ── Hide the original UL tab bar without touching anything else ───────
         display:none is safe here — jQuery UI only reads it on init (already done).
         We rebuild all interaction in our sidebar div below.              */
      #tabs > ul.ui-tabs-nav {
        display: none !important;
      }

      /* ── Our sidebar container ─────────────────────────────────────────────*/
      #pt-sb {
        position: fixed;
        top: ${taRect.top + scrollTop}px;
        left: ${sidebarLeft}px;
        width: ${sidebarWidth}px;
        z-index: 8000;
        background: #f5f5f5;
        border: 1px solid #ccc;
        border-radius: 4px;
        display: flex;
        flex-direction: column;
        font-family: inherit;
        font-size: 13px;
        box-shadow: 1px 1px 4px rgba(0,0,0,0.07);
        /* Height matched to textarea — set by positionSidebar() */
      }

      #pt-sb-head {
        padding: 6px 8px 5px;
        font-size: 10px;
        font-weight: bold;
        color: #999;
        text-transform: uppercase;
        letter-spacing: 0.07em;
        border-bottom: 1px solid #ddd;
        flex-shrink: 0;
      }

      #pt-sb-list {
        flex: 1;
        overflow-y: auto;
        overflow-x: hidden;
        padding: 3px 0;
        min-height: 0;
      }
      #pt-sb-list::-webkit-scrollbar { width: 4px; }
      #pt-sb-list::-webkit-scrollbar-thumb { background: #ccc; border-radius: 2px; }
      #pt-sb-list::-webkit-scrollbar-track { background: transparent; }

      .pt-sb-row {
        display: flex;
        align-items: center;
        padding: 5px 4px 5px 8px;
        cursor: pointer;
        border-left: 3px solid transparent;
        color: #444;
        line-height: 1.4;
        transition: background 0.1s;
        gap: 4px;
        user-select: none;
      }
      .pt-sb-row:hover { background: #ebebeb; }
      .pt-sb-row.pt-sb-active {
        border-left-color: #555;
        background: #e2e2e2;
        color: #111;
        font-weight: 500;
      }
      .pt-sb-row.dragging  { opacity: 0.4; }
      .pt-sb-row.drag-over { border-top: 2px solid #666; }

      .pt-sb-grip {
        font-size: 11px;
        color: #ccc;
        cursor: grab;
        user-select: none;
        flex-shrink: 0;
        line-height: 1;
      }
      .pt-sb-grip:active { cursor: grabbing; }

      .pt-sb-label {
        flex: 1;
        overflow: hidden;
        text-overflow: ellipsis;
        white-space: nowrap;
        font-size: 12px;
      }

      .pt-sb-x {
        flex-shrink: 0;
        width: 16px;
        height: 16px;
        line-height: 16px;
        border-radius: 3px;
        border: none;
        background: transparent;
        color: #bbb;
        font-size: 15px;
        cursor: pointer;
        text-align: center;
        padding: 0;
        transition: background 0.1s, color 0.1s;
        opacity: 0;
        pointer-events: none;
        font-family: Arial, sans-serif;
      }
      .pt-sb-row:hover .pt-sb-x {
        opacity: 1;
        pointer-events: all;
      }
      .pt-sb-x:hover { background: #e0e0e0; color: #c00; }

      #pt-sb-add {
        margin: 5px 7px;
        padding: 4px 7px;
        font-size: 11px;
        cursor: pointer;
        background: #fff;
        border: 1px solid #bbb;
        border-radius: 3px;
        color: #555;
        text-align: center;
        flex-shrink: 0;
        transition: background 0.1s;
        user-select: none;
      }
      #pt-sb-add:hover { background: #e8e8e8; }
    `;
    document.head.appendChild(style);

    // ── Build sidebar DOM ─────────────────────────────────────────────────────
    const sb     = document.createElement('div'); sb.id     = 'pt-sb';
    const head   = document.createElement('div'); head.id   = 'pt-sb-head'; head.textContent = 'Tabs';
    const list   = document.createElement('div'); list.id   = 'pt-sb-list';
    const addBtn = document.createElement('div'); addBtn.id = 'pt-sb-add'; addBtn.textContent = '+ New tab';

    sb.appendChild(head);
    sb.appendChild(list);
    sb.appendChild(addBtn);
    document.body.appendChild(sb);

    // ── Keep sidebar height matched to textarea ───────────────────────────────
    function positionSidebar() {
      const panel  = getActivePanel();
      const ta     = panel && panel.querySelector('textarea');
      if (!ta) return;
      const r = ta.getBoundingClientRect();
      sb.style.top    = (r.top + window.scrollY) + 'px';
      sb.style.height = r.height + 'px';
      // Also re-check left position hasn't drifted (e.g. after window resize)
      const newLeft = r.left - sidebarWidth - 6;
      sb.style.left = newLeft + 'px';
    }

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

    // Reposition when panels resize (jQuery UI sets inline height on them)
    new ResizeObserver(positionSidebar).observe(tabsEl);

    // ── Switch tab via jQuery UI API — zero flash ─────────────────────────────
    // We call $('#tabs').tabs('option','active', idx) directly.
    // The UL is display:none so it never appears.
    function switchToTab(idx) {
      try {
        if (typeof $ !== 'undefined') {
          const $t = $('#tabs');
          if ($t.data('ui-tabs')) { $t.tabs('option', 'active', idx); return true; }
          if ($t.data('tabs'))    { $t.tabs('option', 'active', idx); return true; }
        }
      } catch(e) { console.warn('PT Sidebar switchToTab:', e); }
      return false;
    }

    // ── Delete tab ────────────────────────────────────────────────────────────
    // jQuery UI's remove method OR click the real hidden remove element
    function deleteTab(idx, li) {
      // Try jQuery UI built-in remove
      try {
        if (typeof $ !== 'undefined') {
          const $t = $('#tabs');
          if ($t.data('ui-tabs')) {
            // jQuery UI 1.10+ method
            try { $t.tabs('remove', idx); return; } catch(_) {}
          }
        }
      } catch(_) {}

      // Fallback: find and click the real remove element in the hidden LI.
      // We use dispatchEvent so it fires even with display:none on the UL.
      const removeEl = getRemoveEl(li);
      if (removeEl) {
        removeEl.dispatchEvent(new MouseEvent('click', { bubbles: true, cancelable: true }));
      } else {
        console.warn('PT Sidebar: remove element not found — run ptSidebarDebug()');
      }
    }

    // ── Find remove button inside a <li> ──────────────────────────────────────
    function getRemoveEl(li) {
      for (const el of li.querySelectorAll('*')) {
        const t = el.textContent.trim();
        const title = (el.title || '').toLowerCase();
        if (t === 'Remove Tab' || title.includes('remove') || title.includes('delete')) return el;
      }
      for (const el of li.querySelectorAll('*')) {
        const s = ((el.id || '') + ' ' + (el.className || '')).toLowerCase();
        if (s.includes('remove') || s.includes('delete') || s.includes('close')) return el;
      }
      return null;
    }

    // ── Get clean label — text nodes of <a> only, skips child elements ────────
    function getLabel(li) {
      const a = li.querySelector('a');
      if (!a) return null;
      let text = '';
      a.childNodes.forEach(n => {
        if (n.nodeType === Node.TEXT_NODE) text += n.textContent;
      });
      return text.trim() || 'Tab';
    }

    // ── Find the + add-tab button ─────────────────────────────────────────────
    function findAddBtn() {
      const byId = document.querySelector('[id*="addTab"],[id*="add-tab"],[id*="newTab"]');
      if (byId) return byId;
      for (const el of document.querySelectorAll('button,a,span,li,div')) {
        if (el.childElementCount === 0 && el.textContent.trim() === '+') return el;
      }
      return null;
    }

    addBtn.addEventListener('click', () => {
      const btn = findAddBtn();
      if (btn) btn.click();
      else console.warn('PT Sidebar: add-tab not found — run ptSidebarDebug()');
    });

    // ── Build tab list ────────────────────────────────────────────────────────
    let dragSrcIdx = null;

    function buildList() {
      list.innerHTML = '';

      Array.from(ul.querySelectorAll('li')).forEach((li, idx) => {
        const label = getLabel(li);
        if (label === null) return;

        const isActive = li.classList.contains('ui-tabs-active') ||
                         li.classList.contains('active') ||
                         li.getAttribute('aria-selected') === 'true';

        const row = document.createElement('div');
        row.className = 'pt-sb-row' + (isActive ? ' pt-sb-active' : '');
        row.title = label;

        const grip = document.createElement('span');
        grip.className = 'pt-sb-grip';
        grip.textContent = '⠿';

        const lbl = document.createElement('span');
        lbl.className = 'pt-sb-label';
        lbl.textContent = label;

        // × as a real × character (U+00D7), not text "x"
        const xBtn = document.createElement('span');
        xBtn.className = 'pt-sb-x';
        xBtn.title = 'Delete tab';
        xBtn.textContent = '×';
        xBtn.addEventListener('click', e => {
          e.stopPropagation();
          deleteTab(idx, li);
        });

        row.appendChild(grip);
        row.appendChild(lbl);
        row.appendChild(xBtn);

        row.addEventListener('click', e => {
          if (e.target === grip || e.target === xBtn) return;
          switchToTab(idx);
          list.querySelectorAll('.pt-sb-row').forEach(r => r.classList.remove('pt-sb-active'));
          row.classList.add('pt-sb-active');
          setTimeout(positionSidebar, 80);
        });

        // Drag to reorder
        row.setAttribute('draggable', 'true');
        row.addEventListener('dragstart', e => {
          dragSrcIdx = idx;
          row.classList.add('dragging');
          e.dataTransfer.effectAllowed = 'move';
          e.dataTransfer.setData('text/plain', String(idx));
        });
        row.addEventListener('dragend', () => {
          row.classList.remove('dragging');
          list.querySelectorAll('.pt-sb-row').forEach(r => r.classList.remove('drag-over'));
        });
        row.addEventListener('dragover', e => {
          e.preventDefault();
          e.dataTransfer.dropEffect = 'move';
          list.querySelectorAll('.pt-sb-row').forEach(r => r.classList.remove('drag-over'));
          row.classList.add('drag-over');
        });
        row.addEventListener('dragleave', () => row.classList.remove('drag-over'));
        row.addEventListener('drop', e => {
          e.preventDefault();
          row.classList.remove('drag-over');
          if (dragSrcIdx === null || dragSrcIdx === idx) return;
          const all = Array.from(ul.querySelectorAll('li'));
          const srcLi = all[dragSrcIdx];
          const dstLi = all[idx];
          if (!srcLi || !dstLi) return;
          dragSrcIdx < idx
            ? ul.insertBefore(srcLi, dstLi.nextSibling)
            : ul.insertBefore(srcLi, dstLi);
          dragSrcIdx = null;
          buildList();
          try {
            if (typeof $ !== 'undefined' && $('#tabs').data('ui-tabs')) $('#tabs').tabs('refresh');
          } catch(_) {}
        });

        list.appendChild(row);
      });

      positionSidebar();
    }

    buildList();

    // Re-sync when tabs change
    new MutationObserver(buildList).observe(ul, {
      childList: true, subtree: true, attributes: true,
      attributeFilter: ['class', 'aria-selected']
    });
  }

})();