• 海夜为眠

    前面发了个帖,吐 L 站的回复有时候不太好跟:

    没人觉得论坛的回复看起来很不方便吗?

    引用、展开的回复和后面的楼层混在一起,同一段内容容易看好几遍。于是找 AI 帮忙折腾了个油猴脚本,样式又来回改了几轮,发出来给有同样困扰的佬试试。

    先看效果,下面是现在的 v4.2.0。

    桌面效果

    图片

    手机端效果

    图片

    入口在主帖下面的浏览量/参与者那一行,可以切两种模式:

    - 楼中楼:把同一串回复放到一起,显示“回复谁”,头像之间加了条浅灰色指引线。更深的回复也不继续往右挤,手机上能留够看正文的地方。重复的引用和展开预览会隐藏。

    - 引用:按原楼层顺序看,保留原来的引用;没引用的回复,会补上被回复评论的原文,已有同一条引用就不再补一份。

    主帖和论坛其他布局保留,点赞、回复还是原来的按钮。楼主标签、Boost、解决方案这些,也一起收了下大小和间距。

    右侧进度条也改了。楼中楼重新排过顺序,再拿原楼号当进度容易对不上,所以现在显示实际滚动的阅读百分比,可以点击、拖动。总帖数仍然包含楼中楼回复;长帖没全加载时,会写明“当前阅读段”和“已加载 N/共 M 帖”。手机上是个小进度标签,点开仍能按原楼号跳转。

    安装: 装好篡改猴 → 添加新脚本 → 用下面的完整代码替换默认内容 → 保存、刷新帖子。扩展记得允许访问 linux.do。旧版直接替换代码就行。

    手机的样式也做了适配,不过浏览器或扩展得支持用户脚本。想临时关掉,篡改猴菜单里有“开启 / 关闭评论整理”。

    实现方式,想看技术细节的佬可以展开

    复用论坛已有的评论节点和已加载数据,通过 CSS 调整显示顺序和样式,原来的操作事件保留。引用缺少父评论时才补取对应内容,请求串行并限制频率。

    长帖继续跟着论坛滚动加载,没有为了统计主评论数量去把整帖一次性拉下来。原始引用节点也保留,切换模式时再显示出来。

    不同主题下可能还有间距需要调。有排版问题可以贴截图,或者直接改源码,完整代码放下面。

    完整源码(v4.2.0):

    展开代码,复制全部内容到篡改猴```
    // ==UserScript==
    // @name Linux DO 楼中楼阅读
    // @namespace local.linuxdo.threaded-reading
    // @version 4.2.0
    // @description 楼中楼与引用切换、回复指引线和阅读进度,保留原生操作,适配手机。
    // @match https://linux.do/*
    // @run-at document-idle
    // @grant GM_addStyle
    // @grant GM_getValue
    // @grant GM_setValue
    // @grant GM_registerMenuCommand
    // @grant unsafeWindow
    // ==/UserScript==

    (() => {
    'use strict';

    GM_addStyle(`
    .post-stream.ldt-thread { display: flex; flex-direction: column; }
    .post-stream.ldt-thread > .ldt-modebar { order: 1; }
    .post-stream.ldt-active .embedded-posts,
    .post-stream.ldt-active .show-replies,
    .post-stream.ldt-active .post__collapse-button,
    .post-stream.ldt-active [data-ldt-depth] .reply-to-tab,
    .post-stream.ldt-thread [data-ldt-depth] .cooked aside.quote { display: none !important; }

    .ldt-active > [data-ldt-depth] .post-menu-area { margin: 6px 0 8px; }
    .ldt-active > [data-ldt-depth] .cooked p:last-of-type { margin-bottom: 0; }
    .ldt-active > [data-ldt-depth] .discourse-boosts__post-menu:not(:has(*)) { padding: 0; }
    .ldt-active > [data-ldt-depth] .names { flex-wrap: wrap; flex-direction: row; align-items: center; gap: 3px 6px; }
    .ldt-active > [data-ldt-depth] .names > .first { order: 0; margin: 0 !important; }
    .ldt-active > [data-ldt-depth] .names > .second,
    .ldt-active > [data-ldt-depth] .names > .user-title { order: 2; }
    .ldt-active > [data-ldt-depth] .names > .user-status-message-wrap { order: 1; }
    .ldt-active > .topic-owner[data-ldt-depth] .cooked::after { content: none !important; display: none !important; }
    .ldt-owner-label {
    order: 1; display: inline-flex !important; align-items: center; align-self: center; margin: 0 !important;
    font-size: 10px !important; font-weight: 400; line-height: 14px !important; height: 16px; box-sizing: border-box;
    color: var(--primary-medium, #777); border: 1px solid var(--primary-low, #ddd);
    border-radius: 3px; padding: 0 4px; white-space: nowrap;
    }

    / 指引线沿头像栏连接整组讨论,不随回复深度增加缩进。 /
    .ldt-thread > [data-ldt-root],
    .ldt-thread > [data-ldt-depth]:not([data-ldt-depth="0"]):not(.post-stream--cloaked) { position: relative; }
    .ldt-thread > [data-ldt-root]::before {
    content: ""; display: block; position: absolute; left: 23px; top: 74px; bottom: -8px;
    width: 2px; border-radius: 2px; background: var(--primary-low, #e2e4e7); pointer-events: none;
    }
    .ldt-thread > [data-ldt-first]:not(.post-stream--cloaked)::after {
    content: ""; display: block; position: absolute; left: -9px; top: 0; width: 15px; height: 28px;
    border-left: 2px solid var(--primary-low, #e2e4e7); border-bottom: 2px solid var(--primary-low, #e2e4e7);
    border-bottom-left-radius: 12px; box-sizing: border-box; pointer-events: none;
    }
    .ldt-thread > [data-ldt-depth]:not([data-ldt-depth="0"]):not([data-ldt-last]):not(.post-stream--cloaked)::before {
    content: ""; display: block; position: absolute; left: 25px; top: 49px; bottom: -5px;
    width: 2px; border-radius: 2px; background: var(--primary-low, #e2e4e7); pointer-events: none;
    }

    / 一条讨论只缩进一次;具体回复对象放在作者旁边。 /
    .post-stream.ldt-thread > [data-ldt-depth]:not([data-ldt-depth="0"]):not(.post-stream--cloaked) {
    --topic-avatar-width: 28px;
    margin-block: 0; margin-inline-start: 32px !important; padding: 0 12px;
    min-width: 0; box-sizing: border-box;
    background: var(--primary-very-low, #f7f8fa);
    }
    .post-stream.ldt-thread > [data-ldt-first] { border-radius: 9px 9px 0 0; margin-top: 8px !important; }
    .post-stream.ldt-thread > [data-ldt-last] { border-radius: 0 0 9px 9px; margin-bottom: 20px !important; padding-bottom: 6px; }
    .post-stream.ldt-thread > [data-ldt-first][data-ldt-last] { border-radius: 9px; }
    .ldt-thread > [data-ldt-depth]:not([data-ldt-depth="0"]) .topic-avatar {
    width: 28px; padding: 13px 0 0; margin: 0 !important; border: 0 !important; position: static;
    }
    .ldt-thread > [data-ldt-depth]:not([data-ldt-depth="0"]) .topic-avatar .avatar { width: 28px; height: 28px; }
    .ldt-thread > [data-ldt-depth]:not([data-ldt-depth="0"]) .avatar-flair {
    width: 12px; height: 12px; background-size: 12px; right: -2px; bottom: -2px;
    }
    .ldt-thread > [data-ldt-depth]:not([data-ldt-depth="0"]) .boxed { padding: 0; border: 0; }
    .ldt-thread > [data-ldt-depth]:not([data-ldt-depth="0"]) .topic-body { padding: 12px 0 9px; border: 0; }
    .ldt-thread > [data-ldt-depth]:not([data-ldt-depth="0"]) .contents { padding-top: 0; }
    .ldt-thread > [data-ldt-depth]:not([data-ldt-depth="0"]) .topic-meta-data { padding: 0 0 4px 10px; gap: 8px; }
    .ldt-thread > [data-ldt-depth]:not([data-ldt-depth="0"]) .names {
    flex-wrap: wrap; align-items: center; gap: 3px 7px; min-width: 0; font-size: 13px;
    }
    .ldt-thread > [data-ldt-depth]:not([data-ldt-depth="0"]) .names .second,
    .ldt-thread > [data-ldt-depth]:not([data-ldt-depth="0"]) .names .user-title { display: none; }
    .ldt-thread > [data-ldt-depth]:not([data-ldt-depth="0"]) .post-date { font-size: 12px; }
    .ldt-thread > [data-ldt-depth]:not([data-ldt-depth="0"]) .cooked {
    padding: 2px 0 0 10px; font-size: 15px; line-height: 1.65;
    }
    .ldt-thread > [data-ldt-depth]:not([data-ldt-depth="0"]) .cooked p { margin: 0 0 .6em; }
    .ldt-thread > [data-ldt-depth]:not([data-ldt-depth="0"]) .cooked > :last-child,
    .ldt-thread > [data-ldt-depth]:not([data-ldt-depth="0"]) .cooked p:last-of-type { margin-bottom: 0; }
    .ldt-thread > [data-ldt-depth]:not([data-ldt-depth="0"]) .post-menu-area { margin: 6px 0 0; padding: 0 0 0 10px; }
    .ldt-thread > [data-ldt-depth]:not([data-ldt-depth="0"]) .post-menu-area .btn {
    min-height: 30px; padding: 5px 6px; font-size: 12px; color: var(--primary-medium, #777);
    }
    .ldt-thread > [data-ldt-depth]:not([data-ldt-depth="0"]) .post-menu-area .d-icon { width: 14px; height: 14px; color: inherit; }
    .ldt-thread > [data-ldt-depth]:not([data-ldt-depth="0"]) .post-menu-area {
    display: grid; grid-template-columns: minmax(0, 1fr) auto;
    align-items: center; column-gap: 10px; row-gap: 0;
    }
    .ldt-thread > [data-ldt-depth]:not([data-ldt-depth="0"]) .post-controls { grid-column: 2; grid-row: 1; width: auto; margin: 0; }
    .ldt-thread > [data-ldt-depth]:not([data-ldt-depth="0"]) .actions { display: flex; align-items: center; gap: 4px; float: none; margin: 0; }
    .ldt-thread > [data-ldt-depth]:not([data-ldt-depth="0"]) .small-user-list { grid-column: 1 / -1; grid-row: 2; }
    .ldt-thread > [data-ldt-depth]:not([data-ldt-depth="0"]) .extra-buttons { display: inline-flex; align-items: center; margin: 0; }
    .ldt-thread > [data-ldt-depth]:not([data-ldt-depth="0"]) .accepted-text {
    display: inline-flex; align-items: center; gap: 4px; padding: 3px 7px;
    border: 1px solid color-mix(in srgb, var(--success, #38955b) 15%, var(--secondary, #fff));
    border-radius: 5px; box-sizing: border-box; font-size: 11px !important; line-height: 18px;
    color: color-mix(in srgb, var(--success, #38955b) 40%, var(--primary, #333));
    background: color-mix(in srgb, var(--success, #38955b) 8%, var(--secondary, #fff));
    }
    .ldt-thread > [data-ldt-depth]:not([data-ldt-depth="0"]) .accepted-label { font-size: 11px !important; line-height: 18px; }
    .ldt-thread > [data-ldt-depth]:not([data-ldt-depth="0"]) .accepted-text .d-icon { width: 12px; height: 12px; color: inherit; }
    .ldt-thread > [data-ldt-depth]:not([data-ldt-depth="0"]) .discourse-boosts__post-menu { grid-column: 1; grid-row: 1; padding: 0; min-width: 0; }
    .ldt-thread > [data-ldt-depth]:not([data-ldt-depth="0"]) .discourse-boosts__list { display: flex; flex-wrap: wrap; align-items: center; gap: 5px; }
    .ldt-thread > [data-ldt-depth]:not([data-ldt-depth="0"]) .discourse-boosts__bubble {
    display: inline-flex; align-items: center; gap: 5px; max-width: 100%;
    padding: 3px 7px; margin: 0; border: 1px solid var(--primary-low, #ddd);
    border-radius: 5px; background: var(--secondary, #fff); box-shadow: none;
    font-size: 12px; line-height: 18px; box-sizing: border-box;
    }
    .ldt-thread > [data-ldt-depth]:not([data-ldt-depth="0"]) .discourse-boosts__bubble > a { display: inline-flex; flex: 0 0 auto; }
    .ldt-thread > [data-ldt-depth]:not([data-ldt-depth="0"]) .discourse-boosts__bubble .avatar { width: 16px; height: 16px; }
    .ldt-thread > [data-ldt-depth]:not([data-ldt-depth="0"]) .discourse-boosts__cooked { min-width: 0; font-size: inherit; line-height: inherit; overflow-wrap: anywhere; }
    .ldt-thread > [data-ldt-depth]:not([data-ldt-depth="0"]) .discourse-boosts__cooked p { margin: 0; }
    .ldt-relation { order: 3; margin: 0 !important; font-size: 12px; font-weight: 400; color: var(--primary-medium, #777); line-height: 1.5; overflow-wrap: anywhere; }
    .post-stream:not(.ldt-thread) .ldt-relation { display: none; }

    / 融入原有统计行,不增加独立标题或大号切换面板。 /
    .ldt-modebar { display: inline-flex; align-items: center; margin-inline-start: auto; flex: 0 0 auto; }
    .ldt-modes { display: inline-flex; align-items: center; gap: 14px; }
    .ldt-modes .btn {
    position: relative; min-height: 32px; padding: 5px 0; border: 0;
    background: transparent !important; box-shadow: none !important;
    font-size: 12px; line-height: 1.5; color: var(--primary-medium, #777);
    }
    .ldt-modes .btn[aria-pressed="true"] { color: var(--tertiary, #3979a3); font-weight: 600; }
    .ldt-modes .btn[aria-pressed="true"]::after {
    content: ""; position: absolute; bottom: 2px; left: 0; right: 0;
    height: 2px; border-radius: 2px; background: currentColor;
    }
    .ldt-modes .btn:hover { color: var(--primary, #333); }
    .ldt-modes .btn:focus-visible { outline: 2px solid var(--tertiary, #3979a3); outline-offset: 4px; border-radius: 2px; }
    .topic-map__stats:has(> .ldt-modebar) .topic-map__users-list { min-width: 0; overflow: hidden; }
    .post-stream > .ldt-modebar { justify-content: flex-end; margin: 0; padding: 4px 0; }
    [data-ldt-native-progress] { display: none !important; }
    .ldt-reading-progress { display: flex; flex-direction: column; gap: 8px; height: 300px; width: 140px; max-width: 100%; }
    .ldt-progress-caption, .ldt-progress-total { font-size: 12px; line-height: 1.5; color: var(--primary-medium, #777); }
    .ldt-progress-total { font-size: 11px; }
    .ldt-progress-rail { position: relative; flex: 1; width: 100%; padding: 0; border: 0; background: transparent; touch-action: none; cursor: ns-resize; }
    .ldt-progress-rail::before { content: ""; position: absolute; left: 8px; top: 10px; bottom: 10px; width: 2px; background: var(--primary-low, #ddd); }
    .ldt-progress-fill { position: absolute; left: 8px; top: 10px; width: 2px; height: calc((100% - 20px) * var(--ldt-progress, 0)); background: var(--tertiary, #3979a3); }
    .ldt-progress-handle { position: absolute; left: 6px; top: calc((100% - 20px) * var(--ldt-progress, 0)); width: 6px; height: 20px; border-radius: 4px; background: var(--tertiary, #3979a3); }
    .ldt-progress-percent { position: absolute; left: 23px; top: calc((100% - 20px) * var(--ldt-progress, 0)); font-size: 16px; font-weight: 600; line-height: 20px; color: var(--primary, #333); }
    #topic-progress[data-ldt-mobile-progress] { display: flex; align-items: center; justify-content: center; box-sizing: border-box; height: auto; min-height: 40px; padding: 4px 10px; border: 1px solid var(--primary-low, #ddd); border-radius: 6px; background: var(--secondary, #fff); }
    #topic-progress[data-ldt-mobile-progress] > .nums,
    #topic-progress[data-ldt-mobile-progress] > .bg { display: none !important; }
    .ldt-progress-navigation { display: block; margin-bottom: 6px; color: var(--primary-medium, #777); font-size: 12px; }
    .ldt-progress-mobile { display: block; color: var(--primary, #333); font-size: 12px; font-weight: 600; line-height: 1.5; white-space: nowrap; }
    .ldt-progress-rail:focus-visible { outline: 2px solid var(--tertiary, #3979a3); outline-offset: 3px; border-radius: 4px; }
    .ldt-auto-quote blockquote { white-space: pre-wrap; overflow-wrap: anywhere; }
    @media (max-width: 640px) {
    .ldt-thread > [data-ldt-root] .cooked { padding-inline-start: 58px; }
    .ldt-thread > [data-ldt-root] .post-menu-area { margin-inline-start: 58px; }
    .ldt-thread > [data-ldt-first]:not(.post-stream--cloaked)::after { left: -5px; width: 9px; border-bottom-left-radius: 9px; }
    .ldt-thread > [data-ldt-depth]:not([data-ldt-depth="0"]):not([data-ldt-last]):not(.post-stream--cloaked)::before { left: 23px; }
    .post-stream.ldt-thread > [data-ldt-depth]:not([data-ldt-depth="0"]):not(.post-stream--cloaked) { margin-inline-start: 28px !important; padding-inline: 10px; }
    .ldt-thread > [data-ldt-depth]:not([data-ldt-depth="0"]) .topic-meta-data { padding-inline-start: 0; margin-inline-start: 38px; }
    .ldt-thread > [data-ldt-depth]:not([data-ldt-depth="0"]) .cooked { padding-inline-start: 38px; }
    .ldt-thread > [data-ldt-depth]:not([data-ldt-depth="0"]) .post-menu-area { padding-inline-start: 38px; }
    .ldt-thread > [data-ldt-depth]:not([data-ldt-depth="0"]) .post-menu-area .btn { min-height: 40px; min-width: 36px; font-size: 13px; }
    .ldt-thread > [data-ldt-depth]:not([data-ldt-depth="0"]) .ldt-relation { flex-basis: 100%; }
    .ldt-thread > [data-ldt-depth]:not([data-ldt-depth="0"]) .post-menu-area:has(.discourse-boosts__bubble) { grid-template-columns: minmax(0, 1fr); row-gap: 5px; }
    .ldt-thread > [data-ldt-depth]:not([data-ldt-depth="0"]) .post-menu-area:has(.discourse-boosts__bubble) .post-controls { grid-column: 1; grid-row: 2; justify-self: end; max-width: 100%; }
    .ldt-thread > [data-ldt-depth]:not([data-ldt-depth="0"]) .post-menu-area:has(.discourse-boosts__bubble) .small-user-list { grid-row: 3; }
    .ldt-thread > [data-ldt-depth]:not([data-ldt-depth="0"]) .actions { flex-wrap: wrap; justify-content: flex-end; }
    .ldt-modes { gap: 16px; }
    .ldt-modes .btn { min-height: 40px; min-width: 44px; justify-content: center; }
    }
    `);

    let mode = typeof GM_getValue === 'function' ? GM_getValue('reading-mode', 'thread') : 'thread';
    if (!['thread', 'quote'].includes(mode)) mode = 'thread';
    let enabled = true;
    let state;
    let timer;
    let progressFrame;
    const observer = new MutationObserver(schedule);
    const observe = () => observer.observe(document.body, { childList: true, subtree: true });

    function topicId() {
    const parts = location.pathname.split('/').filter(Boolean);
    return parts[0] === 't' ? Number(/^\d+$/.test(parts[1] || '') ? parts[1] : parts[2]) || 0 : 0;
    }

    function element(tag, className, text) {
    const node = document.createElement(tag);
    node.className = className;
    if (text !== undefined) node.textContent = text;
    return node;
    }

    function merge(s, posts) {
    for (const p of posts || []) {
    if (p.post_number > 0) s.posts.set(Number(p.post_number), {
    number: Number(p.post_number), parent: Number(p.reply_to_post_number) || 0,
    author: p.display_username || p.name || p.username || '', cooked: p.cooked || '',
    });
    }
    }

    // 直接复用论坛已加载的数据,评论 DOM 和原生事件保持不变。
    function snapshot(s) {
    try {
    const page = typeof unsafeWindow !== 'undefined' ? unsafeWindow : window;
    const model = page.Discourse?.lookup('controller:topic')?.model;
    if (Number(model?.id) !== s.id) return false;
    s.total = Number(model.postStream?.filteredPostsCount || model.posts_count) || 0;
    merge(s, model.postStream?.posts);
    return s.posts.size > 0;
    } catch { return false; }
    }

    async function load(s, parent = 0) {
    if (s.requested.has(parent)) return;
    s.requested.add(parent);
    s.queue = s.queue.then(async () => {
    const delay = Math.max(0, s.nextRequest - Date.now());
    if (delay) await new Promise(resolve => setTimeout(resolve, delay));
    if (s !== state || s.abort.signal.aborted) return;
    s.nextRequest = Date.now() + 1000;
    try {
    const response = await fetch(parent ? /t/${s.id}/${parent}.json : /t/${s.id}.json, {
    credentials: 'same-origin', signal: s.abort.signal,
    headers: { Accept: 'application/json', 'X-Requested-With': 'XMLHttpRequest' },
    });
    if (!response.ok) return;
    const data = await response.json();
    if (s !== state) return;
    s.total = Number(data.posts_count) || s.total;
    merge(s, data.post_stream?.posts);
    schedule();
    } catch { / 权限或网络失败时仍保留原生评论。 / }
    });
    }

    function layout(s, wrappers) {
    const posts = new Map(s.posts);
    for (const node of wrappers) {
    const n = Number(node.dataset.postNumber);
    if (!posts.has(n)) posts.set(n, { number: n, parent: 0 });
    }
    const children = new Map();
    const roots = [];
    for (const p of [...posts.values()].sort((a, b) => a.number - b.number)) {
    if (p.number === 1) continue;
    if (p.parent > 1 && p.parent < p.number && posts.has(p.parent)) {
    if (!children.has(p.parent)) children.set(p.parent, []);
    children.get(p.parent).push(p);
    } else roots.push(p);
    }
    const result = new Map();
    let rank = 2;
    for (const root of roots) {
    const discussion = [];
    function collect(p, depth) {
    discussion.push({ post: p, depth });
    for (const child of children.get(p.number) || []) collect(child, depth + 1);
    }
    collect(root, 0);
    discussion.sort((a, b) => a.post.number - b.post.number);
    for (const { post, depth } of discussion) result.set(post.number, { rank: rank++ * 10, depth, root: root.number });
    }
    return result;
    }

    function quoteText(cooked) {
    const body = document.createElement('div');
    body.innerHTML = cooked;
    body.querySelectorAll('aside.quote, script, style, iframe').forEach(n => n.remove());
    body.querySelectorAll('br').forEach(n => n.replaceWith('\n'));
    body.querySelectorAll('p, li, pre, h1, h2, h3, h4, h5, h6').forEach(n => n.append('\n'));
    body.querySelectorAll('img').forEach(n => n.replaceWith(n.alt || '[图片]'));
    return body.textContent.trim().replace(/\n{3,}/g, '\n\n');
    }

    function updateQuote(s, wrapper, post) {
    const cooked = [...wrapper.querySelectorAll('.topic-body > .contents > .cooked, .topic-body > .cooked')]
    .find(n => !n.closest('.embedded-posts'));
    if (!cooked) return;
    const owned = cooked.querySelector(':scope > .ldt-auto-quote');
    const authored = [...cooked.querySelectorAll('aside.quote:not(.ldt-auto-quote)')].some(q =>
    Number(q.dataset.post) === post?.parent && (!q.dataset.topic || Number(q.dataset.topic) === s.id));
    if (mode !== 'quote' || !post?.parent || authored) { owned?.remove(); return; }
    const parent = s.posts.get(post.parent);
    if (!parent?.cooked) { owned?.remove(); load(s, post.parent); return; }
    const text = quoteText(parent.cooked);
    if (!text) { owned?.remove(); return; }
    const signature = JSON.stringify([post.parent, parent.author, text]);
    if (owned?.dataset.signature === signature) return;
    owned?.remove();
    const quote = element('aside', 'quote ldt-auto-quote');
    quote.dataset.signature = signature;
    quote.dataset.post = post.parent;
    quote.dataset.topic = s.id;
    quote.append(element('div', 'title', ${parent.author || '被回复的评论'}:),
    element('blockquote', '', text));
    cooked.prepend(quote);
    }

    function modebar(s) {
    const bar = element('div', 'ldt-modebar');
    const group = element('div', 'ldt-modes');
    group.setAttribute('role', 'group');
    group.setAttribute('aria-label', '评论阅读方式');
    for (const [value, label] of [['thread', '楼中楼'], ['quote', '引用']]) {
    const button = element('button', 'btn btn-flat', label);
    button.type = 'button';
    button.dataset.mode = value;
    button.title = value === 'thread' ? '按讨论关系阅读' : '按楼层顺序和引用阅读';
    button.addEventListener('click', () => {
    const anchor = [...s.stream.querySelectorAll(':scope > [data-ldt-depth]')].find(n => n.getBoundingClientRect().bottom > 0);
    const top = anchor?.getBoundingClientRect().top;
    mode = value;
    if (typeof GM_setValue === 'function') GM_setValue('reading-mode', mode);
    refresh();
    if (anchor?.isConnected && top !== undefined) window.scrollBy(0, anchor.getBoundingClientRect().top - top);
    });
    group.append(button);
    }
    bar.append(group);
    return bar;
    }

    function clearProgress(s) {
    s.progress?.remove();
    s.progress = null;
    s.mobileProgress?.remove();
    s.mobileProgress = null;
    s.navigationNote?.remove();
    s.navigationNote = null;
    document.querySelectorAll('[data-ldt-native-progress], [data-ldt-mobile-progress]').forEach(n => { delete n.dataset.ldtNativeProgress; delete n.dataset.ldtMobileProgress; });
    }

    function updateProgress(s) {
    if (mode !== 'thread') { clearProgress(s); return; }
    const candidate = document.querySelector('.timeline-scrollarea');
    const fullscreen = candidate?.closest('.timeline-fullscreen');
    const native = fullscreen ? null : candidate;
    if (fullscreen) {
    delete candidate.dataset.ldtNativeProgress;
    if (!s.navigationNote) s.navigationNote = element('span', 'ldt-progress-navigation', '原楼号导航 · 含回复');
    if (candidate.previousElementSibling !== s.navigationNote) candidate.before(s.navigationNote);
    } else s.navigationNote?.remove();
    const mobile = document.querySelector('#topic-progress');
    if (!native) s.progress?.remove();
    if (!mobile) s.mobileProgress?.remove();
    if (!native && !mobile) return;
    if (native && !s.progress) {
    const panel = element('div', 'ldt-reading-progress');
    const rail = element('button', 'ldt-progress-rail');
    rail.type = 'button';
    rail.setAttribute('role', 'slider');
    rail.setAttribute('aria-label', '阅读位置');
    rail.setAttribute('aria-orientation', 'vertical');
    rail.setAttribute('aria-valuemin', '0');
    rail.setAttribute('aria-valuemax', '100');
    rail.append(element('span', 'ldt-progress-fill'), element('span', 'ldt-progress-handle'), element('span', 'ldt-progress-percent'));
    const jump = ratio => window.scrollTo({ top: s.progressStart + Math.max(0, Math.min(1, ratio)) * s.progressRange, behavior: 'auto' });
    const move = event => {
    const r = rail.getBoundingClientRect();
    if (r.height) jump((event.clientY - r.top - 10) / Math.max(1, r.height - 20));
    };
    rail.addEventListener('pointerdown', event => {
    if (event.button !== 0) return;
    event.preventDefault();
    rail.focus();
    rail.setPointerCapture(event.pointerId);
    move(event);
    });
    rail.addEventListener('pointermove', event => { if (rail.hasPointerCapture(event.pointerId)) move(event); });
    rail.addEventListener('keydown', event => {
    const value = Number(rail.getAttribute('aria-valuenow')) / 100;
    const target = { ArrowUp: value - .03, ArrowDown: value + .03, PageUp: value - .15, PageDown: value + .15, Home: 0, End: 1 }[event.key];
    if (target !== undefined) { event.preventDefault(); jump(target); }
    });
    panel.append(element('span', 'ldt-progress-caption'), rail, element('span', 'ldt-progress-total'));
    s.progress = panel;
    }
    if (native) {
    native.dataset.ldtNativeProgress = '';
    if (native.nextElementSibling !== s.progress) native.after(s.progress);
    s.progress.style.height = native.style.height || '300px';
    }
    const rect = s.stream.getBoundingClientRect();
    const header = Math.max(0, document.querySelector('.d-header')?.getBoundingClientRect().bottom || 0);
    s.progressStart = Math.max(0, rect.top + window.scrollY - header);
    s.progressRange = Math.max(0, rect.height - Math.max(1, window.innerHeight - header));
    const ratio = s.progressRange ? Math.max(0, Math.min(1, (window.scrollY - s.progressStart) / s.progressRange)) : (rect.bottom <= window.innerHeight ? 1 : 0);
    const loaded = s.stream.querySelectorAll(':scope > [data-post-number]').length;
    const partial = !s.total || loaded < s.total;
    const count = s.total ? (partial ? 已加载 ${loaded} / 共 ${s.total} 帖 : 共 ${s.total} 帖 · 含回复) : 已加载 ${loaded} 帖 · 含回复;
    const percent = Math.round(ratio * 100);
    if (native) {
    for (const [selector, text] of [['.ldt-progress-caption', partial ? '当前阅读段' : '阅读进度'], ['.ldt-progress-total', count], ['.ldt-progress-percent', ${percent}%]]) {
    const node = s.progress.querySelector(selector);
    if (node.textContent !== text) node.textContent = text;
    }
    const rail = s.progress.querySelector('.ldt-progress-rail');
    rail.style.setProperty('--ldt-progress', ratio);
    rail.setAttribute('aria-valuenow', percent);
    rail.setAttribute('aria-valuetext', ${partial ? '当前阅读段' : '阅读进度'} ${percent}%,${count});
    }
    if (mobile) {
    if (!s.mobileProgress) s.mobileProgress = element('span', 'ldt-progress-mobile');
    mobile.dataset.ldtMobileProgress = '';
    if (s.mobileProgress.parentElement !== mobile) mobile.append(s.mobileProgress);
    const text = ${partial ? '本段' : '阅读'} ${percent}% · ${partial && s.total ? ${loaded}/${s.total} : s.total || loaded}帖;
    if (s.mobileProgress.textContent !== text) s.mobileProgress.textContent = text;
    s.mobileProgress.title = ${partial ? '当前阅读段' : '阅读进度'},${count};
    }
    }

    function scheduleProgress() {
    if (!state || mode !== 'thread' || progressFrame) return;
    progressFrame = requestAnimationFrame(() => { progressFrame = 0; if (state) updateProgress(state); });
    }

    function cleanup() {
    if (!state) return;
    state.abort.abort();
    clearProgress(state);
    state.bar.remove();
    state.style.remove();
    const stream = state.stream;
    stream.classList.remove('ldt-active', 'ldt-thread');
    stream.querySelectorAll('.ldt-relation, .ldt-owner-label, .ldt-auto-quote').forEach(n => n.remove());
    for (const n of stream.children) {
    delete n.dataset.ldtDepth;
    delete n.dataset.ldtRank;
    delete n.dataset.ldtFirst;
    delete n.dataset.ldtLast;
    delete n.dataset.ldtRoot;
    }
    state = null;
    }

    function refresh() {
    observer.disconnect();
    const id = topicId();
    const stream = document.querySelector('.post-stream');
    if (!enabled || !id || !stream) { cleanup(); observe(); return; }
    if (state?.id !== id || state.stream !== stream) {
    cleanup();
    state = { id, stream, posts: new Map(), requested: new Set(), abort: new AbortController(), queue: Promise.resolve(), nextRequest: 0 };
    state.bar = modebar(state);
    state.style = element('style', 'ldt-layout');
    document.head.append(state.style);
    }
    const s = state;
    if (!snapshot(s) && !s.posts.size) load(s);
    const wrappers = [...stream.children].filter(n => Number(n.dataset.postNumber) > 0);
    const positions = layout(s, wrappers);
    stream.classList.add('ldt-active');
    stream.classList.toggle('ldt-thread', mode === 'thread');
    const op = wrappers.find(n => Number(n.dataset.postNumber) === 1);
    const stats = op?.querySelector('.topic-map__stats');
    if (stats && s.bar.parentElement !== stats) stats.append(s.bar);
    else if (!stats && op && op.nextElementSibling !== s.bar) op.after(s.bar);
    else if (!stats && !op && stream.firstElementChild !== s.bar) stream.prepend(s.bar);
    s.bar.querySelectorAll('button').forEach(b => b.setAttribute('aria-pressed', String(b.dataset.mode === mode)));
    const rules = [];
    const branches = new Map();
    for (const wrapper of wrappers) {
    const number = Number(wrapper.dataset.postNumber);
    if (number === 1) continue;
    const post = s.posts.get(number);
    const position = positions.get(number);
    wrapper.dataset.ldtDepth = position.depth;
    rules.push(.post-stream.ldt-thread > [data-post-number="${number}"] { order: ${position.rank}; });
    delete wrapper.dataset.ldtFirst;
    delete wrapper.dataset.ldtLast;
    delete wrapper.dataset.ldtRoot;
    if (position.depth > 0) {
    if (!branches.has(position.root)) branches.set(position.root, []);
    branches.get(position.root).push(wrapper);
    }
    const names = [...wrapper.querySelectorAll('.topic-body > .topic-meta-data .names')]
    .find(n => !n.closest('.embedded-posts'));
    let badge = names?.querySelector('.ldt-owner-label');
    if (names && wrapper.classList.contains('topic-owner')) {
    if (!badge) badge = element('span', 'ldt-owner-label', '楼主');
    const author = names.querySelector(':scope > .first');
    if (author && author.nextElementSibling !== badge) author.after(badge);
    else if (!author && badge.parentElement !== names) names.append(badge);
    } else badge?.remove();
    let relation = wrapper.querySelector('.ldt-relation');
    if (post?.parent > 1 && names) {
    if (!relation) { relation = element('span', 'ldt-relation'); names.append(relation); }
    const parent = s.posts.get(post.parent);
    const label = parent?.author ? 回复 ${parent.author} : '回复此前的评论';
    if (relation.textContent !== label) relation.textContent = label;
    } else relation?.remove();
    updateQuote(s, wrapper, post);
    }
    for (const [root, replies] of branches) {
    const parent = wrappers.find(n => Number(n.dataset.postNumber) === root);
    if (parent && !parent.classList.contains('post-stream--cloaked')) parent.dataset.ldtRoot = '';
    replies.sort((a, b) => positions.get(Number(a.dataset.postNumber)).rank - positions.get(Number(b.dataset.postNumber)).rank);
    replies[0].dataset.ldtFirst = '';
    replies.at(-1).dataset.ldtLast = '';
    }
    // 原生加载边界、时间分隔线仍跟随对应楼层,不参与回复关系排序。
    const children = [...stream.children];
    children.forEach((node, i) => {
    if (node === s.bar || node.dataset.postNumber) return;
    const next = children.slice(i + 1).find(n => Number(n.dataset.postNumber) > 0);
    node.dataset.ldtRank = i;
    const rank = next ? (positions.get(Number(next.dataset.postNumber))?.rank || 0) - 1 : (positions.size + 3) * 10;
    rules.push(.post-stream.ldt-thread > [data-ldt-rank="${i}"] { order: ${rank}; });
    });
    const css = rules.join('\n');
    if (s.style.textContent !== css) s.style.textContent = css;
    updateProgress(s);
    observe();
    }

    function schedule() { clearTimeout(timer); timer = setTimeout(refresh, 80); }
    if (typeof GM_registerMenuCommand === 'function') GM_registerMenuCommand('开启 / 关闭评论整理', () => { enabled = !enabled; refresh(); });
    window.addEventListener('popstate', schedule);
    window.addEventListener('scroll', scheduleProgress, { passive: true });
    window.addEventListener('resize', scheduleProgress);
    setInterval(() => { if (enabled && topicId() !== (state?.id || 0)) schedule(); }, 1000);
    refresh();
    })();

    主楼
  • 文月

    之前有段时间还原生自带这种看帖模式的,唉

    #1
  • 拾叁

    图片

    已应用 ^-^

    #2
  • 海夜为眠

    唉,加了脚本现在看舒服多了。。

    #3
  • lekko

    哎我记得站里不是支持 nested 风格吗?怎么找不到了

    #4
  • zhengnl

    已经严肃使用,好用

    #5
  • BadWomen

    图片

    已经严肃使用,很好用谢谢佬

    #6
  • 霜枫_王

    以前好像有一个自带看贴的脚本,现在找不到了,大佬辛苦你也整一个。

    #7
  • HaxIOX

    已严肃使用,感谢佬

    #8
  • Blazer

    这个好啊,不用一个个翻下去了

    #9
  • Paulwalker

    一直都觉得这个很好,没明白l站楼中楼为什么这样设计

    #10
  • Adularia_Z

    已严肃使用 效果超棒! 感谢佬

    #11
  • 海夜为眠

    对的,没找到

    #12
  • 海夜为眠

    自带看贴是啥意思

    #13
  • Kevin9010

    这个脚本确实很舒服

    #14
  • Johnnnnn

    非常好用,感谢佬的分享!

    #15
  • Asuna

    太好用了,感谢佬友分享 ^-^

    #16
  • IAmor

    舒服了 太舒服了 佬牛逼~

    #17
  • Wlong_yan

    使用了!谢谢佬

    #18
  • L_Ysunshine123

    这个确实好啊,不用点开下拉箭头在看了,直接一目了然,牛的佬

    #19

发表回复

登录后回复