Linux.do 双栏阅读预览油猴脚本

沧浪同学 2026-08-05 17:40 1

从 如果设计成这种视图帖子的阅读效率会更高 - 运营反馈 / 运营反馈, Lv1 - LINUX DO 继续讨论


做了个 Linux.do 双栏阅读预览油猴脚本。


点击帖子标题后,右侧打开帖子详情,左侧列表保持不跳转。支持拖动宽度、关闭、放大、刷新和原页打开,方便连续浏览帖子。


目前还在细节调整中,欢迎反馈。

宽窄可在中间手动拖动


// ==UserScript==
// @name Linux.do 双栏阅读预览
// @namespace https://linux.do/
// @version 0.1.0
// @description 在 Linux.do 话题列表中用右侧面板预览帖子
// @match https://linux.do/*
// @run-at document-start
// @noframes
// @grant none
// ==/UserScript==

(function () {
'use strict';

const PANEL_ID = 'ld-double-column-panel';
const STYLE_ID = 'ld-double-column-style';
const FRAME_STYLE_ID = 'ld-double-column-frame-style';
const WIDTH_KEY = 'ld-double-column-width';
const DEFAULT_WIDTH = 600;
const MAX_WIDTH = 680;
const MIN_WIDTH = 380;
const MOBILE_BREAKPOINT = 780;

const state = {
panel: null,
frame: null,
title: null,
status: null,
currentUrl: '',
open: false,
fullscreen: false,
width: readWidth(),
savedScrollY: 0,
parentUrl: '',
historyActive: false,
closeFromHistory: false,
};

function readWidth() {
const value = Number.parseInt(localStorage.getItem(WIDTH_KEY), 10);
return Number.isFinite(value) ? value : DEFAULT_WIDTH;
}

function clampWidth(value) {
const max = Math.max(MIN_WIDTH, Math.min(MAX_WIDTH, Math.floor(window.innerWidth * 0.6)));
return Math.min(Math.max(value, MIN_WIDTH), max);
}

function normalizeTopicUrl(href) {
try {
const url = new URL(href, location.href);
if (url.origin !== location.origin) return null;
if (!/^\/t\/topic\/\d+(?:\/\d+)?$/.test(url.pathname)) return null;
url.hash = '';
return url.href;
} catch {
return null;
}
}

function isModifiedClick(event) {
return event.button !== 0 || event.metaKey || event.ctrlKey || event.shiftKey || event.altKey;
}

function addStyle() {
if (!document.head || document.getElementById(STYLE_ID)) return;

const style = document.createElement('style');
style.id = STYLE_ID;
style.textContent = `
:root {
--ld-preview-width: ${state.width}px;
}

#${PANEL_ID} {
position: fixed;
z-index: 2147483000;
inset: 0 0 0 auto;
display: none;
width: var(--ld-preview-width);
min-width: 0;
flex-direction: column;
overflow: hidden;
background: var(--secondary, #ffffff);
color: var(--primary, #202124);
border-left: 1px solid var(--primary-low, #d7d7d7);
box-shadow: -8px 0 24px rgba(0, 0, 0, 0.18);
}

body.ld-double-column-open #${PANEL_ID} {
display: flex;
}

body.ld-double-column-open #main-outlet {
width: calc(100% - var(--ld-preview-width)) !important;
max-width: none !important;
min-width: 0 !important;
margin-right: var(--ld-preview-width) !important;
box-sizing: border-box !important;
}

body.ld-double-column-open #main-outlet > .container,
body.ld-double-column-open #main-outlet .topic-list,
body.ld-double-column-open #main-outlet .topic-list-body {
width: 100% !important;
max-width: none !important;
min-width: 0 !important;
}

body.ld-double-column-fullscreen #${PANEL_ID} {
width: 100vw;
}

body.ld-double-column-fullscreen #main-outlet {
visibility: hidden !important;
}

#${PANEL_ID} .ld-preview-resizer {
position: absolute;
z-index: 2;
top: 0;
bottom: 0;
left: -5px;
width: 10px;
cursor: col-resize;
touch-action: none;
}

#${PANEL_ID} .ld-preview-resizer::after {
position: absolute;
top: 0;
bottom: 0;
left: 4px;
width: 2px;
content: '';
background: transparent;
transition: background 120ms ease;
}

#${PANEL_ID} .ld-preview-resizer:hover::after,
body.ld-double-column-resizing #${PANEL_ID} .ld-preview-resizer::after {
background: var(--tertiary, #0088cc);
}

#${PANEL_ID} .ld-preview-toolbar {
display: flex;
align-items: center;
gap: 6px;
min-height: 42px;
padding: 0 8px 0 14px;
color: var(--primary, #202124);
background: var(--secondary, #ffffff);
border-bottom: 1px solid var(--primary-low, #d7d7d7);
}

#${PANEL_ID} .ld-preview-heading {
min-width: 0;
flex: 1;
overflow: hidden;
font-size: 14px;
font-weight: 600;
line-height: 1.3;
text-overflow: ellipsis;
white-space: nowrap;
}

#${PANEL_ID} .ld-preview-action {
display: inline-grid;
width: 30px;
height: 30px;
flex: 0 0 30px;
place-items: center;
padding: 0;
color: inherit;
background: transparent;
border: 0;
border-radius: 4px;
cursor: pointer;
font: inherit;
font-size: 18px;
line-height: 1;
}

#${PANEL_ID} .ld-preview-action:hover,
#${PANEL_ID} .ld-preview-action:focus-visible {
background: var(--primary-low, #e9e9e9);
outline: none;
}

#${PANEL_ID} .ld-preview-content {
position: relative;
min-height: 0;
flex: 1;
background: var(--secondary, #ffffff);
}

#${PANEL_ID} .ld-preview-frame {
display: block;
width: 100%;
height: 100%;
border: 0;
opacity: 1;
transition: opacity 120ms ease;
}

#${PANEL_ID}.is-loading .ld-preview-frame {
opacity: 0.35;
}

#${PANEL_ID} .ld-preview-status {
position: absolute;
top: 50%;
left: 50%;
z-index: 1;
max-width: min(80%, 360px);
padding: 12px 16px;
color: var(--primary, #202124);
background: var(--secondary, #ffffff);
border: 1px solid var(--primary-low, #d7d7d7);
border-radius: 6px;
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.14);
font-size: 14px;
line-height: 1.5;
text-align: center;
transform: translate(-50%, -50%);
}

#${PANEL_ID} .ld-preview-status[hidden] {
display: none;
}

body.ld-double-column-resizing,
body.ld-double-column-resizing * {
cursor: col-resize !important;
user-select: none !important;
}

@media (max-width: ${MOBILE_BREAKPOINT}px) {
#${PANEL_ID} {
width: 100vw;
}

body.ld-double-column-open #main-outlet {
width: 100% !important;
margin-right: 0 !important;
}

body.ld-double-column-open:not(.ld-double-column-fullscreen) #main-outlet {
visibility: hidden !important;
}

#${PANEL_ID} .ld-preview-resizer {
display: none;
}
}
`;
document.head.appendChild(style);
}

function createButton(icon, label, handler) {
const button = document.createElement('button');
button.type = 'button';
button.className = 'ld-preview-action';
button.textContent = icon;
button.title = label;
button.setAttribute('aria-label', label);
button.addEventListener('click', handler);
return button;
}

function ensurePanel() {
if (state.panel) return;
addStyle();

const panel = document.createElement('aside');
panel.id = PANEL_ID;
panel.setAttribute('aria-label', '帖子预览');

const resizer = document.createElement('div');
resizer.className = 'ld-preview-resizer';
resizer.title = '拖动调整宽度';
resizer.setAttribute('role', 'separator');
resizer.setAttribute('aria-orientation', 'vertical');

const toolbar = document.createElement('div');
toolbar.className = 'ld-preview-toolbar';

const heading = document.createElement('div');
heading.className = 'ld-preview-heading';
heading.textContent = '帖子预览';
heading.title = '帖子预览';

const content = document.createElement('div');
content.className = 'ld-preview-content';

const frame = document.createElement('iframe');
frame.className = 'ld-preview-frame';
frame.title = '帖子预览';
frame.loading = 'eager';
frame.referrerPolicy = 'origin-when-cross-origin';
frame.addEventListener('load', handleFrameLoad);

const status = document.createElement('div');
status.className = 'ld-preview-status';
status.hidden = true;

const originalButton = createButton('↗', '在原页面打开', () => {
if (state.currentUrl) window.open(state.currentUrl, '_blank', 'noopener,noreferrer');
});
const refreshButton = createButton('↻', '刷新预览', () => {
if (state.frame?.contentWindow) state.frame.contentWindow.location.reload();
});
const fullscreenButton = createButton('⛶', '放大预览', () => {
state.fullscreen = !state.fullscreen;
document.body.classList.toggle('ld-double-column-fullscreen', state.fullscreen);
fullscreenButton.textContent = state.fullscreen ? '⛶' : '⛶';
fullscreenButton.title = state.fullscreen ? '恢复双栏' : '放大预览';
fullscreenButton.setAttribute('aria-label', fullscreenButton.title);
});
const closeButton = createButton('×', '关闭预览', closePanel);

toolbar.append(heading, originalButton, refreshButton, fullscreenButton, closeButton);
content.append(frame, status);
panel.append(resizer, toolbar, content);
document.body.appendChild(panel);

state.panel = panel;
state.frame = frame;
state.title = heading;
state.status = status;

installResizer(resizer);
}

function installResizer(resizer) {
let pointerId = null;

resizer.addEventListener('pointerdown', (event) => {
if (!state.open || window.innerWidth <= MOBILE_BREAKPOINT) return;
pointerId = event.pointerId;
resizer.setPointerCapture(pointerId);
document.body.classList.add('ld-double-column-resizing');
event.preventDefault();
});

resizer.addEventListener('pointermove', (event) => {
if (pointerId !== event.pointerId) return;
setWidth(window.innerWidth - event.clientX);
});

const stopResize = (event) => {
if (pointerId !== event.pointerId) return;
pointerId = null;
document.body.classList.remove('ld-double-column-resizing');
try {
resizer.releasePointerCapture(event.pointerId);
} catch {
// The pointer may already have been released by the browser.
}
};

resizer.addEventListener('pointerup', stopResize);
resizer.addEventListener('pointercancel', stopResize);
}

function setWidth(value) {
state.width = clampWidth(value);
document.documentElement.style.setProperty('--ld-preview-width', `${state.width}px`);
localStorage.setItem(WIDTH_KEY, String(state.width));
}

function setStatus(message) {
if (!state.status) return;
state.status.textContent = message;
state.status.hidden = !message;
}

function setPreviewHistory(url, replace) {
const base = new URL(state.parentUrl || location.href);
base.hash = `ld-preview=${encodeURIComponent(new URL(url).pathname)}`;
const previousState = history.state && typeof history.state === 'object' ? history.state : {};
const nextState = {
...previousState,
ldDoubleColumn: true,
ldPreviewUrl: url,
ldPreviewBase: state.parentUrl,
};

if (replace) history.replaceState(nextState, '', base.href);
else history.pushState(nextState, '', base.href);
}

function openPanel(url, options = {}) {
const normalizedUrl = normalizeTopicUrl(url);
if (!normalizedUrl) return;
ensurePanel();

if (!state.open) {
state.open = true;
state.fullscreen = false;
state.savedScrollY = window.scrollY;
state.parentUrl = location.href;
state.historyActive = !options.fromHistory;
document.body.classList.add('ld-double-column-open');
document.body.classList.remove('ld-double-column-fullscreen');
if (!options.fromHistory) setPreviewHistory(normalizedUrl, false);
requestAnimationFrame(() => window.scrollTo(0, state.savedScrollY));
} else if (!options.fromHistory && state.historyActive) {
setPreviewHistory(normalizedUrl, true);
}

state.currentUrl = normalizedUrl;
state.panel.classList.add('is-loading');
state.title.textContent = '正在加载帖子...';
state.title.title = normalizedUrl;
setStatus('正在加载帖子...');
state.frame.src = normalizedUrl;
}

function closePanel() {
if (!state.open) return;
const shouldGoBack = state.historyActive && history.state?.ldDoubleColumn && !state.closeFromHistory;
state.closeFromHistory = false;
state.open = false;
state.fullscreen = false;
state.currentUrl = '';
document.body.classList.remove('ld-double-column-open', 'ld-double-column-fullscreen');
state.panel.classList.remove('is-loading');
state.frame.removeAttribute('src');
setStatus('');
state.title.textContent = '帖子预览';
state.title.title = '帖子预览';
requestAnimationFrame(() => window.scrollTo(0, state.savedScrollY));

if (shouldGoBack) {
state.historyActive = false;
history.back();
}
}

function handleFrameLoad() {
state.panel?.classList.remove('is-loading');
setStatus('');

try {
const frameDocument = state.frame.contentDocument;
if (!frameDocument) throw new Error('preview document unavailable');

let style = frameDocument.getElementById(FRAME_STYLE_ID);
if (!style) {
style = frameDocument.createElement('style');
style.id = FRAME_STYLE_ID;
style.textContent = `
html, body { min-width: 0 !important; }
.d-header,
.sidebar-container,
.sidebar-wrapper,
.sidebar-hamburger,
.before-header-panel-outlet,
.topic-list { display: none !important; }
#main-outlet,
#topic,
.topic-area,
.topic-post,
.boxed { width: auto !important; max-width: none !important; margin-left: 0 !important; margin-right: 0 !important; }
#main-outlet { padding: 14px 20px 30px !important; }
.topic-post { border-left: 0 !important; border-right: 0 !important; }
`;
frameDocument.head.appendChild(style);
}

const topicTitle = frameDocument.querySelector('h1')?.textContent?.replace(/\s+/g, ' ').trim();
if (topicTitle) {
state.title.textContent = topicTitle;
state.title.title = topicTitle;
} else {
state.title.textContent = '帖子预览';
state.title.title = state.currentUrl;
}
} catch {
state.title.textContent = '帖子预览';
state.title.title = state.currentUrl;
}
}

function handleTopicClick(event) {
if (isModifiedClick(event) || event.defaultPrevented) return;
const rawTarget = event.target;
const target = rawTarget && typeof rawTarget.closest === 'function'
? rawTarget.closest('a[href]')
: null;
if (!target || target.closest(`#${PANEL_ID}`)) return;
if (!target.matches('a.title.raw-topic-link') && !target.closest('tr.topic-list-item')) return;

const topicUrl = normalizeTopicUrl(target.href);
if (!topicUrl) return;

event.preventDefault();
event.stopImmediatePropagation();
openPanel(topicUrl);
}

function handleHistoryChange() {
const historyState = history.state;
if (historyState?.ldDoubleColumn && historyState.ldPreviewUrl) {
openPanel(historyState.ldPreviewUrl, { fromHistory: true });
return;
}

if (state.open) {
state.closeFromHistory = true;
closePanel();
}
}

function start() {
addStyle();
window.addEventListener('click', handleTopicClick, true);
window.addEventListener('popstate', handleHistoryChange);
window.addEventListener('keydown', (event) => {
if (event.key === 'Escape' && state.open) closePanel();
});
setWidth(state.width);
}

if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', start, { once: true });
} else {
start();
}
})();

最新回复 (4)
  • X DO 08-05 17:45
    1

    已经用上了,挺方便的,总算不用跳来跳去或者鼠标移动上去点击了。

    不过目前对宽屏显示器不太友好,我自己用 codex 优化下

  • X DO 08-14 17:17
    2

    在佬的基础上做了一版简化,没有使用 iframe 替换,改成 json 渲染。


    https://raw.githubusercontent.com/ppanphper/linuxdo-double-column/main/linuxdo-double-column.user.jslinux 双栏预览脚本

  • wadax 08-19 10:57
    3

    能不能整一个实时显示最新帖子的油猴脚本

  • 火车嘟嘟 08-19 11:00
    4

    good 实测 好用 真不错 真不错

* 帖子来源Linux.do
返回