// ==UserScript==
// @name Freetar: Widescreen Layout w/ Adjustable columns + Show Columns toggle
// @namespace https://example.com/
// @version 3.8
// @description Columns with width from longest line, per-column ▲▼ controls; global toggle; fills remaining viewport height; saves immediately after manual moves with verify+retry; restores per size/zoom bucket (nearest on load); auto-removes empty columns so adjustments can continue seamlessly.
// @match http://localhost:22000/*
// @run-at document-idle
// @noframes
// @grant GM_getValue
// @grant GM_setValue
// @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 EXTRA_CH = 3; // extra width beyond the longest line
const TOGGLE_ID = 'checkbox_show_columns';
const MIN_HEIGHT_PX = 120; // guard for very small viewports
// Bucketed manual-save with nearest-restore
const BUCKET_STEP_H = 60; // px bucket step for available height
const DPR_STEP = 0.25; // bucket step for devicePixelRatio (captures zoom)
const MAX_BUCKETS = 8; // keep at most N recent buckets per page
const INDEX_KEY_PREFIX = 'ftColumnsIdx:'; // per-page list of saved buckets
// Robust save config
const SAVE_RETRY_DELAY_MS = 60; // small delay before retry if verify failed
let enabled = typeof GM_getValue === 'function' ? GM_getValue(GLOBAL_ENABLE_KEY, true) : true;
let moEnhancer = null;
let moToolbar = null;
let enhanced = false;
let originalHTML = null;
let tabEl = null;
let onResizeHandler = null;
// Prefer Promise-based API if present
const hasAsyncGM = typeof GM !== 'undefined'
&& typeof GM.getValue === 'function'
&& typeof GM.setValue === 'function';
async function gmGet(key, def) {
try {
if (hasAsyncGM) return await GM.getValue(key, def);
if (typeof GM_getValue === 'function') return GM_getValue(key, def);
} catch {}
return def;
}
async function gmSet(key, value) {
try {
if (hasAsyncGM) return await GM.setValue(key, value);
if (typeof GM_setValue === 'function') { GM_setValue(key, value); return; }
} catch {}
}
// --- Storage keys ---
function storageKeyForPage() { // legacy fallback key (single layout per page)
return STORAGE_KEY_PREFIX + location.pathname;
}
function indexKeyForPage() { return INDEX_KEY_PREFIX + location.pathname; }
function storageKeyForBucket(bucket) { return `${STORAGE_KEY_PREFIX}${location.pathname}|${bucket}`; }
// --- Bucket helpers ---
function roundToStep(n, step) { return Math.round(n / step) * step; }
// Height available to the columns (top of block to viewport bottom)
function getAvailableHeightForBucket() {
const el = tabEl || document.querySelector(SELECTOR);
const topPx = el ? Math.max(0, el.getBoundingClientRect().top) : 0;
return Math.max(MIN_HEIGHT_PX, Math.floor(window.innerHeight - topPx));
}
// Build a bucket id like "h660|d1.00"
function computeBucketId() {
const h = getAvailableHeightForBucket();
const hb = Math.max(BUCKET_STEP_H, roundToStep(h, BUCKET_STEP_H));
const dpr = window.devicePixelRatio || 1;
const dprb = Math.max(0.5, roundToStep(dpr, DPR_STEP));
return `h${hb}|d${dprb.toFixed(2)}`;
}
function parseBucketId(id) {
const m = /^h(\d+)\|d(\d+(?:\.\d+)?)$/.exec(id);
return m ? { h: parseInt(m[1], 10), dpr: parseFloat(m[2]) } : null;
}
function bucketDistance(a, b) {
// Prefer same zoom strongly; DPR diff is weighted higher than height diff
return Math.abs(a.h - b.h) + 100 * Math.abs(a.dpr - b.dpr);
}
function readBucketIndex() {
try {
const raw = typeof GM_getValue === 'function' ? GM_getValue(indexKeyForPage(), '[]') : '[]';
return JSON.parse(raw) || [];
} catch { return []; }
}
function writeBucketIndex(list) {
try { if (typeof GM_setValue === 'function') GM_setValue(indexKeyForPage(), JSON.stringify(list)); } catch {}
}
function addBucketToIndex(bucket) {
const list = readBucketIndex();
const idx = list.indexOf(bucket);
if (idx !== -1) list.splice(idx, 1); // move to end (most recent)
list.push(bucket);
while (list.length > MAX_BUCKETS) list.shift();
writeBucketIndex(list);
}
// --- Save/load with verify+retry; only called after manual ▲/▼ ---
function getCurrentCounts(root) {
return Array.from(root.querySelectorAll('.ft-col-body')).map(b => b.children.length);
}
async function persistCountsReliably(root) {
const counts = getCurrentCounts(root);
const bucket = computeBucketId();
const key = storageKeyForBucket(bucket);
const payloadStr = JSON.stringify({ counts, v: 8, bucket, savedAt: Date.now() });
// Write -> read verify -> small-delay retry if needed
await gmSet(key, payloadStr);
let got = await gmGet(key, null);
if (got !== payloadStr) {
await new Promise(r => setTimeout(r, SAVE_RETRY_DELAY_MS));
await gmSet(key, payloadStr);
got = await gmGet(key, null);
}
// Update index
addBucketToIndex(bucket);
if (got !== payloadStr) {
try { if (typeof GM_setValue === 'function') GM_setValue(storageKeyForPage(), payloadStr); } catch {}
console.warn('[Freetar] Save verify failed after retry; wrote legacy key as fallback:', key);
}
}
function saveCountsAfterManual(root) {
// Save now and once more on the next frame (covers late reflows)
persistCountsReliably(root);
try { requestAnimationFrame(() => { if (root.isConnected) persistCountsReliably(root); }); } catch {}
}
// On load, try exact bucket; else nearest saved bucket (prefer most recent on ties); else legacy key
function loadCounts() {
try {
const curId = computeBucketId();
// Exact
let raw = typeof GM_getValue === 'function' ? GM_getValue(storageKeyForBucket(curId)) : null;
// Nearest
if (!raw) {
const list = readBucketIndex();
const cur = parseBucketId(curId);
if (cur && list.length) {
let best = null, bestDist = Infinity, bestSavedAt = -1;
for (const id of list) {
const candidate = typeof GM_getValue === 'function' ? GM_getValue(storageKeyForBucket(id)) : null;
if (!candidate) continue;
const parsed = JSON.parse(candidate);
if (!parsed || !Array.isArray(parsed.counts)) continue;
const spec = parseBucketId(id);
const d = spec ? bucketDistance(spec, cur) : Infinity;
const ts = typeof parsed.savedAt === 'number' ? parsed.savedAt : 0;
if (d < bestDist || (d === bestDist && ts > bestSavedAt)) {
best = parsed; bestDist = d; bestSavedAt = ts;
}
}
if (best) raw = JSON.stringify(best);
}
}
// Legacy fallback
if (!raw) raw = typeof GM_getValue === 'function' ? GM_getValue(storageKeyForPage()) : null;
if (!raw) return null;
const parsed = JSON.parse(raw);
return (parsed && Array.isArray(parsed.counts)) ? parsed.counts : 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 = ' ';
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; }
.container { max-width: 100% !important; width: 100% !important; }
${SELECTOR}.ft-enhanced {
display: flex;
align-items: stretch;
gap: 2rem;
height: var(--ft-columns-height, 80vh);
max-height: var(--ft-columns-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;
border-right: 1px solid rgba(128,128,128,0.35);
}
${SELECTOR}.ft-enhanced .ft-col:last-child { border-right: none; }
${SELECTOR}.ft-enhanced .ft-col-body {
flex: 1 1 auto;
overflow: hidden;
line-height: 1.45;
}
${SELECTOR}.ft-enhanced .ft-line { white-space: pre; 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); }
@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')); }
// NEW: remove any empty columns (not just trailing), keeping at least one column
function pruneEmptyColumns(root) {
while (true) {
const cols = getColumns(root);
if (cols.length <= 1) break;
let removed = false;
for (let i = 0; i < cols.length; i++) {
const b = cols[i].querySelector('.ft-col-body');
if (b && b.children.length === 0) {
cols[i].remove();
removed = true;
break; // restart with fresh list
}
}
if (!removed) break;
}
}
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 any empty columns anywhere (so next adjustments skip gaps)
pruneEmptyColumns(root);
}
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); // reflow + prune empties
saveCountsAfterManual(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);
// no return; still prune empties and save
}
ensureNoOverflow(root); // reflow + prune empties
saveCountsAfterManual(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); // also prunes empties
}
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); // also prunes empties
}
// Fill to bottom of viewport when scrolled to the top (avoids jumps mid-page)
function setViewportFillingHeight() {
if (!tabEl || !tabEl.classList.contains('ft-enhanced')) return;
if (window.scrollY > 0) {
tabEl.style.removeProperty('--ft-columns-height'); // fallback to 80vh
return;
}
const rect = tabEl.getBoundingClientRect(); // relative to viewport
const topPx = Math.max(0, rect.top);
const target = Math.max(MIN_HEIGHT_PX, Math.floor(window.innerHeight - topPx));
tabEl.style.setProperty('--ft-columns-height', `${target}px`);
}
function injectStylesOnce() {
injectStyles();
}
function enhance() {
if (enhanced) return;
const tab = document.querySelector(SELECTOR);
if (!tab) return;
// Save original content for restoration
tabEl = tab;
originalHTML = tabEl.innerHTML;
injectStylesOnce();
// 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);
}
setTimeout(() => {
ensureNoOverflow(tabEl);
setViewportFillingHeight();
}, 0);
onResizeHandler = () => {
ensureNoOverflow(tabEl);
setViewportFillingHeight();
// no save on resize/zoom
};
window.addEventListener('resize', onResizeHandler, { passive: true });
window.addEventListener('load', () => {
setViewportFillingHeight();
}, { once: true });
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;
if (onResizeHandler) {
window.removeEventListener('resize', onResizeHandler);
onResizeHandler = null;
}
if (moEnhancer) {
moEnhancer.disconnect();
moEnhancer = null;
}
if (tabEl) {
tabEl.classList.remove('ft-enhanced');
tabEl.removeAttribute('style');
tabEl.innerHTML = originalHTML || tabEl.innerHTML;
}
removeStyles();
enhanced = false;
}
// Insert the "Show Columns" toggle (global)
function insertToggle() {
if (document.getElementById(TOGGLE_ID)) return;
const transposeDown = document.getElementById('transpose_down');
if (!transposeDown) return;
const transposeBlock = transposeDown.closest('div');
const toolbar = transposeBlock?.parentElement;
if (!toolbar) return;
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);
if (transposeBlock.nextSibling) {
toolbar.insertBefore(wrap, transposeBlock.nextSibling);
} else {
toolbar.appendChild(wrap);
}
input.addEventListener('change', async () => {
enabled = input.checked;
try {
if (hasAsyncGM) await GM.setValue(GLOBAL_ENABLE_KEY, enabled);
else if (typeof GM_setValue === 'function') GM_setValue(GLOBAL_ENABLE_KEY, enabled);
} catch {}
if (enabled) enhance();
else 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();
})();