Preview Linux.do topics in a right-side drawer without leaving the current page.
// ==UserScript==
// @name Linux.do SidePeek
// @namespace https://github.com/BobDLA/linux-do-sidepeek
// @version 0.6.0
// @description Preview Linux.do topics in a right-side drawer without leaving the current page.
// @author Linux.do SidePeek
// @match https://linux.do/*
// @run-at document-idle
// @grant none
// @license MIT
// @homepageURL https://github.com/BobDLA/linux-do-sidepeek
// ==/UserScript==
(function () {
"use strict";
// --- Inject CSS ---
const styleEl = document.createElement("style");
styleEl.textContent = `
:root {
--ld-drawer-width: clamp(360px, 42vw, 920px);
--ld-post-body-font-size: 15px;
--ld-image-preview-scale: 1;
--ld-topic-tracker-left: 50vw;
--ld-topic-tracker-top: 112px;
--ld-topic-tracker-max-width: min(720px, calc(100vw - 32px));
}
body.ld-drawer-page-open {
padding-right: var(--ld-drawer-width) !important;
transition: padding-right 0.2s ease;
}
body.ld-drawer-page-open.ld-drawer-mode-overlay {
padding-right: 0 !important;
}
body.ld-drawer-page-open.ld-drawer-mode-overlay::after {
content: "";
position: fixed;
inset: 0;
z-index: 2147483646;
background: rgba(15, 23, 42, 0.18);
backdrop-filter: blur(1px);
}
body.ld-drawer-resizing,
body.ld-drawer-resizing * {
cursor: ew-resize !important;
user-select: none !important;
}
#ld-drawer-root {
position: fixed;
inset: 0 0 0 auto;
width: var(--ld-drawer-width);
z-index: 2147483647;
transform: translateX(100%);
transition: transform 0.2s ease;
color: var(--primary, #1f2937);
pointer-events: none;
}
body.ld-drawer-resizing #ld-drawer-root,
body.ld-drawer-resizing {
transition: none !important;
}
body.ld-drawer-page-open #ld-drawer-root {
transform: translateX(0);
}
#ld-drawer-root .ld-drawer-resize-handle {
position: absolute;
inset: 0 auto 0 0;
width: 12px;
transform: translateX(-50%);
cursor: ew-resize;
pointer-events: auto;
}
#ld-drawer-root .ld-drawer-resize-handle::before {
content: "";
position: absolute;
inset: 0 4px;
background: transparent;
}
#ld-drawer-root .ld-drawer-resize-handle:hover::before {
background: color-mix(in srgb, var(--tertiary, #3b82f6) 30%, transparent);
}
#ld-drawer-root .ld-drawer-shell {
position: relative;
height: 100%;
display: flex;
flex-direction: row;
background: var(--secondary, #ffffff);
border-left: 1px solid var(--primary-low, rgba(15, 23, 42, 0.12));
box-shadow: -16px 0 40px rgba(15, 23, 42, 0.18);
pointer-events: auto;
}
#ld-drawer-root .ld-drawer-side-actions {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 2px;
padding: 8px 5px;
flex-shrink: 0;
border-right: 1px solid var(--primary-low, rgba(15, 23, 42, 0.10));
background: color-mix(in srgb, var(--secondary, #fff) 97%, var(--primary-low, rgba(15, 23, 42, 0.04)));
z-index: 3;
}
#ld-drawer-root .ld-update-popup {
display: none;
position: absolute;
right: 12px;
bottom: max(12px, env(safe-area-inset-bottom, 0px) + 8px);
width: min(260px, calc(100% - 24px));
pointer-events: auto;
z-index: 2147483646;
padding: 10px 10px 10px;
border-radius: 12px;
background: color-mix(in srgb, var(--secondary, #fff) 90%, transparent);
border: 1px solid var(--primary-low, rgba(15, 23, 42, 0.14));
box-shadow: 0 18px 50px rgba(15, 23, 42, 0.22);
backdrop-filter: blur(10px);
flex-direction: column;
gap: 8px;
}
#ld-drawer-root .ld-update-popup.is-visible {
display: flex;
}
#ld-drawer-root .ld-update-popup-text {
font-size: 12px;
line-height: 1.3;
color: var(--primary-low, rgba(15, 23, 42, 0.86));
}
#ld-drawer-root .ld-update-popup-actions {
display: flex;
gap: 8px;
justify-content: flex-end;
align-items: center;
}
#ld-drawer-root .ld-update-popup-link {
display: inline-flex;
align-items: center;
justify-content: center;
height: 30px;
padding: 0 12px;
border-radius: 10px;
background: color-mix(in srgb, var(--tertiary, #3b82f6) 18%, transparent);
color: var(--tertiary, #3b82f6);
border: 1px solid color-mix(in srgb, var(--tertiary, #3b82f6) 30%, transparent);
text-decoration: none;
font-size: 12px;
font-weight: 600;
cursor: pointer;
transition: background-color 0.15s ease, border-color 0.15s ease;
white-space: nowrap;
}
#ld-drawer-root .ld-update-popup-link:hover {
background: color-mix(in srgb, var(--tertiary, #3b82f6) 28%, transparent);
border-color: color-mix(in srgb, var(--tertiary, #3b82f6) 48%, transparent);
}
#ld-drawer-root .ld-update-popup-close {
width: 30px;
height: 30px;
border-radius: 10px;
background: transparent;
color: var(--primary-low, rgba(15, 23, 42, 0.62));
border: 1px solid var(--primary-low, rgba(15, 23, 42, 0.12));
cursor: pointer;
font-size: 14px;
line-height: 1;
transition: background-color 0.15s ease, border-color 0.15s ease, color 0.15s ease;
}
#ld-drawer-root .ld-update-popup-close:hover {
background: color-mix(in srgb, var(--secondary, #fff) 70%, transparent);
border-color: var(--primary-low, rgba(15, 23, 42, 0.22));
color: var(--primary, #1f2937);
}
#ld-drawer-root .ld-side-divider {
width: 20px;
height: 1px;
margin: 4px 0;
background: var(--primary-low, rgba(15, 23, 42, 0.14));
flex-shrink: 0;
}
#ld-drawer-root .ld-drawer-side-actions .ld-drawer-nav,
#ld-drawer-root .ld-drawer-side-actions .ld-drawer-refresh,
#ld-drawer-root .ld-drawer-side-actions .ld-drawer-reply-toggle,
#ld-drawer-root .ld-drawer-side-actions .ld-drawer-link,
#ld-drawer-root .ld-drawer-side-actions .ld-drawer-close,
#ld-drawer-root .ld-drawer-side-actions .ld-drawer-settings-toggle {
display: flex;
align-items: center;
justify-content: center;
width: 32px;
height: 32px;
padding: 0;
border-radius: 8px;
flex-shrink: 0;
}
#ld-drawer-root .ld-drawer-side-actions svg {
width: 16px;
height: 16px;
display: block;
flex-shrink: 0;
}
#ld-drawer-root .ld-drawer-side-actions [data-tooltip] {
position: relative;
}
#ld-drawer-root .ld-drawer-side-actions [data-tooltip]::after {
content: attr(data-tooltip);
position: absolute;
left: calc(100% + 10px);
top: 50%;
transform: translateY(-50%);
white-space: nowrap;
background: rgba(15, 23, 42, 0.86);
color: #fff;
font-size: 12px;
line-height: 1;
padding: 5px 9px;
border-radius: 6px;
pointer-events: none;
opacity: 0;
transition: opacity 0.15s ease;
z-index: 100;
}
#ld-drawer-root .ld-drawer-side-actions [data-tooltip]:hover::after {
opacity: 1;
}
#ld-drawer-root .ld-drawer-side-actions .ld-drawer-reply-toggle {
border-color: color-mix(in srgb, var(--tertiary, #3b82f6) 30%, var(--primary-low, rgba(15, 23, 42, 0.12)));
background: color-mix(in srgb, var(--tertiary, #3b82f6) 10%, var(--secondary, #fff));
color: var(--tertiary, #3b82f6);
}
#ld-drawer-root .ld-drawer-side-actions .ld-drawer-reply-toggle[aria-expanded="true"] {
border-color: var(--tertiary, #3b82f6);
background: color-mix(in srgb, var(--tertiary, #3b82f6) 16%, var(--secondary, #fff));
}
#ld-drawer-root .ld-drawer-side-actions .ld-drawer-reply-toggle:disabled,
#ld-drawer-root .ld-drawer-side-actions .ld-drawer-reply-toggle.is-disabled {
opacity: 0.64;
cursor: not-allowed;
}
#ld-drawer-root .ld-drawer-refresh.is-refreshing svg {
animation: ld-spin 0.8s linear infinite;
transform-origin: center;
}
@keyframes ld-spin {
from { transform: rotate(0deg); }
to { transform: rotate(360deg); }
}
#ld-drawer-root .ld-drawer-main {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
position: relative;
overflow: hidden;
}
#ld-drawer-root .ld-image-preview {
position: absolute;
inset: 0;
z-index: 8;
display: flex;
align-items: center;
justify-content: center;
padding: 48px 18px 18px;
background: rgba(15, 23, 42, 0.72);
backdrop-filter: blur(6px);
opacity: 1;
animation: ld-image-preview-fade-in 180ms ease-out;
}
#ld-drawer-root .ld-image-preview[hidden] {
display: none !important;
}
#ld-drawer-root .ld-image-preview-close {
position: absolute;
top: 14px;
right: 18px;
border: 1px solid rgba(255, 255, 255, 0.28);
background: rgba(15, 23, 42, 0.4);
color: #fff;
border-radius: 999px;
padding: 7px 11px;
font-size: 12px;
line-height: 1;
cursor: pointer;
transition: background-color 0.18s ease, border-color 0.18s ease, transform 0.18s ease;
}
#ld-drawer-root .ld-image-preview-close:hover {
background: rgba(15, 23, 42, 0.58);
border-color: rgba(255, 255, 255, 0.44);
transform: translateY(-1px);
}
#ld-drawer-root .ld-image-preview-stage {
display: flex;
align-items: center;
justify-content: center;
width: 100%;
height: 100%;
overflow: hidden;
}
#ld-drawer-root .ld-image-preview-image {
display: block;
max-width: 100%;
max-height: 100%;
object-fit: contain;
border-radius: 14px;
box-shadow: 0 20px 48px rgba(0, 0, 0, 0.38);
background: rgba(255, 255, 255, 0.04);
opacity: 0;
transform: translateY(10px) scale(calc(0.985 * var(--ld-image-preview-scale, 1)));
transition: opacity 0.2s ease, transform 0.24s ease, box-shadow 0.24s ease;
transform-origin: center center;
will-change: transform;
}
#ld-drawer-root .ld-image-preview-image.is-ready {
opacity: 1;
transform: translateY(0) scale(var(--ld-image-preview-scale, 1));
}
#ld-drawer-root .ld-image-preview.is-zoomed {
cursor: zoom-out;
}
#ld-drawer-root .ld-image-preview.is-zoomed .ld-image-preview-image {
box-shadow: 0 24px 54px rgba(0, 0, 0, 0.44);
}
#ld-drawer-root .ld-drawer-header {
position: sticky;
top: 0;
z-index: 2;
display: grid;
grid-template-columns: minmax(0, 1fr);
gap: 10px;
align-items: start;
padding: 14px 18px 12px;
background: color-mix(in srgb, var(--secondary, #fff) 92%, transparent);
backdrop-filter: blur(10px);
border-bottom: 1px solid var(--primary-low, rgba(15, 23, 42, 0.08));
}
#ld-drawer-root .ld-drawer-title-group {
min-width: 0;
width: 100%;
display: grid;
gap: 4px;
}
#ld-drawer-root .ld-drawer-eyebrow {
font-size: 12px;
line-height: 1;
letter-spacing: 0.08em;
color: var(--tertiary, #3b82f6);
}
#ld-drawer-root .ld-drawer-title {
margin: 0;
min-width: 0;
font-size: 20px;
line-height: 1.25;
font-weight: 700;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
#ld-drawer-root .ld-drawer-toolbar {
display: grid;
width: 100%;
grid-template-columns: auto minmax(0, 1fr);
align-items: start;
gap: 8px 12px;
min-width: 0;
}
#ld-drawer-root .ld-drawer-meta {
min-width: 0;
max-width: 100%;
color: var(--primary-medium, rgba(15, 23, 42, 0.64));
font-size: 13px;
line-height: 1.35;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
#ld-drawer-root .ld-drawer-meta:empty {
display: none;
}
#ld-drawer-root .ld-drawer-actions {
display: flex;
min-width: 0;
align-items: center;
flex-wrap: wrap;
justify-content: flex-end;
gap: 8px;
}
#ld-drawer-root .ld-drawer-nav,
#ld-drawer-root .ld-drawer-refresh,
#ld-drawer-root .ld-drawer-reply-toggle,
#ld-drawer-root .ld-drawer-link,
#ld-drawer-root .ld-drawer-close,
#ld-drawer-root .ld-drawer-settings-toggle,
#ld-drawer-root .ld-settings-close,
#ld-drawer-root .ld-settings-reset {
border: 1px solid var(--primary-low, rgba(15, 23, 42, 0.12));
background: var(--secondary, #fff);
color: inherit;
border-radius: 999px;
padding: 7px 11px;
font-size: 12px;
line-height: 1;
cursor: pointer;
text-decoration: none;
}
#ld-drawer-root .ld-drawer-nav:disabled,
#ld-drawer-root .ld-drawer-refresh:disabled,
#ld-drawer-root .ld-drawer-reply-toggle:disabled {
opacity: 0.48;
cursor: not-allowed;
}
#ld-drawer-root .ld-drawer-nav:disabled:hover,
#ld-drawer-root .ld-drawer-refresh:disabled:hover,
#ld-drawer-root .ld-drawer-reply-toggle:disabled:hover {
border-color: var(--primary-low, rgba(15, 23, 42, 0.12));
color: inherit;
}
#ld-drawer-root .ld-drawer-close:hover,
#ld-drawer-root .ld-drawer-nav:hover,
#ld-drawer-root .ld-drawer-refresh:hover,
#ld-drawer-root .ld-drawer-reply-toggle:hover,
#ld-drawer-root .ld-drawer-link:hover,
#ld-drawer-root .ld-drawer-settings-toggle:hover,
#ld-drawer-root .ld-settings-close:hover,
#ld-drawer-root .ld-settings-reset:hover {
border-color: var(--tertiary, #3b82f6);
color: var(--tertiary, #3b82f6);
}
#ld-drawer-root .ld-drawer-settings-toggle[aria-expanded="true"] {
border-color: var(--tertiary, #3b82f6);
color: var(--tertiary, #3b82f6);
background: color-mix(in srgb, var(--tertiary, #3b82f6) 10%, var(--secondary, #fff));
}
#ld-drawer-root .ld-drawer-settings {
position: absolute;
inset: 0;
z-index: 6;
display: flex;
align-items: flex-start;
justify-content: center;
padding: calc(var(--ld-settings-top, 84px) + 8px) 18px 18px;
background: rgba(15, 23, 42, 0.14);
backdrop-filter: blur(2px);
}
#ld-drawer-root .ld-drawer-settings[hidden] {
display: none !important;
}
#ld-drawer-root .ld-drawer-settings-card {
display: grid;
grid-template-columns: 1fr;
gap: 12px;
width: min(420px, 100%);
max-height: 100%;
overflow: auto;
padding: 14px;
border: 1px solid var(--primary-low, rgba(15, 23, 42, 0.12));
border-radius: 18px;
box-shadow: 0 16px 38px rgba(15, 23, 42, 0.18);
background: color-mix(in srgb, var(--secondary, #fff) 98%, var(--primary-low, rgba(15, 23, 42, 0.04)));
}
#ld-drawer-root .ld-settings-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
}
#ld-drawer-root .ld-settings-title {
font-size: 14px;
font-weight: 700;
}
#ld-drawer-root .ld-settings-close {
flex-shrink: 0;
}
#ld-drawer-root .ld-setting-field {
display: grid;
gap: 6px;
}
#ld-drawer-root .ld-setting-field.is-disabled {
opacity: 0.58;
}
#ld-drawer-root .ld-setting-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
}
#ld-drawer-root .ld-setting-label {
font-size: 12px;
color: var(--primary-medium, rgba(15, 23, 42, 0.64));
}
#ld-drawer-root .ld-setting-value {
color: var(--primary-medium, rgba(15, 23, 42, 0.72));
font-size: 12px;
font-variant-numeric: tabular-nums;
}
#ld-drawer-root .ld-setting-hint {
font-size: 11px;
color: var(--primary-medium, rgba(15, 23, 42, 0.56));
}
#ld-drawer-root .ld-setting-control {
width: 100%;
min-width: 0;
border: 1px solid var(--primary-low, rgba(15, 23, 42, 0.14));
border-radius: 12px;
background: var(--secondary, #fff);
color: inherit;
font-size: 13px;
padding: 10px 12px;
}
#ld-drawer-root .ld-setting-range {
width: 100%;
margin: 0;
accent-color: var(--tertiary, #3b82f6);
cursor: pointer;
}
#ld-drawer-root .ld-setting-field.is-disabled .ld-setting-range {
cursor: not-allowed;
}
#ld-drawer-root .ld-setting-range:focus-visible {
outline: 2px solid color-mix(in srgb, var(--tertiary, #3b82f6) 24%, transparent);
outline-offset: 3px;
border-radius: 999px;
}
#ld-drawer-root .ld-settings-reset {
align-self: end;
justify-self: end;
}
#ld-drawer-root .ld-drawer-body {
flex: 1;
min-height: 0;
overflow-y: auto;
overflow-x: hidden;
}
#ld-drawer-root .ld-drawer-body,
#ld-drawer-root .ld-drawer-settings-card,
#ld-drawer-root .ld-drawer-reply-panel {
overscroll-behavior: contain;
scrollbar-width: thin;
scrollbar-color: color-mix(in srgb, var(--tertiary, #3b82f6) 40%, transparent) transparent;
}
#ld-drawer-root .ld-drawer-body::-webkit-scrollbar,
#ld-drawer-root .ld-drawer-settings-card::-webkit-scrollbar,
#ld-drawer-root .ld-drawer-reply-panel::-webkit-scrollbar {
width: 6px;
height: 6px;
}
#ld-drawer-root .ld-drawer-body::-webkit-scrollbar-track,
#ld-drawer-root .ld-drawer-settings-card::-webkit-scrollbar-track,
#ld-drawer-root .ld-drawer-reply-panel::-webkit-scrollbar-track {
background: transparent;
}
#ld-drawer-root .ld-drawer-body::-webkit-scrollbar-thumb,
#ld-drawer-root .ld-drawer-settings-card::-webkit-scrollbar-thumb,
#ld-drawer-root .ld-drawer-reply-panel::-webkit-scrollbar-thumb {
background: color-mix(in srgb, var(--tertiary, #3b82f6) 40%, transparent);
border-radius: 999px;
}
#ld-drawer-root .ld-drawer-body::-webkit-scrollbar-thumb:hover,
#ld-drawer-root .ld-drawer-settings-card::-webkit-scrollbar-thumb:hover,
#ld-drawer-root .ld-drawer-reply-panel::-webkit-scrollbar-thumb:hover {
background: color-mix(in srgb, var(--tertiary, #3b82f6) 64%, transparent);
}
#ld-drawer-root .ld-drawer-content {
padding: 16px 18px 28px;
}
#ld-drawer-root.ld-drawer-iframe-mode .ld-drawer-body {
overflow: hidden;
}
#ld-drawer-root.ld-drawer-iframe-mode .ld-drawer-content {
height: 100%;
min-height: 0;
display: flex;
padding: 12px;
}
#ld-drawer-root.ld-drawer-iframe-mode .ld-drawer-reply-fab,
#ld-drawer-root.ld-drawer-iframe-mode .ld-drawer-reply-panel {
display: none !important;
}
#ld-drawer-root .ld-drawer-reply-fab[hidden] {
display: none !important;
}
#ld-drawer-root .ld-drawer-reply-fab {
position: absolute;
top: 50%;
right: 14px;
z-index: 5;
display: inline-flex;
align-items: center;
gap: 8px;
transform: translateY(-50%);
border: 1px solid color-mix(in srgb, var(--tertiary, #3b82f6) 24%, var(--primary-low, rgba(15, 23, 42, 0.12)));
border-radius: 999px;
background: color-mix(in srgb, var(--secondary, #fff) 82%, transparent);
color: var(--tertiary, #3b82f6);
box-shadow: 0 14px 36px rgba(15, 23, 42, 0.18);
backdrop-filter: blur(12px);
padding: 10px 12px;
cursor: pointer;
transition: transform 0.18s ease, box-shadow 0.18s ease, border-color 0.18s ease;
}
#ld-drawer-root .ld-drawer-reply-fab:hover {
transform: translateY(calc(-50% - 1px));
box-shadow: 0 18px 40px rgba(15, 23, 42, 0.22);
border-color: var(--tertiary, #3b82f6);
}
#ld-drawer-root .ld-drawer-reply-fab[aria-expanded="true"] {
border-color: var(--tertiary, #3b82f6);
background: color-mix(in srgb, var(--tertiary, #3b82f6) 12%, var(--secondary, #fff));
}
#ld-drawer-root .ld-drawer-reply-fab:disabled,
#ld-drawer-root .ld-drawer-reply-fab.is-disabled {
opacity: 0.64;
cursor: not-allowed;
}
#ld-drawer-root .ld-drawer-reply-fab-icon {
width: 18px;
height: 18px;
display: inline-flex;
}
#ld-drawer-root .ld-drawer-reply-fab-icon svg {
width: 18px;
height: 18px;
}
#ld-drawer-root .ld-drawer-reply-fab-label {
font-size: 13px;
font-weight: 700;
line-height: 1;
}
#ld-drawer-root .ld-drawer-reply-panel {
position: absolute;
top: calc(var(--ld-reply-panel-top, 84px) + 8px);
right: 14px;
z-index: 5;
width: min(360px, calc(100% - 28px));
display: grid;
gap: 12px;
max-height: calc(100% - var(--ld-reply-panel-top, 84px) - 22px);
overflow: auto;
padding: 14px;
border: 1px solid var(--primary-low, rgba(15, 23, 42, 0.12));
border-radius: 18px;
background: color-mix(in srgb, var(--secondary, #fff) 97%, var(--primary-low, rgba(15, 23, 42, 0.04)));
box-shadow: 0 20px 48px rgba(15, 23, 42, 0.22);
backdrop-filter: blur(14px);
}
#ld-drawer-root .ld-drawer-reply-panel[hidden] {
display: none !important;
}
#ld-drawer-root .ld-reply-panel-head,
#ld-drawer-root .ld-reply-actions {
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
}
#ld-drawer-root .ld-reply-panel-title {
font-size: 14px;
font-weight: 700;
}
#ld-drawer-root .ld-reply-panel-close,
#ld-drawer-root .ld-reply-action {
border: 1px solid var(--primary-low, rgba(15, 23, 42, 0.12));
background: var(--secondary, #fff);
color: inherit;
border-radius: 999px;
padding: 7px 11px;
font-size: 12px;
line-height: 1;
cursor: pointer;
}
#ld-drawer-root .ld-reply-action-primary {
border-color: color-mix(in srgb, var(--tertiary, #3b82f6) 30%, var(--primary-low, rgba(15, 23, 42, 0.12)));
color: var(--tertiary, #3b82f6);
}
#ld-drawer-root .ld-reply-panel-close:hover,
#ld-drawer-root .ld-reply-action:hover {
border-color: var(--tertiary, #3b82f6);
color: var(--tertiary, #3b82f6);
}
#ld-drawer-root .ld-reply-textarea {
width: 100%;
min-height: 168px;
resize: vertical;
border: 1px solid var(--primary-low, rgba(15, 23, 42, 0.14));
border-radius: 16px;
background: var(--secondary, #fff);
color: inherit;
font: inherit;
line-height: 1.6;
padding: 12px 14px;
}
#ld-drawer-root .ld-reply-textarea:focus {
outline: 2px solid color-mix(in srgb, var(--tertiary, #3b82f6) 28%, transparent);
outline-offset: 1px;
}
#ld-drawer-root .ld-reply-status {
min-height: 20px;
color: var(--primary-medium, rgba(15, 23, 42, 0.64));
font-size: 12px;
line-height: 1.5;
}
.ld-drawer-topic-link-active {
color: var(--tertiary, #3b82f6) !important;
}
#ld-drawer-root .ld-tag-list {
display: flex;
flex-wrap: wrap;
gap: 8px;
margin-bottom: 14px;
}
#ld-drawer-root .ld-tag {
display: inline-flex;
align-items: center;
padding: 4px 10px;
border-radius: 999px;
background: color-mix(in srgb, var(--tertiary, #3b82f6) 10%, transparent);
color: var(--tertiary, #3b82f6);
font-size: 12px;
font-weight: 600;
}
#ld-drawer-root .ld-topic-view {
display: grid;
gap: 20px;
}
#ld-drawer-root .ld-post-card {
border: 1px solid var(--primary-low, rgba(15, 23, 42, 0.12));
border-radius: 18px;
background: color-mix(in srgb, var(--secondary, #fff) 96%, var(--primary-low, rgba(15, 23, 42, 0.08)));
overflow: hidden;
}
#ld-drawer-root .ld-post-header {
display: flex;
align-items: center;
gap: 12px;
padding: 14px 16px 0;
}
#ld-drawer-root .ld-post-avatar {
width: 40px;
height: 40px;
border-radius: 50%;
object-fit: cover;
background: var(--primary-low, rgba(15, 23, 42, 0.1));
}
#ld-drawer-root .ld-post-author {
min-width: 0;
display: grid;
gap: 4px;
}
#ld-drawer-root .ld-post-author-row {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 8px;
}
#ld-drawer-root .ld-post-topic-owner-badge {
display: inline-flex;
align-items: center;
padding: 2px 8px;
border-radius: 999px;
background: color-mix(in srgb, var(--tertiary, #3b82f6) 12%, transparent);
color: var(--tertiary, #3b82f6);
font-size: 11px;
font-weight: 700;
line-height: 1.4;
white-space: nowrap;
}
#ld-drawer-root .ld-post-username,
#ld-drawer-root .ld-post-meta {
color: var(--primary-medium, rgba(15, 23, 42, 0.64));
font-size: 12px;
}
#ld-drawer-root .ld-post-body {
padding: 16px;
line-height: 1.72;
font-size: var(--ld-post-body-font-size, 14px);
overflow-wrap: break-word;
word-break: break-word;
min-width: 0;
}
#ld-drawer-root .ld-post-actions {
display: flex;
justify-content: space-between;
align-items: center;
gap: 8px;
padding: 0 16px 14px;
}
#ld-drawer-root .ld-post-actions-left,
#ld-drawer-root .ld-post-actions-right {
display: flex;
align-items: center;
gap: 6px;
}
#ld-drawer-root .ld-post-reply-button {
display: inline-flex;
align-items: center;
gap: 6px;
border: 1px solid var(--primary-low, rgba(15, 23, 42, 0.12));
background: color-mix(in srgb, var(--secondary, #fff) 94%, transparent);
color: var(--primary-medium, rgba(15, 23, 42, 0.72));
border-radius: 999px;
padding: 6px 10px;
font-size: 12px;
line-height: 1;
cursor: pointer;
}
#ld-drawer-root .ld-post-reply-button:hover {
border-color: var(--tertiary, #3b82f6);
color: var(--tertiary, #3b82f6);
}
#ld-drawer-root .ld-post-reply-button-icon {
width: 14px;
height: 14px;
display: inline-flex;
}
#ld-drawer-root .ld-post-reply-button-icon svg {
width: 14px;
height: 14px;
}
#ld-drawer-root .ld-post-reply-button-label {
white-space: nowrap;
}
/* reply-to-tab: chip shown above post body when the post replies to a specific post */
#ld-drawer-root .ld-reply-to-tab {
display: inline-flex;
align-items: center;
gap: 4px;
margin: 0 16px 8px;
padding: 3px 10px;
border: 1px solid var(--primary-low, rgba(15, 23, 42, 0.10));
border-radius: 999px;
background: color-mix(in srgb, var(--primary-low, rgba(15, 23, 42, 0.06)) 60%, transparent);
color: var(--primary-medium, rgba(15, 23, 42, 0.60));
font-size: 12px;
line-height: 1.4;
cursor: pointer;
max-width: calc(100% - 32px);
transition: color 0.15s, border-color 0.15s, background 0.15s;
}
#ld-drawer-root .ld-reply-to-tab:hover {
border-color: var(--tertiary, #3b82f6);
color: var(--tertiary, #3b82f6);
background: color-mix(in srgb, var(--tertiary, #3b82f6) 8%, transparent);
}
#ld-drawer-root .ld-reply-to-tab-icon {
width: 12px;
height: 12px;
display: inline-flex;
flex-shrink: 0;
}
#ld-drawer-root .ld-reply-to-tab-icon svg {
width: 12px;
height: 12px;
}
#ld-drawer-root .ld-reply-to-tab-label {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
/* post-infos: stats row (reads, likes, replies) shown below post body */
#ld-drawer-root .ld-post-infos {
display: flex;
align-items: center;
gap: 12px;
padding: 0 16px 10px;
flex-wrap: wrap;
}
#ld-drawer-root .ld-post-info-item {
display: inline-flex;
align-items: center;
gap: 4px;
color: var(--primary-medium, rgba(15, 23, 42, 0.50));
font-size: 12px;
line-height: 1;
}
#ld-drawer-root .ld-post-info-icon {
width: 13px;
height: 13px;
display: inline-flex;
flex-shrink: 0;
}
#ld-drawer-root .ld-post-info-icon svg {
width: 13px;
height: 13px;
}
/* Small icon-only buttons: copy link, bookmark, flag */
#ld-drawer-root .ld-post-icon-btn {
display: inline-flex;
align-items: center;
justify-content: center;
width: 28px;
height: 28px;
border: 1px solid transparent;
background: transparent;
color: var(--primary-medium, rgba(15, 23, 42, 0.55));
border-radius: 6px;
cursor: pointer;
transition: color 0.15s, background 0.15s;
flex-shrink: 0;
}
#ld-drawer-root .ld-post-icon-btn svg {
width: 15px;
height: 15px;
display: block;
}
#ld-drawer-root .ld-post-icon-btn:hover {
background: color-mix(in srgb, var(--primary-low, rgba(15, 23, 42, 0.08)) 50%, transparent);
color: var(--primary, #1f2937);
}
#ld-drawer-root .ld-post-icon-btn--bookmarked {
color: var(--tertiary, #3b82f6);
}
#ld-drawer-root .ld-post-icon-btn--bookmarked:hover {
background: color-mix(in srgb, var(--tertiary, #3b82f6) 12%, transparent);
color: var(--tertiary, #3b82f6);
}
#ld-drawer-root .ld-post-icon-btn--flag:hover {
color: var(--danger, #e45735);
background: color-mix(in srgb, var(--danger, #e45735) 10%, transparent);
}
/* Reactions button (replaces old like button) */
#ld-drawer-root .ld-post-react-wrap {
position: relative;
}
#ld-drawer-root .ld-post-react-btn {
display: inline-flex;
align-items: center;
gap: 5px;
border: 1px solid var(--primary-low, rgba(15, 23, 42, 0.12));
background: color-mix(in srgb, var(--secondary, #fff) 94%, transparent);
color: var(--primary-medium, rgba(15, 23, 42, 0.72));
border-radius: 999px;
padding: 6px 10px;
font-size: 12px;
line-height: 1;
cursor: pointer;
transition: color 0.15s, border-color 0.15s, background 0.15s;
}
#ld-drawer-root .ld-post-react-btn:hover {
border-color: var(--love, #e45735);
color: var(--love, #e45735);
}
#ld-drawer-root .ld-post-react-btn--reacted {
color: var(--love, #e45735);
border-color: var(--love, #e45735);
background: color-mix(in srgb, var(--love, #e45735) 10%, var(--secondary, #fff));
}
#ld-drawer-root .ld-post-react-btn:disabled {
opacity: 0.5;
cursor: default;
}
#ld-drawer-root .ld-post-react-btn-icon {
width: 14px;
height: 14px;
display: inline-flex;
align-items: center;
justify-content: center;
}
#ld-drawer-root .ld-post-react-btn-icon img {
width: 14px;
height: 14px;
object-fit: contain;
}
#ld-drawer-root .ld-post-react-btn-icon svg {
width: 14px;
height: 14px;
}
#ld-drawer-root .ld-post-react-count {
font-variant-numeric: tabular-nums;
}
/* Reactions hover popover */
#ld-drawer-root .ld-reactions-popover {
position: absolute;
bottom: calc(100% + 8px);
right: 0;
display: flex;
gap: 2px;
padding: 6px 8px;
background: var(--secondary, #fff);
border: 1px solid var(--primary-low, rgba(15, 23, 42, 0.12));
border-radius: 14px;
box-shadow: 0 4px 20px rgba(15, 23, 42, 0.14);
z-index: 20;
white-space: nowrap;
}
#ld-drawer-root .ld-reactions-popover[hidden] {
display: none !important;
}
#ld-drawer-root .ld-reaction-btn {
display: inline-flex;
flex-direction: column;
align-items: center;
gap: 3px;
border: 1px solid transparent;
background: transparent;
border-radius: 8px;
padding: 5px 6px;
cursor: pointer;
transition: background 0.12s, transform 0.12s;
line-height: 1;
}
#ld-drawer-root .ld-reaction-btn:hover {
background: color-mix(in srgb, var(--tertiary, #3b82f6) 12%, transparent);
transform: scale(1.25);
}
#ld-drawer-root .ld-reaction-btn--active {
background: color-mix(in srgb, var(--love, #e45735) 14%, transparent);
border-color: color-mix(in srgb, var(--love, #e45735) 40%, transparent);
}
#ld-drawer-root .ld-reaction-btn--active:hover {
background: color-mix(in srgb, var(--love, #e45735) 22%, transparent);
}
#ld-drawer-root .ld-reaction-btn img {
width: 24px;
height: 24px;
object-fit: contain;
display: block;
}
#ld-drawer-root .ld-reaction-btn-count {
font-size: 10px;
color: var(--primary-medium, rgba(15, 23, 42, 0.6));
min-width: 14px;
text-align: center;
}
/* Flag popover */
#ld-drawer-root .ld-flag-wrap {
position: relative;
}
#ld-drawer-root .ld-flag-popover {
position: absolute;
bottom: calc(100% + 8px);
left: 0;
min-width: 190px;
background: var(--secondary, #fff);
border: 1px solid var(--primary-low, rgba(15, 23, 42, 0.12));
border-radius: 10px;
box-shadow: 0 4px 20px rgba(15, 23, 42, 0.14);
z-index: 20;
overflow: hidden;
}
#ld-drawer-root .ld-flag-popover[hidden] {
display: none !important;
}
#ld-drawer-root .ld-flag-option {
display: flex;
align-items: center;
gap: 9px;
width: 100%;
padding: 10px 13px;
border: none;
background: transparent;
color: var(--primary, #1f2937);
font-size: 13px;
text-align: left;
cursor: pointer;
line-height: 1.3;
}
#ld-drawer-root .ld-flag-option:hover {
background: color-mix(in srgb, var(--primary-low, rgba(15, 23, 42, 0.08)) 80%, transparent);
}
#ld-drawer-root .ld-flag-option svg {
width: 15px;
height: 15px;
color: var(--primary-medium, rgba(15, 23, 42, 0.55));
flex-shrink: 0;
}
@keyframes ld-popover-in {
from { opacity: 0; transform: translateY(4px) scale(0.97); }
to { opacity: 1; transform: translateY(0) scale(1); }
}
#ld-drawer-root .ld-reactions-popover:not([hidden]),
#ld-drawer-root .ld-flag-popover:not([hidden]) {
animation: ld-popover-in 0.12s ease;
}
#ld-drawer-root .ld-post-body > :first-child {
margin-top: 0;
}
#ld-drawer-root .ld-post-body > :last-child {
margin-bottom: 0;
}
#ld-drawer-root .ld-post-body pre,
#ld-drawer-root .ld-post-body code {
font-size: max(12px, calc(var(--ld-post-body-font-size, 14px) - 1px));
}
#ld-drawer-root .ld-post-body pre {
overflow-x: auto;
overflow-y: visible;
border-radius: 12px;
white-space: pre;
word-break: normal;
overflow-wrap: normal;
max-width: 100%;
}
#ld-drawer-root .ld-post-body img,
#ld-drawer-root .ld-post-body video,
#ld-drawer-root .ld-post-body iframe {
max-width: 100%;
}
#ld-drawer-root .ld-post-body img {
cursor: zoom-in;
}
#ld-drawer-root .ld-post-body blockquote {
margin-left: 0;
padding-left: 14px;
border-left: 3px solid var(--primary-low, rgba(15, 23, 42, 0.16));
}
#ld-drawer-root .ld-post-body table {
display: block;
max-width: 100%;
overflow-x: auto;
}
#ld-drawer-root .ld-topic-note {
padding: 14px 16px;
border-radius: 14px;
font-size: 13px;
line-height: 1.6;
background: color-mix(in srgb, var(--secondary, #fff) 94%, var(--primary-low, rgba(15, 23, 42, 0.08)));
border: 1px dashed var(--primary-low, rgba(15, 23, 42, 0.14));
color: var(--primary-medium, rgba(15, 23, 42, 0.72));
}
#ld-drawer-root .ld-topic-note-error {
border-style: solid;
color: #b91c1c;
background: rgba(254, 226, 226, 0.72);
}
#ld-drawer-root .ld-topic-note-warning {
border-style: solid;
color: #92400e;
background: rgba(255, 247, 237, 0.92);
}
#ld-drawer-root .ld-iframe-fallback {
display: flex;
flex: 1;
min-height: 0;
flex-direction: column;
gap: 12px;
}
#ld-drawer-root .ld-topic-iframe {
width: 100%;
flex: 1;
min-height: 0;
height: 100%;
border: 1px solid var(--primary-low, rgba(15, 23, 42, 0.12));
border-radius: 16px;
background: #fff;
}
#ld-drawer-root .ld-loading-state {
display: grid;
gap: 12px;
}
#ld-drawer-root .ld-loading-bar,
#ld-drawer-root .ld-loading-card {
position: relative;
overflow: hidden;
background: rgba(148, 163, 184, 0.16);
}
#ld-drawer-root .ld-loading-bar::after,
#ld-drawer-root .ld-loading-card::after {
content: "";
position: absolute;
inset: 0;
transform: translateX(-100%);
background: linear-gradient(90deg, transparent, rgba(255, 255, 255, 0.7), transparent);
animation: ld-drawer-shimmer 1.2s infinite;
}
#ld-drawer-root .ld-loading-bar {
height: 16px;
border-radius: 999px;
}
#ld-drawer-root .ld-loading-bar-short {
width: 55%;
}
#ld-drawer-root .ld-loading-card {
height: 180px;
border-radius: 18px;
}
@keyframes ld-drawer-shimmer {
100% {
transform: translateX(100%);
}
}
@keyframes ld-image-preview-fade-in {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
/* 固定 Discourse 的“查看 xx 个新的或更新的话题”提示条,避免它随列表滚走。 */
@media (min-width: 768px) {
#list-area .show-more.has-topics,
.contents > .show-more.has-topics {
position: fixed !important;
top: var(--ld-topic-tracker-top) !important;
left: var(--ld-topic-tracker-left) !important;
right: auto !important;
margin: 0 !important;
width: fit-content !important;
max-width: var(--ld-topic-tracker-max-width) !important;
translate: -50% 0 !important;
z-index: 1001 !important;
}
#list-area .show-more.has-topics .alert,
.contents > .show-more.has-topics .alert {
max-width: 100%;
box-shadow: 0 10px 26px rgba(15, 23, 42, 0.16);
}
}
@media (max-width: 1120px) {
body.ld-drawer-page-open {
padding-right: 0 !important;
}
#ld-drawer-root {
width: min(100vw, 760px);
}
#ld-drawer-root .ld-drawer-settings {
padding-left: 12px;
padding-right: 12px;
}
#ld-drawer-root .ld-drawer-reply-panel {
width: min(320px, calc(100% - 24px));
}
#ld-drawer-root .ld-drawer-resize-handle {
display: none;
}
}
@media (max-width: 720px) {
#ld-drawer-root {
width: 100vw;
}
#ld-drawer-root .ld-image-preview {
padding: max(18px, env(safe-area-inset-top, 0px) + 8px)
12px
max(12px, env(safe-area-inset-bottom, 0px) + 8px);
align-items: stretch;
}
#ld-drawer-root .ld-image-preview-close {
top: max(10px, env(safe-area-inset-top, 0px) + 2px);
right: 12px;
padding: 8px 12px;
background: rgba(15, 23, 42, 0.56);
backdrop-filter: blur(10px);
}
#ld-drawer-root .ld-image-preview-stage {
align-items: center;
padding-top: 40px;
padding-bottom: 6px;
}
#ld-drawer-root .ld-image-preview-image {
max-width: 100%;
max-height: calc(100dvh - 88px);
border-radius: 12px;
box-shadow: 0 14px 34px rgba(0, 0, 0, 0.32);
}
#ld-drawer-root .ld-drawer-header {
gap: 8px;
}
#ld-drawer-root .ld-drawer-title {
font-size: 18px;
}
#ld-drawer-root .ld-drawer-side-actions {
gap: 2px;
padding: 6px 4px;
}
#ld-drawer-root .ld-drawer-meta {
font-size: 12px;
}
#ld-drawer-root .ld-drawer-settings {
padding-left: 12px;
padding-right: 12px;
}
#ld-drawer-root .ld-drawer-reply-fab {
top: auto;
right: 12px;
bottom: max(16px, env(safe-area-inset-bottom, 0px) + 8px);
transform: none;
}
#ld-drawer-root .ld-drawer-reply-fab:hover {
transform: translateY(-1px);
}
#ld-drawer-root .ld-drawer-reply-panel {
top: auto;
right: 12px;
left: 12px;
bottom: max(72px, env(safe-area-inset-bottom, 0px) + 56px);
width: auto;
max-height: calc(100dvh - max(72px, env(safe-area-inset-bottom, 0px) + 56px) - 16px);
}
}
@media (prefers-reduced-motion: reduce) {
#ld-drawer-root .ld-image-preview,
#ld-drawer-root .ld-image-preview-close,
#ld-drawer-root .ld-image-preview-image {
animation: none;
transition: none;
}
}
`;
document.head.appendChild(styleEl);
// --- Core Logic ---
const CURRENT_VERSION = "0.6.0";
const GREASYFORK_URL = "https://greasyfork.org/zh-CN/scripts/570223-linux-do-sidepeek";
const GREASYFORK_API_URL = "https://greasyfork.org/scripts/570223.json";
const UPDATE_CHECK_KEY = "ld-update-check-v1";
const UPDATE_CHECK_TTL = 24 * 60 * 60 * 1000;
const UPDATE_DISMISS_KEY = "ld-update-dismiss-v1";
const ROOT_ID = "ld-drawer-root";
const PAGE_OPEN_CLASS = "ld-drawer-page-open";
const PAGE_IFRAME_OPEN_CLASS = "ld-drawer-page-iframe-open";
const ACTIVE_LINK_CLASS = "ld-drawer-topic-link-active";
const IFRAME_MODE_CLASS = "ld-drawer-iframe-mode";
const SETTINGS_KEY = "ld-drawer-settings-v1";
const LOAD_MORE_BATCH_SIZE = 20;
const LOAD_MORE_TRIGGER_OFFSET = 240;
const IMAGE_PREVIEW_SCALE_MIN = 1;
const IMAGE_PREVIEW_SCALE_MAX = 4;
const IMAGE_PREVIEW_SCALE_STEP = 0.2;
const POST_BODY_FONT_SIZE_MIN = 13;
const POST_BODY_FONT_SIZE_MAX = 18;
const REPLY_UPLOAD_MARKER = "\u2063";
const DEFAULT_SETTINGS = {
previewMode: "iframe",
postMode: "all",
postBodyFontSize: 15,
authorFilter: "all",
replyOrder: "default",
floatingReplyButton: "off",
drawerWidth: "narrow",
drawerWidthCustom: 720,
drawerMode: "overlay"
};
const DRAWER_WIDTHS = {
narrow: "clamp(320px, 34vw, 680px)",
medium: "clamp(360px, 42vw, 920px)",
wide: "clamp(420px, 52vw, 1200px)"
};
const LIST_ROW_SELECTOR = [
"tr.topic-list-item",
".topic-list-item",
".latest-topic-list-item",
"tbody.topic-list-body tr"
].join(", ");
const PRIMARY_TOPIC_LINK_SELECTOR = [
"a.title",
".main-link a.raw-topic-link",
".main-link a.title",
".search-link",
".search-result-topic a",
".user-stream .title a",
".user-main .item .title a"
].join(", ");
const ENTRY_CONTAINER_SELECTOR = [
LIST_ROW_SELECTOR,
".search-result",
".fps-result",
".user-stream .item",
".user-main .item"
].join(", ");
const MAIN_CONTENT_SELECTOR = "#main-outlet";
const TOPIC_TRACKER_SELECTOR = [
"#list-area .show-more.has-topics",
".contents > .show-more.has-topics"
].join(", ");
// 选择器列表不能直接拼 `${TOPIC_TRACKER_SELECTOR} ...`,否则只会给最后一段补后缀。
const TOPIC_TRACKER_CLICKABLE_SELECTOR = TOPIC_TRACKER_SELECTOR
.split(",")
.map((selector) => `${selector.trim()} .alert.clickable`)
.join(", ");
const TOPIC_TRACKER_VERTICAL_SELECTOR = [
".list-controls .navigation-container",
".navigation-container",
".list-controls",
"#navigation-bar"
].join(", ");
const EXCLUDED_LINK_CONTEXT_SELECTOR = [
".cooked",
".topic-post",
".topic-body",
".topic-map",
".timeline-container",
"#reply-control",
".d-editor-container",
".composer-popup",
".select-kit",
".modal",
".menu-panel",
".popup-menu",
".user-card",
".group-card"
].join(", ");
const state = {
root: null,
header: null,
title: null,
meta: null,
drawerBody: null,
content: null,
replyToggleButton: null,
replyFabButton: null,
replyPanel: null,
replyPanelTitle: null,
replyTextarea: null,
replySubmitButton: null,
replyCancelButton: null,
replyStatus: null,
imagePreview: null,
imagePreviewImage: null,
imagePreviewCloseButton: null,
imagePreviewScale: 1,
openInTab: null,
settingsPanel: null,
settingsCard: null,
postBodyFontSizeField: null,
postBodyFontSizeControl: null,
postBodyFontSizeValue: null,
postBodyFontSizeHint: null,
settingsCloseButton: null,
settingsToggle: null,
latestRepliesRefreshButton: null,
prevButton: null,
nextButton: null,
resizeHandle: null,
activeLink: null,
currentUrl: "",
currentEntryElement: null,
currentEntryKey: "",
currentTopicIdHint: null,
currentTopicTrackingKey: "",
currentViewTracked: false,
currentTrackRequest: null,
currentTrackRequestKey: "",
currentResolvedTargetPostNumber: null,
currentFallbackTitle: "",
currentTopic: null,
currentLatestRepliesTopic: null,
currentTargetSpec: null,
replyTargetPostNumber: null,
replyTargetLabel: "",
abortController: null,
loadMoreAbortController: null,
replyAbortController: null,
replyUploadControllers: [],
replyUploadPendingCount: 0,
replyUploadSerial: 0,
replyComposerSessionId: 0,
deferOwnerFilterAutoLoad: false,
lastLocation: location.href,
settings: loadSettings(),
isResizing: false,
isLoadingMorePosts: false,
isRefreshingLatestReplies: false,
isReplySubmitting: false,
loadMoreError: "",
loadMoreStatus: null,
hasShownPreviewNotice: false,
topicTrackerSyncQueued: false,
topicTrackerRefreshTimer: 0,
topicTrackerRefreshStartedAt: 0,
topicTrackerRefreshLoadingObserved: false,
availableReactions: null,
updatePopup: null,
updatePopupVersionLabel: null,
updatePopupCloseButton: null,
updateLatestVersion: ""
};
function init() {
ensureDrawer();
bindEvents();
watchLocationChanges();
checkForUpdate();
}
function ensureDrawer() {
if (state.root) {
return;
}
const root = document.createElement("aside");
root.id = ROOT_ID;
root.setAttribute("aria-hidden", "true");
root.innerHTML = `
<div class="ld-drawer-resize-handle" role="separator" aria-label="调整抽屉宽度" aria-orientation="vertical" title="拖动调整宽度"></div>
<div class="ld-drawer-shell">
<div class="ld-update-popup" id="ld-update-popup" role="status" aria-live="polite" aria-label="发现新版本提示">
<div class="ld-update-popup-text">发现新版本:<span id="ld-update-popup-version"></span>,点击更新跳转</div>
<div class="ld-update-popup-actions">
<a class="ld-update-popup-link" href="${GREASYFORK_URL}" target="_blank" rel="noopener noreferrer">更新</a>
<button class="ld-update-popup-close" id="ld-update-popup-close" type="button" aria-label="关闭更新提示">x</button>
</div>
</div>
<div class="ld-drawer-side-actions" role="toolbar" aria-label="抽屉操作">
<button class="ld-drawer-nav" type="button" data-nav="prev" data-tooltip="上一帖" aria-label="上一帖">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M15 18l-6-6 6-6"/></svg>
</button>
<button class="ld-drawer-nav" type="button" data-nav="next" data-tooltip="下一帖" aria-label="下一帖">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M9 18l6-6-6-6"/></svg>
</button>
<div class="ld-side-divider" role="separator"></div>
<button class="ld-drawer-settings-toggle" type="button" aria-expanded="false" aria-controls="ld-drawer-settings" data-tooltip="选项" aria-label="选项">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83-2.83l.06-.06A1.65 1.65 0 0 0 4.68 15a1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 2.83-2.83l.06.06A1.65 1.65 0 0 0 9 4.68a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 2.83l-.06.06A1.65 1.65 0 0 0 19.4 9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z"/></svg>
</button>
<button class="ld-drawer-refresh" type="button" aria-label="刷新最新回复" data-tooltip="刷新最新回复" hidden>
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="1 4 1 10 7 10"/><path d="M3.51 15a9 9 0 1 0 .49-3.5"/></svg>
</button>
<button class="ld-drawer-reply-toggle ld-drawer-reply-trigger" type="button" aria-expanded="false" aria-controls="ld-drawer-reply-panel" aria-label="回复当前主题" data-tooltip="回复当前主题" hidden>
<svg viewBox="0 0 24 24" fill="currentColor" aria-hidden="true"><path d="M4 12.5c0-4.14 3.36-7.5 7.5-7.5h7a1.5 1.5 0 0 1 0 3h-7A4.5 4.5 0 0 0 7 12.5v1.38l1.44-1.44a1.5 1.5 0 0 1 2.12 2.12l-4 4a1.5 1.5 0 0 1-2.12 0l-4-4a1.5 1.5 0 1 1 2.12-2.12L4 13.88V12.5Z"/></svg>
</button>
<a class="ld-drawer-link" href="https://linux.do/latest" target="_blank" rel="noopener noreferrer" data-tooltip="新标签打开" aria-label="新标签打开">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6"/><polyline points="15 3 21 3 21 9"/><line x1="10" y1="14" x2="21" y2="3"/></svg>
</a>
<div class="ld-side-divider" role="separator"></div>
<button class="ld-drawer-close" type="button" aria-label="关闭抽屉" data-tooltip="关闭">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M18 6L6 18M6 6l12 12"/></svg>
</button>
</div>
<div class="ld-drawer-main">
<div class="ld-drawer-header">
<div class="ld-drawer-title-group">
<div class="ld-drawer-eyebrow">LINUX DO 预览</div>
<h2 class="ld-drawer-title">点击帖子标题开始预览</h2>
</div>
<div class="ld-drawer-meta"></div>
</div>
<div class="ld-drawer-settings" id="ld-drawer-settings" hidden>
<div class="ld-drawer-settings-card" role="dialog" aria-modal="true" aria-label="预览选项">
<div class="ld-settings-head">
<div class="ld-settings-title">预览选项</div>
<button class="ld-settings-close" type="button" aria-label="关闭预览选项">关闭</button>
</div>
<label class="ld-setting-field">
<span class="ld-setting-label">预览模式</span>
<select class="ld-setting-control" data-setting="previewMode">
<option value="smart">智能预览</option>
<option value="iframe">整页模式</option>
</select>
</label>
<label class="ld-setting-field">
<span class="ld-setting-label">内容范围</span>
<select class="ld-setting-control" data-setting="postMode">
<option value="all">完整主题</option>
<option value="first">仅首帖</option>
</select>
</label>
<label class="ld-setting-field" data-setting-group="postBodyFontSize">
<div class="ld-setting-row">
<span class="ld-setting-label">正文字号</span>
<span class="ld-setting-value" data-setting-value="postBodyFontSize">15px</span>
</div>
<input
class="ld-setting-range"
type="range"
min="${POST_BODY_FONT_SIZE_MIN}"
max="${POST_BODY_FONT_SIZE_MAX}"
step="1"
data-setting="postBodyFontSize"
/>
<span class="ld-setting-hint" data-setting-hint="postBodyFontSize">只调整帖子正文和代码字号,不影响标题和按钮</span>
</label>
<label class="ld-setting-field">
<span class="ld-setting-label">作者过滤</span>
<select class="ld-setting-control" data-setting="authorFilter">
<option value="all">全部作者</option>
<option value="topicOwner">只看楼主</option>
</select>
<span class="ld-setting-hint">只在智能预览里过滤显示,不影响原帖内容</span>
</label>
<label class="ld-setting-field">
<span class="ld-setting-label">回复排序</span>
<select class="ld-setting-control" data-setting="replyOrder">
<option value="default">默认顺序</option>
<option value="latestFirst">首帖 + 最新回复</option>
</select>
<span class="ld-setting-hint">长帖下会优先显示最新一批回复,不代表把整帖一次性完整倒序</span>
</label>
<label class="ld-setting-field">
<span class="ld-setting-label">悬浮回复入口</span>
<select class="ld-setting-control" data-setting="floatingReplyButton">
<option value="off">关闭</option>
<option value="on">开启</option>
</select>
<span class="ld-setting-hint">关闭后只保留侧边栏的回复按钮,开启后额外显示右侧悬浮快捷入口</span>
</label>
<label class="ld-setting-field">
<span class="ld-setting-label">抽屉模式</span>
<select class="ld-setting-control" data-setting="drawerMode">
<option value="push">挤压模式</option>
<option value="overlay">浮层模式</option>
</select>
<span class="ld-setting-hint">浮层模式下抽屉悬浮于页面上方,不压缩原有内容</span>
</label>
<label class="ld-setting-field">
<span class="ld-setting-label">抽屉宽度</span>
<select class="ld-setting-control" data-setting="drawerWidth">
<option value="narrow">窄</option>
<option value="medium">中</option>
<option value="wide">宽</option>
<option value="custom">自定义</option>
</select>
<span class="ld-setting-hint">也可以直接拖动抽屉左边边缘</span>
</label>
<button class="ld-settings-reset" type="button">恢复默认</button>
</div>
</div>
<div class="ld-drawer-body">
<div class="ld-drawer-content"></div>
</div>
<button class="ld-drawer-reply-fab ld-drawer-reply-trigger" type="button" aria-expanded="false" aria-controls="ld-drawer-reply-panel" aria-label="回复当前主题" title="回复当前主题">
<span class="ld-drawer-reply-fab-icon" aria-hidden="true">
<svg viewBox="0 0 24 24" focusable="false">
<path d="M4 12.5c0-4.14 3.36-7.5 7.5-7.5h7a1.5 1.5 0 0 1 0 3h-7A4.5 4.5 0 0 0 7 12.5v1.38l1.44-1.44a1.5 1.5 0 0 1 2.12 2.12l-4 4a1.5 1.5 0 0 1-2.12 0l-4-4a1.5 1.5 0 1 1 2.12-2.12L4 13.88V12.5Z" fill="currentColor"></path>
</svg>
</span>
<span class="ld-drawer-reply-fab-label">回复</span>
</button>
<div class="ld-drawer-reply-panel" id="ld-drawer-reply-panel" hidden>
<div class="ld-reply-panel-head">
<div class="ld-reply-panel-title">回复主题</div>
<button class="ld-reply-panel-close" type="button" aria-label="关闭快速回复">关闭</button>
</div>
<textarea class="ld-reply-textarea" rows="7" placeholder="写点什么... 支持 Markdown,可直接粘贴图片自动上传。Ctrl+Enter 或 Cmd+Enter 可发送"></textarea>
<div class="ld-reply-status" aria-live="polite"></div>
<div class="ld-reply-actions">
<button class="ld-reply-action" type="button" data-action="cancel">取消</button>
<button class="ld-reply-action ld-reply-action-primary" type="button" data-action="submit">发送回复</button>
</div>
</div>
<div class="ld-image-preview" hidden aria-hidden="true">
<button class="ld-image-preview-close" type="button" aria-label="关闭图片预览">关闭</button>
<div class="ld-image-preview-stage">
<img class="ld-image-preview-image" alt="图片预览" />
</div>
</div>
</div>
</div>
`;
document.body.appendChild(root);
state.root = root;
state.header = root.querySelector(".ld-drawer-header");
state.title = root.querySelector(".ld-drawer-title");
state.meta = root.querySelector(".ld-drawer-meta");
state.drawerBody = root.querySelector(".ld-drawer-body");
state.content = root.querySelector(".ld-drawer-content");
state.replyToggleButton = root.querySelector(".ld-drawer-reply-toggle");
state.replyFabButton = root.querySelector(".ld-drawer-reply-fab");
state.replyPanel = root.querySelector(".ld-drawer-reply-panel");
state.replyPanelTitle = root.querySelector(".ld-reply-panel-title");
state.replyTextarea = root.querySelector(".ld-reply-textarea");
state.replySubmitButton = root.querySelector('[data-action="submit"]');
state.replyCancelButton = root.querySelector('[data-action="cancel"]');
state.replyStatus = root.querySelector(".ld-reply-status");
state.imagePreview = root.querySelector(".ld-image-preview");
state.imagePreviewImage = root.querySelector(".ld-image-preview-image");
state.imagePreviewCloseButton = root.querySelector(".ld-image-preview-close");
state.openInTab = root.querySelector(".ld-drawer-link");
state.settingsPanel = root.querySelector(".ld-drawer-settings");
state.settingsCard = root.querySelector(".ld-drawer-settings-card");
state.postBodyFontSizeField = root.querySelector('[data-setting-group="postBodyFontSize"]');
state.postBodyFontSizeControl = root.querySelector('[data-setting="postBodyFontSize"]');
state.postBodyFontSizeValue = root.querySelector('[data-setting-value="postBodyFontSize"]');
state.postBodyFontSizeHint = root.querySelector('[data-setting-hint="postBodyFontSize"]');
state.settingsCloseButton = root.querySelector(".ld-settings-close");
state.settingsToggle = root.querySelector(".ld-drawer-settings-toggle");
state.latestRepliesRefreshButton = root.querySelector(".ld-drawer-refresh");
state.prevButton = root.querySelector('[data-nav="prev"]');
state.nextButton = root.querySelector('[data-nav="next"]');
state.resizeHandle = root.querySelector(".ld-drawer-resize-handle");
state.updatePopup = root.querySelector("#ld-update-popup");
state.updatePopupVersionLabel = root.querySelector("#ld-update-popup-version");
state.updatePopupCloseButton = root.querySelector("#ld-update-popup-close");
root.querySelector(".ld-drawer-close").addEventListener("click", closeDrawer);
state.prevButton.addEventListener("click", () => navigateTopic(-1));
state.nextButton.addEventListener("click", () => navigateTopic(1));
state.settingsToggle.addEventListener("click", toggleSettingsPanel);
state.latestRepliesRefreshButton.addEventListener("click", handleLatestRepliesRefresh);
state.replyToggleButton.addEventListener("click", toggleReplyPanel);
state.replyFabButton.addEventListener("click", toggleReplyPanel);
state.replyCancelButton.addEventListener("click", () => setReplyPanelOpen(false));
state.replySubmitButton.addEventListener("click", handleReplySubmit);
root.querySelector(".ld-reply-panel-close").addEventListener("click", () => setReplyPanelOpen(false));
state.replyTextarea.addEventListener("keydown", handleReplyTextareaKeydown);
state.replyTextarea.addEventListener("paste", handleReplyTextareaPaste);
root.addEventListener("click", handleDrawerRootClick);
root.addEventListener("wheel", handleDrawerRootWheel, { passive: false });
state.drawerBody.addEventListener("scroll", handleDrawerBodyScroll, { passive: true });
state.settingsPanel.addEventListener("click", handleSettingsPanelClick);
state.settingsPanel.addEventListener("input", handleSettingsInput);
state.settingsPanel.addEventListener("change", handleSettingsChange);
state.settingsCloseButton.addEventListener("click", () => setSettingsPanelOpen(false));
state.settingsPanel.querySelector(".ld-settings-reset").addEventListener("click", resetSettings);
state.resizeHandle.addEventListener("pointerdown", startDrawerResize);
state.updatePopupCloseButton?.addEventListener("click", () => hideUpdatePopup(true));
syncSettingsUI();
applyPostBodyFontSize();
applyDrawerWidth();
applyDrawerMode();
syncNavigationState();
syncLatestRepliesRefreshUI();
syncReplyUI();
updateSettingsPopoverPosition();
}
function bindEvents() {
document.addEventListener("click", handleDocumentClick, true);
document.addEventListener("keydown", handleKeydown, true);
document.addEventListener("pointermove", handleDrawerResizeMove, true);
document.addEventListener("pointerup", stopDrawerResize, true);
document.addEventListener("pointercancel", stopDrawerResize, true);
window.addEventListener("resize", handleWindowResize, true);
window.addEventListener("scroll", handleWindowScroll, { capture: true, passive: true });
}
function handleDocumentClick(event) {
if (event.defaultPrevented) {
return;
}
if (event.button !== 0 || event.metaKey || event.ctrlKey || event.shiftKey || event.altKey) {
return;
}
const target = event.target;
if (!(target instanceof Element)) {
return;
}
if (!state.settingsPanel?.hidden && !target.closest(".ld-drawer-settings-card") && !target.closest(".ld-drawer-settings-toggle")) {
setSettingsPanelOpen(false);
}
if (!state.replyPanel?.hidden && !target.closest(".ld-drawer-reply-panel") && !target.closest(".ld-drawer-reply-trigger")) {
setReplyPanelOpen(false);
}
if (!target.closest(".ld-flag-wrap") && !target.closest(".ld-post-react-wrap")) {
closeAllPopovers();
}
if (handleTopicTrackerClick(target)) {
return;
}
const link = target.closest("a[href]");
if (link && !link.closest(`#${ROOT_ID}`)) {
const topicUrl = getTopicUrlFromLink(link);
if (topicUrl) {
event.preventDefault();
event.stopPropagation();
openDrawer(topicUrl, link.textContent.trim(), link);
return;
}
}
if (
state.settings.drawerMode === "overlay" &&
document.body.classList.contains(PAGE_OPEN_CLASS) &&
!target.closest(`#${ROOT_ID}`)
) {
event.preventDefault();
event.stopPropagation();
closeDrawer();
return;
}
}
function handleKeydown(event) {
if (event.key === "Escape" && !state.imagePreview?.hidden) {
event.preventDefault();
event.stopPropagation();
closeImagePreview();
return;
}
if (event.key === "Escape" && !state.settingsPanel?.hidden) {
event.preventDefault();
event.stopPropagation();
setSettingsPanelOpen(false);
return;
}
if (event.key === "Escape" && !state.replyPanel?.hidden) {
event.preventDefault();
event.stopPropagation();
setReplyPanelOpen(false);
return;
}
if (isTypingTarget(event.target)) {
return;
}
if (event.key === "Escape" && document.body.classList.contains(PAGE_OPEN_CLASS)) {
closeDrawer();
return;
}
if (!document.body.classList.contains(PAGE_OPEN_CLASS)) {
return;
}
if (event.altKey && !event.metaKey && !event.ctrlKey && !event.shiftKey) {
if (event.key === "ArrowUp") {
event.preventDefault();
navigateTopic(-1);
} else if (event.key === "ArrowDown") {
event.preventDefault();
navigateTopic(1);
}
}
}
function getTopicUrlFromLink(link) {
if (!(link instanceof HTMLAnchorElement)) {
return null;
}
if (link.target && link.target !== "_self") {
return null;
}
if (link.hasAttribute("download")) {
return null;
}
if (!link.closest(MAIN_CONTENT_SELECTOR) || link.closest(`#${ROOT_ID}`)) {
return null;
}
if (link.closest(EXCLUDED_LINK_CONTEXT_SELECTOR)) {
return null;
}
if (!isPrimaryTopicLink(link)) {
return null;
}
let url;
try {
url = new URL(link.href, location.href);
} catch {
return null;
}
if (url.origin !== location.origin || !url.pathname.startsWith("/t/")) {
return null;
}
return normalizeTopicUrl(url);
}
function openDrawer(topicUrl, fallbackTitle, activeLink) {
ensureDrawer();
const entryElement = activeLink instanceof Element
? getTopicEntryContainer(activeLink)
: null;
const topicIdHint = activeLink instanceof Element
? (getTopicIdHintFromLink(activeLink) || getTopicIdFromUrl(topicUrl))
: getTopicIdFromUrl(topicUrl);
const currentEntry = activeLink instanceof Element
? getTopicEntries().find((entry) => entry.link === activeLink || entry.entryElement === entryElement)
: null;
const nextTrackingKey = getTopicTrackingKey(topicUrl, topicIdHint);
const isSameTrackedTopic = Boolean(state.currentTopicTrackingKey) && state.currentTopicTrackingKey === nextTrackingKey;
state.currentEntryElement = entryElement;
state.currentEntryKey = currentEntry?.entryKey || buildEntryKey(topicUrl, 1);
state.currentTopicIdHint = topicIdHint;
if (!isSameTrackedTopic) {
state.currentViewTracked = false;
state.currentTrackRequest = null;
state.currentTrackRequestKey = "";
}
state.currentTopicTrackingKey = nextTrackingKey;
if (state.currentUrl === topicUrl && document.body.classList.contains(PAGE_OPEN_CLASS)) {
highlightLink(activeLink);
syncNavigationState();
if (shouldRefreshCurrentTopicOnRepeatOpen()) {
handleLatestRepliesRefresh();
return;
}
if (!state.currentViewTracked && !state.currentTrackRequest) {
loadTopic(topicUrl, fallbackTitle, topicIdHint);
}
return;
}
state.currentUrl = topicUrl;
state.currentFallbackTitle = fallbackTitle || "";
state.currentResolvedTargetPostNumber = null;
state.currentTargetSpec = null;
state.currentTopic = null;
state.currentLatestRepliesTopic = null;
state.deferOwnerFilterAutoLoad = false;
state.loadMoreError = "";
state.isLoadingMorePosts = false;
state.isRefreshingLatestReplies = false;
resetReplyComposer();
state.title.textContent = fallbackTitle || "加载中…";
state.meta.textContent = "正在载入帖子内容…";
state.openInTab.href = topicUrl;
state.content.innerHTML = renderLoading();
highlightLink(activeLink);
syncNavigationState();
document.body.classList.add(PAGE_OPEN_CLASS);
state.root.setAttribute("aria-hidden", "false");
setIframeModeEnabled(state.settings.previewMode === "iframe");
applyDrawerMode();
updateSettingsPopoverPosition();
scheduleTopicTrackerPositionSync();
syncLatestRepliesRefreshUI();
loadTopic(topicUrl, fallbackTitle, topicIdHint);
}
function closeDrawer() {
if (state.abortController) {
state.abortController.abort();
state.abortController = null;
}
cancelLoadMoreRequest();
cancelReplyRequest();
document.body.classList.remove(PAGE_OPEN_CLASS);
document.body.classList.remove("ld-drawer-mode-overlay");
setIframeModeEnabled(false);
state.root?.setAttribute("aria-hidden", "true");
state.currentUrl = "";
state.currentEntryElement = null;
state.currentEntryKey = "";
state.currentTopicIdHint = null;
state.currentTopicTrackingKey = "";
state.currentViewTracked = false;
state.currentTrackRequest = null;
state.currentTrackRequestKey = "";
state.currentResolvedTargetPostNumber = null;
state.currentFallbackTitle = "";
state.currentTopic = null;
state.currentLatestRepliesTopic = null;
state.currentTargetSpec = null;
state.deferOwnerFilterAutoLoad = false;
state.isRefreshingLatestReplies = false;
state.meta.textContent = "";
state.loadMoreError = "";
state.isLoadingMorePosts = false;
resetReplyComposer();
closeImagePreview();
clearHighlight();
setSettingsPanelOpen(false);
syncNavigationState();
syncLatestRepliesRefreshUI();
scheduleTopicTrackerPositionSync();
}
function handleTopicTrackerClick(target) {
const clickable = getTopicTrackerClickable(target);
if (!clickable) {
return false;
}
armTopicTrackerRefreshSync();
return true;
}
function getTopicTrackerClickable(target = document) {
if (!(target instanceof Element) && !(target instanceof Document)) {
return null;
}
const clickable = target instanceof Document
? target.querySelector(TOPIC_TRACKER_CLICKABLE_SELECTOR)
: target.closest(TOPIC_TRACKER_CLICKABLE_SELECTOR);
if (!(clickable instanceof Element)) {
return null;
}
return clickable;
}
function getTopicTrackerAlignmentTarget() {
return document.querySelector(TOPIC_TRACKER_VERTICAL_SELECTOR)
|| document.querySelector(".list-controls")
|| document.querySelector(MAIN_CONTENT_SELECTOR);
}
function armTopicTrackerRefreshSync() {
clearTopicTrackerRefreshSync();
state.topicTrackerRefreshStartedAt = Date.now();
state.topicTrackerRefreshLoadingObserved = isTopicTrackerLoading();
scrollDiscoveryContentToTop();
scheduleTopicTrackerPositionSync();
runTopicTrackerRefreshSync();
}
function runTopicTrackerRefreshSync() {
if (state.topicTrackerRefreshTimer) {
clearTimeout(state.topicTrackerRefreshTimer);
}
scrollDiscoveryContentToTop();
const loading = isTopicTrackerLoading();
const trackerVisible = Boolean(getTopicTrackerClickable());
if (loading) {
state.topicTrackerRefreshLoadingObserved = true;
}
const refreshFinished =
state.topicTrackerRefreshLoadingObserved && !loading;
const timeoutReached =
Date.now() - state.topicTrackerRefreshStartedAt > 2500;
if (refreshFinished || !trackerVisible || timeoutReached) {
scrollDiscoveryContentToTop();
requestAnimationFrame(() => scrollDiscoveryContentToTop());
window.setTimeout(() => scrollDiscoveryContentToTop(), 80);
clearTopicTrackerRefreshSync();
return;
}
state.topicTrackerRefreshTimer = window.setTimeout(
runTopicTrackerRefreshSync,
loading ? 80 : 140
);
}
function clearTopicTrackerRefreshSync() {
if (state.topicTrackerRefreshTimer) {
clearTimeout(state.topicTrackerRefreshTimer);
state.topicTrackerRefreshTimer = 0;
}
state.topicTrackerRefreshStartedAt = 0;
state.topicTrackerRefreshLoadingObserved = false;
}
function isTopicTrackerLoading() {
return Boolean(getTopicTrackerClickable()?.classList.contains("loading"));
}
function scrollDiscoveryContentToTop() {
const scrollingElement = document.scrollingElement || document.documentElement;
const scrollTop = 0;
const html = document.documentElement;
const body = document.body;
const previousHtmlBehavior = html.style.scrollBehavior;
const previousBodyBehavior = body.style.scrollBehavior;
html.style.scrollBehavior = "auto";
body.style.scrollBehavior = "auto";
window.scrollTo(0, scrollTop);
scrollingElement.scrollTop = scrollTop;
html.scrollTop = scrollTop;
body.scrollTop = scrollTop;
requestAnimationFrame(() => {
window.scrollTo(0, scrollTop);
scrollingElement.scrollTop = scrollTop;
html.scrollTop = scrollTop;
body.scrollTop = scrollTop;
});
requestAnimationFrame(() => {
html.style.scrollBehavior = previousHtmlBehavior;
body.style.scrollBehavior = previousBodyBehavior;
});
}
function highlightLink(link) {
clearHighlight();
state.activeLink = link;
state.activeLink?.classList.add(ACTIVE_LINK_CLASS);
syncNavigationState();
}
function clearHighlight() {
state.activeLink?.classList.remove(ACTIVE_LINK_CLASS);
state.activeLink = null;
}
function getTopicEntries() {
const entries = [];
const seen = new WeakSet();
const duplicateCounts = new Map();
const mainContent = document.querySelector(MAIN_CONTENT_SELECTOR);
if (!(mainContent instanceof Element)) {
return entries;
}
for (const link of mainContent.querySelectorAll(PRIMARY_TOPIC_LINK_SELECTOR)) {
if (!(link instanceof HTMLAnchorElement)) {
continue;
}
const url = getTopicUrlFromLink(link);
if (!url) {
continue;
}
const entryElement = getTopicEntryContainer(link);
if (seen.has(entryElement)) {
continue;
}
seen.add(entryElement);
const occurrence = (duplicateCounts.get(url) || 0) + 1;
duplicateCounts.set(url, occurrence);
entries.push({
entryElement,
entryKey: buildEntryKey(url, occurrence),
topicIdHint: getTopicIdHintFromLink(link) || getTopicIdFromUrl(url),
url,
title: link.textContent.trim() || url,
link
});
}
return entries;
}
function resolveCurrentEntryIndex(entries) {
if (!Array.isArray(entries) || !entries.length) {
return -1;
}
if (state.currentEntryKey) {
const indexByKey = entries.findIndex((entry) => entry.entryKey === state.currentEntryKey);
if (indexByKey !== -1) {
return indexByKey;
}
}
if (state.currentEntryElement) {
const indexByElement = entries.findIndex((entry) => entry.entryElement === state.currentEntryElement);
if (indexByElement !== -1) {
return indexByElement;
}
}
return entries.findIndex((entry) => entry.url === state.currentUrl);
}
function syncNavigationState() {
if (!state.prevButton || !state.nextButton) {
return;
}
const entries = getTopicEntries();
const currentIndex = resolveCurrentEntryIndex(entries);
const hasDrawerOpen = Boolean(state.currentUrl);
state.prevButton.disabled = !hasDrawerOpen || currentIndex <= 0;
state.nextButton.disabled = !hasDrawerOpen || currentIndex === -1 || currentIndex >= entries.length - 1;
}
function navigateTopic(offset) {
const entries = getTopicEntries();
const currentIndex = resolveCurrentEntryIndex(entries);
const nextEntry = currentIndex === -1 ? null : entries[currentIndex + offset];
if (!nextEntry) {
syncNavigationState();
return;
}
nextEntry.link.scrollIntoView({ block: "nearest" });
openDrawer(nextEntry.url, nextEntry.title, nextEntry.link);
}
async function loadTopic(topicUrl, fallbackTitle, topicIdHint = null, options = {}) {
closeImagePreview();
cancelLoadMoreRequest();
state.isLoadingMorePosts = false;
state.loadMoreError = "";
if (state.abortController) {
state.abortController.abort();
state.abortController = null;
}
if (state.settings.previewMode === "iframe") {
renderIframeFallback(topicUrl, fallbackTitle, null, true);
syncLatestRepliesRefreshUI();
return;
}
if (!state.currentViewTracked) {
state.currentTrackRequest = null;
state.currentTrackRequestKey = "";
}
const controller = new AbortController();
state.abortController = controller;
try {
const targetSpec = getTopicTargetSpec(topicUrl, topicIdHint);
let resolvedTargetPostNumber = null;
let topic;
let targetedTopic = null;
let latestRepliesTopic = null;
if (state.currentViewTracked) {
topic = await fetchTrackedTopicJson(topicUrl, controller.signal, topicIdHint, {
canonical: true,
trackVisit: false
});
} else {
topic = await ensureTrackedTopicVisit(topicUrl, topicIdHint, controller.signal);
}
if (shouldFetchTargetedTopic(topic, targetSpec)) {
targetedTopic = await fetchTrackedTopicJson(topicUrl, controller.signal, topicIdHint, {
canonical: false,
trackVisit: false
});
topic = mergeTopicPreviewData(topic, targetedTopic);
resolvedTargetPostNumber = resolveTopicTargetPostNumber(targetSpec, topic, targetedTopic);
} else {
resolvedTargetPostNumber = resolveTopicTargetPostNumber(targetSpec, topic, null);
}
if (shouldLoadLatestRepliesTopic(topic, targetSpec)) {
if (targetSpec?.targetToken === "last" && targetedTopic) {
latestRepliesTopic = targetedTopic;
} else {
try {
latestRepliesTopic = await fetchLatestRepliesTopic(topicUrl, controller.signal, topicIdHint);
} catch (latestError) {
if (controller.signal.aborted) {
throw latestError;
}
latestRepliesTopic = null;
}
}
}
if (controller.signal.aborted || state.currentUrl !== topicUrl) {
return;
}
renderTopic(topic, topicUrl, fallbackTitle, resolvedTargetPostNumber, {
latestRepliesTopic,
targetSpec,
preserveScrollTop: options.preserveScrollTop
});
} catch (error) {
if (controller.signal.aborted) {
return;
}
renderIframeFallback(topicUrl, fallbackTitle, error);
} finally {
if (state.abortController === controller) {
state.abortController = null;
}
syncLatestRepliesRefreshUI();
}
}
function renderTopic(topic, topicUrl, fallbackTitle, resolvedTargetPostNumber = null, options = {}) {
setIframeModeEnabled(false);
const posts = topic?.post_stream?.posts || [];
if (!posts.length) {
renderIframeFallback(topicUrl, fallbackTitle, new Error("No posts available"));
return;
}
const targetSpec = options.targetSpec || getTopicTargetSpec(topicUrl, state.currentTopicIdHint);
const latestRepliesTopic = options.latestRepliesTopic || null;
const viewModel = buildTopicViewModel(topic, latestRepliesTopic, targetSpec);
const shouldPreserveScroll = Number.isFinite(options.preserveScrollTop);
state.currentTopic = topic;
state.currentLatestRepliesTopic = latestRepliesTopic;
state.currentTargetSpec = targetSpec;
state.currentTopicIdHint = typeof topic?.id === "number" ? topic.id : state.currentTopicIdHint;
state.currentResolvedTargetPostNumber = resolvedTargetPostNumber;
state.deferOwnerFilterAutoLoad = shouldDeferOwnerFilterAutoLoad(viewModel);
state.title.textContent = topic.title || fallbackTitle || "帖子预览";
state.meta.textContent = buildTopicMeta(topic, viewModel.posts.length);
state.content.replaceChildren(buildTopicView(topic, viewModel));
syncLatestRepliesRefreshUI();
syncReplyUI();
if (shouldPreserveScroll && state.drawerBody) {
state.drawerBody.scrollTop = options.preserveScrollTop;
} else {
scrollTopicViewToTargetPost(resolvedTargetPostNumber);
}
updateLoadMoreStatus();
queueAutoLoadCheck();
}
function buildTopicView(topic, viewModel) {
const wrapper = document.createElement("div");
wrapper.className = "ld-topic-view";
const visiblePosts = viewModel.posts;
const basePosts = topic?.post_stream?.posts || [];
const topicOwner = getTopicOwnerIdentity(topic);
if (!state.hasShownPreviewNotice) {
const notice = document.createElement("div");
notice.className = "ld-topic-note ld-topic-note-warning";
notice.textContent = "抽屉预览是便捷阅读视图,标签和回复顺序可能与原帖页略有差异;需要完整阅读时可点右上角“新标签打开”。";
wrapper.appendChild(notice);
state.hasShownPreviewNotice = true;
}
if (Array.isArray(topic.tags) && topic.tags.length) {
const tagList = document.createElement("div");
tagList.className = "ld-tag-list";
for (const tag of topic.tags) {
const label = getTagLabel(tag);
if (!label) {
continue;
}
const item = document.createElement("span");
item.className = "ld-tag";
item.textContent = label;
tagList.appendChild(item);
}
if (tagList.childElementCount > 0) {
wrapper.appendChild(tagList);
}
}
const postList = document.createElement("div");
postList.className = "ld-topic-post-list";
for (const post of visiblePosts) {
postList.appendChild(buildPostCard(post, topicOwner));
}
wrapper.appendChild(postList);
const totalPosts = topic?.posts_count || basePosts.length;
const footer = document.createElement("div");
footer.className = "ld-topic-footer";
if (state.settings.postMode === "first" && basePosts.length > 1) {
const note = document.createElement("div");
note.className = "ld-topic-note";
note.textContent = `当前为"仅首帖"模式。想看回复,可在右上角选项里切回"完整主题"。`;
footer.appendChild(note);
}
const replyModeNote = buildReplyModeNote(viewModel);
if (replyModeNote) {
const note = document.createElement("div");
note.className = "ld-topic-note";
note.textContent = replyModeNote;
footer.appendChild(note);
}
const authorFilterNote = buildAuthorFilterNote(viewModel, topicOwner);
if (authorFilterNote) {
const note = document.createElement("div");
note.className = "ld-topic-note";
note.textContent = authorFilterNote;
footer.appendChild(note);
}
if (viewModel.hasHiddenPosts) {
const note = document.createElement("div");
note.className = "ld-topic-note";
note.textContent = viewModel.canAutoLoadMore
? `当前已加载 ${visiblePosts.length} / ${totalPosts} 条帖子,继续下滑会自动加载更多回复。`
: `当前抽屉预览了 ${visiblePosts.length} / ${totalPosts} 条帖子,完整内容可点右上角“新标签打开”。`;
footer.appendChild(note);
}
if (viewModel.canAutoLoadMore) {
const status = document.createElement("div");
status.className = "ld-topic-note ld-topic-note-loading";
status.setAttribute("aria-live", "polite");
footer.appendChild(status);
state.loadMoreStatus = status;
} else {
state.loadMoreStatus = null;
}
if (footer.childElementCount > 0) {
wrapper.appendChild(footer);
}
return wrapper;
}
function buildTopicViewModel(topic, latestRepliesTopic = null, targetSpec = null) {
const posts = topic?.post_stream?.posts || [];
const moreAvailable = hasMoreTopicPosts(topic);
if (state.settings.postMode === "first") {
return applyAuthorFilterToViewModel({
posts: posts.slice(0, 1),
mode: "first",
canAutoLoadMore: false,
hasHiddenPosts: posts.length > 1 || moreAvailable
}, topic);
}
if (targetSpec?.targetPostNumber) {
return applyAuthorFilterToViewModel({
posts,
mode: "targeted",
targetPostNumber: targetSpec.targetPostNumber,
canAutoLoadMore: false,
hasHiddenPosts: moreAvailable
}, topic);
}
if (state.settings.replyOrder !== "latestFirst" || posts.length <= 1) {
return applyAuthorFilterToViewModel({
posts,
mode: "default",
canAutoLoadMore: !targetSpec?.hasTarget,
hasHiddenPosts: moreAvailable
}, topic);
}
if (topicHasCompletePostStream(topic)) {
return applyAuthorFilterToViewModel({
posts: [posts[0], ...posts.slice(1).reverse()],
mode: "latestComplete",
canAutoLoadMore: false,
hasHiddenPosts: false
}, topic);
}
if (latestRepliesTopic) {
return applyAuthorFilterToViewModel({
posts: getLatestRepliesDisplayPosts(topic, latestRepliesTopic),
mode: "latestWindow",
canAutoLoadMore: false,
hasHiddenPosts: moreAvailable
}, topic);
}
return applyAuthorFilterToViewModel({
posts,
mode: "latestUnavailable",
canAutoLoadMore: false,
hasHiddenPosts: moreAvailable
}, topic);
}
function applyAuthorFilterToViewModel(viewModel, topic) {
if (!viewModel || state.settings.authorFilter !== "topicOwner") {
return {
...(viewModel || {}),
authorFilter: "all",
filterHiddenCount: 0,
filterUnavailable: false,
preservedTargetPostNumber: null
};
}
const topicOwner = getTopicOwnerIdentity(topic);
const sourcePosts = Array.isArray(viewModel.posts) ? viewModel.posts : [];
if (!topicOwner) {
return {
...viewModel,
authorFilter: "topicOwner",
filterHiddenCount: 0,
filterUnavailable: true,
preservedTargetPostNumber: null
};
}
const targetPostNumber = viewModel.mode === "targeted" && Number.isFinite(viewModel.targetPostNumber)
? Number(viewModel.targetPostNumber)
: null;
let preservedTargetPostNumber = null;
const filteredPosts = sourcePosts.filter((post) => {
if (isTopicOwnerPost(post, topicOwner)) {
return true;
}
if (targetPostNumber !== null && Number(post?.post_number) === targetPostNumber) {
preservedTargetPostNumber = targetPostNumber;
return true;
}
return false;
});
return {
...viewModel,
posts: filteredPosts,
authorFilter: "topicOwner",
filterHiddenCount: Math.max(0, sourcePosts.length - filteredPosts.length),
filterUnavailable: false,
preservedTargetPostNumber,
hasHiddenPosts: Boolean(viewModel.hasHiddenPosts) || filteredPosts.length !== sourcePosts.length
};
}
function getTagLabel(tag) {
if (typeof tag === "string") {
return tag;
}
if (!tag || typeof tag !== "object") {
return "";
}
return tag.name || tag.id || tag.text || tag.label || "";
}
function buildPostCard(post, topicOwner = null) {
const article = document.createElement("article");
article.className = "ld-post-card";
if (typeof post.post_number === "number") {
article.dataset.postNumber = String(post.post_number);
}
const header = document.createElement("div");
header.className = "ld-post-header";
const avatar = document.createElement("img");
avatar.className = "ld-post-avatar";
avatar.alt = post.username || "avatar";
avatar.loading = "lazy";
avatar.src = avatarUrl(post.avatar_template);
const authorBlock = document.createElement("div");
authorBlock.className = "ld-post-author";
const authorRow = document.createElement("div");
authorRow.className = "ld-post-author-row";
const displayName = document.createElement("strong");
displayName.textContent = post.name || post.username || "匿名用户";
const username = document.createElement("span");
username.className = "ld-post-username";
username.textContent = post.username ? `@${post.username}` : "";
authorRow.append(displayName, username);
const topicOwnerBadge = buildTopicOwnerBadge(post, topicOwner);
if (topicOwnerBadge) {
authorRow.appendChild(topicOwnerBadge);
}
const meta = document.createElement("div");
meta.className = "ld-post-meta";
meta.textContent = buildPostMeta(post);
authorBlock.append(authorRow, meta);
header.append(avatar, authorBlock);
const replyToTab = buildReplyToTab(post);
const body = document.createElement("div");
body.className = "ld-post-body cooked";
body.innerHTML = post.cooked || "";
for (const link of body.querySelectorAll("a[href]")) {
link.target = "_blank";
link.rel = "noopener noreferrer";
}
const postInfos = buildPostInfos(post);
const actions = document.createElement("div");
actions.className = "ld-post-actions";
// --- Left group: copy link, bookmark, flag ---
const actionsLeft = document.createElement("div");
actionsLeft.className = "ld-post-actions-left";
const copyLinkBtn = document.createElement("button");
copyLinkBtn.type = "button";
copyLinkBtn.className = "ld-post-icon-btn";
copyLinkBtn.setAttribute("aria-label", "复制帖子链接");
copyLinkBtn.title = "将此帖子的链接复制到剪贴板";
copyLinkBtn.innerHTML = `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71"/><path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71"/></svg>`;
copyLinkBtn.addEventListener("click", () => handleCopyPostLink(copyLinkBtn, post));
const isBookmarked = post.bookmarked === true;
const bookmarkBtn = document.createElement("button");
bookmarkBtn.type = "button";
bookmarkBtn.className = "ld-post-icon-btn" + (isBookmarked ? " ld-post-icon-btn--bookmarked" : "");
bookmarkBtn.setAttribute("aria-label", isBookmarked ? "取消书签" : "添加书签");
bookmarkBtn.title = "将此帖子加入书签";
bookmarkBtn.innerHTML = isBookmarked
? `<svg viewBox="0 0 24 24" fill="currentColor" aria-hidden="true"><path d="M5 4a2 2 0 0 1 2-2h10a2 2 0 0 1 2 2v17l-7-3.5L5 21V4z"/></svg>`
: `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M5 4a2 2 0 0 1 2-2h10a2 2 0 0 1 2 2v17l-7-3.5L5 21V4z"/></svg>`;
bookmarkBtn.addEventListener("click", () => handlePostBookmark(bookmarkBtn, post));
const flagWrap = document.createElement("div");
flagWrap.className = "ld-flag-wrap";
const flagBtn = document.createElement("button");
flagBtn.type = "button";
flagBtn.className = "ld-post-icon-btn ld-post-icon-btn--flag";
flagBtn.setAttribute("aria-label", "举报此帖子");
flagBtn.title = "以私密方式举报此帖子以引起注意,或发送一个关于它的个人消息";
flagBtn.innerHTML = `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M4 15s1-1 4-1 5 2 8 2 4-1 4-1V3s-1 1-4 1-5-2-8-2-4 1-4 1z"/><line x1="4" y1="22" x2="4" y2="15"/></svg>`;
const flagPopover = buildFlagPopover(post);
flagPopover.setAttribute("hidden", "");
flagBtn.addEventListener("click", (e) => {
e.stopPropagation();
const isHidden = flagPopover.hasAttribute("hidden");
closeAllPopovers();
if (isHidden) {
flagPopover.removeAttribute("hidden");
}
});
flagWrap.append(flagBtn, flagPopover);
actionsLeft.append(copyLinkBtn, bookmarkBtn, flagWrap);
// --- Right group: reactions, reply ---
const actionsRight = document.createElement("div");
actionsRight.className = "ld-post-actions-right";
const reactWrap = document.createElement("div");
reactWrap.className = "ld-post-react-wrap";
const postReactions = Array.isArray(post.reactions) ? post.reactions : [];
const likeAction = Array.isArray(post.actions_summary)
? post.actions_summary.find((a) => a.id === 2)
: null;
const hasReacted = postReactions.some((r) => r.reacted === true) || likeAction?.acted === true;
const reactCount = postReactions.reduce((sum, r) => sum + (r.count || 0), 0)
|| post.like_count
|| likeAction?.count
|| 0;
const reactBtn = document.createElement("button");
reactBtn.type = "button";
reactBtn.className = "ld-post-react-btn" + (hasReacted ? " ld-post-react-btn--reacted" : "");
reactBtn.setAttribute("aria-label", hasReacted ? "取消反应" : "添加反应");
reactBtn.innerHTML = `
<span class="ld-post-react-btn-icon" aria-hidden="true">
<svg viewBox="0 0 24 24" focusable="false">
<path d="M12 21.35l-1.45-1.32C5.4 15.36 2 12.28 2 8.5 2 5.42 4.42 3 7.5 3c1.74 0 3.41.81 4.5 2.09C13.09 3.81 14.76 3 16.5 3 19.58 3 22 5.42 22 8.5c0 3.78-3.4 6.86-8.55 11.54L12 21.35z" fill="currentColor"/>
</svg>
</span>
${reactCount > 0 ? `<span class="ld-post-react-count">${reactCount}</span>` : ""}
`;
const reactionsPopover = document.createElement("div");
reactionsPopover.className = "ld-reactions-popover";
reactionsPopover.setAttribute("hidden", "");
let reactHideTimer = null;
function showReactionsPopover() {
clearTimeout(reactHideTimer);
closeAllPopovers();
reactionsPopover.removeAttribute("hidden");
if (!reactionsPopover.dataset.loaded) {
reactionsPopover.dataset.loaded = "1";
populateReactionsPopover(reactionsPopover, post, reactBtn);
}
}
function hideReactionsPopoverDelayed() {
reactHideTimer = setTimeout(() => {
reactionsPopover.setAttribute("hidden", "");
}, 250);
}
reactWrap.addEventListener("mouseenter", showReactionsPopover);
reactWrap.addEventListener("mouseleave", hideReactionsPopoverDelayed);
reactionsPopover.addEventListener("mouseenter", () => clearTimeout(reactHideTimer));
reactionsPopover.addEventListener("mouseleave", hideReactionsPopoverDelayed);
reactBtn.addEventListener("click", () => {
reactionsPopover.setAttribute("hidden", "");
handlePostReact(reactBtn, post, "heart", reactionsPopover);
});
reactWrap.append(reactBtn, reactionsPopover);
const replyButton = document.createElement("button");
replyButton.type = "button";
replyButton.className = "ld-post-reply-button";
replyButton.setAttribute("aria-label", `回复第 ${post.post_number || "?"} 条`);
replyButton.innerHTML = `
<span class="ld-post-reply-button-icon" aria-hidden="true">
<svg viewBox="0 0 24 24" focusable="false">
<path d="M4 12.5c0-4.14 3.36-7.5 7.5-7.5h7a1.5 1.5 0 0 1 0 3h-7A4.5 4.5 0 0 0 7 12.5v1.38l1.44-1.44a1.5 1.5 0 0 1 2.12 2.12l-4 4a1.5 1.5 0 0 1-2.12 0l-4-4a1.5 1.5 0 1 1 2.12-2.12L4 13.88V12.5Z" fill="currentColor"></path>
</svg>
</span>
<span class="ld-post-reply-button-label">回复这条</span>
`;
replyButton.addEventListener("click", () => openReplyPanelForPost(post));
actionsRight.append(reactWrap, replyButton);
actions.append(actionsLeft, actionsRight);
if (replyToTab) {
article.append(header, replyToTab, body);
} else {
article.append(header, body);
}
if (postInfos) {
article.appendChild(postInfos);
}
article.appendChild(actions);
return article;
}
function handleDrawerBodyScroll() {
maybeLoadMorePosts();
}
function toggleReplyPanel() {
if (!state.currentTopic || state.isReplySubmitting) {
return;
}
if (state.replyPanel?.hidden) {
setReplyTarget(null);
}
setReplyPanelOpen(state.replyPanel?.hidden);
}
function openReplyPanelForPost(post) {
if (!state.currentTopic || !post || state.isReplySubmitting) {
return;
}
setReplyTarget(post);
setReplyPanelOpen(true);
}
function forEachReplyTriggerButton(callback) {
for (const button of [state.replyToggleButton, state.replyFabButton]) {
if (button instanceof HTMLButtonElement) {
callback(button);
}
}
}
function setReplyPanelOpen(isOpen) {
if (!state.replyPanel) {
return;
}
if (isOpen && !state.currentTopic) {
return;
}
state.replyPanel.hidden = !isOpen;
forEachReplyTriggerButton((button) => {
button.setAttribute("aria-expanded", String(isOpen));
});
if (!isOpen) {
setReplyTarget(null);
return;
}
queueMicrotask(() => state.replyTextarea?.focus());
}
function setReplyTarget(post) {
if (post && typeof post === "object" && Number.isFinite(post.post_number)) {
state.replyTargetPostNumber = Number(post.post_number);
state.replyTargetLabel = buildReplyTargetLabel(post);
} else {
state.replyTargetPostNumber = null;
state.replyTargetLabel = "";
}
syncReplyUI();
}
function buildReplyTargetLabel(post) {
const parts = [];
if (Number.isFinite(post?.post_number)) {
parts.push(`#${post.post_number}`);
}
if (post?.username) {
parts.push(`@${post.username}`);
}
return parts.join(" ") || "这条回复";
}
function handleReplyTextareaKeydown(event) {
if (!event.metaKey && !event.ctrlKey) {
return;
}
if (event.key !== "Enter") {
return;
}
event.preventDefault();
handleReplySubmit();
}
function handleReplyTextareaPaste(event) {
if (
event.defaultPrevented ||
event.target !== state.replyTextarea ||
!state.currentTopic ||
state.isReplySubmitting
) {
return;
}
const files = getReplyPasteImageFiles(event);
if (!files.length) {
return;
}
event.preventDefault();
queueReplyPasteUploads(files).catch(() => {});
}
function getReplyPasteImageFiles(event) {
const clipboardData = event?.clipboardData;
if (!clipboardData) {
return [];
}
const types = Array.from(clipboardData.types || []);
if (types.includes("text/plain") || types.includes("text/html")) {
return [];
}
return Array.from(clipboardData.files || [])
.map(normalizeReplyUploadFile)
.filter((file) => file instanceof File && isImageUploadFile(file));
}
function normalizeReplyUploadFile(file) {
if (!(file instanceof Blob)) {
return null;
}
const fileName = resolveReplyUploadFileName(file);
if (file instanceof File && file.name) {
return file;
}
if (typeof File === "function") {
return new File([file], fileName, {
type: file.type || "image/png",
lastModified: file instanceof File ? file.lastModified : Date.now()
});
}
try {
file.name = fileName;
} catch {
// 某些浏览器实现里 name 只读,忽略即可。
}
return file;
}
function resolveReplyUploadFileName(file) {
const originalName = typeof file?.name === "string"
? file.name.trim()
: "";
if (originalName) {
return originalName;
}
return `image.${mimeTypeToFileExtension(file?.type)}`;
}
function mimeTypeToFileExtension(mimeType) {
const normalized = String(mimeType || "").toLowerCase();
if (normalized === "image/jpeg") {
return "jpg";
}
if (normalized === "image/svg+xml") {
return "svg";
}
const match = normalized.match(/^image\/([a-z0-9.+-]+)$/i);
if (!match) {
return "png";
}
return match[1].replace("svg+xml", "svg");
}
function isImageUploadFile(file) {
if (!(file instanceof File)) {
return false;
}
if (String(file.type || "").toLowerCase().startsWith("image/")) {
return true;
}
return isImageUploadName(file.name || "");
}
async function queueReplyPasteUploads(files) {
if (!state.replyTextarea || !state.currentTopic) {
return;
}
const sessionId = state.replyComposerSessionId;
const placeholders = insertReplyUploadPlaceholders(files);
if (!placeholders.length) {
return;
}
state.replyUploadPendingCount += placeholders.length;
syncReplyUI();
updateReplyUploadStatus();
const results = await Promise.allSettled(
placeholders.map((entry) => uploadReplyPasteFile(entry, sessionId))
);
if (sessionId !== state.replyComposerSessionId || state.replyUploadPendingCount > 0 || !state.replyStatus) {
return;
}
const successCount = results.filter((result) => result.status === "fulfilled").length;
const failures = results.filter((result) => result.status === "rejected");
if (!failures.length) {
state.replyStatus.textContent = successCount > 1
? `已上传 ${successCount} 张图片,已插入回复内容。`
: "图片已上传,已插入回复内容。";
return;
}
if (!successCount) {
state.replyStatus.textContent = failures.length > 1
? `图片上传失败(${failures.length} 张):${failures.map((item) => item.reason?.message || "未知错误").join(";")}`
: `图片上传失败:${failures[0].reason?.message || "未知错误"}`;
return;
}
state.replyStatus.textContent = `图片上传完成:${successCount} 张成功,${failures.length} 张失败。`;
}
function insertReplyUploadPlaceholders(files) {
if (!state.replyTextarea) {
return [];
}
const entries = files.map((file) => buildReplyUploadPlaceholder(file));
insertReplyTextareaText(entries.map((entry) => entry.insertedText).join(""));
return entries;
}
function buildReplyUploadPlaceholder(file) {
const uploadId = `ld-upload-${Date.now()}-${++state.replyUploadSerial}`;
const visibleLabel = `[图片上传中:${sanitizeReplyUploadFileName(file.name || "image.png")}]`;
const marker = `${REPLY_UPLOAD_MARKER}${uploadId}${REPLY_UPLOAD_MARKER}${visibleLabel}${REPLY_UPLOAD_MARKER}/${uploadId}${REPLY_UPLOAD_MARKER}`;
return {
file,
marker,
insertedText: `${marker}\n`
};
}
function sanitizeReplyUploadFileName(fileName) {
return String(fileName || "image.png")
.replace(/\s+/g, " ")
.trim();
}
function insertReplyTextareaText(text) {
if (!state.replyTextarea) {
return;
}
const textarea = state.replyTextarea;
const start = Number.isFinite(textarea.selectionStart)
? textarea.selectionStart
: textarea.value.length;
const end = Number.isFinite(textarea.selectionEnd)
? textarea.selectionEnd
: start;
textarea.focus();
textarea.setRangeText(text, start, end, "end");
textarea.dispatchEvent(new Event("input", { bubbles: true }));
}
async function uploadReplyPasteFile(entry, sessionId) {
const controller = new AbortController();
addReplyUploadController(controller);
try {
const upload = await createComposerUpload(entry.file, controller.signal, { pasted: true });
if (controller.signal.aborted || sessionId !== state.replyComposerSessionId) {
return upload;
}
const markdown = buildComposerUploadMarkdown(upload);
const inserted = replaceReplyUploadPlaceholder(entry.marker, `${markdown}\n`);
if (!inserted) {
insertReplyTextareaText(`\n${markdown}\n`);
}
return upload;
} catch (error) {
if (!controller.signal.aborted && sessionId === state.replyComposerSessionId) {
removeReplyUploadPlaceholder(entry.marker);
}
if (controller.signal.aborted) {
return null;
}
throw error;
} finally {
removeReplyUploadController(controller);
if (state.replyUploadPendingCount > 0) {
state.replyUploadPendingCount -= 1;
}
syncReplyUI();
if (sessionId === state.replyComposerSessionId && state.replyUploadPendingCount > 0) {
updateReplyUploadStatus();
}
}
}
function replaceReplyUploadPlaceholder(marker, replacement) {
return replaceReplyTextareaText(marker, replacement);
}
function removeReplyUploadPlaceholder(marker) {
replaceReplyTextareaText(marker, "");
}
function replaceReplyTextareaText(searchText, replacementText) {
if (!state.replyTextarea) {
return false;
}
const textarea = state.replyTextarea;
const start = textarea.value.indexOf(searchText);
if (start === -1) {
return false;
}
textarea.setRangeText(
replacementText,
start,
start + searchText.length,
"preserve"
);
textarea.dispatchEvent(new Event("input", { bubbles: true }));
return true;
}
function addReplyUploadController(controller) {
state.replyUploadControllers.push(controller);
}
function removeReplyUploadController(controller) {
state.replyUploadControllers = state.replyUploadControllers.filter((item) => item !== controller);
}
function cancelReplyUploads() {
for (const controller of state.replyUploadControllers) {
controller.abort();
}
state.replyUploadControllers = [];
state.replyUploadPendingCount = 0;
}
function updateReplyUploadStatus() {
if (!state.replyStatus || state.replyUploadPendingCount <= 0) {
return;
}
state.replyStatus.textContent = state.replyUploadPendingCount > 1
? `正在上传 ${state.replyUploadPendingCount} 张图片...`
: "正在上传图片...";
}
async function handleReplySubmit() {
if (!state.currentTopic || state.isReplySubmitting || !state.replyTextarea || !state.replyStatus) {
return;
}
if (state.replyUploadPendingCount > 0) {
state.replyStatus.textContent = state.replyUploadPendingCount > 1
? `还有 ${state.replyUploadPendingCount} 张图片正在上传,请稍候再发送。`
: "图片还在上传中,请稍候再发送。";
return;
}
const raw = state.replyTextarea.value.trim();
if (!raw) {
state.replyStatus.textContent = "先写点内容再发送。";
state.replyTextarea.focus();
return;
}
cancelReplyRequest();
state.isReplySubmitting = true;
syncReplyUI();
state.replyStatus.textContent = "正在发送回复...";
const controller = new AbortController();
state.replyAbortController = controller;
try {
const createdPost = await createTopicReply(
state.currentTopic.id,
raw,
controller.signal,
state.replyTargetPostNumber
);
if (controller.signal.aborted) {
return;
}
state.replyTextarea.value = "";
state.replyStatus.textContent = "回复已发送。";
appendCreatedReplyToCurrentTopic(createdPost);
setReplyPanelOpen(false);
} catch (error) {
if (controller.signal.aborted) {
return;
}
state.replyStatus.textContent = error?.message || "回复发送失败";
} finally {
if (state.replyAbortController === controller) {
state.replyAbortController = null;
}
state.isReplySubmitting = false;
syncReplyUI();
}
}
function queueAutoLoadCheck() {
requestAnimationFrame(() => {
maybeLoadMorePosts();
});
}
function maybeLoadMorePosts() {
if (!state.drawerBody || !state.currentTopic) {
return;
}
if (state.settings.postMode === "first" || state.settings.replyOrder === "latestFirst" || state.currentTargetSpec?.hasTarget || state.isLoadingMorePosts || !hasMoreTopicPosts(state.currentTopic)) {
updateLoadMoreStatus();
return;
}
if (state.deferOwnerFilterAutoLoad && state.drawerBody.scrollTop <= 0) {
updateLoadMoreStatus();
return;
}
const remainingDistance = state.drawerBody.scrollHeight - state.drawerBody.scrollTop - state.drawerBody.clientHeight;
if (remainingDistance > LOAD_MORE_TRIGGER_OFFSET) {
updateLoadMoreStatus();
return;
}
loadMorePosts().catch(() => {});
}
async function loadMorePosts() {
if (!state.currentTopic || state.isLoadingMorePosts || state.currentTargetSpec?.hasTarget) {
return;
}
const nextPostIds = getNextTopicPostIds(state.currentTopic);
if (!nextPostIds.length) {
updateLoadMoreStatus();
return;
}
cancelLoadMoreRequest();
state.isLoadingMorePosts = true;
state.loadMoreError = "";
updateLoadMoreStatus();
const controller = new AbortController();
const currentUrl = state.currentUrl;
const previousScrollTop = state.drawerBody?.scrollTop || 0;
state.loadMoreAbortController = controller;
try {
const posts = await fetchTopicPostsBatch(currentUrl, nextPostIds, controller.signal, state.currentTopicIdHint);
if (controller.signal.aborted || state.currentUrl !== currentUrl || !posts.length) {
return;
}
const nextTopic = mergeTopicPreviewData(state.currentTopic, {
posts_count: state.currentTopic.posts_count,
post_stream: {
posts
}
});
state.isLoadingMorePosts = false;
state.loadMoreError = "";
renderTopic(nextTopic, currentUrl, state.currentFallbackTitle, state.currentResolvedTargetPostNumber, {
targetSpec: state.currentTargetSpec,
preserveScrollTop: previousScrollTop
});
} catch (error) {
if (controller.signal.aborted) {
return;
}
state.isLoadingMorePosts = false;
state.loadMoreError = error?.message || "加载更多失败";
updateLoadMoreStatus();
} finally {
if (state.loadMoreAbortController === controller) {
state.loadMoreAbortController = null;
}
}
}
function cancelLoadMoreRequest() {
if (state.loadMoreAbortController) {
state.loadMoreAbortController.abort();
state.loadMoreAbortController = null;
}
}
function cancelReplyRequest() {
if (state.replyAbortController) {
state.replyAbortController.abort();
state.replyAbortController = null;
}
}
function updateLoadMoreStatus() {
if (!state.loadMoreStatus) {
return;
}
if (!state.currentTopic || state.currentTargetSpec?.hasTarget) {
state.loadMoreStatus.textContent = "";
state.loadMoreStatus.hidden = true;
return;
}
state.loadMoreStatus.hidden = false;
if (state.isLoadingMorePosts) {
state.loadMoreStatus.textContent = "正在加载更多回复...";
return;
}
if (state.loadMoreError) {
state.loadMoreStatus.textContent = `加载更多失败:${state.loadMoreError}`;
return;
}
if (hasMoreTopicPosts(state.currentTopic)) {
const loadedCount = (state.currentTopic.post_stream?.posts || []).length;
const totalCount = state.currentTopic.posts_count || loadedCount;
state.loadMoreStatus.textContent = `已加载 ${loadedCount} / ${totalCount},继续下滑自动加载更多`;
return;
}
state.loadMoreStatus.textContent = "已加载完当前主题内容";
}
function handleDrawerRootClick(event) {
const target = event.target;
if (!(target instanceof Element)) {
return;
}
if (!state.imagePreview?.hidden) {
if (target.closest(".ld-image-preview-close") || !target.closest(".ld-image-preview-image")) {
event.preventDefault();
closeImagePreview();
}
return;
}
const image = target.closest(".ld-post-body img");
if (!(image instanceof HTMLImageElement)) {
return;
}
event.preventDefault();
event.stopPropagation();
openImagePreview(image);
}
function openImagePreview(image) {
if (!state.imagePreview || !state.imagePreviewImage) {
return;
}
const previewSrc = getPreviewImageSrc(image);
if (!previewSrc) {
return;
}
resetImagePreviewScale();
state.imagePreviewImage.src = previewSrc;
state.imagePreviewImage.alt = image.alt || "图片预览";
state.imagePreviewImage.classList.remove("is-ready");
state.imagePreview.hidden = false;
state.imagePreview.setAttribute("aria-hidden", "false");
if (state.imagePreviewImage.complete) {
state.imagePreviewImage.classList.add("is-ready");
} else {
state.imagePreviewImage.addEventListener("load", handlePreviewImageLoad, { once: true });
state.imagePreviewImage.addEventListener("error", handlePreviewImageLoad, { once: true });
}
state.imagePreviewCloseButton?.focus();
}
function closeImagePreview() {
if (!state.imagePreview || !state.imagePreviewImage) {
return;
}
state.imagePreview.hidden = true;
state.imagePreview.setAttribute("aria-hidden", "true");
resetImagePreviewScale();
state.imagePreviewImage.classList.remove("is-ready");
state.imagePreviewImage.removeAttribute("src");
state.imagePreviewImage.alt = "图片预览";
}
function handlePreviewImageLoad() {
state.imagePreviewImage?.classList.add("is-ready");
}
function handleDrawerRootWheel(event) {
const target = event.target;
if (!(target instanceof Element)) {
return;
}
if (!state.imagePreview?.hidden && target.closest(".ld-image-preview-stage")) {
event.preventDefault();
const nextScale = clampImagePreviewScale(
state.imagePreviewScale + (event.deltaY < 0 ? IMAGE_PREVIEW_SCALE_STEP : -IMAGE_PREVIEW_SCALE_STEP)
);
if (nextScale === state.imagePreviewScale) {
return;
}
updateImagePreviewTransformOrigin(event.clientX, event.clientY);
state.imagePreviewScale = nextScale;
applyImagePreviewScale();
return;
}
if (event.deltaY <= 0 || !target.closest(".ld-drawer-body") || !shouldLoadMoreFromOwnerFilterWheel()) {
return;
}
event.preventDefault();
loadMorePosts().catch(() => {});
}
function resetImagePreviewScale() {
state.imagePreviewScale = IMAGE_PREVIEW_SCALE_MIN;
if (state.imagePreviewImage) {
state.imagePreviewImage.style.transformOrigin = "center center";
}
applyImagePreviewScale();
}
function applyImagePreviewScale() {
if (!state.imagePreview || !state.imagePreviewImage) {
return;
}
state.imagePreviewImage.style.setProperty("--ld-image-preview-scale", String(state.imagePreviewScale));
state.imagePreview.classList.toggle("is-zoomed", state.imagePreviewScale > IMAGE_PREVIEW_SCALE_MIN);
}
function clampImagePreviewScale(value) {
return Math.min(IMAGE_PREVIEW_SCALE_MAX, Math.max(IMAGE_PREVIEW_SCALE_MIN, Number(value) || IMAGE_PREVIEW_SCALE_MIN));
}
function updateImagePreviewTransformOrigin(clientX, clientY) {
if (!state.imagePreviewImage) {
return;
}
const rect = state.imagePreviewImage.getBoundingClientRect();
if (!rect.width || !rect.height) {
return;
}
const offsetX = ((clientX - rect.left) / rect.width) * 100;
const offsetY = ((clientY - rect.top) / rect.height) * 100;
const originX = Math.min(100, Math.max(0, offsetX));
const originY = Math.min(100, Math.max(0, offsetY));
state.imagePreviewImage.style.transformOrigin = `${originX}% ${originY}%`;
}
function getPreviewImageSrc(image) {
if (!(image instanceof HTMLImageElement)) {
return "";
}
const link = image.closest("a[href]");
if (link instanceof HTMLAnchorElement && looksLikeImageUrl(link.href)) {
return link.href;
}
return image.currentSrc || image.src || "";
}
function looksLikeImageUrl(url) {
try {
const parsed = new URL(url, location.href);
return /\.(avif|bmp|gif|jpe?g|png|svg|webp)(?:$|[?#])/i.test(parsed.pathname);
} catch {
return false;
}
}
function renderTopicError(topicUrl, fallbackTitle, error) {
cancelLoadMoreRequest();
cancelReplyRequest();
state.currentTopic = null;
state.currentLatestRepliesTopic = null;
state.currentTargetSpec = null;
state.currentResolvedTargetPostNumber = null;
state.deferOwnerFilterAutoLoad = false;
state.isLoadingMorePosts = false;
state.isRefreshingLatestReplies = false;
state.isReplySubmitting = false;
state.loadMoreError = "";
state.loadMoreStatus = null;
state.title.textContent = fallbackTitle || "帖子预览";
state.meta.textContent = "智能预览暂时不可用。";
resetReplyComposer();
syncLatestRepliesRefreshUI();
const container = document.createElement("div");
container.className = "ld-topic-error-state";
const errorNote = document.createElement("div");
errorNote.className = "ld-topic-note ld-topic-note-error";
errorNote.textContent = `预览加载失败:${error?.message || "未知错误"}`;
const hintNote = document.createElement("div");
hintNote.className = "ld-topic-note";
hintNote.textContent = `可以点右上角“新标签打开”查看原帖:${topicUrl}`;
container.append(errorNote, hintNote);
state.content.replaceChildren(container);
}
function renderIframeFallback(topicUrl, fallbackTitle, error, forcedIframe = false) {
setIframeModeEnabled(true);
cancelLoadMoreRequest();
cancelReplyRequest();
state.currentTopic = null;
state.currentLatestRepliesTopic = null;
state.currentTargetSpec = null;
state.currentResolvedTargetPostNumber = null;
state.deferOwnerFilterAutoLoad = false;
state.isLoadingMorePosts = false;
state.isRefreshingLatestReplies = false;
state.isReplySubmitting = false;
state.loadMoreError = "";
state.loadMoreStatus = null;
state.title.textContent = fallbackTitle || "帖子预览";
state.meta.textContent = forcedIframe ? "当前为整页模式。" : "接口预览失败,已回退为完整页面。";
resetReplyComposer();
syncLatestRepliesRefreshUI();
const container = document.createElement("div");
container.className = "ld-iframe-fallback";
if (error) {
const note = document.createElement("div");
note.className = "ld-topic-note ld-topic-note-error";
note.textContent = `预览接口不可用:${error?.message || "未知错误"}`;
container.append(note);
}
const iframe = document.createElement("iframe");
iframe.className = "ld-topic-iframe";
iframe.src = topicUrl;
iframe.loading = "lazy";
iframe.referrerPolicy = "strict-origin-when-cross-origin";
container.append(iframe);
state.content.replaceChildren(container);
}
function setIframeModeEnabled(enabled) {
state.root?.classList.toggle(IFRAME_MODE_CLASS, enabled);
document.body.classList.toggle(PAGE_IFRAME_OPEN_CLASS, Boolean(state.currentUrl) && enabled);
}
async function handleLatestRepliesRefresh() {
if (!canRefreshLatestReplies()) {
return;
}
state.isRefreshingLatestReplies = true;
syncLatestRepliesRefreshUI();
try {
await loadTopic(
state.currentUrl,
state.currentFallbackTitle,
state.currentTopicIdHint,
{ preserveScrollTop: state.drawerBody?.scrollTop }
);
} finally {
state.isRefreshingLatestReplies = false;
syncLatestRepliesRefreshUI();
}
}
function shouldRefreshCurrentTopicOnRepeatOpen() {
return canRefreshLatestReplies()
&& !state.isRefreshingLatestReplies
&& !state.abortController;
}
function refreshCurrentView() {
if (!state.currentUrl) {
return;
}
if (state.settings.previewMode === "iframe") {
if (state.abortController) {
state.abortController.abort();
state.abortController = null;
}
if (!state.currentViewTracked) {
state.currentTrackRequest = null;
state.currentTrackRequestKey = "";
}
renderIframeFallback(state.currentUrl, state.currentFallbackTitle, null, true);
syncLatestRepliesRefreshUI();
return;
}
if (state.currentTopic) {
const targetSpec = getTopicTargetSpec(state.currentUrl, state.currentTopicIdHint);
const needsTargetReload = shouldFetchTargetedTopic(state.currentTopic, targetSpec)
&& !state.currentResolvedTargetPostNumber;
const needsLatestRepliesReload = shouldLoadLatestRepliesTopic(state.currentTopic, targetSpec)
&& !state.currentLatestRepliesTopic;
if (!needsTargetReload && !needsLatestRepliesReload) {
renderTopic(state.currentTopic, state.currentUrl, state.currentFallbackTitle, state.currentResolvedTargetPostNumber, {
latestRepliesTopic: state.currentLatestRepliesTopic,
targetSpec
});
return;
}
}
loadTopic(state.currentUrl, state.currentFallbackTitle, state.currentTopicIdHint);
}
function canRefreshLatestReplies() {
if (!state.currentUrl || !state.currentTopic) {
return false;
}
if (state.root?.classList.contains(IFRAME_MODE_CLASS)) {
return false;
}
if (state.settings.postMode === "first" || state.settings.replyOrder !== "latestFirst") {
return false;
}
const targetSpec = state.currentTargetSpec || getTopicTargetSpec(state.currentUrl, state.currentTopicIdHint);
if (targetSpec?.targetPostNumber) {
return false;
}
if (targetSpec?.hasTarget && targetSpec.targetToken && targetSpec.targetToken !== "last") {
return false;
}
return true;
}
function syncLatestRepliesRefreshUI() {
if (!state.latestRepliesRefreshButton) {
return;
}
const shouldShow = canRefreshLatestReplies();
const isRefreshing = state.isRefreshingLatestReplies;
state.latestRepliesRefreshButton.hidden = !shouldShow;
state.latestRepliesRefreshButton.disabled = !shouldShow || isRefreshing || Boolean(state.abortController);
state.latestRepliesRefreshButton.classList.toggle("is-refreshing", isRefreshing);
const label = isRefreshing ? "刷新中..." : "刷新最新回复";
state.latestRepliesRefreshButton.setAttribute("data-tooltip", label);
state.latestRepliesRefreshButton.setAttribute("aria-label", label);
}
function shouldLoadLatestRepliesTopic(topic, targetSpec) {
if (state.settings.postMode === "first" || state.settings.replyOrder !== "latestFirst") {
return false;
}
if (targetSpec?.targetPostNumber) {
return false;
}
if (targetSpec?.hasTarget && targetSpec.targetToken && targetSpec.targetToken !== "last") {
return false;
}
return !topicHasCompletePostStream(topic);
}
function getLatestRepliesDisplayPosts(topic, latestRepliesTopic) {
const firstPost = getFirstTopicPost(topic) || getFirstTopicPost(latestRepliesTopic);
const replies = [];
const seenPostNumbers = new Set();
for (const post of latestRepliesTopic?.post_stream?.posts || []) {
if (typeof post?.post_number !== "number") {
continue;
}
if (firstPost && post.post_number === firstPost.post_number) {
continue;
}
if (seenPostNumbers.has(post.post_number)) {
continue;
}
seenPostNumbers.add(post.post_number);
replies.push(post);
}
replies.sort((left, right) => right.post_number - left.post_number);
if (!firstPost) {
return replies;
}
return [firstPost, ...replies];
}
function getFirstTopicPost(topic) {
const posts = topic?.post_stream?.posts || [];
return posts.find((post) => post?.post_number === 1) || posts[0] || null;
}
function buildReplyModeNote(viewModel) {
if (viewModel.mode === "latestComplete") {
return `当前为\u201C首帖 + 最新回复\u201D模式。首帖固定在顶部,其余回复按从新到旧显示。`;
}
if (viewModel.mode === "latestWindow") {
return `当前为\u201C首帖 + 最新回复\u201D模式。首帖固定在顶部,下面显示的是最新一批回复;长帖不会一次性把整帖完整倒序。`;
}
if (viewModel.mode === "latestUnavailable") {
return `当前已切到\u201C首帖 + 最新回复\u201D模式,但这次没拿到最新回复窗口,暂按当前顺序显示。`;
}
return "";
}
function buildAuthorFilterNote(viewModel, topicOwner) {
if (viewModel.authorFilter !== "topicOwner") {
return "";
}
if (viewModel.filterUnavailable || !topicOwner) {
return `当前已切到\u201C只看楼主\u201D模式,但这次没识别出楼主身份,暂按当前结果显示。`;
}
const ownerLabel = topicOwner.displayUsername ? `@${topicOwner.displayUsername}` : "楼主";
if (Number.isFinite(viewModel.preservedTargetPostNumber)) {
return `当前为\u201C只看楼主\u201D模式,已保留当前定位的 #${viewModel.preservedTargetPostNumber},其余仅显示 ${ownerLabel} 的发言。`;
}
if (!viewModel.posts.length && viewModel.canAutoLoadMore) {
return `当前为\u201C只看楼主\u201D模式,已加载范围内还没有 ${ownerLabel} 的更多发言,继续下滑会继续尝试加载。`;
}
return `当前为\u201C只看楼主\u201D模式,仅显示 ${ownerLabel} 的发言。`;
}
function getLatestRepliesTopicUrl(topicUrl, topicIdHint = null) {
const url = new URL(topicUrl);
const parsed = parseTopicPath(url.pathname, topicIdHint);
url.hash = "";
url.search = "";
url.pathname = parsed?.topicPath
? `${parsed.topicPath}/last`
: `${stripTrailingSlash(url.pathname)}/last`;
return url.toString().replace(/\/$/, "");
}
async function fetchLatestRepliesTopic(topicUrl, signal, topicIdHint = null) {
return fetchTrackedTopicJson(getLatestRepliesTopicUrl(topicUrl, topicIdHint), signal, topicIdHint, {
canonical: false,
trackVisit: false
});
}
function renderLoading() {
return `
<div class="ld-loading-state" aria-label="loading">
<div class="ld-loading-bar"></div>
<div class="ld-loading-bar ld-loading-bar-short"></div>
<div class="ld-loading-card"></div>
<div class="ld-loading-card"></div>
</div>
`;
}
function toTopicJsonUrl(topicUrl, options = {}) {
const { canonical = false, trackVisit = true, topicIdHint = null } = options;
const url = new URL(topicUrl);
const parsed = parseTopicPath(url.pathname, topicIdHint);
url.hash = "";
url.search = "";
url.pathname = `${canonical ? (parsed?.topicPath || stripTrailingSlash(url.pathname)) : stripTrailingSlash(url.pathname)}.json`;
if (trackVisit) {
url.searchParams.set("track_visit", "true");
}
return url.toString();
}
async function fetchTrackedTopicJson(topicUrl, signal, topicIdHint = null, options = {}) {
const { canonical = false, trackVisit = true } = options;
const topicId = topicIdHint || getTopicIdFromUrl(topicUrl);
const response = await fetch(toTopicJsonUrl(topicUrl, { canonical, trackVisit, topicIdHint }), {
credentials: "include",
signal,
headers: trackVisit ? buildTopicRequestHeaders(topicId) : { Accept: "application/json" }
});
const contentType = response.headers.get("content-type") || "";
if (!response.ok || !contentType.includes("json")) {
throw new Error(`Unexpected response: ${response.status}`);
}
return response.json();
}
function ensureTrackedTopicVisit(topicUrl, topicIdHint = null, signal) {
const trackingKey = getTopicTrackingKey(topicUrl, topicIdHint);
if (state.currentTrackRequest && state.currentTrackRequestKey === trackingKey) {
return state.currentTrackRequest;
}
const request = fetchTrackedTopicJson(topicUrl, signal, topicIdHint, {
canonical: true,
trackVisit: true
}).then((topic) => {
if (state.currentTopicTrackingKey === trackingKey) {
state.currentViewTracked = true;
}
return topic;
}).finally(() => {
if (state.currentTrackRequest === request) {
state.currentTrackRequest = null;
state.currentTrackRequestKey = "";
}
});
state.currentTrackRequest = request;
state.currentTrackRequestKey = trackingKey;
return request;
}
function toTopicPostsJsonUrl(topicUrl, postIds, topicIdHint = null) {
const url = new URL(topicUrl);
const parsed = parseTopicPath(url.pathname, topicIdHint);
url.hash = "";
url.search = "";
url.pathname = parsed?.topicId
? `/t/${parsed.topicId}/posts.json`
: `${stripTrailingSlash(url.pathname)}/posts.json`;
for (const postId of postIds) {
if (Number.isFinite(postId)) {
url.searchParams.append("post_ids[]", String(postId));
}
}
return url.toString().replace(/\/$/, "");
}
async function fetchTopicPostsBatch(topicUrl, postIds, signal, topicIdHint = null) {
const response = await fetch(toTopicPostsJsonUrl(topicUrl, postIds, topicIdHint), {
credentials: "include",
signal,
headers: {
Accept: "application/json"
}
});
const contentType = response.headers.get("content-type") || "";
if (!response.ok || !contentType.includes("json")) {
throw new Error(`Unexpected response: ${response.status}`);
}
const data = await response.json();
return data?.post_stream?.posts || [];
}
async function createTopicReply(topicId, raw, signal, replyToPostNumber = null) {
const csrfToken = getCsrfToken();
if (!csrfToken) {
throw new Error("未找到登录令牌,请刷新页面后重试");
}
const body = new URLSearchParams();
body.set("raw", raw);
body.set("topic_id", String(topicId));
if (Number.isFinite(replyToPostNumber)) {
body.set("reply_to_post_number", String(replyToPostNumber));
}
const response = await fetch(`${location.origin}/posts.json`, {
method: "POST",
credentials: "include",
signal,
headers: {
Accept: "application/json",
"Content-Type": "application/x-www-form-urlencoded; charset=UTF-8",
"X-Requested-With": "XMLHttpRequest",
"X-CSRF-Token": csrfToken
},
body
});
const contentType = response.headers.get("content-type") || "";
const data = contentType.includes("json")
? await response.json()
: null;
if (!response.ok) {
const message = Array.isArray(data?.errors) && data.errors.length > 0
? data.errors.join(";")
: (data?.error || `Unexpected response: ${response.status}`);
throw new Error(message);
}
return data;
}
async function populateReactionsPopover(popoverEl, post, reactBtn) {
const available = await fetchAvailableReactions();
const reactions = Array.isArray(post.reactions) ? post.reactions : [];
popoverEl.replaceChildren();
for (const r of available) {
const existing = reactions.find((rx) => rx.id === r.id);
const count = existing?.count || 0;
const isActive = existing?.reacted === true
|| (r.id === "heart" && (post.actions_summary?.find((a) => a.id === 2)?.acted === true));
const btn = document.createElement("button");
btn.type = "button";
btn.className = "ld-reaction-btn" + (isActive ? " ld-reaction-btn--active" : "");
btn.setAttribute("aria-label", r.id + (count > 0 ? ` (${count})` : ""));
btn.title = r.id;
const img = document.createElement("img");
img.alt = `:${r.id}:`;
img.loading = "lazy";
img.src = buildReactionEmojiSrc(r.id);
img.onerror = () => {
img.onerror = null;
btn.hidden = true;
btn.style.display = "none";
};
const countEl = document.createElement("span");
countEl.className = "ld-reaction-btn-count";
countEl.textContent = count > 0 ? String(count) : "";
btn.append(img, countEl);
btn.addEventListener("click", () => {
popoverEl.setAttribute("hidden", "");
handlePostReact(reactBtn, post, r.id, popoverEl);
});
popoverEl.appendChild(btn);
}
}
async function fetchAvailableReactions() {
if (state.availableReactions) {
return state.availableReactions;
}
// 1. Read from Discourse's live Ember app (most reliable — TM @grant none runs in page context)
try {
const siteSettings = window.Discourse?.SiteSettings;
if (siteSettings) {
const enabledStr = siteSettings.discourse_reactions_enabled_reactions;
if (typeof enabledStr === "string" && enabledStr.trim()) {
const ids = enabledStr.split("|").map((s) => s.trim()).filter(Boolean);
if (ids.length > 0) {
state.availableReactions = ids.map((id) => ({ id, type: "emoji" }));
return state.availableReactions;
}
}
}
} catch { /* ignore */ }
// 2. Try API endpoint (works when authenticated and endpoint exists)
try {
const res = await fetch(`${location.origin}/discourse-reactions/custom-reactions`, {
credentials: "include",
headers: { Accept: "application/json" }
});
if (res.ok) {
const data = await res.json();
if (Array.isArray(data) && data.length) {
state.availableReactions = data;
return state.availableReactions;
}
}
} catch { /* ignore */ }
// 3. Reasonable fallback matching Discourse defaults
state.availableReactions = [
{ id: "heart", type: "emoji" },
{ id: "+1", type: "emoji" },
{ id: "laughing", type: "emoji" },
{ id: "open_mouth", type: "emoji" },
{ id: "cry", type: "emoji" },
{ id: "angry", type: "emoji" },
{ id: "tada", type: "emoji" }
];
return state.availableReactions;
}
const REACTION_URL_CACHE = {};
function buildReactionEmojiSrc(reactionId) {
if (REACTION_URL_CACHE[reactionId]) {
return REACTION_URL_CACHE[reactionId];
}
const set = (url) => {
if (url) REACTION_URL_CACHE[reactionId] = url;
return url;
};
// 1. Discourse AMD module for standard emojis (heart, +1, laughing, etc.)
try {
for (const loaderKey of ["require", "requirejs"]) {
const loader = window[loaderKey];
if (typeof loader !== "function") continue;
for (const modPath of ["discourse/lib/emoji", "discourse-common/utils/emoji"]) {
try {
const mod = loader(modPath);
if (!mod) continue;
const buildFn = mod.buildEmojiUrl || mod.default?.buildEmojiUrl
|| mod.emojiUrlFor || mod.default?.emojiUrlFor;
if (typeof buildFn === "function") {
const url = buildFn(reactionId, window.Discourse?.SiteSettings);
if (url) return set(url);
}
} catch { /* ignore */ }
}
break;
}
} catch { /* ignore */ }
// 2. Standard emoji path fallback
const emojiSet = window.Discourse?.SiteSettings?.emoji_set || "twitter";
return `/images/emoji/${emojiSet}/${encodeURIComponent(reactionId)}.png`;
}
async function handlePostReact(reactBtn, post, reactionId, popoverEl) {
if (reactBtn.disabled) {
return;
}
reactBtn.disabled = true;
try {
const reactions = Array.isArray(post.reactions) ? post.reactions : [];
const existing = reactions.find((r) => r.id === reactionId);
const wasReacted = existing?.reacted === true;
const legacyLikeAction = !reactions.length && Array.isArray(post.actions_summary)
? post.actions_summary.find((a) => a.id === 2)
: null;
const wasLegacyLiked = legacyLikeAction?.acted === true;
const isUndo = wasReacted || (reactionId === "heart" && wasLegacyLiked);
const updated = await performToggleReaction(post.id, reactionId);
if (Array.isArray(updated?.reactions)) {
post.reactions = updated.reactions;
post.like_count = updated.reactions.reduce((sum, r) => sum + (r.count || 0), 0);
} else {
if (!Array.isArray(post.reactions)) {
post.reactions = [];
}
if (isUndo) {
if (existing) {
existing.reacted = false;
existing.count = Math.max(0, (existing.count || 1) - 1);
}
} else {
if (existing) {
existing.reacted = true;
existing.count = (existing.count || 0) + 1;
} else {
post.reactions.push({ id: reactionId, type: "emoji", count: 1, reacted: true });
}
}
post.like_count = post.reactions.reduce((sum, r) => sum + (r.count || 0), 0);
if (legacyLikeAction) {
legacyLikeAction.acted = !wasLegacyLiked;
legacyLikeAction.count = Math.max(0, (legacyLikeAction.count || 0) + (isUndo ? -1 : 1));
}
}
const nowReacted = post.reactions?.some((r) => r.reacted) || false;
const newCount = post.like_count || 0;
reactBtn.classList.toggle("ld-post-react-btn--reacted", nowReacted);
reactBtn.setAttribute("aria-label", nowReacted ? "取消反应" : "添加反应");
let countEl = reactBtn.querySelector(".ld-post-react-count");
if (newCount > 0) {
if (countEl) {
countEl.textContent = String(newCount);
} else {
countEl = document.createElement("span");
countEl.className = "ld-post-react-count";
countEl.textContent = String(newCount);
reactBtn.appendChild(countEl);
}
} else if (countEl) {
countEl.remove();
}
if (popoverEl && !popoverEl.hasAttribute("hidden")) {
delete popoverEl.dataset.loaded;
populateReactionsPopover(popoverEl, post, reactBtn);
} else if (popoverEl) {
delete popoverEl.dataset.loaded;
}
} catch {
// silently ignore
} finally {
reactBtn.disabled = false;
}
}
async function performToggleReaction(postId, reactionId) {
const csrfToken = getCsrfToken();
if (!csrfToken) {
throw new Error("未找到 CSRF 令牌");
}
const response = await fetch(
`${location.origin}/discourse-reactions/posts/${postId}/custom-reactions/${encodeURIComponent(reactionId)}/toggle`,
{
method: "PUT",
credentials: "include",
headers: {
Accept: "application/json",
"X-Requested-With": "XMLHttpRequest",
"X-CSRF-Token": csrfToken
}
}
);
if (!response.ok) {
if (reactionId === "heart") {
return performLegacyLikeToggle(postId);
}
const data = await response.json().catch(() => null);
throw new Error(
Array.isArray(data?.errors) && data.errors.length
? data.errors.join(";")
: `反应失败:${response.status}`
);
}
return response.json().catch(() => null);
}
async function performLegacyLikeToggle(postId) {
const csrfToken = getCsrfToken();
if (!csrfToken) {
throw new Error("未找到 CSRF 令牌");
}
const likeRes = await fetch(`${location.origin}/post_actions`, {
method: "POST",
credentials: "include",
headers: {
Accept: "application/json",
"Content-Type": "application/x-www-form-urlencoded; charset=UTF-8",
"X-Requested-With": "XMLHttpRequest",
"X-CSRF-Token": csrfToken
},
body: new URLSearchParams({ id: String(postId), post_action_type_id: "2", flag_topic: "false" })
});
if (likeRes.ok) {
return null;
}
const unlikeRes = await fetch(
`${location.origin}/post_actions/${postId}?post_action_type_id=2`,
{
method: "DELETE",
credentials: "include",
headers: {
Accept: "application/json",
"X-Requested-With": "XMLHttpRequest",
"X-CSRF-Token": csrfToken
}
}
);
if (!unlikeRes.ok) {
throw new Error(`操作失败:${unlikeRes.status}`);
}
return null;
}
async function handleCopyPostLink(btn, post) {
const topicId = state.currentTopic?.id || post.topic_id;
const topicSlug = state.currentTopic?.slug || "";
const postNum = post.post_number;
const url = topicSlug
? `${location.origin}/t/${topicSlug}/${topicId}/${postNum}`
: `${location.origin}/t/topic/${topicId}/${postNum}`;
try {
await navigator.clipboard.writeText(url);
const origHTML = btn.innerHTML;
btn.innerHTML = `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><polyline points="20 6 9 17 4 12"/></svg>`;
setTimeout(() => {
btn.innerHTML = origHTML;
}, 1500);
} catch {
// silently ignore
}
}
async function handlePostBookmark(btn, post) {
if (btn.disabled) {
return;
}
btn.disabled = true;
const wasBookmarked = post.bookmarked === true;
try {
if (wasBookmarked && post.bookmark_id) {
await performDeleteBookmark(post.bookmark_id);
post.bookmarked = false;
post.bookmark_id = null;
} else {
const result = await performCreateBookmark(post.id);
post.bookmarked = true;
if (result?.id) {
post.bookmark_id = result.id;
}
}
const nowBookmarked = post.bookmarked === true;
btn.className = "ld-post-icon-btn" + (nowBookmarked ? " ld-post-icon-btn--bookmarked" : "");
btn.setAttribute("aria-label", nowBookmarked ? "取消书签" : "添加书签");
btn.innerHTML = nowBookmarked
? `<svg viewBox="0 0 24 24" fill="currentColor" aria-hidden="true"><path d="M5 4a2 2 0 0 1 2-2h10a2 2 0 0 1 2 2v17l-7-3.5L5 21V4z"/></svg>`
: `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M5 4a2 2 0 0 1 2-2h10a2 2 0 0 1 2 2v17l-7-3.5L5 21V4z"/></svg>`;
} catch {
// silently ignore
} finally {
btn.disabled = false;
}
}
async function performCreateBookmark(postId) {
const csrfToken = getCsrfToken();
if (!csrfToken) {
throw new Error("未找到 CSRF 令牌");
}
const response = await fetch(`${location.origin}/bookmarks`, {
method: "POST",
credentials: "include",
headers: {
Accept: "application/json",
"Content-Type": "application/json",
"X-Requested-With": "XMLHttpRequest",
"X-CSRF-Token": csrfToken
},
body: JSON.stringify({ bookmarkable_type: "Post", bookmarkable_id: postId })
});
if (!response.ok) {
const data = await response.json().catch(() => null);
throw new Error(data?.errors?.join(";") || `书签失败:${response.status}`);
}
return response.json().catch(() => null);
}
async function performDeleteBookmark(bookmarkId) {
const csrfToken = getCsrfToken();
if (!csrfToken) {
throw new Error("未找到 CSRF 令牌");
}
const response = await fetch(`${location.origin}/bookmarks/${bookmarkId}`, {
method: "DELETE",
credentials: "include",
headers: {
Accept: "application/json",
"X-Requested-With": "XMLHttpRequest",
"X-CSRF-Token": csrfToken
}
});
if (!response.ok) {
const data = await response.json().catch(() => null);
throw new Error(data?.errors?.join(";") || `取消书签失败:${response.status}`);
}
}
function buildFlagPopover(post) {
const popover = document.createElement("div");
popover.className = "ld-flag-popover";
popover.setAttribute("role", "menu");
const flagOptions = [
{
id: 3,
label: "垃圾信息",
description: "此帖子是广告或垃圾内容",
icon: `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><line x1="4.93" y1="4.93" x2="19.07" y2="19.07"/></svg>`
},
{
id: 4,
label: "不当内容",
description: "此帖子包含令人反感的内容",
icon: `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M10.29 3.86L1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z"/><line x1="12" y1="9" x2="12" y2="13"/><line x1="12" y1="17" x2="12.01" y2="17"/></svg>`
},
{
id: 7,
label: "需要版主关注",
description: "需要版主处理的问题",
icon: `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M4 15s1-1 4-1 5 2 8 2 4-1 4-1V3s-1 1-4 1-5-2-8-2-4 1-4 1z"/><line x1="4" y1="22" x2="4" y2="15"/></svg>`
}
];
for (const opt of flagOptions) {
const btn = document.createElement("button");
btn.type = "button";
btn.className = "ld-flag-option";
btn.setAttribute("role", "menuitem");
btn.title = opt.description;
btn.innerHTML = `${opt.icon}<span>${opt.label}</span>`;
btn.addEventListener("click", () => handleFlagPost(post.id, opt.id, popover));
popover.appendChild(btn);
}
return popover;
}
async function handleFlagPost(postId, actionTypeId, popoverEl) {
popoverEl.setAttribute("hidden", "");
try {
await performFlagPost(postId, actionTypeId);
} catch {
// silently ignore
}
}
async function performFlagPost(postId, actionTypeId) {
const csrfToken = getCsrfToken();
if (!csrfToken) {
throw new Error("未找到 CSRF 令牌");
}
const response = await fetch(`${location.origin}/post_actions`, {
method: "POST",
credentials: "include",
headers: {
Accept: "application/json",
"Content-Type": "application/x-www-form-urlencoded; charset=UTF-8",
"X-Requested-With": "XMLHttpRequest",
"X-CSRF-Token": csrfToken
},
body: new URLSearchParams({
id: String(postId),
post_action_type_id: String(actionTypeId),
flag_topic: "false"
})
});
if (!response.ok) {
const data = await response.json().catch(() => null);
throw new Error(data?.errors?.join(";") || `举报失败:${response.status}`);
}
}
async function createComposerUpload(file, signal, options = {}) {
const csrfToken = getCsrfToken();
if (!csrfToken) {
throw new Error("未找到登录令牌,请刷新页面后重试");
}
const formData = new FormData();
formData.set("upload_type", "composer");
formData.set("file", file, file.name || "image.png");
if (options.pasted) {
formData.set("pasted", "true");
}
const response = await fetch(`${location.origin}/uploads.json`, {
method: "POST",
credentials: "include",
signal,
headers: {
Accept: "application/json",
"X-CSRF-Token": csrfToken
},
body: formData
});
const contentType = response.headers.get("content-type") || "";
const data = contentType.includes("json")
? await response.json()
: null;
if (!response.ok) {
const message = Array.isArray(data?.errors) && data.errors.length > 0
? data.errors.join(";")
: (data?.message || data?.error || `Unexpected response: ${response.status}`);
throw new Error(message);
}
if (!data || typeof data !== "object") {
throw new Error(`Unexpected response: ${response.status}`);
}
return data;
}
function buildComposerUploadMarkdown(upload) {
const fileName = upload?.original_filename || "image.png";
const uploadUrl = upload?.short_url || upload?.url || "";
if (!uploadUrl) {
throw new Error("上传成功但未返回可用图片地址");
}
if (isImageUploadName(fileName)) {
return buildComposerImageMarkdown(upload, uploadUrl);
}
return `[${fileName}|attachment](${uploadUrl})`;
}
function buildComposerImageMarkdown(upload, uploadUrl) {
const altText = markdownNameFromFileName(upload?.original_filename || "image.png");
const width = Number(upload?.thumbnail_width || upload?.width || 0);
const height = Number(upload?.thumbnail_height || upload?.height || 0);
const sizeSegment = width > 0 && height > 0
? `|${width}x${height}`
: "";
return ``;
}
function markdownNameFromFileName(fileName) {
const normalized = String(fileName || "").trim();
const dotIndex = normalized.lastIndexOf(".");
const baseName = dotIndex > 0
? normalized.slice(0, dotIndex)
: normalized;
return (baseName || "image").replace(/[\[\]|]/g, "");
}
function isImageUploadName(fileName) {
return /\.(avif|bmp|gif|jpe?g|png|svg|webp)$/i.test(String(fileName || ""));
}
function closeAllPopovers() {
state.root?.querySelectorAll(".ld-reactions-popover, .ld-flag-popover").forEach((p) => {
p.setAttribute("hidden", "");
});
}
function getCsrfToken() {
const token = document.querySelector('meta[name="csrf-token"]')?.getAttribute("content") || "";
return token.trim();
}
function buildTopicRequestHeaders(topicId) {
const headers = {
Accept: "application/json"
};
if (topicId) {
headers["Discourse-Track-View"] = "true";
headers["Discourse-Track-View-Topic-Id"] = String(topicId);
}
return headers;
}
function getTopicStreamIds(topic) {
const stream = topic?.post_stream?.stream;
if (Array.isArray(stream) && stream.length > 0) {
return stream.filter((postId) => Number.isFinite(postId));
}
return (topic?.post_stream?.posts || [])
.map((post) => post?.id)
.filter((postId) => Number.isFinite(postId));
}
function getLoadedTopicPostIds(topic) {
return (topic?.post_stream?.posts || [])
.map((post) => post?.id)
.filter((postId) => Number.isFinite(postId));
}
function getNextTopicPostIds(topic, batchSize = LOAD_MORE_BATCH_SIZE) {
const streamIds = getTopicStreamIds(topic);
if (!streamIds.length) {
return [];
}
const loadedPostIds = new Set(getLoadedTopicPostIds(topic));
return streamIds.filter((postId) => !loadedPostIds.has(postId)).slice(0, batchSize);
}
function hasMoreTopicPosts(topic) {
if (getNextTopicPostIds(topic, 1).length > 0) {
return true;
}
const posts = topic?.post_stream?.posts || [];
const totalPosts = Number(topic?.posts_count || 0);
return totalPosts > 0 && posts.length < totalPosts;
}
function topicHasPostNumber(topic, postNumber) {
if (!postNumber) {
return false;
}
return (topic?.post_stream?.posts || []).some((post) => post?.post_number === postNumber);
}
function getTopicTargetSpec(topicUrl, topicIdHint = null) {
try {
const parsed = parseTopicPath(new URL(topicUrl).pathname, topicIdHint);
if (!parsed) {
return null;
}
return {
hasTarget: parsed.targetSegments.length > 0,
targetSegments: parsed.targetSegments,
targetPostNumber: parsed.targetPostNumber,
targetToken: parsed.targetToken
};
} catch {
return null;
}
}
function shouldFetchTargetedTopic(topic, targetSpec) {
if (!targetSpec?.hasTarget || state.settings.postMode === "first") {
return false;
}
if (targetSpec.targetPostNumber) {
return !topicHasPostNumber(topic, targetSpec.targetPostNumber);
}
if (targetSpec.targetToken === "last") {
return !topicHasCompletePostStream(topic);
}
return true;
}
function topicHasCompletePostStream(topic) {
return !hasMoreTopicPosts(topic);
}
function resolveTopicTargetPostNumber(targetSpec, topic, targetedTopic) {
if (!targetSpec?.hasTarget) {
return null;
}
if (targetSpec.targetPostNumber) {
if (topicHasPostNumber(targetedTopic, targetSpec.targetPostNumber) || topicHasPostNumber(topic, targetSpec.targetPostNumber)) {
return targetSpec.targetPostNumber;
}
return null;
}
const sourcePosts = targetedTopic?.post_stream?.posts || [];
if (sourcePosts.length > 0) {
if (targetSpec.targetToken === "last") {
return sourcePosts[sourcePosts.length - 1]?.post_number || null;
}
return sourcePosts[0]?.post_number || null;
}
const fallbackPosts = topic?.post_stream?.posts || [];
if (targetSpec.targetToken === "last" && topicHasCompletePostStream(topic) && fallbackPosts.length > 0) {
return fallbackPosts[fallbackPosts.length - 1]?.post_number || null;
}
return null;
}
function mergeTopicPreviewData(primaryTopic, supplementalTopic) {
const mergedPosts = new Map();
const mergedStream = [];
const seenStreamPostIds = new Set();
for (const post of primaryTopic?.post_stream?.posts || []) {
if (typeof post?.post_number === "number") {
mergedPosts.set(post.post_number, post);
}
}
for (const post of supplementalTopic?.post_stream?.posts || []) {
if (typeof post?.post_number === "number" && !mergedPosts.has(post.post_number)) {
mergedPosts.set(post.post_number, post);
}
}
for (const postId of getTopicStreamIds(primaryTopic)) {
if (!seenStreamPostIds.has(postId)) {
seenStreamPostIds.add(postId);
mergedStream.push(postId);
}
}
for (const postId of getTopicStreamIds(supplementalTopic)) {
if (!seenStreamPostIds.has(postId)) {
seenStreamPostIds.add(postId);
mergedStream.push(postId);
}
}
for (const postId of getLoadedTopicPostIds({ post_stream: { posts: Array.from(mergedPosts.values()) } })) {
if (!seenStreamPostIds.has(postId)) {
seenStreamPostIds.add(postId);
mergedStream.push(postId);
}
}
const posts = Array.from(mergedPosts.values()).sort((left, right) => left.post_number - right.post_number);
return {
...primaryTopic,
posts_count: Math.max(Number(primaryTopic?.posts_count || 0), Number(supplementalTopic?.posts_count || 0)) || primaryTopic?.posts_count || supplementalTopic?.posts_count,
post_stream: {
...(primaryTopic?.post_stream || {}),
stream: mergedStream,
posts
}
};
}
function appendCreatedReplyToCurrentTopic(createdPost) {
if (!state.currentTopic || !createdPost || typeof createdPost !== "object") {
return;
}
const createdPostId = Number(createdPost.id);
const createdPostNumber = Number(createdPost.post_number);
if (!Number.isFinite(createdPostId) || !Number.isFinite(createdPostNumber)) {
return;
}
const previousScrollTop = state.drawerBody?.scrollTop || 0;
const nextTopic = mergeTopicPreviewData(state.currentTopic, {
posts_count: Math.max(
Number(state.currentTopic.posts_count || 0),
Number(createdPost.topic_posts_count || 0),
(state.currentTopic.post_stream?.posts || []).length + 1
),
post_stream: {
stream: [createdPostId],
posts: [createdPost]
}
});
renderTopic(nextTopic, state.currentUrl, state.currentFallbackTitle, null, {
targetSpec: state.currentTargetSpec,
preserveScrollTop: previousScrollTop
});
requestAnimationFrame(() => {
const target = state.content?.querySelector(`.ld-post-card[data-post-number="${createdPostNumber}"]`);
target?.scrollIntoView({ block: "center", behavior: "smooth" });
});
}
function isPrimaryTopicLink(link) {
if (!(link instanceof HTMLAnchorElement)) {
return false;
}
if (link.closest(LIST_ROW_SELECTOR)) {
return link.matches(PRIMARY_TOPIC_LINK_SELECTOR);
}
return link.matches(PRIMARY_TOPIC_LINK_SELECTOR);
}
function buildEntryKey(url, occurrence) {
return occurrence > 1 ? `${url}::${occurrence}` : url;
}
function getTopicEntryContainer(link) {
if (!(link instanceof Element)) {
return null;
}
return link.closest(ENTRY_CONTAINER_SELECTOR)
|| link.closest("[data-topic-id]")
|| link;
}
function readTopicIdHint(element) {
if (!(element instanceof Element)) {
return null;
}
const rawTopicId = element.getAttribute("data-topic-id") || element.dataset?.topicId || "";
return /^\d+$/.test(rawTopicId) ? Number(rawTopicId) : null;
}
function getTopicIdHintFromLink(link) {
if (!(link instanceof Element)) {
return null;
}
const directTopicId = readTopicIdHint(link);
if (directTopicId) {
return directTopicId;
}
const hintedAncestor = link.closest("[data-topic-id]");
if (hintedAncestor) {
return readTopicIdHint(hintedAncestor);
}
return readTopicIdHint(getTopicEntryContainer(link));
}
function getTopicTrackingKey(topicUrl, topicIdHint = null) {
try {
const parsed = parseTopicPath(new URL(topicUrl).pathname, topicIdHint);
if (parsed?.topicId) {
return `topic:${parsed.topicId}`;
}
return parsed?.topicPath || topicUrl;
} catch {
return topicUrl;
}
}
function normalizeTopicUrl(url) {
const parsed = parseTopicPath(url.pathname);
url.hash = "";
url.search = "";
url.pathname = parsed?.topicPath || stripTrailingSlash(url.pathname);
return url.toString().replace(/\/$/, "");
}
function getTopicIdFromUrl(topicUrl, topicIdHint = null) {
try {
return parseTopicPath(new URL(topicUrl).pathname, topicIdHint)?.topicId || null;
} catch {
return null;
}
}
function getTopicTargetPostNumber(topicUrl, topicIdHint = null) {
return getTopicTargetSpec(topicUrl, topicIdHint)?.targetPostNumber || null;
}
function scrollTopicViewToTargetPost(targetPostNumber) {
if (!targetPostNumber) {
return;
}
requestAnimationFrame(() => {
const target = state.content?.querySelector(`.ld-post-card[data-post-number="${targetPostNumber}"]`);
target?.scrollIntoView({ block: "start", behavior: "auto" });
});
}
function parseTopicPath(pathname, topicIdHint = null) {
const trimmedPath = stripTrailingSlash(pathname);
const segments = trimmedPath.split("/");
const first = segments[2] || "";
const second = segments[3] || "";
if (segments[1] !== "t") {
return null;
}
const firstIsNumber = /^\d+$/.test(first);
const secondIsNumber = /^\d+$/.test(second);
let topicId = null;
let topicPath = "";
let extraSegments = [];
if (firstIsNumber) {
topicId = Number(first);
topicPath = `/t/${first}`;
extraSegments = segments.slice(3).filter(Boolean);
} else if (secondIsNumber) {
topicId = Number(second);
topicPath = `/t/${first}/${second}`;
extraSegments = segments.slice(4).filter(Boolean);
} else {
return null;
}
const destinationPath = extraSegments.length > 0
? `${topicPath}/${extraSegments.join("/")}`
: topicPath;
const targetPostNumber = /^\d+$/.test(extraSegments[0] || "")
? Number(extraSegments[0])
: null;
const targetToken = !targetPostNumber && extraSegments[0]
? String(extraSegments[0])
: null;
return {
topicId,
topicPath,
destinationPath,
targetSegments: extraSegments,
targetPostNumber,
targetToken
};
}
function stripTrailingSlash(pathname) {
return pathname.replace(/\/+$/, "") || pathname;
}
function avatarUrl(template) {
if (!template) {
return "data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='96' height='96'%3E%3Crect width='96' height='96' fill='%23d8dee9'/%3E%3C/svg%3E";
}
return new URL(template.replace("{size}", "96"), location.origin).toString();
}
function normalizeUsername(value) {
return typeof value === "string" ? value.trim().toLowerCase() : "";
}
function getTopicOwnerIdentity(topic) {
const createdBy = topic?.created_by && typeof topic.created_by === "object"
? topic.created_by
: (topic?.details?.created_by && typeof topic.details.created_by === "object" ? topic.details.created_by : null);
if (!createdBy) {
return null;
}
const displayUsername = typeof createdBy.username === "string" ? createdBy.username.trim() : "";
const normalizedUsername = normalizeUsername(createdBy.username);
const userId = Number.isFinite(createdBy.id) ? Number(createdBy.id) : null;
if (!displayUsername && userId === null) {
return null;
}
return { displayUsername, normalizedUsername, userId };
}
function isTopicOwnerPost(post, topicOwner) {
if (!post || typeof post !== "object" || !topicOwner) {
return false;
}
const postUserId = Number.isFinite(post.user_id) ? Number(post.user_id) : null;
if (topicOwner.userId !== null && postUserId !== null && topicOwner.userId === postUserId) {
return true;
}
const postUsername = normalizeUsername(post.username);
return Boolean(topicOwner.normalizedUsername && postUsername && topicOwner.normalizedUsername === postUsername);
}
function buildTopicOwnerBadge(post, topicOwner) {
if (!isTopicOwnerPost(post, topicOwner)) {
return null;
}
const badge = document.createElement("span");
badge.className = "ld-post-topic-owner-badge";
badge.textContent = "Topic Owner";
badge.title = "楼主";
badge.setAttribute("aria-label", "楼主");
return badge;
}
function buildTopicMeta(topic, loadedPostCount) {
const parts = [];
const topicOwner = getTopicOwnerIdentity(topic);
if (topicOwner?.displayUsername) {
parts.push(`楼主 @${topicOwner.displayUsername}`);
}
if (topic.created_at) {
parts.push(formatDate(topic.created_at));
}
if (typeof topic.views === "number") {
parts.push(`${topic.views.toLocaleString()} 浏览`);
}
const totalPosts = topic.posts_count || loadedPostCount;
parts.push(`${totalPosts} 帖`);
return parts.join(" · ");
}
function buildReplyToTab(post) {
const replyPostNum = post.reply_to_post_number;
if (!Number.isFinite(replyPostNum) || replyPostNum === post.post_number) {
return null;
}
const replyUser = post.reply_to_user;
const displayName = replyUser?.username ? `@${replyUser.username}` : `第 ${replyPostNum} 楼`;
const tab = document.createElement("button");
tab.type = "button";
tab.className = "ld-reply-to-tab";
tab.setAttribute("aria-label", `跳转到被回复的帖子:${displayName}`);
tab.title = `跳转到第 ${replyPostNum} 楼`;
const icon = document.createElement("span");
icon.className = "ld-reply-to-tab-icon";
icon.setAttribute("aria-hidden", "true");
icon.innerHTML = `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="9 14 4 9 9 4"/><path d="M20 20v-7a4 4 0 0 0-4-4H4"/></svg>`;
const label = document.createElement("span");
label.className = "ld-reply-to-tab-label";
label.textContent = displayName;
tab.append(icon, label);
tab.addEventListener("click", (e) => {
e.stopPropagation();
scrollTopicViewToTargetPost(replyPostNum);
});
return tab;
}
function buildPostInfos(post) {
const items = [];
if (typeof post.reads === "number" && post.reads > 0) {
items.push({
icon: `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"/><circle cx="12" cy="12" r="3"/></svg>`,
count: post.reads,
label: "阅读"
});
}
const likeCount = typeof post.like_count === "number"
? post.like_count
: (Array.isArray(post.reactions) ? post.reactions.reduce((s, r) => s + (r.count || 0), 0) : 0);
if (likeCount > 0) {
items.push({
icon: `<svg viewBox="0 0 24 24" fill="currentColor" aria-hidden="true"><path d="M12 21.35l-1.45-1.32C5.4 15.36 2 12.28 2 8.5 2 5.42 4.42 3 7.5 3c1.74 0 3.41.81 4.5 2.09C13.09 3.81 14.76 3 16.5 3 19.58 3 22 5.42 22 8.5c0 3.78-3.4 6.86-8.55 11.54L12 21.35z"/></svg>`,
count: likeCount,
label: "点赞"
});
}
if (typeof post.reply_count === "number" && post.reply_count > 0) {
items.push({
icon: `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="9 14 4 9 9 4"/><path d="M20 20v-7a4 4 0 0 0-4-4H4"/></svg>`,
count: post.reply_count,
label: "回复"
});
}
if (!items.length) {
return null;
}
const infos = document.createElement("div");
infos.className = "ld-post-infos";
for (const item of items) {
const span = document.createElement("span");
span.className = "ld-post-info-item";
span.setAttribute("title", item.label);
const iconSpan = document.createElement("span");
iconSpan.className = "ld-post-info-icon";
iconSpan.setAttribute("aria-hidden", "true");
iconSpan.innerHTML = item.icon;
const countSpan = document.createElement("span");
countSpan.textContent = String(item.count);
span.append(iconSpan, countSpan);
infos.appendChild(span);
}
return infos;
}
function buildPostMeta(post) {
const parts = [];
if (post.created_at) {
parts.push(formatDate(post.created_at));
}
return parts.join(" · ");
}
function formatDate(value) {
try {
return new Intl.DateTimeFormat("zh-CN", {
month: "short",
day: "numeric",
hour: "2-digit",
minute: "2-digit"
}).format(new Date(value));
} catch {
return value;
}
}
function isTypingTarget(target) {
return target instanceof HTMLElement && (
target.isContentEditable ||
target.matches("input, textarea, select") ||
Boolean(target.closest("input, textarea, select, [contenteditable='true']"))
);
}
function loadSettings() {
try {
const saved = JSON.parse(localStorage.getItem(SETTINGS_KEY) || "null");
const settings = {
...DEFAULT_SETTINGS,
...(saved && typeof saved === "object" ? saved : {})
};
if (!(settings.drawerWidth in DRAWER_WIDTHS) && settings.drawerWidth !== "custom") {
settings.drawerWidth = DEFAULT_SETTINGS.drawerWidth;
}
if (settings.drawerMode !== "push" && settings.drawerMode !== "overlay") {
settings.drawerMode = DEFAULT_SETTINGS.drawerMode;
}
if (settings.authorFilter !== "all" && settings.authorFilter !== "topicOwner") {
settings.authorFilter = DEFAULT_SETTINGS.authorFilter;
}
if (settings.floatingReplyButton !== "off" && settings.floatingReplyButton !== "on") {
settings.floatingReplyButton = DEFAULT_SETTINGS.floatingReplyButton;
}
settings.postBodyFontSize = clampPostBodyFontSize(settings.postBodyFontSize);
settings.drawerWidthCustom = clampDrawerWidth(settings.drawerWidthCustom);
return settings;
} catch {
return { ...DEFAULT_SETTINGS };
}
}
function saveSettings() {
localStorage.setItem(SETTINGS_KEY, JSON.stringify(state.settings));
}
function resetReplyComposer() {
state.replyComposerSessionId += 1;
cancelReplyUploads();
if (state.replyTextarea) {
state.replyTextarea.value = "";
state.replyTextarea.placeholder = buildReplyTextareaPlaceholder();
}
if (state.replyStatus) {
state.replyStatus.textContent = "";
}
state.isReplySubmitting = false;
setReplyPanelOpen(false);
syncReplyUI();
}
function syncReplyUI() {
const hasTopic = Boolean(state.currentTopic?.id);
const isTargetedReply = Number.isFinite(state.replyTargetPostNumber);
const isReplyUploading = state.replyUploadPendingCount > 0;
const hasCurrentUrl = Boolean(state.currentUrl);
const isIframeMode = state.root?.classList.contains(IFRAME_MODE_CLASS);
const isSettingsOpen = !state.settingsPanel?.hidden;
if (state.replyToggleButton) {
state.replyToggleButton.hidden = !hasCurrentUrl || isIframeMode;
state.replyToggleButton.disabled = !hasTopic || state.isReplySubmitting;
state.replyToggleButton.classList.toggle("is-disabled", !hasTopic || state.isReplySubmitting);
}
if (state.replyFabButton) {
state.replyFabButton.hidden = !hasCurrentUrl
|| isIframeMode
|| isSettingsOpen
|| state.settings.floatingReplyButton !== "on";
state.replyFabButton.disabled = !hasTopic || state.isReplySubmitting;
state.replyFabButton.classList.toggle("is-disabled", !hasTopic || state.isReplySubmitting);
}
if (state.replyTextarea) {
state.replyTextarea.disabled = !hasTopic || state.isReplySubmitting;
if (hasTopic) {
state.replyTextarea.placeholder = isTargetedReply
? buildReplyTextareaPlaceholder(`回复 ${state.replyTargetLabel}`)
: buildReplyTextareaPlaceholder(`回复《${state.currentTopic.title || state.currentFallbackTitle || "当前主题"}》`);
}
}
if (state.replyPanelTitle) {
state.replyPanelTitle.textContent = isTargetedReply
? `回复 ${state.replyTargetLabel}`
: "回复主题";
}
if (state.replySubmitButton) {
state.replySubmitButton.disabled = !hasTopic || state.isReplySubmitting || isReplyUploading;
state.replySubmitButton.textContent = state.isReplySubmitting
? "发送中..."
: (isReplyUploading
? (state.replyUploadPendingCount > 1
? `上传 ${state.replyUploadPendingCount} 张图片中...`
: "图片上传中...")
: "发送回复");
}
if (state.replyCancelButton) {
state.replyCancelButton.disabled = state.isReplySubmitting;
}
}
function buildReplyTextareaPlaceholder(prefix = "写点什么") {
return `${prefix}... 支持 Markdown,可直接粘贴图片自动上传。Ctrl+Enter 或 Cmd+Enter 可发送`;
}
function syncSettingsUI() {
if (!state.settingsPanel) {
return;
}
for (const control of state.settingsPanel.querySelectorAll("[data-setting]")) {
const key = control.dataset.setting;
if (key && key in state.settings) {
control.value = String(state.settings[key]);
}
}
syncPostBodyFontSizeValue();
syncPostBodyFontSizeControlState();
}
function syncPostBodyFontSizeValue() {
if (state.postBodyFontSizeValue) {
state.postBodyFontSizeValue.textContent = `${clampPostBodyFontSize(state.settings.postBodyFontSize)}px`;
}
}
function syncPostBodyFontSizeControlState() {
const isSmartPreview = state.settings.previewMode === "smart";
if (state.postBodyFontSizeField) {
state.postBodyFontSizeField.classList.toggle("is-disabled", !isSmartPreview);
state.postBodyFontSizeField.setAttribute("aria-disabled", String(!isSmartPreview));
}
if (state.postBodyFontSizeControl) {
state.postBodyFontSizeControl.disabled = !isSmartPreview;
}
if (state.postBodyFontSizeHint) {
state.postBodyFontSizeHint.textContent = isSmartPreview
? "只调整帖子正文和代码字号,不影响标题和按钮"
: "仅智能预览可用;当前整页模式下不会改变 iframe 里的字号。";
}
}
function toggleSettingsPanel() {
setSettingsPanelOpen(state.settingsPanel.hidden);
}
function handleSettingsPanelClick(event) {
if (event.target === state.settingsPanel) {
setSettingsPanelOpen(false);
}
}
function setSettingsPanelOpen(isOpen) {
if (!state.settingsPanel || !state.settingsToggle) {
return;
}
if (isOpen) {
setReplyPanelOpen(false);
syncSettingsUI();
updateSettingsPopoverPosition();
queueMicrotask(() => state.settingsCard?.querySelector(".ld-setting-control")?.focus());
}
state.settingsPanel.hidden = !isOpen;
state.settingsToggle.setAttribute("aria-expanded", String(isOpen));
syncReplyUI();
}
function handleSettingsInput(event) {
const target = event.target;
if (!(target instanceof HTMLInputElement) || target.type !== "range") {
return;
}
const key = target.dataset.setting;
if (key !== "postBodyFontSize") {
return;
}
state.settings.postBodyFontSize = clampPostBodyFontSize(target.value);
target.value = String(state.settings.postBodyFontSize);
applyPostBodyFontSize();
}
function handleSettingsChange(event) {
const target = event.target;
if (!(target instanceof HTMLSelectElement) && !(target instanceof HTMLInputElement)) {
return;
}
const key = target.dataset.setting;
if (!key || !(key in state.settings)) {
return;
}
state.settings[key] = key === "postBodyFontSize"
? clampPostBodyFontSize(target.value)
: target.value;
target.value = String(state.settings[key]);
saveSettings();
if (key === "postBodyFontSize") {
applyPostBodyFontSize();
return;
}
if (key === "drawerWidth") {
applyDrawerWidth();
syncSettingsUI();
setSettingsPanelOpen(false);
return;
}
if (key === "drawerMode") {
applyDrawerMode();
setSettingsPanelOpen(false);
return;
}
if (key === "floatingReplyButton") {
syncReplyUI();
setSettingsPanelOpen(false);
return;
}
refreshCurrentView();
setSettingsPanelOpen(false);
}
function resetSettings() {
state.settings = { ...DEFAULT_SETTINGS };
syncSettingsUI();
saveSettings();
applyPostBodyFontSize();
applyDrawerWidth();
applyDrawerMode();
syncReplyUI();
refreshCurrentView();
setSettingsPanelOpen(false);
}
function applyDrawerWidth() {
const width = state.settings.drawerWidth === "custom"
? `${clampDrawerWidth(state.settings.drawerWidthCustom)}px`
: (DRAWER_WIDTHS[state.settings.drawerWidth] || DRAWER_WIDTHS.medium);
document.documentElement.style.setProperty(
"--ld-drawer-width",
width
);
updateSettingsPopoverPosition();
scheduleTopicTrackerPositionSync();
}
function applyPostBodyFontSize() {
document.documentElement.style.setProperty(
"--ld-post-body-font-size",
`${clampPostBodyFontSize(state.settings.postBodyFontSize)}px`
);
syncPostBodyFontSizeValue();
}
function applyDrawerMode() {
const isOverlay = state.settings.drawerMode === "overlay";
document.body.classList.toggle("ld-drawer-mode-overlay", isOverlay);
}
function clampDrawerWidth(value) {
const numeric = Number(value);
const maxWidth = Math.min(1400, Math.max(420, window.innerWidth - 40));
if (!Number.isFinite(numeric)) {
return Math.min(DEFAULT_SETTINGS.drawerWidthCustom, maxWidth);
}
return Math.min(Math.max(Math.round(numeric), 320), maxWidth);
}
function clampPostBodyFontSize(value) {
const numeric = Number(value);
if (!Number.isFinite(numeric)) {
return DEFAULT_SETTINGS.postBodyFontSize;
}
return Math.min(Math.max(Math.round(numeric), POST_BODY_FONT_SIZE_MIN), POST_BODY_FONT_SIZE_MAX);
}
function startDrawerResize(event) {
if (event.button !== 0 || window.innerWidth <= 720) {
return;
}
event.preventDefault();
state.isResizing = true;
document.body.classList.add("ld-drawer-resizing");
state.settings.drawerWidth = "custom";
syncSettingsUI();
updateCustomDrawerWidth(event.clientX);
state.resizeHandle?.setPointerCapture?.(event.pointerId);
}
function handleDrawerResizeMove(event) {
if (!state.isResizing) {
return;
}
event.preventDefault();
updateCustomDrawerWidth(event.clientX);
}
function stopDrawerResize(event) {
if (!state.isResizing) {
return;
}
state.isResizing = false;
document.body.classList.remove("ld-drawer-resizing");
saveSettings();
if (event?.pointerId !== undefined && state.resizeHandle?.hasPointerCapture?.(event.pointerId)) {
state.resizeHandle.releasePointerCapture(event.pointerId);
}
}
function updateCustomDrawerWidth(clientX) {
state.settings.drawerWidth = "custom";
state.settings.drawerWidthCustom = clampDrawerWidth(window.innerWidth - clientX);
applyDrawerWidth();
}
function shouldDeferOwnerFilterAutoLoad(viewModel) {
return Boolean(
viewModel
&& viewModel.authorFilter === "topicOwner"
&& viewModel.canAutoLoadMore
&& Number(viewModel.filterHiddenCount || 0) > 0
);
}
function shouldLoadMoreFromOwnerFilterWheel() {
if (!state.deferOwnerFilterAutoLoad || !state.drawerBody || !state.currentTopic || state.isLoadingMorePosts) {
return false;
}
if (state.settings.postMode === "first" || state.settings.replyOrder === "latestFirst" || state.currentTargetSpec?.hasTarget || !hasMoreTopicPosts(state.currentTopic)) {
return false;
}
return state.drawerBody.scrollHeight - state.drawerBody.clientHeight <= LOAD_MORE_TRIGGER_OFFSET;
}
function updateSettingsPopoverPosition() {
if (!state.header || !state.settingsPanel) {
return;
}
const offset = `${state.header.offsetHeight + 8}px`;
state.root.style.setProperty("--ld-settings-top", offset);
state.root.style.setProperty("--ld-reply-panel-top", offset);
}
function scheduleTopicTrackerPositionSync() {
if (state.topicTrackerSyncQueued) {
return;
}
state.topicTrackerSyncQueued = true;
requestAnimationFrame(() => {
state.topicTrackerSyncQueued = false;
syncTopicTrackerPosition();
});
}
function syncTopicTrackerPosition() {
const tracker = document.querySelector(TOPIC_TRACKER_SELECTOR);
const rootStyle = document.documentElement.style;
if (!tracker) {
rootStyle.removeProperty("--ld-topic-tracker-left");
rootStyle.removeProperty("--ld-topic-tracker-top");
rootStyle.removeProperty("--ld-topic-tracker-max-width");
return;
}
const anchor = tracker.closest("#list-area")
|| document.querySelector("#list-area")
|| tracker.closest(".contents")
|| document.querySelector(MAIN_CONTENT_SELECTOR);
const alignmentTarget = getTopicTrackerAlignmentTarget() || anchor;
const anchorRect = anchor?.getBoundingClientRect();
if (!anchorRect || anchorRect.width <= 0) {
return;
}
const sidePadding = 16;
const centerX = Math.min(
window.innerWidth - sidePadding,
Math.max(sidePadding, Math.round(anchorRect.left + anchorRect.width / 2))
);
const header = document.querySelector(".d-header-wrap")
|| document.querySelector(".d-header")
|| document.querySelector("header");
const headerBottom = header?.getBoundingClientRect()?.bottom;
const alignmentRect = alignmentTarget?.getBoundingClientRect();
const alignmentBottom = alignmentRect?.bottom;
const trackerHeight = Math.round(tracker.getBoundingClientRect().height || 36);
const topBase = Math.round(
Math.max(
(Number.isFinite(headerBottom) ? headerBottom : 64) + 18,
(Number.isFinite(alignmentBottom) ? alignmentBottom : 0) + 10
)
);
const top = Math.max(
Math.round((Number.isFinite(headerBottom) ? headerBottom : 64) + 8),
topBase - trackerHeight - Math.round(trackerHeight * 0.35)
);
const maxWidth = Math.max(
220,
Math.min(window.innerWidth - sidePadding * 2, anchorRect.width - 24)
);
// 让“查看 xx 个新的或更新的话题”固定在中间栏顶部区域,
// 水平居中、垂直位于滚动区上方的固定控制区域。
rootStyle.setProperty("--ld-topic-tracker-left", `${centerX}px`);
rootStyle.setProperty("--ld-topic-tracker-top", `${top}px`);
rootStyle.setProperty("--ld-topic-tracker-max-width", `${Math.round(maxWidth)}px`);
}
function handleWindowResize() {
if (state.settings.drawerWidth === "custom") {
state.settings.drawerWidthCustom = clampDrawerWidth(state.settings.drawerWidthCustom);
applyDrawerWidth();
saveSettings();
} else {
updateSettingsPopoverPosition();
}
scheduleTopicTrackerPositionSync();
}
function handleWindowScroll() {
if (!document.querySelector(TOPIC_TRACKER_SELECTOR)) {
return;
}
scheduleTopicTrackerPositionSync();
}
function watchLocationChanges() {
const originalPushState = history.pushState;
const originalReplaceState = history.replaceState;
history.pushState = function (...args) {
const result = originalPushState.apply(this, args);
queueMicrotask(handleLocationChange);
return result;
};
history.replaceState = function (...args) {
const result = originalReplaceState.apply(this, args);
queueMicrotask(handleLocationChange);
return result;
};
window.addEventListener("popstate", handleLocationChange, true);
let syncQueued = false;
const queueNavigationSync = () => {
if (syncQueued) {
return;
}
syncQueued = true;
requestAnimationFrame(() => {
syncQueued = false;
syncNavigationState();
});
};
const observer = new MutationObserver(() => {
scheduleTopicTrackerPositionSync();
if (location.href !== state.lastLocation) {
handleLocationChange();
} else if (state.currentUrl) {
queueNavigationSync();
}
});
observer.observe(document.documentElement, {
childList: true,
subtree: true
});
}
function compareVersions(a, b) {
const pa = String(a).split(".").map(Number);
const pb = String(b).split(".").map(Number);
for (let i = 0; i < Math.max(pa.length, pb.length); i++) {
const diff = (pa[i] || 0) - (pb[i] || 0);
if (diff !== 0) {
return diff;
}
}
return 0;
}
function hideUpdatePopup(dismiss) {
if (!state.updatePopup) {
return;
}
state.updatePopup.classList.remove("is-visible");
if (dismiss && state.updateLatestVersion) {
try {
localStorage.setItem(UPDATE_DISMISS_KEY, state.updateLatestVersion);
} catch (_) {
// ignore storage errors
}
}
}
function showUpdatePopup(latestVersion) {
if (!state.updatePopup) {
return;
}
if (!latestVersion) {
return;
}
const dismissed = localStorage.getItem(UPDATE_DISMISS_KEY);
if (dismissed && dismissed === latestVersion) {
return;
}
state.updateLatestVersion = latestVersion;
if (state.updatePopupVersionLabel) {
state.updatePopupVersionLabel.textContent = latestVersion;
}
state.updatePopup.classList.add("is-visible");
}
async function checkForUpdate() {
try {
const cached = localStorage.getItem(UPDATE_CHECK_KEY);
if (cached) {
const { ts, latestVersion } = JSON.parse(cached);
if (Date.now() - ts < UPDATE_CHECK_TTL) {
if (compareVersions(latestVersion, CURRENT_VERSION) > 0) {
showUpdatePopup(latestVersion);
}
return;
}
}
} catch (_) {
// ignore malformed cache
}
try {
const resp = await fetch(GREASYFORK_API_URL, { credentials: "omit" });
if (!resp.ok) {
return;
}
const data = await resp.json();
const latestVersion = typeof data?.version === "string" ? data.version : "";
if (!latestVersion) {
return;
}
try {
localStorage.setItem(UPDATE_CHECK_KEY, JSON.stringify({ ts: Date.now(), latestVersion }));
} catch (_) {
// ignore storage errors
}
if (compareVersions(latestVersion, CURRENT_VERSION) > 0) {
showUpdatePopup(latestVersion);
}
} catch (_) {
// network errors are silent
}
}
function hasPreviewableTopicLinks() {
return getTopicEntries().length > 0;
}
function handleLocationChange() {
state.lastLocation = location.href;
clearTopicTrackerRefreshSync();
scheduleTopicTrackerPositionSync();
if (!hasPreviewableTopicLinks()) {
closeDrawer();
return;
}
syncNavigationState();
}
init();
})();