Linux.do SidePeek

Preview Linux.do topics in a right-side drawer without leaving the current page.

You will need to install an extension such as Tampermonkey, Greasemonkey or Violentmonkey to install this script.

You will need to install an extension such as Tampermonkey to install this script.

You will need to install an extension such as Tampermonkey or Violentmonkey to install this script.

You will need to install an extension such as Tampermonkey or Userscripts to install this script.

You will need to install an extension such as Tampermonkey to install this script.

You will need to install a user script manager extension to install this script.

(I already have a user script manager, let me install it!)

You will need to install an extension such as Stylus to install this style.

You will need to install an extension such as Stylus to install this style.

You will need to install an extension such as Stylus to install this style.

You will need to install a user style manager extension to install this style.

You will need to install a user style manager extension to install this style.

You will need to install a user style manager extension to install this style.

(I already have a user style manager, let me install it!)

// ==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 `![${altText}${sizeSegment}](${uploadUrl})`;
    }

    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();
  })();