// ==UserScript==
// @name Freetar: Adjustable columns + global Show Columns toggle
// @namespace https://example.com/
// @version 3.1
// @description 80vh columns, width from longest line, per-column ▲▼ controls, per-URL save; global "Show Columns" toggle.
// @match http://localhost:22000/*
// @run-at document-idle
// @grant GM_getValue
// @grant GM_setValue
// ==/UserScript==
(function () {
'use strict';
const SELECTOR = 'div.tab.font-monospace';
const STORAGE_KEY_PREFIX = 'ftColumns:';
const GLOBAL_ENABLE_KEY = 'ftEnabled';
const SAVE_DEBOUNCE = 150;
const EXTRA_CH = 3; // extra width beyond the longest line
const TOGGLE_ID = 'checkbox_show_columns';
let enabled = GM_getValue(GLOBAL_ENABLE_KEY, true);
let saveTimer = null;
let moEnhancer = null;
let moToolbar = null;
let enhanced = false;
let originalHTML = null;
let tabEl = null;
let onResizeHandler = null;
function storageKeyForPage() {
return STORAGE_KEY_PREFIX + location.pathname;
}
function saveCounts(root) {
if (saveTimer) clearTimeout(saveTimer);
saveTimer = setTimeout(() => {
const counts = Array.from(root.querySelectorAll('.ft-col-body'))
.map(b => b.children.length);
try {
GM_setValue(storageKeyForPage(), JSON.stringify({ counts, v: 2 }));
} catch {}
}, SAVE_DEBOUNCE);
}
function loadCounts() {
try {
const raw = GM_getValue(storageKeyForPage());
if (!raw) return null;
const parsed = JSON.parse(raw);
if (parsed && Array.isArray(parsed.counts)) return parsed.counts;
return null;
} catch {
return null;
}
}
// Convert original tab content into div.ft-line elements (one per visual line)
function extractLines(container) {
const lines = [];
let buffer = [];
const flush = () => {
const el = document.createElement('div');
el.className = 'ft-line';
if (buffer.length === 0) {
el.innerHTML = ' '; // keep blank line
} else {
buffer.forEach(n => el.appendChild(n));
}
lines.push(el);
buffer = [];
};
Array.from(container.childNodes).forEach(node => {
if (node.nodeName === 'BR') {
flush();
} else {
buffer.push(node.cloneNode(true));
}
});
if (buffer.length) flush();
return lines;
}
function computeLongestLineCh(lines) {
let maxLen = 0;
for (const line of lines) {
const t = line.textContent.replace(/\u00A0/g, ' ');
if (t.length > maxLen) maxLen = t.length;
}
return maxLen + EXTRA_CH;
}
function injectStyles() {
if (document.querySelector('style[data-ft-enhanced-style="1"]')) return;
const style = document.createElement('style');
style.setAttribute('data-ft-enhanced-style', '1');
style.textContent = `
:root { color-scheme: light dark; }
/* Make Bootstrap container full-width for more room */
.container {
max-width: 100% !important;
width: 100% !important;
}
${SELECTOR}.ft-enhanced {
display: flex;
align-items: stretch;
gap: 2rem;
height: 80vh;
max-height: 80vh;
overflow-x: auto;
overflow-y: hidden;
-webkit-overflow-scrolling: touch;
overscroll-behavior-x: contain;
scrollbar-gutter: stable both-edges;
padding: 10px;
font-variant-ligatures: none;
}
${SELECTOR}.ft-enhanced .ft-col {
flex: 0 0 auto;
width: var(--ft-col-width, 60ch);
min-width: var(--ft-col-width, 60ch);
max-width: var(--ft-col-width, 60ch);
display: flex;
flex-direction: column;
position: relative;
padding: 0 10px; /* internal padding so columns don't crowd */
border-right: 1px solid rgba(128,128,128,0.35); /* vertical rule */
}
${SELECTOR}.ft-enhanced .ft-col:last-child { border-right: none; }
${SELECTOR}.ft-enhanced .ft-col-body {
flex: 1 1 auto;
overflow: hidden; /* no vertical scroll; we push lines to next col */
line-height: 1.45;
}
${SELECTOR}.ft-enhanced .ft-line {
white-space: pre; /* do not wrap; width is set from longest line */
padding-block: 2px;
}
${SELECTOR}.ft-enhanced .ft-col-controls {
flex: 0 0 auto;
display: flex;
gap: 6px;
justify-content: center;
padding: 6px 0 4px;
opacity: 0.8;
}
${SELECTOR}.ft-enhanced .ft-btn {
width: 24px;
height: 24px;
border-radius: 999px;
border: 1px solid color-mix(in oklab, currentColor 25%, transparent);
background: color-mix(in oklab, currentColor 5%, transparent);
color: inherit;
display: inline-flex;
align-items: center;
justify-content: center;
font-size: 12px;
cursor: pointer;
opacity: 0.55;
transition: opacity .15s ease, background-color .15s ease;
user-select: none;
}
${SELECTOR}.ft-enhanced .ft-btn:hover {
opacity: 0.95;
background: color-mix(in oklab, currentColor 10%, transparent);
}
/* Small screens: fall back to a single flowing block */
@media (max-width: 768px) {
${SELECTOR}.ft-enhanced {
height: auto;
max-height: none;
overflow: visible;
display: block;
padding: 10px 0;
}
${SELECTOR}.ft-enhanced .ft-col {
width: auto;
min-width: 0;
max-width: none;
border-right: none;
padding: 0;
}
${SELECTOR}.ft-enhanced .ft-col-body { overflow: visible; }
${SELECTOR}.ft-enhanced .ft-col-controls {
justify-content: flex-start;
padding-bottom: 0.75rem;
}
}
`;
document.head.appendChild(style);
}
function removeStyles() {
const el = document.querySelector('style[data-ft-enhanced-style="1"]');
if (el) el.remove();
}
function createColumn() {
const col = document.createElement('div');
col.className = 'ft-col';
const body = document.createElement('div');
body.className = 'ft-col-body';
const controls = document.createElement('div');
controls.className = 'ft-col-controls';
const btnUp = document.createElement('button');
btnUp.className = 'ft-btn';
btnUp.title = 'Pull one line from the next column';
btnUp.textContent = '▲';
const btnDown = document.createElement('button');
btnDown.className = 'ft-btn';
btnDown.title = 'Push one line to the next column';
btnDown.textContent = '▼';
controls.append(btnUp, btnDown);
col.append(body, controls);
return { col, body, btnUp, btnDown };
}
function getColumns(root) {
return Array.from(root.querySelectorAll('.ft-col'));
}
function getBodies(root) {
return Array.from(root.querySelectorAll('.ft-col-body'));
}
function ensureNoOverflow(root) {
let bodies = getBodies(root);
for (let i = 0; i < bodies.length; i++) {
const body = bodies[i];
while (body.scrollHeight > body.clientHeight && body.lastElementChild) {
const next = bodies[i + 1] || createColumnAndAttach(root).body;
bodies = getBodies(root);
const current = bodies[i];
const nextBody = bodies[i + 1];
nextBody.prepend(current.lastElementChild);
}
}
// Remove trailing empty columns (keep at least one)
const cols = getColumns(root);
for (let i = cols.length - 1; i > 0; i--) {
const b = cols[i].querySelector('.ft-col-body');
if (b && b.children.length === 0) cols[i].remove();
else break;
}
}
function createColumnAndAttach(root) {
const parts = createColumn();
root.appendChild(parts.col);
attachHandlers(root, parts);
return parts;
}
function attachHandlers(root, parts) {
parts.btnDown.addEventListener('click', () => {
const cols = getColumns(root);
const idx = cols.indexOf(parts.col);
const bodies = getBodies(root);
const body = bodies[idx];
if (!body || !body.lastElementChild) return;
const next = bodies[idx + 1] || createColumnAndAttach(root).body;
next.prepend(body.lastElementChild);
ensureNoOverflow(root);
saveCounts(root);
});
parts.btnUp.addEventListener('click', () => {
const cols = getColumns(root);
const idx = cols.indexOf(parts.col);
const bodies = getBodies(root);
const body = bodies[idx];
const next = bodies[idx + 1];
if (!body || !next || !next.firstElementChild) return;
const candidate = next.firstElementChild;
body.append(candidate);
if (body.scrollHeight > body.clientHeight) {
next.prepend(body.lastElementChild);
return;
}
ensureNoOverflow(root);
saveCounts(root);
});
}
function buildAuto(root, lines) {
let c = createColumnAndAttach(root);
for (let i = 0; i < lines.length; i++) {
c.body.append(lines[i]);
if (c.body.scrollHeight > c.body.clientHeight) {
const next = createColumnAndAttach(root);
next.body.prepend(c.body.lastElementChild);
c = next;
}
}
ensureNoOverflow(root);
}
function buildFromCounts(root, lines, counts) {
let i = 0;
for (let colIdx = 0; colIdx < counts.length; colIdx++) {
const { body } = createColumnAndAttach(root);
for (let put = 0; put < counts[colIdx] && i < lines.length; put++) {
body.append(lines[i++]);
}
}
while (i < lines.length) {
const { body } = createColumnAndAttach(root);
while (i < lines.length) {
body.append(lines[i++]);
if (body.scrollHeight > body.clientHeight) {
const next = createColumnAndAttach(root);
next.body.prepend(body.lastElementChild);
break;
}
}
}
ensureNoOverflow(root);
}
function enhance() {
if (enhanced) return;
const tab = document.querySelector(SELECTOR);
if (!tab) return;
// Save original content for restoration
tabEl = tab;
originalHTML = tabEl.innerHTML;
injectStyles();
// Build columns from lines
const lines = extractLines(tabEl);
const longestCh = computeLongestLineCh(lines);
tabEl.classList.add('ft-enhanced');
tabEl.style.setProperty('--ft-col-width', `${longestCh}ch`);
tabEl.innerHTML = '';
const saved = loadCounts();
if (saved && saved.length) {
buildFromCounts(tabEl, lines, saved);
} else {
buildAuto(tabEl, lines);
saveCounts(tabEl);
}
// keep columns safe after layout settles
setTimeout(() => {
ensureNoOverflow(tabEl);
saveCounts(tabEl);
}, 0);
onResizeHandler = () => {
ensureNoOverflow(tabEl);
saveCounts(tabEl);
};
window.addEventListener('resize', onResizeHandler, { passive: true });
// If page mutates, re-ensure (but do not re-extract/replace)
if (!moEnhancer) {
moEnhancer = new MutationObserver(() => {
if (!enabled) return;
ensureNoOverflow(tabEl);
});
moEnhancer.observe(document.documentElement, { childList: true, subtree: true });
}
enhanced = true;
}
function disableAndRestore() {
if (!enhanced) return;
// Remove resize handler
if (onResizeHandler) {
window.removeEventListener('resize', onResizeHandler);
onResizeHandler = null;
}
// Disconnect enhancer observer
if (moEnhancer) {
moEnhancer.disconnect();
moEnhancer = null;
}
// Restore original content
if (tabEl) {
tabEl.classList.remove('ft-enhanced');
tabEl.removeAttribute('style');
tabEl.innerHTML = originalHTML || tabEl.innerHTML;
}
// Remove injected styles so page returns to original layout
removeStyles();
enhanced = false;
}
// Insert the "Show Columns" toggle (global)
function insertToggle() {
if (document.getElementById(TOGGLE_ID)) return;
// Find the block that contains transpose controls
const transposeDown = document.getElementById('transpose_down');
if (!transposeDown) return;
const transposeBlock = transposeDown.closest('div');
const toolbar = transposeBlock?.parentElement;
if (!toolbar) return;
// Create switch styled like "Show chords"
const wrap = document.createElement('div');
wrap.className = 'form-check form-switch me-4';
const input = document.createElement('input');
input.className = 'form-check-input';
input.type = 'checkbox';
input.role = 'switch';
input.id = TOGGLE_ID;
input.checked = !!enabled;
const label = document.createElement('label');
label.className = 'form-check-label';
label.setAttribute('for', TOGGLE_ID);
label.textContent = 'Show Columns';
wrap.append(input, label);
// Insert right after the transpose block
if (transposeBlock.nextSibling) {
toolbar.insertBefore(wrap, transposeBlock.nextSibling);
} else {
toolbar.appendChild(wrap);
}
input.addEventListener('change', () => {
enabled = input.checked;
GM_setValue(GLOBAL_ENABLE_KEY, enabled);
if (enabled) {
// If previously disabled, re-enable columns
enhance();
} else {
// Turn off and restore original layout
disableAndRestore();
}
});
}
function ensureToggleInserted() {
insertToggle();
if (!moToolbar) {
moToolbar = new MutationObserver(() => insertToggle());
moToolbar.observe(document.documentElement, { childList: true, subtree: true });
}
}
function init() {
ensureToggleInserted();
if (enabled) {
enhance();
} else {
disableAndRestore();
}
}
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', init);
} else {
init();
}
})();