Replace original horizontal tabs with vertical tabs in sidebar.
// ==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']
});
}
})();