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